Merge branch 'master' into fix/clear-extra-args-config

This commit is contained in:
plind 2026-04-05 22:23:50 +09:00 committed by GitHub
commit 23eea392c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
187 changed files with 13296 additions and 1694 deletions

View file

@ -34,6 +34,7 @@ import { InstanceSettings } from "./pages/InstanceSettings";
import { InstanceExperimentalSettings } from "./pages/InstanceExperimentalSettings";
import { PluginManager } from "./pages/PluginManager";
import { PluginSettings } from "./pages/PluginSettings";
import { AdapterManager } from "./pages/AdapterManager";
import { PluginPage } from "./pages/PluginPage";
import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab";
import { OrgChart } from "./pages/OrgChart";
@ -175,6 +176,7 @@ function boardRoutes() {
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
<Route path="design-guide" element={<DesignGuide />} />
<Route path="tests/ux/runs" element={<RunTranscriptUxLab />} />
<Route path="instance/settings/adapters" element={<AdapterManager />} />
<Route path=":pluginRoutePath" element={<PluginPage />} />
<Route path="*" element={<NotFoundPage scope="board" />} />
</>
@ -321,6 +323,7 @@ export function App() {
<Route path="experimental" element={<InstanceExperimentalSettings />} />
<Route path="plugins" element={<PluginManager />} />
<Route path="plugins/:pluginId" element={<PluginSettings />} />
<Route path="adapters" element={<AdapterManager />} />
</Route>
<Route path="companies" element={<UnprefixedBoardRedirect />} />
<Route path="issues" element={<UnprefixedBoardRedirect />} />

View file

@ -0,0 +1,157 @@
/**
* Single source of truth for adapter display metadata.
*
* Built-in adapters have entries in `adapterDisplayMap`. External (plugin)
* adapters get sensible defaults derived from their type string via
* `getAdapterDisplay()`.
*/
import type { ComponentType } from "react";
import {
Bot,
Code,
Gem,
MousePointer2,
Sparkles,
Terminal,
Cpu,
} from "lucide-react";
import { OpenCodeLogoIcon } from "@/components/OpenCodeLogoIcon";
import { HermesIcon } from "@/components/HermesIcon";
// ---------------------------------------------------------------------------
// Type suffix parsing
// ---------------------------------------------------------------------------
const TYPE_SUFFIXES: Record<string, string> = {
_local: "local",
_gateway: "gateway",
};
function getTypeSuffix(type: string): string | null {
for (const [suffix, mode] of Object.entries(TYPE_SUFFIXES)) {
if (type.endsWith(suffix)) return mode;
}
return null;
}
function withSuffix(label: string, suffix: string | null): string {
return suffix ? `${label} (${suffix})` : label;
}
// ---------------------------------------------------------------------------
// Display metadata per adapter type
// ---------------------------------------------------------------------------
export interface AdapterDisplayInfo {
label: string;
description: string;
icon: ComponentType<{ className?: string }>;
recommended?: boolean;
comingSoon?: boolean;
disabledLabel?: string;
}
const adapterDisplayMap: Record<string, AdapterDisplayInfo> = {
claude_local: {
label: "Claude Code",
description: "Local Claude agent",
icon: Sparkles,
recommended: true,
},
codex_local: {
label: "Codex",
description: "Local Codex agent",
icon: Code,
recommended: true,
},
gemini_local: {
label: "Gemini CLI",
description: "Local Gemini agent",
icon: Gem,
},
opencode_local: {
label: "OpenCode",
description: "Local multi-provider agent",
icon: OpenCodeLogoIcon,
},
hermes_local: {
label: "Hermes Agent",
description: "Local Hermes CLI agent",
icon: HermesIcon,
},
pi_local: {
label: "Pi",
description: "Local Pi agent",
icon: Terminal,
},
cursor: {
label: "Cursor",
description: "Local Cursor agent",
icon: MousePointer2,
},
openclaw_gateway: {
label: "OpenClaw Gateway",
description: "Invoke OpenClaw via gateway protocol",
icon: Bot,
comingSoon: true,
disabledLabel: "Configure OpenClaw within the App",
},
process: {
label: "Process",
description: "Internal process adapter",
icon: Cpu,
comingSoon: true,
},
http: {
label: "HTTP",
description: "Internal HTTP adapter",
icon: Cpu,
comingSoon: true,
},
};
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
function humanizeType(type: string): string {
// Strip known type suffixes so "droid_local" → "Droid", not "Droid Local"
let base = type;
for (const suffix of Object.keys(TYPE_SUFFIXES)) {
if (base.endsWith(suffix)) {
base = base.slice(0, -suffix.length);
break;
}
}
return base.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
export function getAdapterLabel(type: string): string {
const base = adapterDisplayMap[type]?.label ?? humanizeType(type);
return withSuffix(base, getTypeSuffix(type));
}
export function getAdapterLabels(): Record<string, string> {
const suffixed: Record<string, string> = {};
for (const [type, info] of Object.entries(adapterDisplayMap)) {
suffixed[type] = withSuffix(info.label, getTypeSuffix(type));
}
return suffixed;
}
export function getAdapterDisplay(type: string): AdapterDisplayInfo {
const known = adapterDisplayMap[type];
if (known) return known;
const suffix = getTypeSuffix(type);
const label = withSuffix(humanizeType(type), suffix);
return {
label,
description: suffix ? `External ${suffix} adapter` : "External adapter",
icon: Cpu,
};
}
export function isKnownAdapterType(type: string): boolean {
return type in adapterDisplayMap;
}

View file

@ -125,9 +125,9 @@ export function ClaudeLocalAdvancedFields({
value={eff(
"adapterConfig",
"maxTurnsPerRun",
Number(config.maxTurnsPerRun ?? 300),
Number(config.maxTurnsPerRun ?? 1000),
)}
onCommit={(v) => mark("adapterConfig", "maxTurnsPerRun", v || 300)}
onCommit={(v) => mark("adapterConfig", "maxTurnsPerRun", v || 1000)}
immediate
className={inputClass}
/>

View file

@ -0,0 +1,33 @@
/**
* Client-side store for disabled adapter types.
*
* Hydrated from the server's GET /api/adapters response.
* Provides synchronous reads so module-level constants can filter against it.
* Falls back to "nothing disabled" before the first hydration.
*
* Usage in components:
* useQuery + adaptersApi.list() populates the store automatically.
*
* Usage in non-React code:
* import { isAdapterTypeHidden } from "@/adapters/disabled-store";
*/
let disabledTypes = new Set<string>();
/** Check if an adapter type is hidden from menus (sync read). */
export function isAdapterTypeHidden(type: string): boolean {
return disabledTypes.has(type);
}
/** Get all hidden adapter types (sync read). */
export function getHiddenAdapterTypes(): Set<string> {
return disabledTypes;
}
/**
* Hydrate the store from a server response.
* Called by components that fetch the adapters list.
*/
export function setDisabledAdapterTypes(types: string[]): void {
disabledTypes = new Set(types);
}

View file

@ -0,0 +1,122 @@
/**
* Dynamic UI parser loading for external adapters.
*
* When the Paperclip UI encounters an adapter type that doesn't have a
* built-in parser (e.g., an external adapter loaded via the plugin system),
* it fetches the parser JS from `/api/adapters/:type/ui-parser.js` and
* evaluates it to create a `parseStdoutLine` function.
*
* The parser module must export:
* - `parseStdoutLine(line: string, ts: string): TranscriptEntry[]`
* - optionally `createStdoutParser(): { parseLine, reset }` for stateful parsers
*
* This is the bridge between the server-side plugin system and the client-side
* UI rendering. Adapter developers ship a `dist/ui-parser.js` with zero
* runtime dependencies, and Paperclip's UI loads it on demand.
*/
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
import type { StatefulStdoutParser, StdoutLineParser, StdoutParserFactory } from "./types";
interface DynamicParserModule {
parseStdoutLine: StdoutLineParser;
createStdoutParser?: StdoutParserFactory;
}
// Cache of dynamically loaded parsers by adapter type.
// Once loaded, the parser is reused for all runs of that adapter type.
const dynamicParserCache = new Map<string, DynamicParserModule>();
// Track which types we've already attempted to load (to avoid repeat 404s).
const failedLoads = new Set<string>();
/**
* Dynamically load a UI parser for an adapter type from the server API.
*
* Fetches `/api/adapters/:type/ui-parser.js`, evaluates the module source
* in a scoped context, and extracts the `parseStdoutLine` export.
*
* @returns A StdoutLineParser function, or null if unavailable.
*/
export async function loadDynamicParser(adapterType: string): Promise<DynamicParserModule | null> {
// Return cached parser if already loaded
const cached = dynamicParserCache.get(adapterType);
if (cached) return cached;
// Don't retry types that previously 404'd
if (failedLoads.has(adapterType)) return null;
try {
const response = await fetch(`/api/adapters/${encodeURIComponent(adapterType)}/ui-parser.js`);
if (!response.ok) {
failedLoads.add(adapterType);
return null;
}
const source = await response.text();
// Evaluate the module source using URL.createObjectURL + dynamic import().
// This properly supports ESM modules with `export` statements.
// (new Function("exports", source) would fail with SyntaxError on `export` keywords.)
const blob = new Blob([source], { type: "application/javascript" });
const blobUrl = URL.createObjectURL(blob);
let parserModule: DynamicParserModule;
try {
const mod = await import(/* @vite-ignore */ blobUrl);
// Prefer the factory function (stateful parser) if available,
// fall back to the static parseStdoutLine function.
if (typeof mod.createStdoutParser === "function") {
const createStdoutParser = mod.createStdoutParser as StdoutParserFactory;
parserModule = {
createStdoutParser,
// Fallback for callers that only know about parseStdoutLine.
parseStdoutLine:
typeof mod.parseStdoutLine === "function"
? (mod.parseStdoutLine as StdoutLineParser)
: ((line: string, ts: string) => {
const parser = createStdoutParser() as StatefulStdoutParser;
const entries = parser.parseLine(line, ts);
parser.reset();
return entries;
}),
};
} else if (typeof mod.parseStdoutLine === "function") {
parserModule = {
parseStdoutLine: mod.parseStdoutLine as StdoutLineParser,
};
} else {
console.warn(`[adapter-ui-loader] Module for "${adapterType}" exports neither parseStdoutLine nor createStdoutParser`);
failedLoads.add(adapterType);
return null;
}
} finally {
URL.revokeObjectURL(blobUrl);
}
// Cache for reuse
dynamicParserCache.set(adapterType, parserModule);
console.info(`[adapter-ui-loader] Loaded dynamic UI parser for "${adapterType}"`);
return parserModule;
} catch (err) {
console.warn(`[adapter-ui-loader] Failed to load UI parser for "${adapterType}":`, err);
failedLoads.add(adapterType);
return null;
}
}
/**
* Invalidate a cached dynamic parser, removing it from both the parser cache
* and the failed-loads set so that the next load attempt will try again.
*/
export function invalidateDynamicParser(adapterType: string): boolean {
const wasCached = dynamicParserCache.has(adapterType);
dynamicParserCache.delete(adapterType);
failedLoads.delete(adapterType);
if (wasCached) {
console.info(`[adapter-ui-loader] Invalidated dynamic UI parser for "${adapterType}"`);
}
return wasCached;
}

View file

@ -1,49 +1,49 @@
import type { AdapterConfigFieldsProps } from "../types";
import {
Field,
DraftInput,
} from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal";
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
const instructionsFileHint =
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
export function HermesLocalConfigFields({
isCreate,
values,
set,
config,
eff,
mark,
hideInstructionsFile,
}: AdapterConfigFieldsProps) {
if (hideInstructionsFile) return null;
return (
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.instructionsFilePath ?? ""
: eff(
"adapterConfig",
"instructionsFilePath",
String(config.instructionsFilePath ?? ""),
)
}
onCommit={(v) =>
isCreate
? set!({ instructionsFilePath: v })
: mark("adapterConfig", "instructionsFilePath", v || undefined)
}
immediate
className={inputClass}
placeholder="/absolute/path/to/AGENTS.md"
/>
<ChoosePathButton />
</div>
</Field>
);
}
import type { AdapterConfigFieldsProps } from "../types";
import {
Field,
DraftInput,
} from "../../components/agent-config-primitives";
import { ChoosePathButton } from "../../components/PathInstructionsModal";
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
const instructionsFileHint =
"Absolute path to a markdown file (e.g. AGENTS.md) that defines this agent's behavior. Injected into the system prompt at runtime.";
export function HermesLocalConfigFields({
isCreate,
values,
set,
config,
eff,
mark,
hideInstructionsFile,
}: AdapterConfigFieldsProps) {
if (hideInstructionsFile) return null;
return (
<Field label="Agent instructions file" hint={instructionsFileHint}>
<div className="flex items-center gap-2">
<DraftInput
value={
isCreate
? values!.instructionsFilePath ?? ""
: eff(
"adapterConfig",
"instructionsFilePath",
String(config.instructionsFilePath ?? ""),
)
}
onCommit={(v) =>
isCreate
? set!({ instructionsFilePath: v })
: mark("adapterConfig", "instructionsFilePath", v || undefined)
}
immediate
className={inputClass}
placeholder="/absolute/path/to/AGENTS.md"
/>
<ChoosePathButton />
</div>
</Field>
);
}

View file

@ -1,12 +1,12 @@
import type { UIAdapterModule } from "../types";
import { parseHermesStdoutLine } from "hermes-paperclip-adapter/ui";
import { HermesLocalConfigFields } from "./config-fields";
import { buildHermesConfig } from "hermes-paperclip-adapter/ui";
export const hermesLocalUIAdapter: UIAdapterModule = {
type: "hermes_local",
label: "Hermes Agent",
parseStdoutLine: parseHermesStdoutLine,
ConfigFields: HermesLocalConfigFields,
buildAdapterConfig: buildHermesConfig,
};
import type { UIAdapterModule } from "../types";
import { parseHermesStdoutLine } from "hermes-paperclip-adapter/ui";
import { SchemaConfigFields, buildSchemaAdapterConfig } from "../schema-config-fields";
import { buildHermesConfig } from "hermes-paperclip-adapter/ui";
export const hermesLocalUIAdapter: UIAdapterModule = {
type: "hermes_local",
label: "Hermes Agent",
parseStdoutLine: parseHermesStdoutLine,
ConfigFields: SchemaConfigFields,
buildAdapterConfig: buildSchemaAdapterConfig,
};

View file

@ -1,4 +1,12 @@
export { getUIAdapter, listUIAdapters } from "./registry";
export {
getUIAdapter,
listUIAdapters,
findUIAdapter,
registerUIAdapter,
unregisterUIAdapter,
syncExternalAdapters,
onAdapterChange,
} from "./registry";
export { buildTranscript } from "./transcript";
export type {
TranscriptEntry,

View file

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { isEnabledAdapterType, listAdapterOptions } from "./metadata";
import type { UIAdapterModule } from "./types";
const externalAdapter: UIAdapterModule = {
type: "external_test",
label: "External Test",
parseStdoutLine: () => [],
ConfigFields: () => null,
buildAdapterConfig: () => ({}),
};
describe("adapter metadata", () => {
it("treats registered external adapters as enabled by default", () => {
expect(isEnabledAdapterType("external_test")).toBe(true);
expect(
listAdapterOptions((type) => type, [externalAdapter]),
).toEqual([
{
value: "external_test",
label: "external_test",
comingSoon: false,
hidden: false,
},
]);
});
it("keeps intentionally withheld built-in adapters marked as coming soon", () => {
expect(isEnabledAdapterType("process")).toBe(false);
expect(isEnabledAdapterType("http")).toBe(false);
});
});

View file

@ -0,0 +1,75 @@
/**
* Adapter metadata utilities built on top of the display registry and UI adapter list.
*
* This module bridges the static display metadata with the dynamic adapter registry.
* "Coming soon" status is derived from the display registry's `comingSoon` flag.
* "Hidden" status comes from the disabled-adapter store (server-side toggle).
*/
import type { UIAdapterModule } from "./types";
import { listUIAdapters } from "./registry";
import { isAdapterTypeHidden } from "./disabled-store";
import { getAdapterLabel, getAdapterDisplay } from "./adapter-display-registry";
export interface AdapterOptionMetadata {
value: string;
label: string;
comingSoon: boolean;
hidden: boolean;
}
export function listKnownAdapterTypes(): string[] {
return listUIAdapters().map((adapter) => adapter.type);
}
/**
* Check whether an adapter type is enabled (not "coming soon").
* Unknown types (external adapters) are always considered enabled.
*/
export function isEnabledAdapterType(type: string): boolean {
// Check display registry first — built-in adapters like process/http are
// intentionally withheld even though they're registered as UI adapters.
if (getAdapterDisplay(type).comingSoon) return false;
// All other types (registered or external) are enabled.
return true;
}
/**
* Check whether an adapter type is a valid choice for new agent creation.
* Includes all registered UI adapters (built-in + external) and
* any non-"coming soon" adapter from the display registry.
*/
export function isValidAdapterType(type: string): boolean {
if (getAdapterDisplay(type).comingSoon) return false;
return true;
}
/**
* Build option metadata for a list of adapters (for dropdowns).
* `labelFor` callback allows callers to override labels; defaults to display registry.
*/
export function listAdapterOptions(
labelFor?: (type: string) => string,
adapters: UIAdapterModule[] = listUIAdapters(),
): AdapterOptionMetadata[] {
const getLabel = labelFor ?? getAdapterLabel;
return adapters.map((adapter) => ({
value: adapter.type,
label: getLabel(adapter.type),
comingSoon: !!getAdapterDisplay(adapter.type).comingSoon,
hidden: isAdapterTypeHidden(adapter.type),
}));
}
/**
* List UI adapters excluding those hidden via the Adapters settings page.
*/
export function listVisibleUIAdapters(): UIAdapterModule[] {
return listUIAdapters().filter((a) => !isAdapterTypeHidden(a.type));
}
/**
* List visible adapter types (for non-React contexts like module-level constants).
*/
export function listVisibleAdapterTypes(): string[] {
return listVisibleUIAdapters().map((a) => a.type);
}

View file

@ -0,0 +1,51 @@
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import type { UIAdapterModule } from "./types";
import {
findUIAdapter,
getUIAdapter,
listUIAdapters,
registerUIAdapter,
unregisterUIAdapter,
} from "./registry";
import { processUIAdapter } from "./process";
import { SchemaConfigFields } from "./schema-config-fields";
const externalUIAdapter: UIAdapterModule = {
type: "external_test",
label: "External Test",
parseStdoutLine: () => [],
ConfigFields: () => null,
buildAdapterConfig: () => ({}),
};
describe("ui adapter registry", () => {
beforeEach(() => {
unregisterUIAdapter("external_test");
});
afterEach(() => {
unregisterUIAdapter("external_test");
});
it("registers adapters for lookup and listing", () => {
registerUIAdapter(externalUIAdapter);
expect(findUIAdapter("external_test")).toBe(externalUIAdapter);
expect(getUIAdapter("external_test")).toBe(externalUIAdapter);
expect(listUIAdapters().some((adapter) => adapter.type === "external_test")).toBe(true);
});
it("falls back to the process parser for unknown types after unregistering", () => {
registerUIAdapter(externalUIAdapter);
unregisterUIAdapter("external_test");
expect(findUIAdapter("external_test")).toBeNull();
const fallback = getUIAdapter("external_test");
// Unknown types return a lazy-loading wrapper (for external adapters),
// not the process adapter directly. The type is preserved.
expect(fallback.type).toBe("external_test");
// But it uses the schema-based config fields for external adapter forms.
expect(fallback.ConfigFields).toBe(SchemaConfigFields);
});
});

View file

@ -3,32 +3,256 @@ import { claudeLocalUIAdapter } from "./claude-local";
import { codexLocalUIAdapter } from "./codex-local";
import { cursorLocalUIAdapter } from "./cursor";
import { geminiLocalUIAdapter } from "./gemini-local";
import { hermesLocalUIAdapter } from "./hermes-local";
import { openCodeLocalUIAdapter } from "./opencode-local";
import { piLocalUIAdapter } from "./pi-local";
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
import { hermesLocalUIAdapter } from "./hermes-local";
import { processUIAdapter } from "./process";
import { httpUIAdapter } from "./http";
import { loadDynamicParser, invalidateDynamicParser } from "./dynamic-loader";
import { SchemaConfigFields, buildSchemaAdapterConfig } from "./schema-config-fields";
const uiAdapters: UIAdapterModule[] = [
claudeLocalUIAdapter,
codexLocalUIAdapter,
geminiLocalUIAdapter,
hermesLocalUIAdapter,
openCodeLocalUIAdapter,
piLocalUIAdapter,
cursorLocalUIAdapter,
openClawGatewayUIAdapter,
processUIAdapter,
httpUIAdapter,
];
const uiAdapters: UIAdapterModule[] = [];
const adaptersByType = new Map<string, UIAdapterModule>();
const adaptersByType = new Map<string, UIAdapterModule>(
uiAdapters.map((a) => [a.type, a]),
);
// Types registered at module load time — allowed to be overridden by
// external adapters that ship their own ui-parser.js via the server.
const builtinTypes = new Set<string>();
// Original builtin adapters stored for restoration when external overrides
// are deactivated or removed.
const builtinAdaptersByType = new Map<string, UIAdapterModule>();
// Tracks which builtin types currently have an active external override.
const activeExternalOverrides = new Set<string>();
// Generation counter to discard stale dynamic parser loads. When an override
// is deactivated while a load is in-flight, the generation is bumped and the
// stale result is discarded in its .then() handler.
const overrideGeneration = new Map<string, number>();
// Subscriber list — components can register to be notified when adapters change
// (e.g., when a dynamic parser replaces a placeholder).
const adapterChangeListeners = new Set<() => void>();
/** Subscribe to adapter registry changes. Returns unsubscribe function. */
export function onAdapterChange(fn: () => void): () => void {
adapterChangeListeners.add(fn);
return () => adapterChangeListeners.delete(fn);
}
function notifyAdapterChange(): void {
for (const fn of adapterChangeListeners) fn();
}
function registerBuiltInUIAdapters() {
for (const adapter of [
claudeLocalUIAdapter,
codexLocalUIAdapter,
geminiLocalUIAdapter,
hermesLocalUIAdapter,
openCodeLocalUIAdapter,
piLocalUIAdapter,
cursorLocalUIAdapter,
openClawGatewayUIAdapter,
processUIAdapter,
httpUIAdapter,
]) {
builtinTypes.add(adapter.type);
builtinAdaptersByType.set(adapter.type, adapter);
registerUIAdapter(adapter);
}
}
export function registerUIAdapter(adapter: UIAdapterModule): void {
const existingIndex = uiAdapters.findIndex((entry) => entry.type === adapter.type);
if (existingIndex >= 0) {
uiAdapters.splice(existingIndex, 1, adapter);
} else {
uiAdapters.push(adapter);
}
adaptersByType.set(adapter.type, adapter);
notifyAdapterChange();
}
export function unregisterUIAdapter(type: string): void {
if (type === processUIAdapter.type || type === httpUIAdapter.type) return;
const existingIndex = uiAdapters.findIndex((entry) => entry.type === type);
if (existingIndex >= 0) {
uiAdapters.splice(existingIndex, 1);
}
adaptersByType.delete(type);
}
export function findUIAdapter(type: string): UIAdapterModule | null {
return adaptersByType.get(type) ?? null;
}
registerBuiltInUIAdapters();
export function getUIAdapter(type: string): UIAdapterModule {
return adaptersByType.get(type) ?? processUIAdapter;
const builtIn = adaptersByType.get(type);
if (!builtIn) {
let loadStarted = false;
return {
type,
label: type,
parseStdoutLine: (line: string, ts: string) => {
if (!loadStarted) {
loadStarted = true;
loadDynamicParser(type).then((parserModule) => {
if (parserModule) {
registerUIAdapter({
type,
label: type,
parseStdoutLine: parserModule.parseStdoutLine,
createStdoutParser: parserModule.createStdoutParser,
ConfigFields: SchemaConfigFields,
buildAdapterConfig: buildSchemaAdapterConfig,
});
}
});
}
return processUIAdapter.parseStdoutLine(line, ts);
},
ConfigFields: SchemaConfigFields,
buildAdapterConfig: buildSchemaAdapterConfig,
};
}
return builtIn;
}
/**
* Keep the UI adapter registry in sync with the server's adapter list.
*
* Two concerns:
*
* 1. **Builtin overrides** when an external adapter ships a ui-parser.js for a
* builtin type, the external parser takes priority. When the external is
* disabled or removed the original builtin parser is restored transparently.
* A generation counter guards against stale loads that resolve after the
* override has been torn down.
*
* 2. **Non-builtin externals** register a bridge adapter that lazily loads the
* dynamic parser on first stdout line, falling back to the generic process
* adapter. Once the parser resolves the bridge is replaced.
*/
export function syncExternalAdapters(
serverAdapters: {
type: string;
label: string;
disabled?: boolean;
/** When true, the external override for a builtin type is client-side paused. */
overrideDisabled?: boolean;
}[],
): void {
const enabledExternalTypes = new Set(
serverAdapters.filter((a) => !a.disabled && !a.overrideDisabled).map((a) => a.type),
);
const allExternalTypes = new Set(
serverAdapters.map((a) => a.type),
);
// ── Builtin override lifecycle ──────────────────────────────────────────
for (const builtinType of builtinTypes) {
const originalBuiltin = builtinAdaptersByType.get(builtinType);
if (!originalBuiltin) continue;
const hasExternal = allExternalTypes.has(builtinType);
const externalEnabled = enabledExternalTypes.has(builtinType);
const wasOverridden = activeExternalOverrides.has(builtinType);
if (hasExternal && externalEnabled && !wasOverridden) {
// Activate: external just became active → replace builtin with bridge.
activeExternalOverrides.add(builtinType);
const gen = (overrideGeneration.get(builtinType) ?? 0) + 1;
overrideGeneration.set(builtinType, gen);
let loadStarted = false;
const fallbackParser = originalBuiltin.parseStdoutLine;
const externalEntry = serverAdapters.find((a) => a.type === builtinType);
const label = externalEntry?.label ?? builtinType;
registerUIAdapter({
type: builtinType,
label,
parseStdoutLine: (line: string, ts: string) => {
if (!loadStarted) {
loadStarted = true;
loadDynamicParser(builtinType).then((parserModule) => {
// Discard if the override was torn down while the load was in-flight.
if (parserModule && overrideGeneration.get(builtinType) === gen) {
registerUIAdapter({
type: builtinType,
label,
parseStdoutLine: parserModule.parseStdoutLine,
createStdoutParser: parserModule.createStdoutParser,
ConfigFields: originalBuiltin.ConfigFields,
buildAdapterConfig: originalBuiltin.buildAdapterConfig,
});
}
});
}
return fallbackParser(line, ts);
},
ConfigFields: originalBuiltin.ConfigFields,
buildAdapterConfig: originalBuiltin.buildAdapterConfig,
});
} else if ((!hasExternal || !externalEnabled) && wasOverridden) {
// Deactivate: external disabled or removed → restore builtin.
activeExternalOverrides.delete(builtinType);
overrideGeneration.delete(builtinType);
invalidateDynamicParser(builtinType);
registerUIAdapter(originalBuiltin);
}
}
// ── Non-builtin externals ───────────────────────────────────────────────
for (const { type, label } of serverAdapters) {
if (builtinTypes.has(type)) continue; // handled above
const existing = adaptersByType.get(type);
// If this type already has an externally-loaded dynamic parser, skip —
// it was loaded from disk on a previous sync. Only re-trigger loading
// when the server returns a new external adapter that hasn't been loaded yet.
if (existing && existing !== processUIAdapter) continue;
let loadStarted = false;
// Use the existing built-in parser as fallback (if any) so we don't
// regress to the generic process parser while the dynamic one loads.
const fallbackParser = existing?.parseStdoutLine ?? processUIAdapter.parseStdoutLine;
registerUIAdapter({
type,
label,
parseStdoutLine: (line: string, ts: string) => {
if (!loadStarted) {
loadStarted = true;
loadDynamicParser(type).then((parserModule) => {
if (parserModule) {
registerUIAdapter({
type,
label,
parseStdoutLine: parserModule.parseStdoutLine,
createStdoutParser: parserModule.createStdoutParser,
ConfigFields: existing?.ConfigFields ?? SchemaConfigFields,
buildAdapterConfig: existing?.buildAdapterConfig ?? buildSchemaAdapterConfig,
});
}
});
}
return fallbackParser(line, ts);
},
ConfigFields: existing?.ConfigFields ?? SchemaConfigFields,
buildAdapterConfig: existing?.buildAdapterConfig ?? buildSchemaAdapterConfig,
});
}
}
export function listUIAdapters(): UIAdapterModule[] {

View file

@ -0,0 +1,507 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { AdapterConfigSchema, ConfigFieldSchema, CreateConfigValues } from "@paperclipai/adapter-utils";
import type { AdapterConfigFieldsProps } from "./types";
import {
Field,
DraftInput,
DraftNumberInput,
DraftTextarea,
ToggleField,
} from "../components/agent-config-primitives";
import { Popover, PopoverContent, PopoverTrigger } from "../components/ui/popover";
import { ChevronDown } from "lucide-react";
// ── Select field (extracted to keep hooks at component top level) ──────
function SelectField({
value,
options,
onChange,
}: {
value: string;
options: Array<{ value: string; label: string }>;
onChange: (value: string) => void;
}) {
const [open, setOpen] = useState(false);
const selectedOpt = options.find((o) => o.value === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className={!value ? "text-muted-foreground" : ""}>
{selectedOpt?.label ?? value ?? "Select..."}
</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
{options.map((opt) => (
<button
key={opt.value}
className={`flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50 ${opt.value === value ? "bg-accent" : ""}`}
onMouseDown={(e) => {
e.preventDefault();
onChange(opt.value);
setOpen(false);
}}
>
<span>{opt.label}</span>
</button>
))}
</PopoverContent>
</Popover>
);
}
const inputClass =
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
// ---------------------------------------------------------------------------
// Combobox: type-to-filter dropdown with free text fallback
// ---------------------------------------------------------------------------
function ComboboxField({
value,
options,
onChange,
placeholder,
}: {
value: string;
options: { label: string; value: string; group?: string }[];
onChange: (val: string) => void;
placeholder?: string;
}) {
const [open, setOpen] = useState(false);
const [filter, setFilter] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
// Sync filter with external value when it changes (e.g. provider switch resets model)
useEffect(() => {
setFilter("");
}, [value]);
const filtered = options.filter((opt) => {
if (!filter) return true;
const q = filter.toLowerCase();
return (
opt.value.toLowerCase().includes(q) ||
opt.label.toLowerCase().includes(q) ||
(opt.group && opt.group.toLowerCase().includes(q))
);
});
const selectedOpt = options.find((o) => o.value === value);
const displayValue = filter || selectedOpt?.value || value || "";
// Group filtered options by `group` field if present
const grouped = new Map<string, typeof filtered>();
for (const opt of filtered) {
const g = opt.group ?? "";
if (!grouped.has(g)) grouped.set(g, []);
grouped.get(g)!.push(opt);
}
const select = useCallback(
(val: string) => {
onChange(val);
setOpen(false);
setFilter("");
inputRef.current?.blur();
},
[onChange],
);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
// If exactly one match, select it. Otherwise commit the typed value.
if (filtered.length === 1) {
select(filtered[0].value);
} else if (filter) {
select(filter);
}
} else if (e.key === "Escape") {
setOpen(false);
setFilter("");
} else if (e.key === "ArrowDown" && !open) {
e.preventDefault();
setOpen(true);
}
};
return (
<div className="relative">
<div className="flex items-center gap-0">
<input
ref={inputRef}
type="text"
className="flex-1 rounded-l-md border border-r-0 border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40 focus:z-10"
value={displayValue}
placeholder={placeholder ?? "Type or select..."}
onChange={(e) => {
setFilter(e.target.value);
if (!open) setOpen(true);
}}
onFocus={() => {
if (!open) setOpen(true);
}}
onBlur={() => {
// Delay close to allow click on option to register
setTimeout(() => setOpen(false), 150);
}}
onKeyDown={handleKeyDown}
/>
<Popover open={open && filtered.length > 0} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button className="rounded-r-md border border-border px-2 py-1.5 hover:bg-accent/50 transition-colors">
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent
className="p-1 max-h-60 overflow-y-auto"
style={{ minWidth: 280 }}
align="start"
onOpenAutoFocus={(e) => e.preventDefault()}
>
{Array.from(grouped.entries()).map(([group, opts]) => (
<div key={group || "_ungrouped"}>
{group && (
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
{group}
</div>
)}
{opts.map((opt) => (
<button
key={opt.value}
className={`flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50 ${
opt.value === value ? "bg-accent" : ""
}`}
onMouseDown={(e) => {
e.preventDefault(); // prevent input blur
select(opt.value);
}}
>
<span className="truncate">{opt.label}</span>
</button>
))}
</div>
))}
{filter && filtered.length === 0 && (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
Use &quot;{filter}&quot; as custom value (press Enter)
</div>
)}
</PopoverContent>
</Popover>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// SchemaConfigFields component
// ---------------------------------------------------------------------------
const schemaCache = new Map<string, AdapterConfigSchema | null>();
const schemaFetchInflight = new Map<string, Promise<AdapterConfigSchema | null>>();
const failedSchemaTypes = new Set<string>();
async function fetchConfigSchema(adapterType: string): Promise<AdapterConfigSchema | null> {
const cached = schemaCache.get(adapterType);
if (cached !== undefined) return cached;
if (failedSchemaTypes.has(adapterType)) return null;
const inflight = schemaFetchInflight.get(adapterType);
if (inflight) return inflight;
const promise = (async () => {
try {
const res = await fetch(`/api/adapters/${encodeURIComponent(adapterType)}/config-schema`);
if (!res.ok) {
failedSchemaTypes.add(adapterType);
return null;
}
const schema = (await res.json()) as AdapterConfigSchema;
schemaCache.set(adapterType, schema);
return schema;
} catch {
failedSchemaTypes.add(adapterType);
return null;
} finally {
schemaFetchInflight.delete(adapterType);
}
})();
schemaFetchInflight.set(adapterType, promise);
return promise;
}
export function invalidateConfigSchemaCache(adapterType: string): void {
schemaCache.delete(adapterType);
failedSchemaTypes.delete(adapterType);
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
function useConfigSchema(adapterType: string): AdapterConfigSchema | null {
const [schema, setSchema] = useState<AdapterConfigSchema | null>(
schemaCache.get(adapterType) ?? null,
);
useEffect(() => {
let cancelled = false;
fetchConfigSchema(adapterType).then((s) => {
if (!cancelled) setSchema(s);
});
return () => {
cancelled = true;
};
}, [adapterType]);
return schema;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function getDefaultValue(field: ConfigFieldSchema): unknown {
if (field.default !== undefined) return field.default;
switch (field.type) {
case "toggle":
return false;
case "number":
return 0;
case "text":
case "textarea":
return "";
case "select":
return field.options?.[0]?.value ?? "";
}
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export function SchemaConfigFields({
adapterType,
isCreate,
values,
set,
config,
eff,
mark,
}: AdapterConfigFieldsProps) {
const schema = useConfigSchema(adapterType);
const [defaultsApplied, setDefaultsApplied] = useState(false);
useEffect(() => {
if (!schema || !isCreate || defaultsApplied) return;
const defaults: Record<string, unknown> = {};
for (const field of schema.fields) {
const def = getDefaultValue(field);
if (def !== undefined && def !== "") {
defaults[field.key] = def;
}
}
if (Object.keys(defaults).length > 0) {
set?.({
adapterSchemaValues: { ...values?.adapterSchemaValues, ...defaults },
});
}
setDefaultsApplied(true);
}, [schema, isCreate, defaultsApplied, set, values?.adapterSchemaValues]);
if (!schema || schema.fields.length === 0) return null;
function readValue(field: ConfigFieldSchema): unknown {
if (isCreate) {
return values?.adapterSchemaValues?.[field.key] ?? getDefaultValue(field);
}
const stored = config[field.key];
return eff("adapterConfig", field.key, (stored ?? getDefaultValue(field)) as string);
}
function writeValue(field: ConfigFieldSchema, value: unknown): void {
if (isCreate) {
const next = {
adapterSchemaValues: {
...values?.adapterSchemaValues,
[field.key]: value,
},
};
// When provider changes, auto-clear model if it's not in the new provider's list
if (field.key === "provider" && schema) {
const modelField = schema.fields.find((f) => f.key === "model");
if (modelField?.meta?.providerModels) {
const modelsByProvider = modelField.meta.providerModels as Record<string, string[]>;
const providerModels = modelsByProvider[String(value)] ?? [];
const currentModel = values?.adapterSchemaValues?.model;
if (currentModel && String(value) !== "auto" && !providerModels.includes(String(currentModel))) {
next.adapterSchemaValues.model = "";
}
}
}
set?.(next);
} else {
mark("adapterConfig", field.key, value);
// Same logic for edit mode
if (field.key === "provider" && schema) {
const modelField = schema.fields.find((f) => f.key === "model");
if (modelField?.meta?.providerModels) {
const modelsByProvider = modelField.meta.providerModels as Record<string, string[]>;
const providerModels = modelsByProvider[String(value)] ?? [];
const currentModel = eff("adapterConfig", "model", "");
if (currentModel && String(value) !== "auto" && !providerModels.includes(String(currentModel))) {
mark("adapterConfig", "model", "");
}
}
}
}
}
return (
<>
{schema.fields.map((field) => {
switch (field.type) {
case "select": {
const currentVal = String(readValue(field) ?? "");
return (
<Field key={field.key} label={field.label} hint={field.hint}>
<SelectField
value={currentVal}
options={field.options ?? []}
onChange={(v) => writeValue(field, v)}
/>
</Field>
);
}
case "toggle":
return (
<ToggleField
key={field.key}
label={field.label}
hint={field.hint}
checked={readValue(field) === true}
onChange={(v) => writeValue(field, v)}
/>
);
case "number":
return (
<Field key={field.key} label={field.label} hint={field.hint}>
<DraftNumberInput
value={Number(readValue(field) ?? 0)}
onCommit={(v) => writeValue(field, v)}
immediate
className={inputClass}
/>
</Field>
);
case "textarea":
return (
<Field key={field.key} label={field.label} hint={field.hint}>
<DraftTextarea
value={String(readValue(field) ?? "")}
onCommit={(v) => writeValue(field, v || undefined)}
immediate
/>
</Field>
);
case "combobox": {
const currentVal = String(readValue(field) ?? "");
// Dynamic options: if meta.providerModels exists, compute options
// based on the current provider value
let comboboxOptions = field.options ?? [];
if (field.meta?.providerModels) {
const providerVal = String(readValue(schema.fields.find((f) => f.key === "provider")!) ?? "auto");
const modelsByProvider = field.meta.providerModels as Record<string, string[]>;
if (providerVal === "auto") {
// Auto: show all models from all providers, grouped by provider
const providerLabel = schema.fields.find((f) => f.key === "provider");
const providerOptions = providerLabel?.options ?? [];
comboboxOptions = Object.entries(modelsByProvider).flatMap(([prov, models]) =>
models.map((m) => ({
label: m,
value: m,
group: providerOptions.find((p) => p.value === prov)?.label ?? prov,
})),
);
} else {
const providerModels = modelsByProvider[providerVal] ?? [];
const providerLabel = schema.fields.find((f) => f.key === "provider");
const provName = providerLabel?.options?.find((p) => p.value === providerVal)?.label ?? providerVal;
comboboxOptions = providerModels.map((m) => ({
label: m,
value: m,
group: provName,
}));
}
}
return (
<Field key={field.key} label={field.label} hint={field.hint}>
<ComboboxField
value={currentVal}
options={comboboxOptions}
onChange={(v) => writeValue(field, v || undefined)}
placeholder={field.hint}
/>
</Field>
);
}
case "text":
default:
return (
<Field key={field.key} label={field.label} hint={field.hint}>
<DraftInput
value={String(readValue(field) ?? "")}
onCommit={(v) => writeValue(field, v || undefined)}
immediate
className={inputClass}
/>
</Field>
);
}
})}
</>
);
}
// ---------------------------------------------------------------------------
// Build adapter config from schema values + standard CreateConfigValues fields
// ---------------------------------------------------------------------------
export function buildSchemaAdapterConfig(
values: CreateConfigValues,
): Record<string, unknown> {
const ac: Record<string, unknown> = {};
if (values.model?.trim()) ac.model = values.model.trim();
if (values.cwd) ac.cwd = values.cwd;
if (values.command) ac.command = values.command;
if (values.instructionsFilePath) ac.instructionsFilePath = values.instructionsFilePath;
if (values.thinkingEffort) ac.thinkingEffort = values.thinkingEffort;
if (values.extraArgs) {
ac.extraArgs = values.extraArgs
.split(/\s+/)
.filter(Boolean);
}
if (values.adapterSchemaValues) {
Object.assign(ac, values.adapterSchemaValues);
}
return ac;
}

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { buildTranscript, type RunLogChunk } from "./transcript";
import type { UIAdapterModule } from "./types";
describe("buildTranscript", () => {
const ts = "2026-03-20T13:00:00.000Z";
@ -27,4 +28,46 @@ describe("buildTranscript", () => {
{ kind: "stderr", ts, text: "stderr /Users/d****/project" },
]);
});
it("creates a fresh stateful parser for each transcript build", () => {
const statefulAdapter: UIAdapterModule = {
type: "stateful_test",
label: "Stateful Test",
parseStdoutLine: (line, entryTs) => [{ kind: "stdout", ts: entryTs, text: line }],
createStdoutParser: () => {
let pending: string | null = null;
return {
parseLine: (line, entryTs) => {
if (line.startsWith("begin:")) {
pending = line.slice("begin:".length);
return [];
}
if (line === "finish" && pending) {
const text = `completed:${pending}`;
pending = null;
return [{ kind: "stdout", ts: entryTs, text }];
}
return [{ kind: "stdout", ts: entryTs, text: `literal:${line}` }];
},
reset: () => {
pending = null;
},
};
},
ConfigFields: () => null,
buildAdapterConfig: () => ({}),
};
const first = buildTranscript(
[{ ts, stream: "stdout", chunk: "begin:task-a\n" }],
statefulAdapter,
);
const second = buildTranscript(
[{ ts, stream: "stdout", chunk: "finish\n" }],
statefulAdapter,
);
expect(first).toEqual([]);
expect(second).toEqual([{ kind: "stdout", ts, text: "literal:finish" }]);
});
});

View file

@ -1,9 +1,20 @@
import { redactHomePathUserSegments, redactTranscriptEntryPaths } from "@paperclipai/adapter-utils";
import type { TranscriptEntry, StdoutLineParser } from "./types";
import type { TranscriptEntry, StdoutLineParser, TranscriptParserSource } from "./types";
export type RunLogChunk = { ts: string; stream: "stdout" | "stderr" | "system"; chunk: string };
type TranscriptBuildOptions = { censorUsernameInLogs?: boolean };
function resolveStdoutParser(source: StdoutLineParser | TranscriptParserSource) {
if (typeof source === "function") {
return { parseLine: source, reset: null as (() => void) | null };
}
if (source.createStdoutParser) {
const parser = source.createStdoutParser();
return { parseLine: parser.parseLine, reset: parser.reset };
}
return { parseLine: source.parseStdoutLine, reset: null as (() => void) | null };
}
export function appendTranscriptEntry(entries: TranscriptEntry[], entry: TranscriptEntry) {
if ((entry.kind === "thinking" || entry.kind === "assistant") && entry.delta) {
const last = entries[entries.length - 1];
@ -24,12 +35,13 @@ export function appendTranscriptEntries(entries: TranscriptEntry[], incoming: Tr
export function buildTranscript(
chunks: RunLogChunk[],
parser: StdoutLineParser,
parserSource: StdoutLineParser | TranscriptParserSource,
opts?: TranscriptBuildOptions,
): TranscriptEntry[] {
const entries: TranscriptEntry[] = [];
let stdoutBuffer = "";
const redactionOptions = { enabled: opts?.censorUsernameInLogs ?? false };
const { parseLine, reset } = resolveStdoutParser(parserSource);
for (const chunk of chunks) {
if (chunk.stream === "stderr") {
@ -47,15 +59,17 @@ export function buildTranscript(
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
appendTranscriptEntries(entries, parser(trimmed, chunk.ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
appendTranscriptEntries(entries, parseLine(trimmed, chunk.ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
}
}
const trailing = stdoutBuffer.trim();
if (trailing) {
const ts = chunks.length > 0 ? chunks[chunks.length - 1]!.ts : new Date().toISOString();
appendTranscriptEntries(entries, parser(trailing, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
appendTranscriptEntries(entries, parseLine(trailing, ts).map((entry) => redactTranscriptEntryPaths(entry, redactionOptions)));
}
reset?.();
return entries;
}

View file

@ -4,6 +4,18 @@ import type { CreateConfigValues } from "@paperclipai/adapter-utils";
// Re-export shared types so local consumers don't need to change imports
export type { TranscriptEntry, StdoutLineParser, CreateConfigValues } from "@paperclipai/adapter-utils";
export interface StatefulStdoutParser {
parseLine: (line: string, ts: string) => import("@paperclipai/adapter-utils").TranscriptEntry[];
reset: () => void;
}
export type StdoutParserFactory = () => StatefulStdoutParser;
export interface TranscriptParserSource {
parseStdoutLine: (line: string, ts: string) => import("@paperclipai/adapter-utils").TranscriptEntry[];
createStdoutParser?: StdoutParserFactory;
}
export interface AdapterConfigFieldsProps {
mode: "create" | "edit";
isCreate: boolean;
@ -24,10 +36,9 @@ export interface AdapterConfigFieldsProps {
hideInstructionsFile?: boolean;
}
export interface UIAdapterModule {
export interface UIAdapterModule extends TranscriptParserSource {
type: string;
label: string;
parseStdoutLine: (line: string, ts: string) => import("@paperclipai/adapter-utils").TranscriptEntry[];
ConfigFields: ComponentType<AdapterConfigFieldsProps>;
buildAdapterConfig: (values: CreateConfigValues) => Record<string, unknown>;
}

View file

@ -0,0 +1,54 @@
import { useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { adaptersApi } from "@/api/adapters";
import { setDisabledAdapterTypes } from "@/adapters/disabled-store";
import { syncExternalAdapters } from "@/adapters/registry";
import { queryKeys } from "@/lib/queryKeys";
/**
* Fetch adapters and keep the disabled-adapter store + UI adapter registry
* in sync with the server.
*
* - Registers external adapter types in the UI registry so they appear in
* dropdowns (done eagerly during render idempotent, no React state).
* - Syncs the disabled-adapter store for non-React consumers (useEffect).
*
* Returns a reactive Set of disabled types for use as useMemo dependencies.
* Call this at the top of any component that renders adapter menus.
*/
export function useDisabledAdaptersSync(): Set<string> {
const { data: adapters } = useQuery({
queryKey: queryKeys.adapters.all,
queryFn: () => adaptersApi.list(),
staleTime: 5 * 60 * 1000,
});
// Eagerly register external adapter types in the UI registry so that
// consumers calling listUIAdapters() in the same render cycle see them.
// This is idempotent — already-registered types are skipped.
if (adapters) {
syncExternalAdapters(
adapters
.filter((a) => a.source === "external")
.map((a) => ({
type: a.type,
label: a.label,
disabled: a.disabled,
overrideDisabled: a.overridePaused,
})),
);
}
// Sync the disabled set to the global store for non-React code
useEffect(() => {
if (!adapters) return;
setDisabledAdapterTypes(
adapters.filter((a) => a.disabled).map((a) => a.type),
);
}, [adapters]);
return useMemo(
() => new Set(adapters?.filter((a) => a.disabled).map((a) => a.type) ?? []),
[adapters],
);
}

59
ui/src/api/adapters.ts Normal file
View file

@ -0,0 +1,59 @@
/**
* @fileoverview Frontend API client for external adapter management.
*/
import { api } from "./client";
export interface AdapterInfo {
type: string;
label: string;
source: "builtin" | "external";
modelsCount: number;
loaded: boolean;
disabled: boolean;
/** Installed version (for external npm adapters) */
version?: string;
/** Package name (for external adapters) */
packageName?: string;
/** Whether the adapter was installed from a local path (vs npm). */
isLocalPath?: boolean;
/** True when an external plugin has replaced a built-in adapter of the same type. */
overriddenBuiltin?: boolean;
/** True when the external override for a builtin type is currently paused. */
overridePaused?: boolean;
}
export interface AdapterInstallResult {
type: string;
packageName: string;
version?: string;
installedAt: string;
}
export const adaptersApi = {
/** List all registered adapters (built-in + external). */
list: () => api.get<AdapterInfo[]>("/adapters"),
/** Install an external adapter from npm or a local path. */
install: (params: { packageName: string; version?: string; isLocalPath?: boolean }) =>
api.post<AdapterInstallResult>("/adapters/install", params),
/** Remove an external adapter by type. */
remove: (type: string) => api.delete<{ type: string; removed: boolean }>(`/adapters/${type}`),
/** Enable or disable an adapter (disabled adapters hidden from agent menus). */
setDisabled: (type: string, disabled: boolean) =>
api.patch<{ type: string; disabled: boolean; changed: boolean }>(`/adapters/${type}`, { disabled }),
/** Pause or resume an external override of a builtin type. */
setOverridePaused: (type: string, paused: boolean) =>
api.patch<{ type: string; paused: boolean; changed: boolean }>(`/adapters/${type}/override`, { paused }),
/** Reload an external adapter (bust server + client caches). */
reload: (type: string) =>
api.post<{ type: string; version?: string; reloaded: boolean }>(`/adapters/${type}/reload`, {}),
/** Reinstall an npm-sourced adapter (pulls latest from registry, then reloads). */
reinstall: (type: string) =>
api.post<{ type: string; version?: string; reinstalled: boolean }>(`/adapters/${type}/reinstall`, {}),
};

View file

@ -32,6 +32,7 @@ export interface DetectedAdapterModel {
model: string;
provider: string;
source: string;
candidates?: string[];
}
export interface ClaudeLoginResult {

View file

@ -1,6 +1,5 @@
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AGENT_ADAPTER_TYPES } from "@paperclipai/shared";
import type {
Agent,
AdapterEnvironmentTestResult,
@ -46,6 +45,9 @@ import { ChoosePathButton } from "./PathInstructionsModal";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
import { ReportsToPicker } from "./ReportsToPicker";
import { shouldShowLegacyWorkingDirectoryField } from "../lib/legacy-agent-config";
import { listAdapterOptions, listVisibleAdapterTypes } from "../adapters/metadata";
import { getAdapterLabel } from "../adapters/adapter-display-registry";
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
/* ---- Create mode values ---- */
@ -180,6 +182,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const { selectedCompanyId } = useCompany();
const queryClient = useQueryClient();
// Sync disabled adapter types from server so dropdown filters them out
const disabledTypes = useDisabledAdaptersSync();
const { data: availableSecrets = [] } = useQuery({
queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"],
queryFn: () => secretsApi.list(selectedCompanyId!),
@ -311,15 +316,9 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
const adapterType = isCreate
? props.values.adapterType
: overlay.adapterType ?? props.agent.adapterType;
const isLocal =
adapterType === "claude_local" ||
adapterType === "codex_local" ||
adapterType === "gemini_local" ||
adapterType === "hermes_local" ||
adapterType === "opencode_local" ||
adapterType === "pi_local" ||
adapterType === "cursor";
const isHermesLocal = adapterType === "hermes_local";
const NONLOCAL_TYPES = new Set(["process", "http", "openclaw_gateway"]);
const isLocal = !NONLOCAL_TYPES.has(adapterType);
const showLegacyWorkingDirectoryField =
isLocal && shouldShowLegacyWorkingDirectoryField({ isCreate, adapterConfig: config });
const uiAdapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
@ -345,13 +344,14 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
: ["agents", "none", "detect-model", adapterType],
queryFn: () => {
if (!selectedCompanyId) {
throw new Error("Select a company to detect the Hermes model");
throw new Error("Select a company to detect the model");
}
return agentsApi.detectModel(selectedCompanyId, adapterType);
},
enabled: Boolean(selectedCompanyId && isHermesLocal),
enabled: Boolean(selectedCompanyId && isLocal),
});
const detectedModel = detectedModelData?.model ?? null;
const detectedModelCandidates = detectedModelData?.candidates ?? [];
const { data: companyAgents = [] } = useQuery({
queryKey: selectedCompanyId ? queryKeys.agents.list(selectedCompanyId) : ["agents", "none", "list"],
@ -583,6 +583,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
<Field label="Adapter type" hint={help.adapterType}>
<AdapterTypeDropdown
value={adapterType}
disabledTypes={disabledTypes}
onChange={(t) => {
if (isCreate) {
// Reset all adapter-specific fields to defaults when switching adapter type
@ -692,8 +693,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</>
)}
{/* Adapter-specific fields */}
<uiAdapter.ConfigFields {...adapterFieldProps} />
{/* Adapter-specific fields are rendered inside Permissions & Configuration */}
</div>
</div>
@ -716,24 +716,19 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
onCommit={(v) =>
isCreate
? set!({ command: v })
: mark("adapterConfig", "command", v || undefined)
: mark("adapterConfig", "command", v || null)
}
immediate
className={inputClass}
placeholder={
adapterType === "codex_local"
? "codex"
: adapterType === "gemini_local"
? "gemini"
: adapterType === "hermes_local"
? "hermes"
: adapterType === "pi_local"
? "pi"
: adapterType === "cursor"
? "agent"
: adapterType === "opencode_local"
? "opencode"
: "claude"
({
claude_local: "claude",
codex_local: "codex",
gemini_local: "gemini",
pi_local: "pi",
cursor: "agent",
opencode_local: "opencode",
} as Record<string, string>)[adapterType] ?? adapterType.replace(/_local$/, "")
}
/>
</Field>
@ -748,18 +743,18 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
}
open={modelOpen}
onOpenChange={setModelOpen}
allowDefault={adapterType !== "opencode_local" && adapterType !== "hermes_local"}
required={adapterType === "opencode_local" || adapterType === "hermes_local"}
allowDefault={adapterType !== "opencode_local"}
required={adapterType === "opencode_local"}
groupByProvider={adapterType === "opencode_local"}
creatable={adapterType === "hermes_local"}
detectedModel={adapterType === "hermes_local" ? detectedModel : null}
onDetectModel={adapterType === "hermes_local"
? async () => {
const result = await refetchDetectedModel();
return result.data?.model ?? null;
}
: undefined}
detectModelLabel={adapterType === "hermes_local" ? "Detect from Hermes config" : undefined}
creatable
detectedModel={detectedModel}
detectedModelCandidates={[]}
onDetectModel={async () => {
const result = await refetchDetectedModel();
return result.data?.model ?? null;
}}
detectModelLabel="Detect model"
emptyDetectHint="No model detected. Select or enter one manually."
/>
{fetchedModelsError && (
<p className="text-xs text-destructive">
@ -820,6 +815,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{adapterType === "claude_local" && (
<ClaudeLocalAdvancedFields {...adapterFieldProps} />
)}
<uiAdapter.ConfigFields {...adapterFieldProps} />
<Field label="Extra args (comma-separated)" hint={help.extraArgs}>
<DraftInput
@ -1024,37 +1020,37 @@ function AdapterEnvironmentResult({ result }: { result: AdapterEnvironmentTestRe
/* ---- Internal sub-components ---- */
const ENABLED_ADAPTER_TYPES = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]);
/** Display list includes all real adapter types plus UI-only coming-soon entries. */
const ADAPTER_DISPLAY_LIST: { value: string; label: string; comingSoon: boolean }[] = [
...AGENT_ADAPTER_TYPES.map((t) => ({
value: t,
label: adapterLabels[t] ?? t,
comingSoon: !ENABLED_ADAPTER_TYPES.has(t),
})),
];
function AdapterTypeDropdown({
value,
onChange,
disabledTypes,
}: {
value: string;
onChange: (type: string) => void;
disabledTypes: Set<string>;
}) {
const [open, setOpen] = useState(false);
const adapterList = useMemo(
() =>
listAdapterOptions((type) => adapterLabels[type] ?? getAdapterLabel(type)).filter(
(item) => !disabledTypes.has(item.value),
),
[disabledTypes],
);
return (
<Popover>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent/50 transition-colors w-full justify-between">
<span className="inline-flex items-center gap-1.5">
{value === "opencode_local" ? <OpenCodeLogoIcon className="h-3.5 w-3.5" /> : null}
<span>{adapterLabels[value] ?? value}</span>
<span>{adapterLabels[value] ?? getAdapterLabel(value)}</span>
</span>
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-1" align="start">
{ADAPTER_DISPLAY_LIST.map((item) => (
{adapterList.map((item) => (
<button
key={item.value}
disabled={item.comingSoon}
@ -1066,7 +1062,10 @@ function AdapterTypeDropdown({
item.value === value && !item.comingSoon && "bg-accent",
)}
onClick={() => {
if (!item.comingSoon) onChange(item.value);
if (!item.comingSoon) {
onChange(item.value);
setOpen(false);
}
}}
>
<span className="inline-flex items-center gap-1.5">
@ -1357,8 +1356,10 @@ function ModelDropdown({
groupByProvider,
creatable,
detectedModel,
detectedModelCandidates,
onDetectModel,
detectModelLabel,
emptyDetectHint,
}: {
models: AdapterModel[];
value: string;
@ -1370,8 +1371,10 @@ function ModelDropdown({
groupByProvider: boolean;
creatable?: boolean;
detectedModel?: string | null;
detectedModelCandidates?: string[];
onDetectModel?: () => Promise<string | null>;
detectModelLabel?: string;
emptyDetectHint?: string;
}) {
const [modelSearch, setModelSearch] = useState("");
const [detectingModel, setDetectingModel] = useState(false);
@ -1382,8 +1385,19 @@ function ModelDropdown({
manualModel &&
!models.some((m) => m.id.toLowerCase() === manualModel.toLowerCase()),
);
// Model IDs already shown as detected/candidate badges — exclude from regular list
const promotedModelIds = useMemo(() => {
const set = new Set<string>();
if (detectedModel) set.add(detectedModel);
for (const c of detectedModelCandidates ?? []) {
if (c) set.add(c);
}
return set;
}, [detectedModel, detectedModelCandidates]);
const filteredModels = useMemo(() => {
return models.filter((m) => {
if (promotedModelIds.has(m.id)) return false;
if (!modelSearch.trim()) return true;
const q = modelSearch.toLowerCase();
const provider = extractProviderId(m.id) ?? "";
@ -1393,7 +1407,7 @@ function ModelDropdown({
provider.toLowerCase().includes(q)
);
});
}, [models, modelSearch]);
}, [models, modelSearch, promotedModelIds]);
const groupedModels = useMemo(() => {
if (!groupByProvider) {
return [
@ -1474,7 +1488,7 @@ function ModelDropdown({
</button>
)}
</div>
{onDetectModel && !detectedModel && !modelSearch.trim() && (
{onDetectModel && !modelSearch.trim() && (
<button
type="button"
className="flex items-center gap-1.5 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground"
@ -1487,10 +1501,10 @@ function ModelDropdown({
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
{detectingModel ? "Detecting..." : (detectModelLabel ?? "Detect from config")}
{detectingModel ? "Detecting..." : detectedModel ? (detectModelLabel?.replace(/^Detect\b/, "Re-detect") ?? "Re-detect from config") : (detectModelLabel ?? "Detect from config")}
</button>
)}
{value && !models.some((m) => m.id === value) && (
{value && (!models.some((m) => m.id === value) || promotedModelIds.has(value)) && (
<button
type="button"
className={cn(
@ -1501,7 +1515,7 @@ function ModelDropdown({
}}
>
<span className="block w-full text-left truncate font-mono text-xs" title={value}>
{value}
{models.find((m) => m.id === value)?.label ?? value}
</span>
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-green-500/15 text-green-400 border border-green-500/20">
current
@ -1520,13 +1534,38 @@ function ModelDropdown({
}}
>
<span className="block w-full text-left truncate font-mono text-xs" title={detectedModel}>
{detectedModel}
{models.find((m) => m.id === detectedModel)?.label ?? detectedModel}
</span>
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-blue-500/15 text-blue-400 border border-blue-500/20">
detected
</span>
</button>
)}
{detectedModelCandidates
?.filter((candidate) => candidate && candidate !== detectedModel && candidate !== value)
.map((candidate) => {
const entry = models.find((m) => m.id === candidate);
return (
<button
key={`detected-${candidate}`}
type="button"
className={cn(
"flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50",
)}
onClick={() => {
onChange(candidate);
onOpenChange(false);
}}
>
<span className="block w-full text-left truncate font-mono text-xs" title={candidate}>
{entry?.label ?? candidate}
</span>
<span className="shrink-0 ml-auto text-[9px] font-medium px-1.5 py-0.5 rounded-full bg-sky-500/15 text-sky-400 border border-sky-500/20">
config
</span>
</button>
);
})}
<div className="max-h-[240px] overflow-y-auto">
{allowDefault && (
<button
@ -1584,11 +1623,11 @@ function ModelDropdown({
))}
</div>
))}
{filteredModels.length === 0 && !canCreateManualModel && (
{filteredModels.length === 0 && !canCreateManualModel && promotedModelIds.size === 0 && (
<div className="px-2 py-2 space-y-2">
<p className="text-xs text-muted-foreground">
{onDetectModel
? "No Hermes model detected yet. Configure Hermes or enter a provider/model manually."
? (emptyDetectHint ?? "No model detected yet. Enter a provider/model manually.")
: "No models found."}
</p>
</div>

View file

@ -3,6 +3,7 @@ import { Link } from "@/lib/router";
import { AGENT_ROLE_LABELS, type Agent, type AgentRuntimeState } from "@paperclipai/shared";
import { agentsApi } from "../api/agents";
import { useCompany } from "../context/CompanyContext";
import { getAdapterLabel } from "../adapters/adapter-display-registry";
import { queryKeys } from "../lib/queryKeys";
import { StatusBadge } from "./StatusBadge";
import { Identity } from "./Identity";
@ -14,17 +15,6 @@ interface AgentPropertiesProps {
runtimeState?: AgentRuntimeState;
}
const adapterLabels: Record<string, string> = {
claude_local: "Claude (local)",
codex_local: "Codex (local)",
gemini_local: "Gemini CLI (local)",
opencode_local: "OpenCode (local)",
openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)",
process: "Process",
http: "HTTP",
};
const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) {
@ -62,7 +52,7 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) {
</PropertyRow>
)}
<PropertyRow label="Adapter">
<span className="text-sm font-mono">{adapterLabels[agent.adapterType] ?? agent.adapterType}</span>
<span className="text-sm font-mono">{getAdapterLabel(agent.adapterType)}</span>
</PropertyRow>
</div>

View file

@ -0,0 +1,147 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import type { Agent } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CommentThread } from "./CommentThread";
vi.mock("./MarkdownBody", () => ({
MarkdownBody: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}));
vi.mock("./MarkdownEditor", () => ({
MarkdownEditor: ({ value, onChange, placeholder }: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
}) => (
<textarea
aria-label="Comment editor"
value={value}
placeholder={placeholder}
onChange={(event) => onChange(event.target.value)}
/>
),
}));
vi.mock("./InlineEntitySelector", () => ({
InlineEntitySelector: () => null,
}));
vi.mock("@/plugins/slots", () => ({
PluginSlotOutlet: () => null,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("CommentThread", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-11T12:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
container.remove();
});
it("renders historical runs as timeline rows using the finished time", () => {
const root = createRoot(container);
const agent: Agent = {
id: "agent-1",
companyId: "company-1",
name: "CodexCoder",
urlKey: "codexcoder",
role: "engineer",
title: null,
icon: "code",
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "process",
adapterConfig: {},
runtimeConfig: {},
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
pauseReason: null,
pausedAt: null,
permissions: { canCreateAgents: false },
lastHeartbeatAt: null,
metadata: null,
createdAt: new Date("2026-03-11T00:00:00.000Z"),
updatedAt: new Date("2026-03-11T00:00:00.000Z"),
};
act(() => {
root.render(
<MemoryRouter>
<CommentThread
comments={[]}
linkedRuns={[{
runId: "run-12345678abcd",
status: "succeeded",
agentId: "agent-1",
createdAt: "2026-03-11T07:00:00.000Z",
startedAt: "2026-03-11T08:00:00.000Z",
finishedAt: "2026-03-11T10:00:00.000Z",
}]}
agentMap={new Map([["agent-1", agent]])}
onAdd={async () => {}}
/>
</MemoryRouter>,
);
});
const runRow = container.querySelector("#run-run-12345678abcd") as HTMLDivElement | null;
expect(runRow).not.toBeNull();
expect(runRow?.className).toContain("py-1.5");
expect(runRow?.className).toContain("items-center");
expect(runRow?.className).not.toContain("border");
expect(container.textContent).toContain("CodexCoder");
expect(container.textContent).toContain("succeeded");
expect(container.textContent).toContain("2h ago");
expect(container.textContent).not.toContain("4h ago");
const runLink = container.querySelector('a[href="/agents/agent-1/runs/run-12345678abcd"]') as HTMLAnchorElement | null;
expect(runLink?.textContent).toContain("run-1234");
expect(runLink?.className).toContain("rounded-md");
expect(runLink?.className).toContain("px-2");
act(() => {
root.unmount();
});
});
it("replaces the composer with a warning when comments are disabled", () => {
const root = createRoot(container);
act(() => {
root.render(
<MemoryRouter>
<CommentThread
comments={[]}
composerDisabledReason="Workspace is closed."
onAdd={async () => {}}
/>
</MemoryRouter>,
);
});
expect(container.textContent).toContain("Workspace is closed.");
expect(container.querySelector('textarea[aria-label="Comment editor"]')).toBeNull();
expect(container.textContent).not.toContain("Comment");
act(() => {
root.unmount();
});
});
});

View file

@ -8,7 +8,8 @@ import type {
IssueComment,
} from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { Check, Copy, Paperclip } from "lucide-react";
import { ArrowRight, Check, Copy, Paperclip } from "lucide-react";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Identity } from "./Identity";
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
import { MarkdownBody } from "./MarkdownBody";
@ -16,7 +17,10 @@ import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./Ma
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
import { StatusBadge } from "./StatusBadge";
import { AgentIcon } from "./AgentIconPicker";
import { formatDateTime } from "../lib/utils";
import { formatAssigneeUserLabel } from "../lib/assignees";
import type { IssueTimelineAssignee, IssueTimelineEvent } from "../lib/issue-timeline-events";
import { timeAgo } from "../lib/timeAgo";
import { cn, formatDateTime } from "../lib/utils";
import { restoreSubmittedCommentDraft } from "../lib/comment-submit-draft";
import { PluginSlotOutlet } from "@/plugins/slots";
@ -35,6 +39,7 @@ interface LinkedRunItem {
agentId: string;
createdAt: Date | string;
startedAt: Date | string | null;
finishedAt?: Date | string | null;
}
interface CommentReassignment {
@ -49,6 +54,7 @@ interface CommentThreadProps {
feedbackDataSharingPreference?: FeedbackDataSharingPreference;
feedbackTermsUrl?: string | null;
linkedRuns?: LinkedRunItem[];
timelineEvents?: IssueTimelineEvent[];
companyId?: string | null;
projectId?: string | null;
onVote?: (
@ -59,6 +65,7 @@ interface CommentThreadProps {
onAdd: (body: string, reopen?: boolean, reassignment?: CommentReassignment) => Promise<void>;
issueStatus?: string;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
imageUploadHandler?: (file: File) => Promise<string>;
/** Callback to attach an image file to the parent issue (not inline in a comment). */
onAttachImage?: (file: File) => Promise<void>;
@ -71,6 +78,7 @@ interface CommentThreadProps {
mentions?: MentionOption[];
onInterruptQueued?: (runId: string) => Promise<void>;
interruptingQueuedRunId?: string | null;
composerDisabledReason?: string | null;
}
const DRAFT_DEBOUNCE_MS = 800;
@ -118,6 +126,82 @@ function parseReassignment(target: string): CommentReassignment | null {
return null;
}
function humanizeValue(value: string | null): string {
if (!value) return "None";
return value.replace(/_/g, " ");
}
function formatTimelineAssigneeLabel(
assignee: IssueTimelineAssignee,
agentMap?: Map<string, Agent>,
currentUserId?: string | null,
) {
if (assignee.agentId) {
return agentMap?.get(assignee.agentId)?.name ?? assignee.agentId.slice(0, 8);
}
if (assignee.userId) {
return formatAssigneeUserLabel(assignee.userId, currentUserId) ?? "Board";
}
return "Unassigned";
}
function formatTimelineActorName(
actorType: IssueTimelineEvent["actorType"],
actorId: string,
agentMap?: Map<string, Agent>,
currentUserId?: string | null,
) {
if (actorType === "agent") {
return agentMap?.get(actorId)?.name ?? actorId.slice(0, 8);
}
if (actorType === "system") {
return "System";
}
return formatAssigneeUserLabel(actorId, currentUserId) ?? "Board";
}
function initialsForName(name: string) {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
function formatRunStatusLabel(status: string) {
switch (status) {
case "timed_out":
return "timed out";
default:
return status.replace(/_/g, " ");
}
}
function runTimestamp(run: LinkedRunItem) {
return run.finishedAt ?? run.startedAt ?? run.createdAt;
}
function runStatusClass(status: string) {
switch (status) {
case "succeeded":
return "text-green-700 dark:text-green-300";
case "failed":
case "error":
return "text-red-700 dark:text-red-300";
case "timed_out":
return "text-orange-700 dark:text-orange-300";
case "running":
return "text-cyan-700 dark:text-cyan-300";
case "queued":
case "pending":
return "text-amber-700 dark:text-amber-300";
case "cancelled":
return "text-muted-foreground";
default:
return "text-foreground";
}
}
function CopyMarkdownButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
return (
@ -253,10 +337,24 @@ function CommentCard({
sharingPreference={feedbackDataSharingPreference}
termsUrl={feedbackTermsUrl}
onVote={onVote}
rightSlot={comment.runId && !isPending ? (
comment.runAgentId ? (
<Link
to={`/agents/${comment.runAgentId}/runs/${comment.runId}`}
className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors"
>
run {comment.runId.slice(0, 8)}
</Link>
) : (
<span className="inline-flex items-center rounded-md border border-border bg-accent/30 px-2 py-1 text-[10px] font-mono text-muted-foreground">
run {comment.runId.slice(0, 8)}
</span>
)
) : undefined}
/>
) : null}
{comment.runId && !isPending ? (
<div className="mt-2 pt-2 border-t border-border/60">
{comment.runId && !isPending && !(comment.authorAgentId && onVote && !isQueued) ? (
<div className="mt-3 pt-3 border-t border-border/60">
{comment.runAgentId ? (
<Link
to={`/agents/${comment.runAgentId}/runs/${comment.runId}`}
@ -277,11 +375,76 @@ function CommentCard({
type TimelineItem =
| { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta }
| { kind: "event"; id: string; createdAtMs: number; event: IssueTimelineEvent }
| { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem };
function TimelineEventCard({
event,
agentMap,
currentUserId,
}: {
event: IssueTimelineEvent;
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
}) {
const actorName = formatTimelineActorName(event.actorType, event.actorId, agentMap, currentUserId);
return (
<div id={`activity-${event.id}`} className="flex items-start gap-2.5 py-1.5">
<Avatar size="sm" className="mt-0.5">
<AvatarFallback>{initialsForName(actorName)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-1 text-sm">
<span className="font-medium text-foreground">{actorName}</span>
<span className="text-muted-foreground">updated this task</span>
<a
href={`#activity-${event.id}`}
className="text-sm text-muted-foreground transition-colors hover:text-foreground hover:underline"
>
{timeAgo(event.createdAt)}
</a>
</div>
{event.statusChange ? (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="w-14 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Status
</span>
<span className="text-muted-foreground">
{humanizeValue(event.statusChange.from)}
</span>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium text-foreground">
{humanizeValue(event.statusChange.to)}
</span>
</div>
) : null}
{event.assigneeChange ? (
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="w-14 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
Assignee
</span>
<span className="text-muted-foreground">
{formatTimelineAssigneeLabel(event.assigneeChange.from, agentMap, currentUserId)}
</span>
<ArrowRight className="h-3.5 w-3.5 text-muted-foreground" />
<span className="font-medium text-foreground">
{formatTimelineAssigneeLabel(event.assigneeChange.to, agentMap, currentUserId)}
</span>
</div>
) : null}
</div>
</div>
);
}
const TimelineList = memo(function TimelineList({
timeline,
agentMap,
currentUserId,
companyId,
projectId,
feedbackVoteByTargetId,
@ -293,6 +456,7 @@ const TimelineList = memo(function TimelineList({
}: {
timeline: TimelineItem[];
agentMap?: Map<string, Agent>;
currentUserId?: string | null;
companyId?: string | null;
projectId?: string | null;
feedbackVoteByTargetId?: Map<string, FeedbackVoteValue>;
@ -307,36 +471,54 @@ const TimelineList = memo(function TimelineList({
highlightCommentId?: string | null;
}) {
if (timeline.length === 0) {
return <p className="text-sm text-muted-foreground">No comments or runs yet.</p>;
return <p className="text-sm text-muted-foreground">No timeline entries yet.</p>;
}
return (
<div className="space-y-3">
{timeline.map((item) => {
if (item.kind === "event") {
return (
<TimelineEventCard
key={`event:${item.event.id}`}
event={item.event}
agentMap={agentMap}
currentUserId={currentUserId}
/>
);
}
if (item.kind === "run") {
const run = item.run;
const actorName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
return (
<div key={`run:${run.runId}`} className="border border-border bg-accent/20 p-3 overflow-hidden min-w-0 rounded-sm">
<div className="flex items-center justify-between mb-2">
<Link to={`/agents/${run.agentId}`} className="hover:underline">
<Identity
name={agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8)}
size="sm"
/>
</Link>
<span className="text-xs text-muted-foreground">
{formatDateTime(run.startedAt ?? run.createdAt)}
</span>
</div>
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">Run</span>
<Link
to={`/agents/${run.agentId}/runs/${run.runId}`}
className="inline-flex items-center rounded-md border border-border bg-accent/40 px-2 py-1 font-mono text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors"
>
{run.runId.slice(0, 8)}
</Link>
<StatusBadge status={run.status} />
<div id={`run-${run.runId}`} key={`run:${run.runId}`} className="flex items-center gap-2.5 py-1.5">
<Avatar size="sm">
<AvatarFallback>{initialsForName(actorName)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm">
<Link to={`/agents/${run.agentId}`} className="font-medium text-foreground transition-colors hover:underline">
{actorName}
</Link>
<span className="text-muted-foreground">run</span>
<Link
to={`/agents/${run.agentId}/runs/${run.runId}`}
className="inline-flex items-center rounded-md border border-border bg-accent/40 px-2 py-1 font-mono text-xs text-muted-foreground transition-colors hover:bg-accent/60 hover:text-foreground"
>
{run.runId.slice(0, 8)}
</Link>
<span className={cn("font-medium", runStatusClass(run.status))}>
{formatRunStatusLabel(run.status)}
</span>
<a
href={`#run-${run.runId}`}
className="text-sm text-muted-foreground transition-colors hover:text-foreground hover:underline"
>
{timeAgo(runTimestamp(run))}
</a>
</div>
</div>
</div>
);
@ -370,11 +552,13 @@ export function CommentThread({
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
linkedRuns = [],
timelineEvents = [],
companyId,
projectId,
onVote,
onAdd,
agentMap,
currentUserId,
imageUploadHandler,
onAttachImage,
draftKey,
@ -386,6 +570,7 @@ export function CommentThread({
mentions: providedMentions,
onInterruptQueued,
interruptingQueuedRunId = null,
composerDisabledReason = null,
}: CommentThreadProps) {
const [body, setBody] = useState("");
const [reopen, setReopen] = useState(true);
@ -408,18 +593,29 @@ export function CommentThread({
createdAtMs: new Date(comment.createdAt).getTime(),
comment,
}));
const eventItems: TimelineItem[] = timelineEvents.map((event) => ({
kind: "event",
id: event.id,
createdAtMs: new Date(event.createdAt).getTime(),
event,
}));
const runItems: TimelineItem[] = linkedRuns.map((run) => ({
kind: "run",
id: run.runId,
createdAtMs: new Date(run.startedAt ?? run.createdAt).getTime(),
createdAtMs: new Date(runTimestamp(run)).getTime(),
run,
}));
return [...commentItems, ...runItems].sort((a, b) => {
return [...commentItems, ...eventItems, ...runItems].sort((a, b) => {
if (a.createdAtMs !== b.createdAtMs) return a.createdAtMs - b.createdAtMs;
if (a.kind === b.kind) return a.id.localeCompare(b.id);
return a.kind === "comment" ? -1 : 1;
const kindOrder = {
event: 0,
comment: 1,
run: 2,
} as const;
return kindOrder[a.kind] - kindOrder[b.kind];
});
}, [comments, linkedRuns]);
}, [comments, timelineEvents, linkedRuns]);
const feedbackVoteByTargetId = useMemo(() => {
const map = new Map<string, FeedbackVoteValue>();
@ -496,7 +692,6 @@ export function CommentThread({
setSubmitting(true);
setBody("");
try {
// TODO: wire an explicit "send + interrupt" action through the composer if we expose it in the UI.
await onAdd(submittedBody, reopen ? true : undefined, reassignment ?? undefined);
if (draftKey) clearDraft(draftKey);
setReopen(true);
@ -551,11 +746,12 @@ export function CommentThread({
return (
<div className="space-y-4">
<h3 className="text-sm font-semibold">Comments &amp; Runs ({timeline.length + queuedComments.length})</h3>
<h3 className="text-sm font-semibold">Timeline ({timeline.length + queuedComments.length})</h3>
<TimelineList
timeline={timeline}
agentMap={agentMap}
currentUserId={currentUserId}
companyId={companyId}
projectId={projectId}
feedbackVoteByTargetId={feedbackVoteByTargetId}
@ -602,90 +798,96 @@ export function CommentThread({
</div>
)}
<div className="space-y-2">
<MarkdownEditor
ref={editorRef}
value={body}
onChange={setBody}
placeholder="Leave a comment..."
mentions={mentions}
onSubmit={handleSubmit}
imageUploadHandler={imageUploadHandler}
contentClassName="min-h-[60px] text-sm"
/>
<div className="flex items-center justify-end gap-3">
{(imageUploadHandler || onAttachImage) && (
<div className="mr-auto flex items-center gap-3">
<input
ref={attachInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={handleAttachFile}
/>
<Button
variant="ghost"
size="icon-sm"
onClick={() => attachInputRef.current?.click()}
disabled={attaching}
title="Attach image"
>
<Paperclip className="h-4 w-4" />
</Button>
</div>
)}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={reopen}
onChange={(e) => setReopen(e.target.checked)}
className="rounded border-border"
/>
Re-open
</label>
{enableReassign && reassignOptions.length > 0 && (
<InlineEntitySelector
value={reassignTarget}
options={reassignOptions}
placeholder="Assignee"
noneLabel="No assignee"
searchPlaceholder="Search assignees..."
emptyMessage="No assignees found."
onChange={setReassignTarget}
className="text-xs h-8"
renderTriggerValue={(option) => {
if (!option) return <span className="text-muted-foreground">Assignee</span>;
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
const agent = agentId ? agentMap?.get(agentId) : null;
return (
<>
{agent ? (
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : null}
<span className="truncate">{option.label}</span>
</>
);
}}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
const agent = agentId ? agentMap?.get(agentId) : null;
return (
<>
{agent ? (
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : null}
<span className="truncate">{option.label}</span>
</>
);
}}
/>
)}
<Button size="sm" disabled={!canSubmit} onClick={handleSubmit}>
{submitting ? "Posting..." : "Comment"}
</Button>
{composerDisabledReason ? (
<div className="rounded-md border border-amber-300/70 bg-amber-50/80 px-3 py-2 text-sm text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-100">
{composerDisabledReason}
</div>
</div>
) : (
<div className="space-y-2">
<MarkdownEditor
ref={editorRef}
value={body}
onChange={setBody}
placeholder="Leave a comment..."
mentions={mentions}
onSubmit={handleSubmit}
imageUploadHandler={imageUploadHandler}
contentClassName="min-h-[60px] text-sm"
/>
<div className="flex items-center justify-end gap-3">
{(imageUploadHandler || onAttachImage) && (
<div className="mr-auto flex items-center gap-3">
<input
ref={attachInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={handleAttachFile}
/>
<Button
variant="ghost"
size="icon-sm"
onClick={() => attachInputRef.current?.click()}
disabled={attaching}
title="Attach image"
>
<Paperclip className="h-4 w-4" />
</Button>
</div>
)}
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={reopen}
onChange={(e) => setReopen(e.target.checked)}
className="rounded border-border"
/>
Re-open
</label>
{enableReassign && reassignOptions.length > 0 && (
<InlineEntitySelector
value={reassignTarget}
options={reassignOptions}
placeholder="Assignee"
noneLabel="No assignee"
searchPlaceholder="Search assignees..."
emptyMessage="No assignees found."
onChange={setReassignTarget}
className="text-xs h-8"
renderTriggerValue={(option) => {
if (!option) return <span className="text-muted-foreground">Assignee</span>;
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
const agent = agentId ? agentMap?.get(agentId) : null;
return (
<>
{agent ? (
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : null}
<span className="truncate">{option.label}</span>
</>
);
}}
renderOption={(option) => {
if (!option.id) return <span className="truncate">{option.label}</span>;
const agentId = option.id.startsWith("agent:") ? option.id.slice("agent:".length) : null;
const agent = agentId ? agentMap?.get(agentId) : null;
return (
<>
{agent ? (
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : null}
<span className="truncate">{option.label}</span>
</>
);
}}
/>
)}
<Button size="sm" disabled={!canSubmit} onClick={handleSubmit}>
{submitting ? "Posting..." : "Comment"}
</Button>
</div>
</div>
)}
</div>
);

View file

@ -88,27 +88,27 @@ export function ExecutionWorkspaceCloseDialog({
<Dialog open={open} onOpenChange={(nextOpen) => {
if (!closeWorkspace.isPending) onOpenChange(nextOpen);
}}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogContent className="max-h-[85vh] overflow-x-hidden overflow-y-auto p-4 sm:max-w-2xl sm:p-6 [&>*]:min-w-0">
<DialogHeader>
<DialogTitle>{actionLabel}</DialogTitle>
<DialogDescription className="break-words">
<DialogDescription className="break-words text-xs sm:text-sm">
Archive <span className="font-medium text-foreground">{workspaceName}</span> and clean up any owned workspace
artifacts. Paperclip keeps the workspace record and issue history, but removes it from active workspace views.
</DialogDescription>
</DialogHeader>
{readinessQuery.isLoading ? (
<div className="flex items-center gap-2 rounded-xl border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<div className="flex items-center gap-2 rounded-xl border border-border bg-muted/30 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
Checking whether this workspace is safe to close...
</div>
) : readinessQuery.error ? (
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
<div className="rounded-xl border border-destructive/30 bg-destructive/5 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-destructive">
{readinessQuery.error instanceof Error ? readinessQuery.error.message : "Failed to inspect workspace close readiness."}
</div>
) : readiness ? (
<div className="space-y-4">
<div className={`rounded-xl border px-4 py-3 text-sm ${readinessTone(readiness.state)}`}>
<div className="min-w-0 space-y-3 sm:space-y-4">
<div className={`rounded-xl border px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm ${readinessTone(readiness.state)}`}>
<div className="font-medium">
{readiness.state === "blocked"
? "Close is blocked"
@ -129,10 +129,10 @@ export function ExecutionWorkspaceCloseDialog({
{blockingIssues.length > 0 ? (
<section className="space-y-2">
<h3 className="text-sm font-medium">Blocking issues</h3>
<div className="space-y-2">
<h3 className="text-xs font-medium sm:text-sm">Blocking issues</h3>
<div className="space-y-1.5 sm:space-y-2">
{blockingIssues.map((issue) => (
<div key={issue.id} className="rounded-xl border border-destructive/20 bg-destructive/5 px-4 py-3 text-sm">
<div key={issue.id} className="rounded-xl border border-destructive/20 bg-destructive/5 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
{issue.identifier ?? issue.id} · {issue.title}
@ -147,10 +147,10 @@ export function ExecutionWorkspaceCloseDialog({
{readiness.blockingReasons.length > 0 ? (
<section className="space-y-2">
<h3 className="text-sm font-medium">Blocking reasons</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
<h3 className="text-xs font-medium sm:text-sm">Blocking reasons</h3>
<ul className="space-y-1.5 text-xs sm:space-y-2 sm:text-sm text-muted-foreground">
{readiness.blockingReasons.map((reason, idx) => (
<li key={`blocking-${idx}`} className="break-words rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2 text-destructive">
<li key={`blocking-${idx}`} className="break-words rounded-lg border border-destructive/20 bg-destructive/5 px-2.5 py-1.5 sm:px-3 sm:py-2 text-destructive">
{reason}
</li>
))}
@ -160,10 +160,10 @@ export function ExecutionWorkspaceCloseDialog({
{readiness.warnings.length > 0 ? (
<section className="space-y-2">
<h3 className="text-sm font-medium">Warnings</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
<h3 className="text-xs font-medium sm:text-sm">Warnings</h3>
<ul className="space-y-1.5 text-xs sm:space-y-2 sm:text-sm text-muted-foreground">
{readiness.warnings.map((warning, idx) => (
<li key={`warning-${idx}`} className="break-words rounded-lg border border-amber-500/20 bg-amber-500/5 px-3 py-2">
<li key={`warning-${idx}`} className="break-words rounded-lg border border-amber-500/20 bg-amber-500/5 px-2.5 py-1.5 sm:px-3 sm:py-2">
{warning}
</li>
))}
@ -173,16 +173,16 @@ export function ExecutionWorkspaceCloseDialog({
{readiness.git ? (
<section className="space-y-2">
<h3 className="text-sm font-medium">Git status</h3>
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
<div className="grid gap-2 sm:grid-cols-2">
<div>
<h3 className="text-xs font-medium sm:text-sm">Git status</h3>
<div className="overflow-hidden rounded-xl border border-border bg-muted/20 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm">
<div className="grid grid-cols-2 gap-2">
<div className="min-w-0">
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Branch</div>
<div className="font-mono text-xs">{readiness.git.branchName ?? "Unknown"}</div>
<div className="truncate font-mono text-xs">{readiness.git.branchName ?? "Unknown"}</div>
</div>
<div>
<div className="min-w-0">
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Base ref</div>
<div className="font-mono text-xs">{readiness.git.baseRef ?? "Not set"}</div>
<div className="truncate font-mono text-xs">{readiness.git.baseRef ?? "Not set"}</div>
</div>
<div>
<div className="text-xs uppercase tracking-[0.16em] text-muted-foreground">Merged into base</div>
@ -209,10 +209,10 @@ export function ExecutionWorkspaceCloseDialog({
{otherLinkedIssues.length > 0 ? (
<section className="space-y-2">
<h3 className="text-sm font-medium">Other linked issues</h3>
<div className="space-y-2">
<h3 className="text-xs font-medium sm:text-sm">Other linked issues</h3>
<div className="space-y-1.5 sm:space-y-2">
{otherLinkedIssues.map((issue) => (
<div key={issue.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
<div key={issue.id} className="rounded-xl border border-border bg-muted/20 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<Link to={issueUrl(issue)} className="min-w-0 break-words font-medium hover:underline">
{issue.identifier ?? issue.id} · {issue.title}
@ -227,10 +227,10 @@ export function ExecutionWorkspaceCloseDialog({
{readiness.runtimeServices.length > 0 ? (
<section className="space-y-2">
<h3 className="text-sm font-medium">Attached runtime services</h3>
<div className="space-y-2">
<h3 className="text-xs font-medium sm:text-sm">Attached runtime services</h3>
<div className="space-y-1.5 sm:space-y-2">
{readiness.runtimeServices.map((service) => (
<div key={service.id} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
<div key={service.id} className="rounded-xl border border-border bg-muted/20 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
<span className="font-medium">{service.serviceName}</span>
<span className="text-xs text-muted-foreground">{service.status} · {service.lifecycle}</span>
@ -245,10 +245,10 @@ export function ExecutionWorkspaceCloseDialog({
) : null}
<section className="space-y-2">
<h3 className="text-sm font-medium">Cleanup actions</h3>
<div className="space-y-2">
<h3 className="text-xs font-medium sm:text-sm">Cleanup actions</h3>
<div className="space-y-1.5 sm:space-y-2">
{readiness.plannedActions.map((action, index) => (
<div key={`${action.kind}-${index}`} className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm">
<div key={`${action.kind}-${index}`} className="rounded-xl border border-border bg-muted/20 px-3 py-2 text-xs sm:px-4 sm:py-3 sm:text-sm">
<div className="font-medium">{action.label}</div>
<div className="mt-1 break-words text-muted-foreground">{action.description}</div>
{action.command ? (
@ -262,20 +262,20 @@ export function ExecutionWorkspaceCloseDialog({
</section>
{currentStatus === "cleanup_failed" ? (
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-4 py-3 text-sm text-muted-foreground">
<div className="rounded-xl border border-amber-500/20 bg-amber-500/5 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-muted-foreground">
Cleanup previously failed on this workspace. Retrying close will rerun the cleanup flow and update the
workspace status if it succeeds.
</div>
) : null}
{currentStatus === "archived" ? (
<div className="rounded-xl border border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
<div className="rounded-xl border border-border bg-muted/20 px-3 py-2.5 text-xs sm:px-4 sm:py-3 sm:text-sm text-muted-foreground">
This workspace is already archived.
</div>
) : null}
{readiness.git?.repoRoot ? (
<div className="break-words text-xs text-muted-foreground">
<div className="overflow-hidden break-words text-xs text-muted-foreground">
Repo root: <span className="font-mono break-all">{readiness.git.repoRoot}</span>
{readiness.git.workspacePath ? (
<>

View file

@ -0,0 +1,151 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { ChevronLeft, ChevronRight, Download, X } from "lucide-react";
import type { IssueAttachment } from "@paperclipai/shared";
interface ImageGalleryModalProps {
images: IssueAttachment[];
initialIndex: number;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function ImageGalleryModal({
images,
initialIndex,
open,
onOpenChange,
}: ImageGalleryModalProps) {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const imageRef = useRef<HTMLImageElement>(null);
useEffect(() => {
if (open) setCurrentIndex(initialIndex);
}, [open, initialIndex]);
const goNext = useCallback(() => {
setCurrentIndex((i) => (i + 1) % images.length);
}, [images.length]);
const goPrev = useCallback(() => {
setCurrentIndex((i) => (i - 1 + images.length) % images.length);
}, [images.length]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "ArrowRight") goNext();
else if (e.key === "ArrowLeft") goPrev();
else if (e.key === "Escape") onOpenChange(false);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, goNext, goPrev, onOpenChange]);
/** Close when clicking empty curtain space (not interactive elements or the image) */
const handleBackdropClick = useCallback(
(e: React.MouseEvent) => {
const target = e.target as HTMLElement;
if (
target.closest("button") ||
target.closest("a") ||
target === imageRef.current
)
return;
onOpenChange(false);
},
[onOpenChange],
);
if (images.length === 0) return null;
const current = images[currentIndex];
if (!current) return null;
return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
{/* Full-screen curtain */}
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/90 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 duration-200" />
<DialogPrimitive.Content
className="fixed inset-0 z-50 flex flex-col outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 duration-200"
onClick={handleBackdropClick}
>
{/* Top bar */}
<div className="flex items-center justify-between px-5 py-3 text-white/80 text-sm shrink-0">
<span className="truncate max-w-[50%] font-medium" title={current.originalFilename ?? undefined}>
{current.originalFilename ?? "Image"}
</span>
<div className="flex items-center gap-4">
<span className="text-white/40 tabular-nums text-xs">
{currentIndex + 1} / {images.length}
</span>
<a
href={current.contentPath}
download={current.originalFilename ?? "image"}
className="text-white/50 hover:text-white transition-colors"
title="Download"
onClick={(e) => e.stopPropagation()}
>
<Download className="h-4.5 w-4.5" />
</a>
<button
type="button"
onClick={() => onOpenChange(false)}
className="text-white/50 hover:text-white transition-colors"
title="Close"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
{/* Main area: nav buttons outside image */}
<div className="flex-1 flex items-center min-h-0">
{/* Left nav zone */}
<div className="w-16 md:w-24 shrink-0 flex items-center justify-center h-full">
{images.length > 1 && (
<button
type="button"
onClick={goPrev}
className="rounded-full bg-white/10 p-3 text-white/60 hover:text-white hover:bg-white/20 transition-colors"
title="Previous"
>
<ChevronLeft className="h-7 w-7" />
</button>
)}
</div>
{/* Image */}
<div className="flex-1 flex items-center justify-center min-w-0 min-h-0 h-full px-2">
<img
ref={imageRef}
src={current.contentPath}
alt={current.originalFilename ?? "attachment"}
className="max-w-full max-h-full object-contain select-none rounded-lg"
draggable={false}
/>
</div>
{/* Right nav zone */}
<div className="w-16 md:w-24 shrink-0 flex items-center justify-center h-full">
{images.length > 1 && (
<button
type="button"
onClick={goNext}
className="rounded-full bg-white/10 p-3 text-white/60 hover:text-white hover:bg-white/20 transition-colors"
title="Next"
>
<ChevronRight className="h-7 w-7" />
</button>
)}
</div>
</div>
{/* Bottom padding for balance */}
<div className="h-6 shrink-0" />
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
);
}

View file

@ -0,0 +1,84 @@
// @vitest-environment jsdom
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { queueContainedBlurCommit } from "./InlineEditor";
vi.mock("./MarkdownEditor", () => ({
MarkdownEditor: () => null,
}));
vi.mock("../hooks/useAutosaveIndicator", () => ({
useAutosaveIndicator: () => ({
state: "idle",
markDirty: () => {},
reset: () => {},
runSave: async (save: () => Promise<void>) => {
await save();
},
}),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
describe("queueContainedBlurCommit", () => {
let container: HTMLDivElement;
let inside: HTMLTextAreaElement;
let outside: HTMLButtonElement;
let originalRequestAnimationFrame: typeof window.requestAnimationFrame;
let originalCancelAnimationFrame: typeof window.cancelAnimationFrame;
beforeEach(() => {
vi.useFakeTimers();
originalRequestAnimationFrame = window.requestAnimationFrame;
originalCancelAnimationFrame = window.cancelAnimationFrame;
window.requestAnimationFrame = ((callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)) as typeof window.requestAnimationFrame;
window.cancelAnimationFrame = ((id: number) => window.clearTimeout(id)) as typeof window.cancelAnimationFrame;
container = document.createElement("div");
inside = document.createElement("textarea");
outside = document.createElement("button");
container.appendChild(inside);
document.body.append(container, outside);
});
afterEach(() => {
window.requestAnimationFrame = originalRequestAnimationFrame;
window.cancelAnimationFrame = originalCancelAnimationFrame;
container.remove();
outside.remove();
vi.useRealTimers();
});
async function flushFrames() {
await act(async () => {
vi.runAllTimers();
await Promise.resolve();
});
}
it("commits when focus stays outside the editor container", async () => {
const onCommit = vi.fn();
const cancel = queueContainedBlurCommit(container, onCommit);
outside.focus();
await flushFrames();
expect(onCommit).toHaveBeenCalledTimes(1);
cancel();
});
it("skips the commit when focus returns inside before the delayed check completes", async () => {
const onCommit = vi.fn();
const cancel = queueContainedBlurCommit(container, onCommit);
outside.focus();
inside.focus();
await flushFrames();
expect(onCommit).not.toHaveBeenCalled();
cancel();
});
});

View file

@ -19,6 +19,23 @@ const pad = "px-1 -mx-1";
const markdownPad = "px-1";
const AUTOSAVE_DEBOUNCE_MS = 900;
export function queueContainedBlurCommit(container: HTMLDivElement, onCommit: () => void) {
let frameId = requestAnimationFrame(() => {
frameId = requestAnimationFrame(() => {
frameId = 0;
const active = document.activeElement;
if (active instanceof Node && container.contains(active)) return;
onCommit();
});
});
return () => {
if (frameId === 0) return;
cancelAnimationFrame(frameId);
frameId = 0;
};
}
export function InlineEditor({
value,
onSave,
@ -35,6 +52,7 @@ export function InlineEditor({
const inputRef = useRef<HTMLTextAreaElement>(null);
const markdownRef = useRef<MarkdownEditorRef>(null);
const autosaveDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const blurCommitFrameRef = useRef<(() => void) | null>(null);
const {
state: autosaveState,
markDirty,
@ -52,6 +70,10 @@ export function InlineEditor({
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
}
if (blurCommitFrameRef.current !== null) {
blurCommitFrameRef.current();
blurCommitFrameRef.current = null;
}
};
}, []);
@ -91,6 +113,30 @@ export function InlineEditor({
}
}, [draft, multiline, onSave, value]);
const cancelPendingBlurCommit = useCallback(() => {
if (blurCommitFrameRef.current === null) return;
blurCommitFrameRef.current();
blurCommitFrameRef.current = null;
}, []);
const scheduleBlurCommit = useCallback((container: HTMLDivElement) => {
cancelPendingBlurCommit();
blurCommitFrameRef.current = queueContainedBlurCommit(container, () => {
blurCommitFrameRef.current = null;
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
}
setMultilineFocused(false);
const trimmed = draft.trim();
if (!trimmed || trimmed === value) {
reset();
void commit();
return;
}
void runSave(() => commit());
});
}, [cancelPendingBlurCommit, commit, draft, reset, runSave, value]);
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && !multiline) {
e.preventDefault();
@ -146,20 +192,13 @@ export function InlineEditor({
"rounded transition-colors",
multilineFocused ? "bg-transparent" : "hover:bg-accent/20",
)}
onFocusCapture={() => setMultilineFocused(true)}
onFocusCapture={() => {
cancelPendingBlurCommit();
setMultilineFocused(true);
}}
onBlurCapture={(event) => {
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
if (autosaveDebounceRef.current) {
clearTimeout(autosaveDebounceRef.current);
}
setMultilineFocused(false);
const trimmed = draft.trim();
if (!trimmed || trimmed === value) {
reset();
void commit();
return;
}
void runSave(() => commit());
scheduleBlurCommit(event.currentTarget);
}}
onKeyDown={handleKeyDown}
>

View file

@ -1,5 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { Clock3, FlaskConical, Puzzle, Settings, SlidersHorizontal } from "lucide-react";
import { Clock3, Cpu, FlaskConical, Puzzle, Settings, SlidersHorizontal } from "lucide-react";
import { NavLink } from "@/lib/router";
import { pluginsApi } from "@/api/plugins";
import { queryKeys } from "@/lib/queryKeys";
@ -26,6 +26,7 @@ export function InstanceSidebar() {
<SidebarNavItem to="/instance/settings/heartbeats" label="Heartbeats" icon={Clock3} end />
<SidebarNavItem to="/instance/settings/experimental" label="Experimental" icon={FlaskConical} />
<SidebarNavItem to="/instance/settings/plugins" label="Plugins" icon={Puzzle} />
<SidebarNavItem to="/instance/settings/adapters" label="Adapters" icon={Cpu} />
{(plugins ?? []).length > 0 ? (
<div className="ml-4 mt-1 flex flex-col gap-0.5 border-l border-border/70 pl-3">
{(plugins ?? []).map((plugin) => (

View file

@ -0,0 +1,354 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ComponentProps } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DocumentRevision, Issue, IssueDocument } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { IssueDocumentsSection } from "./IssueDocumentsSection";
import { queryKeys } from "../lib/queryKeys";
const mockIssuesApi = vi.hoisted(() => ({
listDocuments: vi.fn(),
listDocumentRevisions: vi.fn(),
restoreDocumentRevision: vi.fn(),
upsertDocument: vi.fn(),
deleteDocument: vi.fn(),
getDocument: vi.fn(),
}));
const markdownEditorMockState = vi.hoisted(() => ({
emitMountEmptyChange: false,
}));
vi.mock("../api/issues", () => ({
issuesApi: mockIssuesApi,
}));
vi.mock("../hooks/useAutosaveIndicator", () => ({
useAutosaveIndicator: () => ({
state: "idle",
markDirty: vi.fn(),
reset: vi.fn(),
runSave: async (save: () => Promise<unknown>) => save(),
}),
}));
vi.mock("@/lib/router", () => ({
useLocation: () => ({ hash: "" }),
}));
vi.mock("./MarkdownBody", () => ({
MarkdownBody: ({ children, className }: { children: string; className?: string }) => (
<div className={className}>{children}</div>
),
}));
vi.mock("./MarkdownEditor", async () => {
const React = await import("react");
return {
MarkdownEditor: ({ value, onChange, placeholder, contentClassName }: {
value: string;
onChange?: (value: string) => void;
placeholder?: string;
contentClassName?: string;
}) => {
React.useEffect(() => {
if (!markdownEditorMockState.emitMountEmptyChange) return;
onChange?.("");
}, []);
return (
<div className={contentClassName} data-testid="markdown-editor">
{value || placeholder || ""}
</div>
);
},
};
});
vi.mock("@/components/ui/button", () => ({
Button: ({ children, onClick, type = "button", ...props }: ComponentProps<"button">) => (
<button type={type} onClick={onClick} {...props}>{children}</button>
),
}));
vi.mock("@/components/ui/input", () => ({
Input: (props: ComponentProps<"input">) => <input {...props} />,
}));
vi.mock("@/components/ui/dropdown-menu", async () => {
return {
DropdownMenu: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuItem: ({ children, onClick, onSelect, disabled }: {
children: React.ReactNode;
onClick?: () => void;
onSelect?: () => void;
disabled?: boolean;
}) => (
<button
type="button"
disabled={disabled}
onClick={() => {
onSelect?.();
onClick?.();
}}
>
{children}
</button>
),
DropdownMenuLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuRadioGroup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DropdownMenuRadioItem: ({ children, onSelect, disabled }: {
children: React.ReactNode;
onSelect?: () => void;
disabled?: boolean;
}) => (
<button type="button" disabled={disabled} onClick={() => onSelect?.()}>
{children}
</button>
),
DropdownMenuSeparator: () => <hr />,
};
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((res) => {
resolve = res;
});
return { promise, resolve };
}
async function flush() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
function createIssueDocument(overrides: Partial<IssueDocument> = {}): IssueDocument {
return {
id: "document-1",
companyId: "company-1",
issueId: "issue-1",
key: "plan",
title: "Plan",
format: "markdown",
body: "",
latestRevisionId: "revision-4",
latestRevisionNumber: 4,
createdByAgentId: null,
createdByUserId: "user-1",
updatedByAgentId: null,
updatedByUserId: "user-1",
createdAt: new Date("2026-03-31T12:00:00.000Z"),
updatedAt: new Date("2026-03-31T12:05:00.000Z"),
...overrides,
};
}
function createRevision(overrides: Partial<DocumentRevision> = {}): DocumentRevision {
return {
id: "revision-3",
companyId: "company-1",
documentId: "document-1",
issueId: "issue-1",
key: "plan",
revisionNumber: 3,
title: "Plan",
format: "markdown",
body: "Restored plan body",
changeSummary: null,
createdByAgentId: null,
createdByUserId: "user-1",
createdAt: new Date("2026-03-31T11:00:00.000Z"),
...overrides,
};
}
function createIssue(): Issue {
return {
id: "issue-1",
identifier: "PAP-807",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Plan rendering",
description: null,
status: "in_progress",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: "user-1",
issueNumber: 807,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
labels: [],
labelIds: [],
planDocument: createIssueDocument(),
documentSummaries: [createIssueDocument()],
legacyPlanDocument: null,
createdAt: new Date("2026-03-31T12:00:00.000Z"),
updatedAt: new Date("2026-03-31T12:05:00.000Z"),
};
}
describe("IssueDocumentsSection", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
window.localStorage.clear();
vi.clearAllMocks();
markdownEditorMockState.emitMountEmptyChange = false;
});
afterEach(() => {
container.remove();
});
it("shows the restored document body immediately after a revision restore", async () => {
const blankLatestDocument = createIssueDocument({
body: "",
latestRevisionId: "revision-4",
latestRevisionNumber: 4,
});
const restoredDocument = createIssueDocument({
body: "Restored plan body",
latestRevisionId: "revision-5",
latestRevisionNumber: 5,
updatedAt: new Date("2026-03-31T12:06:00.000Z"),
});
const pendingDocuments = deferred<IssueDocument[]>();
const issue = createIssue();
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
});
mockIssuesApi.listDocuments
.mockResolvedValueOnce([blankLatestDocument])
.mockImplementation(() => pendingDocuments.promise);
mockIssuesApi.restoreDocumentRevision.mockResolvedValue(restoredDocument);
queryClient.setQueryData(
queryKeys.issues.documentRevisions(issue.id, "plan"),
[
createRevision({ id: "revision-4", revisionNumber: 4, body: "", createdAt: new Date("2026-03-31T12:05:00.000Z") }),
createRevision(),
],
);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} />
</QueryClientProvider>,
);
});
await flush();
await flush();
expect(container.textContent).not.toContain("Restored plan body");
const revisionButtons = Array.from(container.querySelectorAll("button"));
const historicalRevisionButton = revisionButtons.find((button) => button.textContent?.includes("rev 3"));
expect(historicalRevisionButton).toBeTruthy();
await act(async () => {
historicalRevisionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(container.textContent).toContain("Viewing revision 3");
expect(container.textContent).toContain("Restored plan body");
const restoreButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Restore this revision"));
expect(restoreButton).toBeTruthy();
await act(async () => {
restoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
expect(mockIssuesApi.restoreDocumentRevision).toHaveBeenCalledWith("issue-1", "plan", "revision-3");
expect(container.textContent).toContain("Restored plan body");
expect(container.textContent).not.toContain("Viewing revision 3");
pendingDocuments.resolve([restoredDocument]);
await flush();
await act(async () => {
root.unmount();
});
queryClient.clear();
});
it("ignores mount-time editor change noise before a document is actively being edited", async () => {
markdownEditorMockState.emitMountEmptyChange = true;
const document = createIssueDocument({
body: "Loaded plan body",
});
const issue = createIssue();
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
});
mockIssuesApi.listDocuments.mockResolvedValue([document]);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<IssueDocumentsSection issue={issue} canDeleteDocuments={false} />
</QueryClientProvider>,
);
});
await flush();
await flush();
expect(container.textContent).toContain("Loaded plan body");
expect(container.textContent).not.toContain("Markdown body");
await act(async () => {
root.unmount();
});
queryClient.clear();
});
});

View file

@ -29,7 +29,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Check, ChevronDown, ChevronRight, Copy, Download, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
import { Check, ChevronDown, ChevronRight, Copy, Download, FilePenLine, FileText, MoreHorizontal, Plus, Trash2, X } from "lucide-react";
type DraftState = {
key: string;
@ -106,6 +106,25 @@ function documentHasUnsavedChanges(doc: IssueDocument, draft: DraftState | null)
return draft.body !== doc.body || (doc.title ?? "") !== draft.title;
}
function toDocumentSummary(document: IssueDocument) {
return {
id: document.id,
companyId: document.companyId,
issueId: document.issueId,
key: document.key,
title: document.title,
format: document.format,
latestRevisionId: document.latestRevisionId,
latestRevisionNumber: document.latestRevisionNumber,
createdByAgentId: document.createdByAgentId,
createdByUserId: document.createdByUserId,
updatedByAgentId: document.updatedByAgentId,
updatedByUserId: document.updatedByUserId,
createdAt: document.createdAt,
updatedAt: document.updatedAt,
};
}
export function IssueDocumentsSection({
issue,
canDeleteDocuments,
@ -181,6 +200,36 @@ export function IssueDocumentsSection({
});
}, [issue.id, queryClient]);
const syncDocumentCaches = useCallback((document: IssueDocument) => {
queryClient.setQueryData<IssueDocument[] | undefined>(
queryKeys.issues.documents(issue.id),
(current) => {
if (!current) return [document];
const existingIndex = current.findIndex((entry) => entry.key === document.key);
if (existingIndex === -1) return [...current, document];
return current.map((entry, index) => index === existingIndex ? document : entry);
},
);
queryClient.setQueryData<Issue | undefined>(
queryKeys.issues.detail(issue.id),
(current) => {
if (!current) return current;
const nextSummaries = (() => {
const summary = toDocumentSummary(document);
const existingIndex = (current.documentSummaries ?? []).findIndex((entry) => entry.key === document.key);
if (existingIndex === -1) return [...(current.documentSummaries ?? []), summary];
return (current.documentSummaries ?? []).map((entry, index) => index === existingIndex ? summary : entry);
})();
return {
...current,
planDocument: document.key === "plan" ? document : current.planDocument ?? null,
documentSummaries: nextSummaries,
legacyPlanDocument: document.key === "plan" ? null : current.legacyPlanDocument ?? null,
};
},
);
}, [issue.id, queryClient]);
const upsertDocument = useMutation({
mutationFn: async (nextDraft: DraftState) =>
issuesApi.upsertDocument(issue.id, nextDraft.key, {
@ -206,7 +255,8 @@ export function IssueDocumentsSection({
const restoreDocumentRevision = useMutation({
mutationFn: ({ key, revisionId }: { key: string; revisionId: string }) =>
issuesApi.restoreDocumentRevision(issue.id, key, revisionId),
onSuccess: (_document, variables) => {
onSuccess: (document, variables) => {
syncDocumentCaches(document);
setSelectedRevisionIds((current) => ({ ...current, [variables.key]: null }));
setDraft((current) => current?.key === variables.key ? null : current);
setDocumentConflict((current) => current?.key === variables.key ? null : current);
@ -369,6 +419,7 @@ export function IssueDocumentsSection({
isNew: false,
};
});
syncDocumentCaches(saved);
invalidateIssueDocuments();
};
@ -408,7 +459,7 @@ export function IssueDocumentsSection({
setError(err instanceof Error ? err.message : "Failed to save document");
return false;
}
}, [documentConflict, invalidateIssueDocuments, issue.id, resetAutosaveState, runSave, sortedDocuments, upsertDocument]);
}, [documentConflict, invalidateIssueDocuments, issue.id, resetAutosaveState, runSave, sortedDocuments, syncDocumentCaches, upsertDocument]);
const reloadDocumentFromServer = useCallback((key: string) => {
if (documentConflict?.key !== key) return;
@ -864,7 +915,14 @@ export function IssueDocumentsSection({
<MoreHorizontal className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align="end">
{!isHistoricalPreview ? (
<DropdownMenuItem onClick={() => beginEdit(doc.key)}>
<FilePenLine className="h-3.5 w-3.5" />
Edit document
</DropdownMenuItem>
) : null}
{!isHistoricalPreview ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
onClick={() => downloadDocumentFile(doc.key, displayedBody)}
>
@ -889,13 +947,6 @@ export function IssueDocumentsSection({
{!isFolded ? (
<div
className="mt-3 space-y-3"
onFocusCapture={!isHistoricalPreview
? () => {
if (!activeDraft) {
beginEdit(doc.key);
}
}
: undefined}
onBlurCapture={!isHistoricalPreview
? async (event) => {
if (activeDraft) {
@ -1026,7 +1077,7 @@ export function IssueDocumentsSection({
<div className="rounded-md border border-amber-500/20 bg-background/50 p-3">
{renderBody(displayedBody, documentBodyContentClassName)}
</div>
) : (
) : activeDraft ? (
<MarkdownEditor
value={displayedBody}
onChange={(body) => {
@ -1035,13 +1086,7 @@ export function IssueDocumentsSection({
if (current && current.key === doc.key && !current.isNew) {
return { ...current, body };
}
return {
key: doc.key,
title: doc.title ?? "",
body,
baseRevisionId: doc.latestRevisionId,
isNew: false,
};
return current;
});
}}
placeholder="Markdown body"
@ -1052,6 +1097,10 @@ export function IssueDocumentsSection({
imageUploadHandler={imageUploadHandler}
onSubmit={() => void commitDraft(activeDraft ?? draft, { clearAfterSave: false, trackAutosave: true })}
/>
) : (
<div className="rounded-md border border-border/60 bg-background/40 p-3">
{renderBody(displayedBody, documentBodyContentClassName)}
</div>
)}
</div>
<div className="flex min-h-4 items-center justify-end px-1">

View file

@ -1,4 +1,4 @@
import { startTransition, useEffect, useMemo, useState, useCallback, useRef } from "react";
import { useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { pickTextColorForPillBg } from "@/lib/color-contrast";
import { useDialog } from "../context/DialogContext";
@ -68,8 +68,6 @@ const quickFilterPresets = [
{ label: "Backlog", statuses: ["backlog"] },
{ label: "Done", statuses: ["done", "cancelled"] },
];
const ISSUE_SEARCH_COMMIT_DELAY_MS = 150;
function getViewState(key: string): IssueViewState {
try {
const raw = localStorage.getItem(key);
@ -144,6 +142,18 @@ function countActiveFilters(state: IssueViewState): number {
return count;
}
function matchesIssueSearch(issue: Issue, normalizedSearch: string): boolean {
if (!normalizedSearch) return true;
return [
issue.identifier,
issue.title,
issue.description,
]
.filter((value): value is string => Boolean(value))
.some((value) => value.toLowerCase().includes(normalizedSearch));
}
/* ── Component ── */
interface Agent {
@ -175,44 +185,6 @@ interface IssuesListProps {
onUpdateIssue: (id: string, data: Record<string, unknown>) => void;
}
interface IssuesSearchInputProps {
initialValue: string;
onValueCommitted: (value: string) => void;
}
function IssuesSearchInput({ initialValue, onValueCommitted }: IssuesSearchInputProps) {
const [value, setValue] = useState(initialValue);
const onValueCommittedRef = useRef(onValueCommitted);
useEffect(() => {
setValue(initialValue);
}, [initialValue]);
useEffect(() => {
onValueCommittedRef.current = onValueCommitted;
}, [onValueCommitted]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
onValueCommittedRef.current(value);
}, ISSUE_SEARCH_COMMIT_DELAY_MS);
return () => window.clearTimeout(timeoutId);
}, [value]);
return (
<div className="relative w-48 sm:w-64 md:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="Search issues..."
className="pl-7 text-xs sm:text-sm"
aria-label="Search issues"
/>
</div>
);
}
export function IssuesList({
issues,
isLoading,
@ -249,7 +221,8 @@ export function IssuesList({
const [assigneePickerIssueId, setAssigneePickerIssueId] = useState<string | null>(null);
const [assigneeSearch, setAssigneeSearch] = useState("");
const [issueSearch, setIssueSearch] = useState(initialSearch ?? "");
const normalizedIssueSearch = issueSearch.trim();
const deferredIssueSearch = useDeferredValue(issueSearch);
const normalizedIssueSearch = deferredIssueSearch.trim().toLowerCase();
useEffect(() => {
setIssueSearch(initialSearch ?? "");
@ -266,13 +239,6 @@ export function IssuesList({
}
}, [scopedKey, initialAssignees]);
const handleIssueSearchCommit = useCallback((nextSearch: string) => {
startTransition(() => {
setIssueSearch(nextSearch);
});
onSearchChange?.(nextSearch);
}, [onSearchChange]);
const updateView = useCallback((patch: Partial<IssueViewState>) => {
setViewState((prev) => {
const next = { ...prev, ...patch };
@ -280,27 +246,18 @@ export function IssuesList({
return next;
});
}, [scopedKey]);
const { data: searchedIssues = [] } = useQuery({
queryKey: [
...queryKeys.issues.search(selectedCompanyId!, normalizedIssueSearch, projectId),
searchFilters ?? {},
],
queryFn: () => issuesApi.list(selectedCompanyId!, { q: normalizedIssueSearch, projectId, ...searchFilters }),
enabled: !!selectedCompanyId && normalizedIssueSearch.length > 0,
placeholderData: (previousData) => previousData,
});
const agentName = useCallback((id: string | null) => {
if (!id || !agents) return null;
return agents.find((a) => a.id === id)?.name ?? null;
}, [agents]);
const filtered = useMemo(() => {
const sourceIssues = normalizedIssueSearch.length > 0 ? searchedIssues : issues;
const sourceIssues = normalizedIssueSearch.length > 0
? issues.filter((issue) => matchesIssueSearch(issue, normalizedIssueSearch))
: issues;
const filteredByControls = applyFilters(sourceIssues, viewState, currentUserId);
return sortIssues(filteredByControls, viewState);
}, [issues, searchedIssues, viewState, normalizedIssueSearch, currentUserId]);
}, [issues, viewState, normalizedIssueSearch, currentUserId]);
const { data: labels } = useQuery({
queryKey: queryKeys.issues.labels(selectedCompanyId!),
@ -343,7 +300,7 @@ export function IssuesList({
}));
}, [filtered, viewState.groupBy, agents, agentName, currentUserId]);
const newIssueDefaults = (groupKey?: string) => {
const newIssueDefaults = useCallback((groupKey?: string) => {
const defaults: Record<string, string> = {};
if (projectId) defaults.projectId = projectId;
if (groupKey) {
@ -355,13 +312,259 @@ export function IssuesList({
}
}
return defaults;
};
}, [projectId, viewState.groupBy]);
const assignIssue = (issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => {
const assignIssue = useCallback((issueId: string, assigneeAgentId: string | null, assigneeUserId: string | null = null) => {
onUpdateIssue(issueId, { assigneeAgentId, assigneeUserId });
setAssigneePickerIssueId(null);
setAssigneeSearch("");
};
}, [onUpdateIssue]);
const listContent = useMemo(() => {
if (viewState.viewMode === "board") {
return (
<KanbanBoard
issues={filtered}
agents={agents}
liveIssueIds={liveIssueIds}
onUpdateIssue={onUpdateIssue}
/>
);
}
return groupedContent.map((group) => (
<Collapsible
key={group.key}
open={!viewState.collapsedGroups.includes(group.key)}
onOpenChange={(open) => {
updateView({
collapsedGroups: open
? viewState.collapsedGroups.filter((k) => k !== group.key)
: [...viewState.collapsedGroups, group.key],
});
}}
>
{group.label && (
<div className="flex items-center py-1.5 pl-1 pr-3">
<CollapsibleTrigger className="flex items-center gap-1.5">
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
<span className="text-sm font-semibold uppercase tracking-wide">
{group.label}
</span>
</CollapsibleTrigger>
<Button
variant="ghost"
size="icon-xs"
className="ml-auto text-muted-foreground"
onClick={() => openNewIssue(newIssueDefaults(group.key))}
>
<Plus className="h-3 w-3" />
</Button>
</div>
)}
<CollapsibleContent>
{group.items.map((issue) => (
<IssueRow
key={issue.id}
issue={issue}
issueLinkState={issueLinkState}
desktopLeadingSpacer
mobileLeading={(
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<StatusIcon
status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
/>
</span>
)}
desktopMetaLeading={(
<>
<span
className="hidden shrink-0 sm:inline-flex"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<StatusIcon
status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
/>
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{liveIssueIds?.has(issue.id) && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
</span>
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
Live
</span>
</span>
)}
</>
)}
mobileMeta={timeAgo(issue.updatedAt)}
desktopTrailing={(
<>
{(issue.labels ?? []).length > 0 && (
<span className="hidden items-center gap-1 overflow-hidden md:flex md:max-w-[240px]">
{(issue.labels ?? []).slice(0, 3).map((label) => (
<span
key={label.id}
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
style={{
borderColor: label.color,
color: pickTextColorForPillBg(label.color, 0.12),
backgroundColor: `${label.color}1f`,
}}
>
{label.name}
</span>
))}
{(issue.labels ?? []).length > 3 && (
<span className="text-[10px] text-muted-foreground">
+{(issue.labels ?? []).length - 3}
</span>
)}
</span>
)}
<Popover
open={assigneePickerIssueId === issue.id}
onOpenChange={(open) => {
setAssigneePickerIssueId(open ? issue.id : null);
if (!open) setAssigneeSearch("");
}}
>
<PopoverTrigger asChild>
<button
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
) : issue.assigneeUserId ? (
<span className="inline-flex items-center gap-1.5 text-xs">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
Assignee
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent
className="w-56 p-1"
align="end"
onClick={(e) => e.stopPropagation()}
onPointerDownOutside={() => setAssigneeSearch("")}
>
<input
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
placeholder="Search assignees..."
value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null, null);
}}
>
No assignee
</button>
{currentUserId && (
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeUserId === currentUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null, currentUserId);
}}
>
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>Me</span>
</button>
)}
{(agents ?? [])
.filter((agent) => {
if (!assigneeSearch.trim()) return true;
return agent.name
.toLowerCase()
.includes(assigneeSearch.toLowerCase());
})
.map((agent) => (
<button
key={agent.id}
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeAgentId === agent.id && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, agent.id, null);
}}
>
<Identity name={agent.name} size="sm" className="min-w-0" />
</button>
))}
</div>
</PopoverContent>
</Popover>
</>
)}
trailingMeta={formatDate(issue.createdAt)}
/>
))}
</CollapsibleContent>
</Collapsible>
));
}, [
agents,
agentName,
assigneePickerIssueId,
assigneeSearch,
assignIssue,
currentUserId,
filtered,
groupedContent,
issueLinkState,
liveIssueIds,
newIssueDefaults,
onUpdateIssue,
openNewIssue,
updateView,
viewState.collapsedGroups,
]);
return (
<div className="space-y-4">
@ -372,10 +575,19 @@ export function IssuesList({
<Plus className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline">New Issue</span>
</Button>
<IssuesSearchInput
initialValue={initialSearch ?? ""}
onValueCommitted={handleIssueSearchCommit}
/>
<div className="relative w-48 sm:w-64 md:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={issueSearch}
onChange={(e) => {
setIssueSearch(e.target.value);
onSearchChange?.(e.target.value);
}}
placeholder="Search issues..."
className="pl-7 text-xs sm:text-sm"
aria-label="Search issues"
/>
</div>
</div>
<div className="flex items-center gap-0.5 sm:gap-1 shrink-0">
@ -658,231 +870,7 @@ export function IssuesList({
/>
)}
{viewState.viewMode === "board" ? (
<KanbanBoard
issues={filtered}
agents={agents}
liveIssueIds={liveIssueIds}
onUpdateIssue={onUpdateIssue}
/>
) : (
groupedContent.map((group) => (
<Collapsible
key={group.key}
open={!viewState.collapsedGroups.includes(group.key)}
onOpenChange={(open) => {
updateView({
collapsedGroups: open
? viewState.collapsedGroups.filter((k) => k !== group.key)
: [...viewState.collapsedGroups, group.key],
});
}}
>
{group.label && (
<div className="flex items-center py-1.5 pl-1 pr-3">
<CollapsibleTrigger className="flex items-center gap-1.5">
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
<span className="text-sm font-semibold uppercase tracking-wide">
{group.label}
</span>
</CollapsibleTrigger>
<Button
variant="ghost"
size="icon-xs"
className="ml-auto text-muted-foreground"
onClick={() => openNewIssue(newIssueDefaults(group.key))}
>
<Plus className="h-3 w-3" />
</Button>
</div>
)}
<CollapsibleContent>
{group.items.map((issue) => (
<IssueRow
key={issue.id}
issue={issue}
issueLinkState={issueLinkState}
desktopLeadingSpacer
mobileLeading={(
<span
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<StatusIcon
status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
/>
</span>
)}
desktopMetaLeading={(
<>
<span
className="hidden shrink-0 sm:inline-flex"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<StatusIcon
status={issue.status}
onChange={(s) => onUpdateIssue(issue.id, { status: s })}
/>
</span>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{issue.identifier ?? issue.id.slice(0, 8)}
</span>
{liveIssueIds?.has(issue.id) && (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-500/10 px-1.5 py-0.5 sm:gap-1.5 sm:px-2">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-pulse rounded-full bg-blue-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-blue-500" />
</span>
<span className="hidden text-[11px] font-medium text-blue-600 dark:text-blue-400 sm:inline">
Live
</span>
</span>
)}
</>
)}
mobileMeta={timeAgo(issue.updatedAt)}
desktopTrailing={(
<>
{(issue.labels ?? []).length > 0 && (
<span className="hidden items-center gap-1 overflow-hidden md:flex md:max-w-[240px]">
{(issue.labels ?? []).slice(0, 3).map((label) => (
<span
key={label.id}
className="inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium"
style={{
borderColor: label.color,
color: pickTextColorForPillBg(label.color, 0.12),
backgroundColor: `${label.color}1f`,
}}
>
{label.name}
</span>
))}
{(issue.labels ?? []).length > 3 && (
<span className="text-[10px] text-muted-foreground">
+{(issue.labels ?? []).length - 3}
</span>
)}
</span>
)}
<Popover
open={assigneePickerIssueId === issue.id}
onOpenChange={(open) => {
setAssigneePickerIssueId(open ? issue.id : null);
if (!open) setAssigneeSearch("");
}}
>
<PopoverTrigger asChild>
<button
className="flex w-[180px] shrink-0 items-center rounded-md px-2 py-1 transition-colors hover:bg-accent/50"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" />
) : issue.assigneeUserId ? (
<span className="inline-flex items-center gap-1.5 text-xs">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
{formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User"}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-muted-foreground/35 bg-muted/30">
<User className="h-3 w-3" />
</span>
Assignee
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent
className="w-56 p-1"
align="end"
onClick={(e) => e.stopPropagation()}
onPointerDownOutside={() => setAssigneeSearch("")}
>
<input
className="mb-1 w-full border-b border-border bg-transparent px-2 py-1.5 text-xs outline-none placeholder:text-muted-foreground/50"
placeholder="Search assignees..."
value={assigneeSearch}
onChange={(e) => setAssigneeSearch(e.target.value)}
autoFocus
/>
<div className="max-h-48 overflow-y-auto overscroll-contain">
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-xs hover:bg-accent/50",
!issue.assigneeAgentId && !issue.assigneeUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null, null);
}}
>
No assignee
</button>
{currentUserId && (
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeUserId === currentUserId && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, null, currentUserId);
}}
>
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span>Me</span>
</button>
)}
{(agents ?? [])
.filter((agent) => {
if (!assigneeSearch.trim()) return true;
return agent.name
.toLowerCase()
.includes(assigneeSearch.toLowerCase());
})
.map((agent) => (
<button
key={agent.id}
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50",
issue.assigneeAgentId === agent.id && "bg-accent",
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
assignIssue(issue.id, agent.id, null);
}}
>
<Identity name={agent.name} size="sm" className="min-w-0" />
</button>
))}
</div>
</PopoverContent>
</Popover>
</>
)}
trailingMeta={formatDate(issue.createdAt)}
/>
))}
</CollapsibleContent>
</Collapsible>
))
)}
{listContent}
</div>
);
}

View file

@ -2,7 +2,7 @@
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared";
import { buildAgentMentionHref, buildProjectMentionHref, buildSkillMentionHref } from "@paperclipai/shared";
import { ThemeProvider } from "../context/ThemeContext";
import { MarkdownBody } from "./MarkdownBody";
@ -30,11 +30,11 @@ describe("MarkdownBody", () => {
expect(html).toContain('alt="Org chart"');
});
it("renders agent and project mentions as chips", () => {
it("renders agent, project, and skill mentions as chips", () => {
const html = renderToStaticMarkup(
<ThemeProvider>
<MarkdownBody>
{`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")})`}
{`[@CodexCoder](${buildAgentMentionHref("agent-123", "code")}) [@Paperclip App](${buildProjectMentionHref("project-456", "#336699")}) [/release-changelog](${buildSkillMentionHref("skill-789", "release-changelog")})`}
</MarkdownBody>
</ThemeProvider>,
);
@ -45,5 +45,7 @@ describe("MarkdownBody", () => {
expect(html).toContain('href="/projects/project-456"');
expect(html).toContain('data-mention-kind="project"');
expect(html).toContain("--paperclip-mention-project-color:#336699");
expect(html).toContain('href="/skills/skill-789"');
expect(html).toContain('data-mention-kind="skill"');
});
});

View file

@ -106,7 +106,9 @@ export function MarkdownBody({ children, className, resolveImageSrc }: MarkdownB
if (parsed) {
const targetHref = parsed.kind === "project"
? `/projects/${parsed.projectId}`
: `/agents/${parsed.agentId}`;
: parsed.kind === "skill"
? `/skills/${parsed.skillId}`
: `/agents/${parsed.agentId}`;
return (
<a
href={targetHref}

View file

@ -0,0 +1,189 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { computeMentionMenuPosition, MarkdownEditor } from "./MarkdownEditor";
const mdxEditorMockState = vi.hoisted(() => ({
emitMountEmptyReset: false,
}));
vi.mock("@mdxeditor/editor", async () => {
const React = await import("react");
function setForwardedRef<T>(ref: React.ForwardedRef<T | null>, value: T | null) {
if (typeof ref === "function") {
ref(value);
return;
}
if (ref) {
(ref as React.MutableRefObject<T | null>).current = value;
}
}
const MDXEditor = React.forwardRef(function MockMDXEditor(
{
markdown,
placeholder,
onChange,
}: {
markdown: string;
placeholder?: string;
onChange?: (value: string) => void;
},
forwardedRef: React.ForwardedRef<{ setMarkdown: (value: string) => void; focus: () => void } | null>,
) {
const [content, setContent] = React.useState(markdown);
const handle = React.useMemo(() => ({
setMarkdown: (value: string) => setContent(value),
focus: () => {},
}), []);
React.useEffect(() => {
setForwardedRef(forwardedRef, null);
const timer = window.setTimeout(() => {
setForwardedRef(forwardedRef, handle);
if (mdxEditorMockState.emitMountEmptyReset) {
setContent("");
onChange?.("");
}
}, 0);
return () => {
window.clearTimeout(timer);
setForwardedRef(forwardedRef, null);
};
}, []);
return <div data-testid="mdx-editor">{content || placeholder || ""}</div>;
});
return {
CodeMirrorEditor: () => null,
MDXEditor,
codeBlockPlugin: () => ({}),
codeMirrorPlugin: () => ({}),
createRootEditorSubscription$: Symbol("createRootEditorSubscription$"),
headingsPlugin: () => ({}),
imagePlugin: () => ({}),
linkDialogPlugin: () => ({}),
linkPlugin: () => ({}),
listsPlugin: () => ({}),
markdownShortcutPlugin: () => ({}),
quotePlugin: () => ({}),
realmPlugin: (plugin: unknown) => plugin,
tablePlugin: () => ({}),
thematicBreakPlugin: () => ({}),
};
});
vi.mock("../lib/mention-deletion", () => ({
mentionDeletionPlugin: () => ({}),
}));
vi.mock("../lib/paste-normalization", () => ({
pasteNormalizationPlugin: () => ({}),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flush() {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
}
describe("MarkdownEditor", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
container.remove();
vi.clearAllMocks();
mdxEditorMockState.emitMountEmptyReset = false;
});
it("applies async external value updates once the editor ref becomes ready", async () => {
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value=""
onChange={() => {}}
placeholder="Markdown body"
/>,
);
});
await act(async () => {
root.render(
<MarkdownEditor
value="Loaded plan body"
onChange={() => {}}
placeholder="Markdown body"
/>,
);
});
await flush();
expect(container.textContent).toContain("Loaded plan body");
await act(async () => {
root.unmount();
});
});
it("keeps the external value when the unfocused editor emits an empty mount reset", async () => {
mdxEditorMockState.emitMountEmptyReset = true;
const handleChange = vi.fn();
const root = createRoot(container);
await act(async () => {
root.render(
<MarkdownEditor
value="Loaded plan body"
onChange={handleChange}
placeholder="Markdown body"
/>,
);
});
await flush();
expect(container.textContent).toContain("Loaded plan body");
expect(handleChange).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
});
});
it("anchors the mention menu inside the visual viewport when mobile offsets are present", () => {
expect(
computeMentionMenuPosition(
{ viewportTop: 180, viewportLeft: 120 },
{ offsetLeft: 24, offsetTop: 320, width: 320, height: 260 },
),
).toEqual({
top: 372,
left: 144,
});
});
it("clamps the mention menu back into view near the viewport edges", () => {
expect(
computeMentionMenuPosition(
{ viewportTop: 260, viewportLeft: 240 },
{ offsetLeft: 0, offsetTop: 0, width: 280, height: 220 },
),
).toEqual({
top: 12,
left: 92,
});
});
});

View file

@ -1,4 +1,5 @@
import {
type ClipboardEvent,
forwardRef,
useCallback,
useEffect,
@ -28,11 +29,16 @@ import {
type RealmPlugin,
} from "@mdxeditor/editor";
import { buildAgentMentionHref, buildProjectMentionHref } from "@paperclipai/shared";
import { Boxes } from "lucide-react";
import { AgentIcon } from "./AgentIconPicker";
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node";
import { mentionDeletionPlugin } from "../lib/mention-deletion";
import { looksLikeMarkdownPaste } from "../lib/markdownPaste";
import { normalizeMarkdown } from "../lib/normalize-markdown";
import { pasteNormalizationPlugin } from "../lib/paste-normalization";
import { cn } from "../lib/utils";
import { useEditorAutocomplete, type SkillCommandOption } from "../context/EditorAutocompleteContext";
/* ---- Mention types ---- */
@ -80,6 +86,8 @@ function isSafeMarkdownLinkUrl(url: string): boolean {
/* ---- Mention detection helpers ---- */
interface MentionState {
trigger: "mention" | "skill";
marker: "@" | "/";
query: string;
top: number;
left: number;
@ -91,6 +99,19 @@ interface MentionState {
endPos: number;
}
type AutocompleteOption = MentionOption | SkillCommandOption;
interface MentionMenuViewport {
offsetLeft: number;
offsetTop: number;
width: number;
height: number;
}
const MENTION_MENU_WIDTH = 188;
const MENTION_MENU_HEIGHT = 208;
const MENTION_MENU_PADDING = 8;
const CODE_BLOCK_LANGUAGES: Record<string, string> = {
txt: "Text",
md: "Markdown",
@ -131,13 +152,17 @@ function detectMention(container: HTMLElement): MentionState | null {
const text = textNode.textContent ?? "";
const offset = range.startOffset;
// Walk backwards from cursor to find @
// Walk backwards from cursor to find an autocomplete trigger.
let atPos = -1;
let trigger: MentionState["trigger"] | null = null;
let marker: MentionState["marker"] | null = null;
for (let i = offset - 1; i >= 0; i--) {
const ch = text[i];
if (ch === "@") {
if (ch === "@" || ch === "/") {
if (i === 0 || /\s/.test(text[i - 1])) {
atPos = i;
trigger = ch === "@" ? "mention" : "skill";
marker = ch;
}
break;
}
@ -156,6 +181,8 @@ function detectMention(container: HTMLElement): MentionState | null {
const containerRect = container.getBoundingClientRect();
return {
trigger: trigger ?? "mention",
marker: marker ?? "@",
query,
top: rect.bottom - containerRect.top,
left: rect.left - containerRect.left,
@ -167,6 +194,58 @@ function detectMention(container: HTMLElement): MentionState | null {
};
}
function getMentionMenuViewport(): MentionMenuViewport {
const viewport = window.visualViewport;
if (viewport) {
return {
offsetLeft: viewport.offsetLeft,
offsetTop: viewport.offsetTop,
width: viewport.width,
height: viewport.height,
};
}
return {
offsetLeft: 0,
offsetTop: 0,
width: window.innerWidth,
height: window.innerHeight,
};
}
export function computeMentionMenuPosition(
anchor: Pick<MentionState, "viewportTop" | "viewportLeft">,
viewport: MentionMenuViewport,
) {
const minLeft = viewport.offsetLeft + MENTION_MENU_PADDING;
const maxLeft = viewport.offsetLeft + viewport.width - MENTION_MENU_WIDTH;
const minTop = viewport.offsetTop + MENTION_MENU_PADDING;
const maxTop = viewport.offsetTop + viewport.height - MENTION_MENU_HEIGHT;
return {
top: Math.max(minTop, Math.min(viewport.offsetTop + anchor.viewportTop + 4, maxTop)),
left: Math.max(minLeft, Math.min(viewport.offsetLeft + anchor.viewportLeft, maxLeft)),
};
}
function nodeInsideCodeLike(container: HTMLElement, node: Node | null): boolean {
if (!node || !container.contains(node)) return false;
const el = node.nodeType === Node.ELEMENT_NODE
? (node as HTMLElement)
: node.parentElement;
return Boolean(el?.closest("pre, code"));
}
function isSelectionInsideCodeLikeElement(container: HTMLElement | null) {
if (!container) return false;
const selection = window.getSelection();
if (!selection) return false;
for (const node of [selection.anchorNode, selection.focusNode]) {
if (nodeInsideCodeLike(container, node)) return true;
}
return false;
}
function mentionMarkdown(option: MentionOption): string {
if (option.kind === "project" && option.projectId) {
return `[@${option.name}](${buildProjectMentionHref(option.projectId, option.projectColor ?? null)}) `;
@ -175,10 +254,18 @@ function mentionMarkdown(option: MentionOption): string {
return `[@${option.name}](${buildAgentMentionHref(agentId, option.agentIcon ?? null)}) `;
}
/** Replace `@<query>` in the markdown string with the selected mention token. */
function applyMention(markdown: string, query: string, option: MentionOption): string {
const search = `@${query}`;
const replacement = mentionMarkdown(option);
function skillMarkdown(option: SkillCommandOption): string {
return `[/${option.slug}](${option.href}) `;
}
function autocompleteMarkdown(option: AutocompleteOption): string {
return option.kind === "skill" ? skillMarkdown(option) : mentionMarkdown(option);
}
/** Replace the active autocomplete token in the markdown string with the selected token. */
function applyMention(markdown: string, state: MentionState, option: AutocompleteOption): string {
const search = `${state.marker}${state.query}`;
const replacement = autocompleteMarkdown(option);
const idx = markdown.lastIndexOf(search);
if (idx === -1) return markdown;
return markdown.slice(0, idx) + replacement + markdown.slice(idx + search.length);
@ -198,9 +285,19 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
mentions,
onSubmit,
}: MarkdownEditorProps, forwardedRef) {
const { slashCommands } = useEditorAutocomplete();
const containerRef = useRef<HTMLDivElement>(null);
const ref = useRef<MDXEditorMethods>(null);
const valueRef = useRef(value);
valueRef.current = value;
const latestValueRef = useRef(value);
const initialChildOnChangeRef = useRef(true);
/**
* After imperative `setMarkdown` (prop sync, mentions, image upload), MDXEditor may emit `onChange`
* with the same markdown. Skip notifying the parent for that echo so controlled parents that
* normalize or transform values cannot loop. Replaces the older blur/focus gate for the same concern.
*/
const echoIgnoreMarkdownRef = useRef<string | null>(null);
const [uploadError, setUploadError] = useState<string | null>(null);
const [isDragOver, setIsDragOver] = useState(false);
const dragDepthRef = useRef(0);
@ -213,7 +310,10 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
const [mentionState, setMentionState] = useState<MentionState | null>(null);
const mentionStateRef = useRef<MentionState | null>(null);
const [mentionIndex, setMentionIndex] = useState(0);
const mentionActive = mentionState !== null && mentions && mentions.length > 0;
const mentionActive = mentionState !== null && (
(mentionState.trigger === "mention" && Boolean(mentions?.length))
|| (mentionState.trigger === "skill" && slashCommands.length > 0)
);
const mentionOptionByKey = useMemo(() => {
const map = new Map<string, MentionOption>();
for (const mention of mentions ?? []) {
@ -228,11 +328,30 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
return map;
}, [mentions]);
const filteredMentions = useMemo(() => {
if (!mentionState || !mentions) return [];
const q = mentionState.query.toLowerCase();
const filteredMentions = useMemo<AutocompleteOption[]>(() => {
if (!mentionState) return [];
const q = mentionState.query.trim().toLowerCase();
if (mentionState.trigger === "skill") {
return slashCommands
.filter((command) => {
if (!q) return true;
return command.aliases.some((alias) => alias.toLowerCase().includes(q));
})
.slice(0, 8);
}
if (!mentions) return [];
return mentions.filter((m) => m.name.toLowerCase().includes(q)).slice(0, 8);
}, [mentionState?.query, mentions]);
}, [mentionState, mentions, slashCommands]);
const setEditorRef = useCallback((instance: MDXEditorMethods | null) => {
ref.current = instance;
if (instance) {
const v = valueRef.current;
echoIgnoreMarkdownRef.current = v;
instance.setMarkdown(v);
latestValueRef.current = v;
}
}, []);
useImperativeHandle(forwardedRef, () => ({
focus: () => {
@ -263,6 +382,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
);
if (updated !== current) {
latestValueRef.current = updated;
echoIgnoreMarkdownRef.current = updated;
ref.current?.setMarkdown(updated);
onChange(updated);
requestAnimationFrame(() => {
@ -286,6 +406,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
linkPlugin({ validateUrl: isSafeMarkdownLinkUrl }),
linkDialogPlugin(),
mentionDeletionPlugin(),
pasteNormalizationPlugin(),
thematicBreakPlugin(),
codeBlockPlugin({
defaultCodeBlockLanguage: "txt",
@ -302,8 +423,12 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
useEffect(() => {
if (value !== latestValueRef.current) {
ref.current?.setMarkdown(value);
latestValueRef.current = value;
if (ref.current) {
// Pair with onChange echo suppression (echoIgnoreMarkdownRef).
echoIgnoreMarkdownRef.current = value;
ref.current.setMarkdown(value);
latestValueRef.current = value;
}
}
}, [value]);
@ -328,6 +453,11 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
continue;
}
if (parsed.kind === "skill") {
applyMentionChipDecoration(link, parsed);
continue;
}
const option = mentionOptionByKey.get(`agent:${parsed.agentId}`);
applyMentionChipDecoration(link, {
...parsed,
@ -338,12 +468,30 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
// Mention detection: listen for selection changes and input events
const checkMention = useCallback(() => {
if (!mentions || mentions.length === 0 || !containerRef.current) {
if (!containerRef.current || isSelectionInsideCodeLikeElement(containerRef.current)) {
mentionStateRef.current = null;
setMentionState(null);
return;
}
const result = detectMention(containerRef.current);
if (
result
&& result.trigger === "mention"
&& (!mentions || mentions.length === 0)
) {
mentionStateRef.current = null;
setMentionState(null);
return;
}
if (
result
&& result.trigger === "skill"
&& slashCommands.length === 0
) {
mentionStateRef.current = null;
setMentionState(null);
return;
}
mentionStateRef.current = result;
if (result) {
setMentionState(result);
@ -351,10 +499,10 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
} else {
setMentionState(null);
}
}, [mentions]);
}, [mentions, slashCommands.length]);
useEffect(() => {
if (!mentions || mentions.length === 0) return;
if ((!mentions || mentions.length === 0) && slashCommands.length === 0) return;
const el = containerRef.current;
// Listen for input events on the container so mention detection
@ -367,7 +515,26 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
document.removeEventListener("selectionchange", checkMention);
el?.removeEventListener("input", onInput, true);
};
}, [checkMention, mentions]);
}, [checkMention, mentions, slashCommands.length]);
useEffect(() => {
if (!mentionActive) return;
const updatePosition = () => requestAnimationFrame(checkMention);
const viewport = window.visualViewport;
viewport?.addEventListener("resize", updatePosition);
viewport?.addEventListener("scroll", updatePosition);
window.addEventListener("resize", updatePosition);
window.addEventListener("scroll", updatePosition, true);
return () => {
viewport?.removeEventListener("resize", updatePosition);
viewport?.removeEventListener("scroll", updatePosition);
window.removeEventListener("resize", updatePosition);
window.removeEventListener("scroll", updatePosition, true);
};
}, [checkMention, mentionActive]);
useEffect(() => {
const editable = containerRef.current?.querySelector('[contenteditable="true"]');
@ -385,15 +552,16 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
}, [decorateProjectMentions, value]);
const selectMention = useCallback(
(option: MentionOption) => {
(option: AutocompleteOption) => {
// Read from ref to avoid stale-closure issues (selectionchange can
// update state between the last render and this callback firing).
const state = mentionStateRef.current;
if (!state) return;
const current = latestValueRef.current;
const next = applyMention(current, state.query, option);
const next = applyMention(current, state, option);
if (next !== current) {
latestValueRef.current = next;
echoIgnoreMarkdownRef.current = next;
ref.current?.setMarkdown(next);
onChange(next);
}
@ -405,17 +573,20 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
decorateProjectMentions();
editable.focus();
const mentionHref = option.kind === "project" && option.projectId
? buildProjectMentionHref(option.projectId, option.projectColor ?? null)
: buildAgentMentionHref(
option.agentId ?? option.id.replace(/^agent:/, ""),
option.agentIcon ?? null,
);
const mentionHref = option.kind === "skill"
? option.href
: option.kind === "project" && option.projectId
? buildProjectMentionHref(option.projectId, option.projectColor ?? null)
: buildAgentMentionHref(
option.agentId ?? option.id.replace(/^agent:/, ""),
option.agentIcon ?? null,
);
const expectedLabel = option.kind === "skill" ? `/${option.slug}` : `@${option.name}`;
const matchingMentions = Array.from(editable.querySelectorAll("a"))
.filter((node): node is HTMLAnchorElement => node instanceof HTMLAnchorElement)
.filter((link) => {
const href = link.getAttribute("href") ?? "";
return href === mentionHref && link.textContent === `@${option.name}`;
return href === mentionHref && link.textContent === expectedLabel;
});
const containerRect = containerRef.current?.getBoundingClientRect();
const target = matchingMentions.sort((a, b) => {
@ -464,6 +635,23 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
}
const canDropImage = Boolean(imageUploadHandler);
const handlePasteCapture = useCallback((event: ClipboardEvent<HTMLDivElement>) => {
const clipboard = event.clipboardData;
if (!clipboard || !ref.current) return;
const types = new Set(Array.from(clipboard.types));
if (types.has("Files") || types.has("text/html")) return;
if (isSelectionInsideCodeLikeElement(containerRef.current)) return;
const rawText = clipboard.getData("text/plain");
if (!looksLikeMarkdownPaste(rawText)) return;
event.preventDefault();
ref.current.insertMarkdown(normalizeMarkdown(rawText));
}, []);
const mentionMenuPosition = mentionState
? computeMentionMenuPosition(mentionState, getMentionMenuViewport())
: null;
return (
<div
@ -541,12 +729,31 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
dragDepthRef.current = 0;
setIsDragOver(false);
}}
onPasteCapture={handlePasteCapture}
>
<MDXEditor
ref={ref}
ref={setEditorRef}
markdown={value}
placeholder={placeholder}
onChange={(next) => {
const echo = echoIgnoreMarkdownRef.current;
if (echo !== null && next === echo) {
echoIgnoreMarkdownRef.current = null;
latestValueRef.current = next;
return;
}
if (echo !== null) {
echoIgnoreMarkdownRef.current = null;
}
if (initialChildOnChangeRef.current) {
initialChildOnChangeRef.current = false;
if (next === "" && value !== "") {
echoIgnoreMarkdownRef.current = value;
ref.current?.setMarkdown(value);
return;
}
}
latestValueRef.current = next;
onChange(next);
}}
@ -565,25 +772,25 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
createPortal(
<div
className="fixed z-[9999] min-w-[180px] max-w-[calc(100vw-16px)] max-h-[200px] overflow-y-auto rounded-md border border-border bg-popover shadow-md"
style={{
top: Math.min(mentionState.viewportTop + 4, window.innerHeight - 208),
left: Math.max(8, Math.min(mentionState.viewportLeft, window.innerWidth - 188)),
}}
style={mentionMenuPosition ?? undefined}
>
{filteredMentions.map((option, i) => (
<button
key={option.id}
type="button"
className={cn(
"flex items-center gap-2 w-full px-3 py-1.5 text-sm text-left hover:bg-accent/50 transition-colors",
i === mentionIndex && "bg-accent",
)}
onMouseDown={(e) => {
onPointerDown={(e) => {
e.preventDefault(); // prevent blur
selectMention(option);
}}
onMouseEnter={() => setMentionIndex(i)}
>
{option.kind === "project" && option.projectId ? (
{option.kind === "skill" ? (
<Boxes className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : option.kind === "project" && option.projectId ? (
<span
className="inline-flex h-2 w-2 rounded-full border border-border/50"
style={{ backgroundColor: option.projectColor ?? "#64748b" }}
@ -594,12 +801,17 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
/>
)}
<span>{option.name}</span>
<span>{option.kind === "skill" ? `/${option.slug}` : option.name}</span>
{option.kind === "project" && option.projectId && (
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
Project
</span>
)}
{option.kind === "skill" && (
<span className="ml-auto text-[10px] uppercase tracking-wide text-muted-foreground">
Skill
</span>
)}
</button>
))}
</div>,

View file

@ -1,10 +1,11 @@
import { useState, type ComponentType } from "react";
import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "@/lib/router";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { agentsApi } from "../api/agents";
import { queryKeys } from "../lib/queryKeys";
import { adaptersApi } from "../api/adapters";
import { queryKeys } from "@/lib/queryKeys";
import {
Dialog,
DialogContent,
@ -13,91 +14,37 @@ import { Button } from "@/components/ui/button";
import {
ArrowLeft,
Bot,
Code,
Gem,
MousePointer2,
Sparkles,
Terminal,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
import { HermesIcon } from "./HermesIcon";
import { listUIAdapters } from "../adapters";
import { getAdapterDisplay } from "../adapters/adapter-display-registry";
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
type AdvancedAdapterType =
| "claude_local"
| "codex_local"
| "gemini_local"
| "opencode_local"
| "pi_local"
| "cursor"
| "openclaw_gateway"
| "hermes_local";
/**
* Adapter types that are suitable for agent creation (excludes internal
* system adapters like "process" and "http").
*/
const SYSTEM_ADAPTER_TYPES = new Set(["process", "http"]);
const ADVANCED_ADAPTER_OPTIONS: Array<{
value: AdvancedAdapterType;
label: string;
desc: string;
icon: ComponentType<{ className?: string }>;
recommended?: boolean;
}> = [
{
value: "claude_local",
label: "Claude Code",
icon: Sparkles,
desc: "Local Claude agent",
recommended: true,
},
{
value: "codex_local",
label: "Codex",
icon: Code,
desc: "Local Codex agent",
recommended: true,
},
{
value: "gemini_local",
label: "Gemini CLI",
icon: Gem,
desc: "Local Gemini agent",
},
{
value: "opencode_local",
label: "OpenCode",
icon: OpenCodeLogoIcon,
desc: "Local multi-provider agent",
},
{
value: "hermes_local",
label: "Hermes Agent",
icon: HermesIcon,
desc: "Local multi-provider agent",
},
{
value: "pi_local",
label: "Pi",
icon: Terminal,
desc: "Local Pi agent",
},
{
value: "cursor",
label: "Cursor",
icon: MousePointer2,
desc: "Local Cursor agent",
},
{
value: "openclaw_gateway",
label: "OpenClaw Gateway",
icon: Bot,
desc: "Invoke OpenClaw via gateway protocol",
},
];
function isAgentAdapterType(type: string): boolean {
return !SYSTEM_ADAPTER_TYPES.has(type);
}
export function NewAgentDialog() {
const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog();
const { selectedCompanyId } = useCompany();
const navigate = useNavigate();
const [showAdvancedCards, setShowAdvancedCards] = useState(false);
const disabledTypes = useDisabledAdaptersSync();
// Fetch registered adapters from server (syncs disabled store + provides data)
const { data: serverAdapters } = useQuery({
queryKey: queryKeys.adapters.all,
queryFn: () => adaptersApi.list(),
staleTime: 5 * 60 * 1000,
});
// Fetch existing agents for the "Ask CEO" flow
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
@ -106,6 +53,33 @@ export function NewAgentDialog() {
const ceoAgent = (agents ?? []).find((a) => a.role === "ceo");
// Build the adapter grid from the UI registry merged with display metadata.
// This automatically includes external/plugin adapters.
const adapterGrid = useMemo(() => {
const registered = listUIAdapters()
.filter((a) => isAgentAdapterType(a.type) && !disabledTypes.has(a.type));
// Sort: recommended first, then alphabetical
return registered
.map((a) => {
const display = getAdapterDisplay(a.type);
return {
value: a.type,
label: display.label,
desc: display.description,
icon: display.icon,
recommended: display.recommended,
comingSoon: display.comingSoon,
disabledLabel: display.disabledLabel,
};
})
.sort((a, b) => {
if (a.recommended && !b.recommended) return -1;
if (!a.recommended && b.recommended) return 1;
return a.label.localeCompare(b.label);
});
}, [disabledTypes, serverAdapters]);
function handleAskCeo() {
closeNewAgent();
openNewIssue({
@ -119,7 +93,7 @@ export function NewAgentDialog() {
setShowAdvancedCards(true);
}
function handleAdvancedAdapterPick(adapterType: AdvancedAdapterType) {
function handleAdvancedAdapterPick(adapterType: string) {
closeNewAgent();
setShowAdvancedCards(false);
navigate(`/agents/new?adapterType=${encodeURIComponent(adapterType)}`);
@ -161,7 +135,7 @@ export function NewAgentDialog() {
{/* Recommendation */}
<div className="text-center space-y-3">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-accent">
<Sparkles className="h-6 w-6 text-foreground" />
<Bot className="h-6 w-6 text-foreground" />
</div>
<p className="text-sm text-muted-foreground">
We recommend letting your CEO handle agent setup they know the
@ -201,13 +175,18 @@ export function NewAgentDialog() {
</div>
<div className="grid grid-cols-2 gap-2">
{ADVANCED_ADAPTER_OPTIONS.map((opt) => (
{adapterGrid.map((opt) => (
<button
key={opt.value}
className={cn(
"flex flex-col items-center gap-1.5 rounded-md border border-border p-3 text-xs transition-colors hover:bg-accent/50 relative"
"flex flex-col items-center gap-1.5 rounded-md border border-border p-3 text-xs transition-colors hover:bg-accent/50 relative",
opt.comingSoon && "opacity-40 cursor-not-allowed",
)}
onClick={() => handleAdvancedAdapterPick(opt.value)}
disabled={!!opt.comingSoon}
title={opt.comingSoon ? opt.disabledLabel : undefined}
onClick={() => {
if (!opt.comingSoon) handleAdvancedAdapterPick(opt.value);
}}
>
{opt.recommended && (
<span className="absolute -top-1.5 right-1.5 bg-green-500 text-white text-[9px] font-semibold px-1.5 py-0.5 rounded-full leading-none">

View file

@ -24,6 +24,7 @@ import {
DialogContent,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import {
Popover,
PopoverContent,
@ -1208,21 +1209,10 @@ export function NewIssueDialog() {
{assigneeAdapterType === "claude_local" && (
<div className="flex items-center justify-between rounded-md border border-border px-2 py-1.5">
<div className="text-xs text-muted-foreground">Enable Chrome (--chrome)</div>
<button
data-slot="toggle"
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
assigneeChrome ? "bg-green-600" : "bg-muted"
)}
onClick={() => setAssigneeChrome((value) => !value)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
assigneeChrome ? "translate-x-4.5" : "translate-x-0.5"
)}
/>
</button>
<ToggleSwitch
checked={assigneeChrome}
onCheckedChange={() => setAssigneeChrome((value) => !value)}
/>
</div>
)}
</div>

View file

@ -23,6 +23,9 @@ import {
extractProviderIdWithFallback
} from "../lib/model-utils";
import { getUIAdapter } from "../adapters";
import { listUIAdapters } from "../adapters";
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
import { getAdapterDisplay } from "../adapters/adapter-display-registry";
import { defaultCreateValues } from "./agent-config-defaults";
import { parseOnboardingGoalInput } from "../lib/onboarding-goal";
import {
@ -38,37 +41,22 @@ import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
import { resolveRouteOnboardingOptions } from "../lib/onboarding-route";
import { AsciiArtAnimation } from "./AsciiArtAnimation";
import { OpenCodeLogoIcon } from "./OpenCodeLogoIcon";
import {
Building2,
Bot,
Code,
Gem,
ListTodo,
Rocket,
ArrowLeft,
ArrowRight,
Terminal,
Sparkles,
MousePointer2,
Check,
Loader2,
ChevronDown,
X
} from "lucide-react";
import { HermesIcon } from "./HermesIcon";
type Step = 1 | 2 | 3 | 4;
type AdapterType =
| "claude_local"
| "codex_local"
| "gemini_local"
| "hermes_local"
| "opencode_local"
| "pi_local"
| "cursor"
| "http"
| "openclaw_gateway";
type AdapterType = string;
const DEFAULT_TASK_DESCRIPTION = `You are the CEO. You set the direction for the company.
@ -85,6 +73,9 @@ export function OnboardingWizard() {
const { companyPrefix } = useParams<{ companyPrefix?: string }>();
const [routeDismissed, setRouteDismissed] = useState(false);
// Sync disabled adapter types from server so adapter grid filters them out
const disabledTypes = useDisabledAdaptersSync();
const routeOnboardingOptions =
companyPrefix && companiesLoading
? null
@ -206,29 +197,33 @@ export function OnboardingWizard() {
queryFn: () => agentsApi.adapterModels(createdCompanyId!, adapterType),
enabled: Boolean(createdCompanyId) && effectiveOnboardingOpen && step === 2
});
const isLocalAdapter =
adapterType === "claude_local" ||
adapterType === "codex_local" ||
adapterType === "gemini_local" ||
adapterType === "hermes_local" ||
adapterType === "opencode_local" ||
adapterType === "pi_local" ||
adapterType === "cursor";
const NONLOCAL_TYPES = new Set(["process", "http", "openclaw_gateway"]);
const isLocalAdapter = !NONLOCAL_TYPES.has(adapterType);
// Build adapter grids dynamically from the UI registry + display metadata.
// External/plugin adapters automatically appear with generic defaults.
const { recommendedAdapters, moreAdapters } = useMemo(() => {
const SYSTEM_ADAPTER_TYPES = new Set(["process", "http"]);
const all = listUIAdapters()
.filter((a) => !SYSTEM_ADAPTER_TYPES.has(a.type) && !disabledTypes.has(a.type))
.map((a) => ({ ...getAdapterDisplay(a.type), type: a.type }));
return {
recommendedAdapters: all.filter((a) => a.recommended),
moreAdapters: all.filter((a) => !a.recommended),
};
}, [disabledTypes]);
const COMMAND_PLACEHOLDERS: Record<string, string> = {
claude_local: "claude",
codex_local: "codex",
gemini_local: "gemini",
pi_local: "pi",
cursor: "agent",
opencode_local: "opencode",
};
const effectiveAdapterCommand =
command.trim() ||
(adapterType === "codex_local"
? "codex"
: adapterType === "gemini_local"
? "gemini"
: adapterType === "hermes_local"
? "hermes"
: adapterType === "pi_local"
? "pi"
: adapterType === "cursor"
? "agent"
: adapterType === "opencode_local"
? "opencode"
: "claude");
(COMMAND_PLACEHOLDERS[adapterType] ?? adapterType.replace(/_local$/, ""));
useEffect(() => {
if (step !== 2) return;
@ -759,32 +754,17 @@ export function OnboardingWizard() {
Adapter type
</label>
<div className="grid grid-cols-2 gap-2">
{[
{
value: "claude_local" as const,
label: "Claude Code",
icon: Sparkles,
desc: "Local Claude agent",
recommended: true
},
{
value: "codex_local" as const,
label: "Codex",
icon: Code,
desc: "Local Codex agent",
recommended: true
}
].map((opt) => (
{recommendedAdapters.map((opt) => (
<button
key={opt.value}
key={opt.type}
className={cn(
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors relative",
adapterType === opt.value
adapterType === opt.type
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
)}
onClick={() => {
const nextType = opt.value as AdapterType;
const nextType = opt.type;
setAdapterType(nextType);
if (nextType === "codex_local" && !model) {
setModel(DEFAULT_CODEX_LOCAL_MODEL);
@ -802,7 +782,7 @@ export function OnboardingWizard() {
<opt.icon className="h-4 w-4" />
<span className="font-medium">{opt.label}</span>
<span className="text-muted-foreground text-[10px]">
{opt.desc}
{opt.description}
</span>
</button>
))}
@ -823,60 +803,21 @@ export function OnboardingWizard() {
{showMoreAdapters && (
<div className="grid grid-cols-2 gap-2 mt-2">
{[
{
value: "gemini_local" as const,
label: "Gemini CLI",
icon: Gem,
desc: "Local Gemini agent"
},
{
value: "opencode_local" as const,
label: "OpenCode",
icon: OpenCodeLogoIcon,
desc: "Local multi-provider agent"
},
{
value: "pi_local" as const,
label: "Pi",
icon: Terminal,
desc: "Local Pi agent"
},
{
value: "cursor" as const,
label: "Cursor",
icon: MousePointer2,
desc: "Local Cursor agent"
},
{
value: "hermes_local" as const,
label: "Hermes Agent",
icon: HermesIcon,
desc: "Local multi-provider agent"
},
{
value: "openclaw_gateway" as const,
label: "OpenClaw Gateway",
icon: Bot,
desc: "Invoke OpenClaw via gateway protocol",
comingSoon: true,
disabledLabel: "Configure OpenClaw within the App"
}
].map((opt) => (
<button
key={opt.value}
disabled={!!opt.comingSoon}
className={cn(
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors relative",
opt.comingSoon
? "border-border opacity-40 cursor-not-allowed"
: adapterType === opt.value
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
)}
onClick={() => {
if (opt.comingSoon) return;
const nextType = opt.value as AdapterType;
{moreAdapters.map((opt) => (
<button
key={opt.type}
disabled={!!opt.comingSoon}
className={cn(
"flex flex-col items-center gap-1.5 rounded-md border p-3 text-xs transition-colors relative",
opt.comingSoon
? "border-border opacity-40 cursor-not-allowed"
: adapterType === opt.type
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
)}
onClick={() => {
if (opt.comingSoon) return;
const nextType = opt.type;
setAdapterType(nextType);
if (nextType === "gemini_local" && !model) {
setModel(DEFAULT_GEMINI_LOCAL_MODEL);
@ -899,9 +840,8 @@ export function OnboardingWizard() {
<span className="font-medium">{opt.label}</span>
<span className="text-muted-foreground text-[10px]">
{opt.comingSoon
? (opt as { disabledLabel?: string })
.disabledLabel ?? "Coming soon"
: opt.desc}
? opt.disabledLabel ?? "Coming soon"
: opt.description}
</span>
</button>
))}
@ -910,13 +850,7 @@ export function OnboardingWizard() {
</div>
{/* Conditional adapter fields */}
{(adapterType === "claude_local" ||
adapterType === "codex_local" ||
adapterType === "gemini_local" ||
adapterType === "hermes_local" ||
adapterType === "opencode_local" ||
adapterType === "pi_local" ||
adapterType === "cursor") && (
{isLocalAdapter && (
<div className="space-y-3">
<div>
<label className="text-xs text-muted-foreground mb-1 block">

View file

@ -19,12 +19,14 @@ export function OutputFeedbackButtons({
sharingPreference = "prompt",
termsUrl = null,
onVote,
rightSlot,
}: {
activeVote?: FeedbackVoteValue | null;
disabled?: boolean;
sharingPreference?: FeedbackDataSharingPreference;
termsUrl?: string | null;
onVote: (vote: FeedbackVoteValue, options?: { allowSharing?: boolean; reason?: string }) => Promise<void>;
rightSlot?: React.ReactNode;
}) {
const [pendingVote, setPendingVote] = useState<{
vote: FeedbackVoteValue;
@ -130,6 +132,7 @@ export function OutputFeedbackButtons({
<ThumbsDown className="mr-1.5 h-3.5 w-3.5" />
Needs work
</Button>
{rightSlot ? <div className="ml-auto">{rightSlot}</div> : null}
</div>
{collectingDownvoteReason ? (
<div className="mt-2 rounded-md border border-border/60 bg-accent/20 p-3">
@ -216,6 +219,7 @@ export function OutputFeedbackButtons({
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={!pendingVote || isSaving}
onClick={() => {
if (!pendingVote) return;

View file

@ -16,6 +16,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { AlertCircle, Archive, ArchiveRestore, Check, ExternalLink, Github, Loader2, Plus, Trash2, X } from "lucide-react";
import { ChoosePathButton } from "./PathInstructionsModal";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { DraftInput } from "./agent-config-primitives";
import { InlineEditor } from "./InlineEditor";
@ -886,26 +887,14 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
</div>
</div>
{onUpdate || onFieldUpdate ? (
<button
data-slot="toggle"
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
executionWorkspacesEnabled ? "bg-green-600" : "bg-muted",
)}
type="button"
onClick={() =>
<ToggleSwitch
checked={executionWorkspacesEnabled}
onCheckedChange={() =>
commitField(
"execution_workspace_enabled",
updateExecutionWorkspacePolicy({ enabled: !executionWorkspacesEnabled })!,
)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
executionWorkspacesEnabled ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
/>
) : (
<span className="text-xs text-muted-foreground">
{executionWorkspacesEnabled ? "Enabled" : "Disabled"}
@ -925,14 +914,9 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
If disabled, new issues stay on the project's primary checkout unless someone opts in.
</div>
</div>
<button
data-slot="toggle"
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
executionWorkspaceDefaultMode === "isolated_workspace" ? "bg-green-600" : "bg-muted",
)}
type="button"
onClick={() =>
<ToggleSwitch
checked={executionWorkspaceDefaultMode === "isolated_workspace"}
onCheckedChange={() =>
commitField(
"execution_workspace_default_mode",
updateExecutionWorkspacePolicy({
@ -942,16 +926,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
: "isolated_workspace",
})!,
)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
executionWorkspaceDefaultMode === "isolated_workspace"
? "translate-x-4.5"
: "translate-x-0.5",
)}
/>
</button>
/>
</div>
<div className="border-t border-border/60 pt-2">

View file

@ -0,0 +1,38 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { ThemeProvider } from "../context/ThemeContext";
import { RunInvocationCard } from "../pages/AgentDetail";
describe("RunInvocationCard", () => {
it("keeps verbose invocation details collapsed by default", () => {
const html = renderToStaticMarkup(
<ThemeProvider>
<RunInvocationCard
payload={{
adapterType: "claude_local",
cwd: "/tmp/workspace",
command: "claude",
commandArgs: ["--dangerously-skip-permissions"],
commandNotes: ["Prompt is piped to claude via stdin."],
prompt: "very long prompt body",
context: { triggeredBy: "board" },
env: { ANTHROPIC_API_KEY: "***REDACTED***" },
}}
censorUsernameInLogs={false}
/>
</ThemeProvider>,
);
expect(html).toContain("Invocation");
expect(html).toContain("Adapter:");
expect(html).toContain("Working dir:");
expect(html).toContain("Details");
expect(html).not.toContain("Command:");
expect(html).not.toContain("Prompt is piped to claude via stdin.");
expect(html).not.toContain("very long prompt body");
expect(html).not.toContain("ANTHROPIC_API_KEY");
expect(html).not.toContain("triggeredBy");
});
});

View file

@ -24,7 +24,7 @@ export const defaultCreateValues: CreateConfigValues = {
workspaceBranchTemplate: "",
worktreeParentDir: "",
runtimeServicesJson: "",
maxTurnsPerRun: 300,
maxTurnsPerRun: 1000,
heartbeatEnabled: false,
intervalSec: 300,
};

View file

@ -4,6 +4,7 @@ import {
TooltipTrigger,
TooltipContent,
} from "@/components/ui/tooltip";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import {
Dialog,
DialogContent,
@ -57,17 +58,9 @@ export const help: Record<string, string> = {
budgetMonthlyCents: "Monthly spending limit in cents. 0 means no limit.",
};
export const adapterLabels: Record<string, string> = {
claude_local: "Claude (local)",
codex_local: "Codex (local)",
gemini_local: "Gemini CLI (local)",
opencode_local: "OpenCode (local)",
openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)",
hermes_local: "Hermes Agent",
process: "Process",
http: "HTTP",
};
import { getAdapterLabels } from "../adapters/adapter-display-registry";
export const adapterLabels = getAdapterLabels();
export const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
@ -119,23 +112,11 @@ export function ToggleField({
<span className="text-xs text-muted-foreground">{label}</span>
{hint && <HintIcon text={hint} />}
</div>
<button
data-slot="toggle"
<ToggleSwitch
checked={checked}
onCheckedChange={onChange}
data-testid={toggleTestId}
type="button"
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
checked ? "bg-green-600" : "bg-muted"
)}
onClick={() => onChange(!checked)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
checked ? "translate-x-4.5" : "translate-x-0.5"
)}
/>
</button>
/>
</div>
);
}
@ -170,21 +151,10 @@ export function ToggleWithNumber({
<span className="text-xs text-muted-foreground">{label}</span>
{hint && <HintIcon text={hint} />}
</div>
<button
data-slot="toggle"
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0",
checked ? "bg-green-600" : "bg-muted"
)}
onClick={() => onCheckedChange(!checked)}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
checked ? "translate-x-4.5" : "translate-x-0.5"
)}
/>
</button>
<ToggleSwitch
checked={checked}
onCheckedChange={onCheckedChange}
/>
</div>
{showNumber && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">

View file

@ -81,4 +81,33 @@ describe("RunTranscriptView", () => {
text: "Working on the task.",
});
});
it("renders successful result summaries as markdown in nice mode", () => {
const html = renderToStaticMarkup(
<ThemeProvider>
<RunTranscriptView
density="compact"
entries={[
{
kind: "result",
ts: "2026-03-12T00:00:02.000Z",
text: "## Summary\n\n- fixed deploy config\n- posted issue update",
inputTokens: 10,
outputTokens: 20,
cachedTokens: 0,
costUsd: 0,
subtype: "success",
isError: false,
errors: [],
},
]}
/>
</ThemeProvider>,
);
expect(html).toContain("<h2>Summary</h2>");
expect(html).toContain("<li>fixed deploy config</li>");
expect(html).toContain("<li>posted issue update</li>");
expect(html).not.toContain("result");
});
});

View file

@ -7,6 +7,7 @@ import {
ChevronDown,
ChevronRight,
CircleAlert,
GitCompare,
TerminalSquare,
User,
Wrench,
@ -92,6 +93,12 @@ type TranscriptBlock =
endTs?: string;
lines: Array<{ ts: string; text: string }>;
}
| {
type: "system_group";
ts: string;
endTs?: string;
lines: Array<{ ts: string; text: string }>;
}
| {
type: "stdout";
ts: string;
@ -104,6 +111,16 @@ type TranscriptBlock =
tone: "info" | "warn" | "error" | "neutral";
text: string;
detail?: string;
}
| {
type: "diff_group";
ts: string;
endTs?: string;
filePath?: string;
hunks: Array<{
changeType: "add" | "remove" | "context" | "hunk" | "file_header" | "truncation";
text: string;
}>;
};
function asRecord(value: unknown): Record<string, unknown> | null {
@ -491,6 +508,10 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
label: "result",
tone: entry.isError ? "error" : "info",
text: entry.text.trim() || entry.errors[0] || (entry.isError ? "Run failed" : "Completed"),
detail:
!entry.isError && entry.text.trim().length > 0
? `${formatTokens(entry.inputTokens)} / ${formatTokens(entry.outputTokens)} / $${entry.costUsd.toFixed(6)}`
: undefined,
});
continue;
}
@ -543,13 +564,19 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
}
continue;
}
blocks.push({
type: "event",
ts: entry.ts,
label: "system",
tone: "warn",
text: entry.text,
});
// Batch consecutive system events into a single collapsible group
const prev = blocks[blocks.length - 1];
if (prev && prev.type === "system_group") {
prev.lines.push({ ts: entry.ts, text: entry.text });
prev.endTs = entry.ts;
} else {
blocks.push({
type: "system_group",
ts: entry.ts,
endTs: entry.ts,
lines: [{ ts: entry.ts, text: entry.text }],
});
}
continue;
}
@ -564,6 +591,28 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
continue;
}
// ── Diff entries — accumulate into diff_group blocks ──────────
if (entry.kind === "diff") {
const prev = blocks[blocks.length - 1];
if (prev && prev.type === "diff_group") {
if (entry.changeType === "file_header") {
// New file in the same diff block — update filePath
prev.filePath = entry.text;
}
prev.hunks.push({ changeType: entry.changeType, text: entry.text });
prev.endTs = entry.ts;
} else {
blocks.push({
type: "diff_group",
ts: entry.ts,
endTs: entry.ts,
filePath: entry.changeType === "file_header" ? entry.text : undefined,
hunks: [{ changeType: entry.changeType, text: entry.text }],
});
}
continue;
}
if (previous?.type === "stdout") {
previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`;
previous.ts = entry.ts;
@ -1062,9 +1111,14 @@ function TranscriptEventRow({
)}
<div className="min-w-0 flex-1">
{block.label === "result" && block.tone !== "error" ? (
<div className={cn("whitespace-pre-wrap break-words text-sky-700 dark:text-sky-300", compact ? "text-[11px]" : "text-xs")}>
<MarkdownBody
className={cn(
"[&>*:first-child]:mt-0 [&>*:last-child]:mb-0 text-sky-700 dark:text-sky-300",
compact ? "text-[11px] leading-5" : "text-xs leading-5",
)}
>
{block.text}
</div>
</MarkdownBody>
) : (
<div className={cn("whitespace-pre-wrap break-words", compact ? "text-[11px]" : "text-xs")}>
<span className="text-[10px] font-semibold uppercase tracking-[0.1em] text-muted-foreground/70">
@ -1084,6 +1138,103 @@ function TranscriptEventRow({
);
}
function TranscriptDiffGroup({
block,
density,
}: {
block: Extract<TranscriptBlock, { type: "diff_group" }>;
density: TranscriptDensity;
}) {
const [open, setOpen] = useState(false);
const compact = density === "compact";
// Count add/remove lines (exclude context, hunk, file_header, truncation)
const addCount = block.hunks.filter((h) => h.changeType === "add").length;
const removeCount = block.hunks.filter((h) => h.changeType === "remove").length;
const hasChanges = addCount > 0 || removeCount > 0;
// Extract a short file name from the path
const shortFile = block.filePath
? block.filePath.split("/").pop() ?? block.filePath
: "diff";
return (
<div className="rounded-xl border border-blue-500/20 bg-blue-500/[0.04] p-2">
<div
role="button"
tabIndex={0}
className="flex cursor-pointer items-center gap-2"
onClick={() => setOpen((v) => !v)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }}
>
<GitCompare className={compact ? "h-3.5 w-3.5" : "h-4 w-4"} />
<span className={cn("text-[11px] font-semibold uppercase tracking-[0.14em] text-blue-700 dark:text-blue-300")}>
{shortFile}
</span>
{hasChanges && (
<span className="text-[10px] tabular-nums">
<span className="text-emerald-600 dark:text-emerald-400">+{addCount}</span>
{" "}
<span className="text-red-600 dark:text-red-400">-{removeCount}</span>
</span>
)}
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</div>
{open && (
<pre className={cn(
"mt-2 overflow-x-auto whitespace-pre-wrap break-words font-mono pl-5",
compact ? "text-[11px]" : "text-xs",
)}>
{block.hunks.map((hunk, i) => {
const key = `${i}-${hunk.changeType}`;
switch (hunk.changeType) {
case "remove":
return (
<span key={key} className="block bg-red-500/[0.10] text-red-700 dark:text-red-300 -mx-2 px-2">
<span className="select-none mr-2 text-red-500/60 dark:text-red-400/50">-</span>
{hunk.text}
{"\n"}
</span>
);
case "add":
return (
<span key={key} className="block bg-emerald-500/[0.10] text-emerald-700 dark:text-emerald-300 -mx-2 px-2">
<span className="select-none mr-2 text-emerald-500/60 dark:text-emerald-400/50">+</span>
{hunk.text}
{"\n"}
</span>
);
case "file_header":
return (
<span key={key} className="block font-semibold text-blue-600 dark:text-blue-300 mt-2 first:mt-0">
{hunk.text}
{"\n"}
</span>
);
case "truncation":
return (
<span key={key} className="block text-muted-foreground italic mt-1">
{hunk.text}
{"\n"}
</span>
);
case "context":
default:
return (
<span key={key} className="block text-muted-foreground/70">
{" "}
{hunk.text}
{"\n"}
</span>
);
}
})}
</pre>
)}
</div>
);
}
function TranscriptStderrGroup({
block,
density,
@ -1121,6 +1272,43 @@ function TranscriptStderrGroup({
);
}
function TranscriptSystemGroup({
block,
density,
}: {
block: Extract<TranscriptBlock, { type: "system_group" }>;
density: TranscriptDensity;
}) {
const [open, setOpen] = useState(false);
return (
<div className="rounded-xl border border-blue-500/20 bg-blue-500/[0.04] p-2 text-blue-700 dark:text-blue-300">
<div
role="button"
tabIndex={0}
className="flex cursor-pointer items-center gap-2"
onClick={() => setOpen((v) => !v)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }}
>
<TerminalSquare className="h-3.5 w-3.5 shrink-0" />
<span className="text-[10px] font-semibold uppercase tracking-[0.14em]">
{block.lines.length} system {block.lines.length === 1 ? "message" : "messages"}
</span>
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
</div>
{open && (
<pre className="mt-2 overflow-x-auto whitespace-pre-wrap break-words font-mono text-[11px] text-blue-700/80 dark:text-blue-300/80 pl-5">
{block.lines.map((line, i) => (
<span key={`${line.ts}-${i}`}>
<span className="select-none text-blue-500/40 dark:text-blue-400/30">{i > 0 ? "\n" : ""}</span>
{line.text}
</span>
))}
</pre>
)}
</div>
);
}
function TranscriptStdoutRow({
block,
density,
@ -1242,7 +1430,9 @@ export function RunTranscriptView({
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
{block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />}
{block.type === "tool_group" && <TranscriptToolGroup block={block} density={density} />}
{block.type === "diff_group" && <TranscriptDiffGroup block={block} density={density} />}
{block.type === "stderr_group" && <TranscriptStderrGroup block={block} density={density} />}
{block.type === "system_group" && <TranscriptSystemGroup block={block} density={density} />}
{block.type === "stdout" && (
<TranscriptStdoutRow block={block} density={density} collapseByDefault={collapseStdout} />
)}

View file

@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import type { LiveEvent } from "@paperclipai/shared";
import { instanceSettingsApi } from "../../api/instanceSettings";
import { heartbeatsApi, type LiveRunForIssue } from "../../api/heartbeats";
import { buildTranscript, getUIAdapter, type RunLogChunk, type TranscriptEntry } from "../../adapters";
import { buildTranscript, getUIAdapter, onAdapterChange, type RunLogChunk, type TranscriptEntry } from "../../adapters";
import { queryKeys } from "../../lib/queryKeys";
const LOG_POLL_INTERVAL_MS = 2000;
@ -68,6 +68,11 @@ export function useLiveRunTranscripts({
const seenChunkKeysRef = useRef(new Set<string>());
const pendingLogRowsByRunRef = useRef(new Map<string, string>());
const logOffsetByRunRef = useRef(new Map<string, number>());
// Tick counter to force transcript recomputation when dynamic parser loads
const [parserTick, setParserTick] = useState(0);
useEffect(() => {
return onAdapterChange(() => setParserTick((t) => t + 1));
}, []);
const { data: generalSettings } = useQuery({
queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(),
@ -279,13 +284,13 @@ export function useLiveRunTranscripts({
const adapter = getUIAdapter(run.adapterType);
next.set(
run.id,
buildTranscript(chunksByRun.get(run.id) ?? [], adapter.parseStdoutLine, {
buildTranscript(chunksByRun.get(run.id) ?? [], adapter, {
censorUsernameInLogs,
}),
);
}
return next;
}, [chunksByRun, generalSettings?.censorUsernameInLogs, runs]);
}, [chunksByRun, generalSettings?.censorUsernameInLogs, parserTick, runs]);
return {
transcriptByRun,

View file

@ -0,0 +1,59 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface ToggleSwitchProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
size?: "default" | "lg";
}
export const ToggleSwitch = React.forwardRef<
HTMLButtonElement,
ToggleSwitchProps
>(
(
{ checked, onCheckedChange, size = "default", className, disabled, ...props },
ref,
) => {
const isLg = size === "lg";
return (
<button
ref={ref}
type="button"
role="switch"
aria-checked={checked}
data-slot="toggle"
disabled={disabled}
className={cn(
"relative inline-flex shrink-0 items-center rounded-full transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
// Track: larger on mobile (<640px), standard on desktop
isLg ? "h-7 w-12 sm:h-6 sm:w-11" : "h-6 w-10 sm:h-5 sm:w-9",
checked ? "bg-green-600" : "bg-muted",
className,
)}
onClick={() => onCheckedChange(!checked)}
{...props}
>
<span
className={cn(
"pointer-events-none inline-block rounded-full bg-white shadow-sm transition-transform",
// Thumb
isLg ? "size-5.5 sm:size-5" : "size-4.5 sm:size-3.5",
// Slide position
checked
? isLg
? "translate-x-5 sm:translate-x-5"
: "translate-x-5 sm:translate-x-4.5"
: "translate-x-0.5",
)}
/>
</button>
);
},
);
ToggleSwitch.displayName = "ToggleSwitch";

View file

@ -0,0 +1,61 @@
import { createContext, useContext, useMemo, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { buildSkillMentionHref } from "@paperclipai/shared";
import { companySkillsApi } from "../api/companySkills";
import { useCompany } from "./CompanyContext";
import { queryKeys } from "../lib/queryKeys";
export interface SkillCommandOption {
id: string;
kind: "skill";
skillId: string;
key: string;
name: string;
slug: string;
description: string | null;
href: string;
aliases: string[];
}
interface EditorAutocompleteContextValue {
slashCommands: SkillCommandOption[];
}
const EditorAutocompleteContext = createContext<EditorAutocompleteContextValue>({
slashCommands: [],
});
export function EditorAutocompleteProvider({ children }: { children: ReactNode }) {
const { selectedCompanyId } = useCompany();
const { data: companySkills = [] } = useQuery({
queryKey: selectedCompanyId
? queryKeys.companySkills.list(selectedCompanyId)
: ["company-skills", "__none__"],
queryFn: () => companySkillsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const value = useMemo<EditorAutocompleteContextValue>(() => ({
slashCommands: companySkills.map((skill) => ({
id: `skill:${skill.id}`,
kind: "skill",
skillId: skill.id,
key: skill.key,
name: skill.name,
slug: skill.slug,
description: skill.description ?? null,
href: buildSkillMentionHref(skill.id, skill.slug),
aliases: [skill.slug, skill.name, skill.key],
})),
}), [companySkills]);
return (
<EditorAutocompleteContext.Provider value={value}>
{children}
</EditorAutocompleteContext.Provider>
);
}
export function useEditorAutocomplete() {
return useContext(EditorAutocompleteContext);
}

View file

@ -142,6 +142,11 @@
label {
touch-action: manipulation;
}
/* Let font-mono (utilities layer) override for monospace editors */
.paperclip-mdxeditor [class*="_placeholder_"],
.paperclip-mdxeditor-content {
font-family: inherit;
}
}
@media (pointer: coarse) {
@ -319,14 +324,12 @@
}
.paperclip-mdxeditor [class*="_placeholder_"] {
font-family: inherit;
font-size: 0.875rem;
line-height: 1.5;
color: var(--muted-foreground);
}
.paperclip-mdxeditor-content {
font-family: inherit;
font-size: inherit;
line-height: inherit;
color: inherit;

View file

@ -180,6 +180,7 @@ function makeIssue(id: string, isUnreadForMe: boolean): Issue {
labelIds: [],
myLastTouchAt: new Date("2026-03-11T00:00:00.000Z"),
lastExternalCommentAt: new Date("2026-03-11T01:00:00.000Z"),
lastActivityAt: new Date("2026-03-11T01:00:00.000Z"),
isUnreadForMe,
};
}
@ -357,10 +358,10 @@ describe("inbox helpers", () => {
it("mixes approvals into the inbox feed by most recent activity", () => {
const newerIssue = makeIssue("1", true);
newerIssue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
newerIssue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z");
const olderIssue = makeIssue("2", false);
olderIssue.lastExternalCommentAt = new Date("2026-03-11T02:00:00.000Z");
olderIssue.lastActivityAt = new Date("2026-03-11T02:00:00.000Z");
const approval = makeApprovalWithTimestamps(
"approval-between",
@ -385,19 +386,21 @@ describe("inbox helpers", () => {
]);
});
it("sorts touched issues by latest external comment timestamp", () => {
const newerIssue = makeIssue("1", true);
newerIssue.lastExternalCommentAt = new Date("2026-03-11T05:00:00.000Z");
it("prefers canonical lastActivityAt over comment-only timestamps", () => {
const activityIssue = makeIssue("1", true);
activityIssue.lastExternalCommentAt = new Date("2026-03-11T01:00:00.000Z");
activityIssue.lastActivityAt = new Date("2026-03-11T05:00:00.000Z");
const olderIssue = makeIssue("2", true);
olderIssue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
const commentIssue = makeIssue("2", true);
commentIssue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
commentIssue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z");
expect(getRecentTouchedIssues([olderIssue, newerIssue]).map((issue) => issue.id)).toEqual(["1", "2"]);
expect(getRecentTouchedIssues([commentIssue, activityIssue]).map((issue) => issue.id)).toEqual(["1", "2"]);
});
it("mixes join requests into the inbox feed by most recent activity", () => {
const issue = makeIssue("1", true);
issue.lastExternalCommentAt = new Date("2026-03-11T04:00:00.000Z");
issue.lastActivityAt = new Date("2026-03-11T04:00:00.000Z");
const joinRequest = makeJoinRequest("join-1");
joinRequest.createdAt = new Date("2026-03-11T03:00:00.000Z");
@ -482,7 +485,7 @@ describe("inbox helpers", () => {
it("limits recent touched issues before unread badge counting", () => {
const issues = Array.from({ length: RECENT_ISSUES_LIMIT + 5 }, (_, index) => {
const issue = makeIssue(String(index + 1), index < 3);
issue.lastExternalCommentAt = new Date(Date.UTC(2026, 2, 31, 0, 0, 0, 0) - index * 60_000);
issue.lastActivityAt = new Date(Date.UTC(2026, 2, 31, 0, 0, 0, 0) - index * 60_000);
return issue;
});

View file

@ -217,6 +217,9 @@ export function normalizeTimestamp(value: string | Date | null | undefined): num
}
export function issueLastActivityTimestamp(issue: Issue): number {
const lastActivityAt = normalizeTimestamp(issue.lastActivityAt);
if (lastActivityAt > 0) return lastActivityAt;
const lastExternalCommentAt = normalizeTimestamp(issue.lastExternalCommentAt);
if (lastExternalCommentAt > 0) return lastExternalCommentAt;

View file

@ -0,0 +1,153 @@
import { describe, expect, it } from "vitest";
import type { ActivityEvent } from "@paperclipai/shared";
import { extractIssueTimelineEvents } from "./issue-timeline-events";
describe("extractIssueTimelineEvents", () => {
it("extracts and sorts status and assignee changes from issue updates", () => {
const events = extractIssueTimelineEvents([
{
id: "evt-2",
companyId: "company-1",
actorType: "user",
actorId: "local-board",
action: "issue.updated",
entityType: "issue",
entityId: "issue-1",
agentId: null,
runId: null,
createdAt: new Date("2026-03-31T12:02:00.000Z"),
details: {
assigneeAgentId: "agent-2",
assigneeUserId: null,
_previous: {
assigneeAgentId: "agent-1",
assigneeUserId: null,
},
},
},
{
id: "evt-1",
companyId: "company-1",
actorType: "user",
actorId: "local-board",
action: "issue.updated",
entityType: "issue",
entityId: "issue-1",
agentId: null,
runId: null,
createdAt: new Date("2026-03-31T12:01:00.000Z"),
details: {
status: "in_progress",
_previous: {
status: "todo",
},
},
},
{
id: "evt-ignored",
companyId: "company-1",
actorType: "user",
actorId: "local-board",
action: "issue.comment_added",
entityType: "issue",
entityId: "issue-1",
agentId: null,
runId: null,
createdAt: new Date("2026-03-31T12:03:00.000Z"),
details: {
commentId: "comment-1",
},
},
] satisfies ActivityEvent[]);
expect(events).toEqual([
{
id: "evt-1",
createdAt: new Date("2026-03-31T12:01:00.000Z"),
actorType: "user",
actorId: "local-board",
statusChange: {
from: "todo",
to: "in_progress",
},
},
{
id: "evt-2",
createdAt: new Date("2026-03-31T12:02:00.000Z"),
actorType: "user",
actorId: "local-board",
assigneeChange: {
from: {
agentId: "agent-1",
userId: null,
},
to: {
agentId: "agent-2",
userId: null,
},
},
},
]);
});
it("uses reopenedFrom when a reopen update omits _previous", () => {
const events = extractIssueTimelineEvents([
{
id: "evt-reopen",
companyId: "company-1",
actorType: "agent",
actorId: "agent-1",
action: "issue.updated",
entityType: "issue",
entityId: "issue-1",
agentId: "agent-1",
runId: "run-1",
createdAt: new Date("2026-03-31T12:01:00.000Z"),
details: {
status: "todo",
reopened: true,
reopenedFrom: "done",
source: "comment",
},
},
] satisfies ActivityEvent[]);
expect(events).toEqual([
{
id: "evt-reopen",
createdAt: new Date("2026-03-31T12:01:00.000Z"),
actorType: "agent",
actorId: "agent-1",
statusChange: {
from: "done",
to: "todo",
},
},
]);
});
it("ignores issue updates without visible status or assignee transitions", () => {
const events = extractIssueTimelineEvents([
{
id: "evt-title",
companyId: "company-1",
actorType: "user",
actorId: "local-board",
action: "issue.updated",
entityType: "issue",
entityId: "issue-1",
agentId: null,
runId: null,
createdAt: new Date("2026-03-31T12:01:00.000Z"),
details: {
title: "New title",
_previous: {
title: "Old title",
},
},
},
] satisfies ActivityEvent[]);
expect(events).toEqual([]);
});
});

View file

@ -0,0 +1,105 @@
import type { ActivityEvent } from "@paperclipai/shared";
export interface IssueTimelineAssignee {
agentId: string | null;
userId: string | null;
}
export interface IssueTimelineEvent {
id: string;
createdAt: Date | string;
actorType: ActivityEvent["actorType"];
actorId: string;
statusChange?: {
from: string | null;
to: string | null;
};
assigneeChange?: {
from: IssueTimelineAssignee;
to: IssueTimelineAssignee;
};
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function hasOwn(record: Record<string, unknown>, key: string) {
return Object.prototype.hasOwnProperty.call(record, key);
}
function nullableString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
function toTimestamp(value: Date | string) {
return new Date(value).getTime();
}
function sameAssignee(left: IssueTimelineAssignee, right: IssueTimelineAssignee) {
return left.agentId === right.agentId && left.userId === right.userId;
}
function sortTimelineEvents<T extends { createdAt: Date | string; id: string }>(events: T[]) {
return [...events].sort((a, b) => {
const createdAtDiff = toTimestamp(a.createdAt) - toTimestamp(b.createdAt);
if (createdAtDiff !== 0) return createdAtDiff;
return a.id.localeCompare(b.id);
});
}
export function extractIssueTimelineEvents(activity: ActivityEvent[] | null | undefined): IssueTimelineEvent[] {
const events: IssueTimelineEvent[] = [];
for (const event of activity ?? []) {
if (event.action !== "issue.updated") continue;
const details = asRecord(event.details);
if (!details) continue;
const previous = asRecord(details._previous);
const timelineEvent: IssueTimelineEvent = {
id: event.id,
createdAt: event.createdAt,
actorType: event.actorType,
actorId: event.actorId,
};
if (hasOwn(details, "status")) {
const from = nullableString(previous?.status) ?? nullableString(details.reopenedFrom);
const to = nullableString(details.status);
if (from !== to) {
timelineEvent.statusChange = { from, to };
}
}
if (hasOwn(details, "assigneeAgentId") || hasOwn(details, "assigneeUserId")) {
const previousAssignee: IssueTimelineAssignee = {
agentId: nullableString(previous?.assigneeAgentId),
userId: nullableString(previous?.assigneeUserId),
};
const nextAssignee: IssueTimelineAssignee = {
agentId: hasOwn(details, "assigneeAgentId")
? nullableString(details.assigneeAgentId)
: previousAssignee.agentId,
userId: hasOwn(details, "assigneeUserId")
? nullableString(details.assigneeUserId)
: previousAssignee.userId,
};
if (!sameAssignee(previousAssignee, nextAssignee)) {
timelineEvent.assigneeChange = {
from: previousAssignee,
to: nextAssignee,
};
}
}
if (timelineEvent.statusChange || timelineEvent.assigneeChange) {
events.push(timelineEvent);
}
}
return sortTimelineEvents(events);
}

View file

@ -33,6 +33,12 @@ describe("keyboardShortcuts helpers", () => {
expect(hasBlockingShortcutDialog(document.createElement("div"))).toBe(false);
});
it("ignores non-dialog elements that happen to be aria-modal", () => {
const root = document.createElement("div");
root.innerHTML = `<section aria-modal="true"></section>`;
expect(hasBlockingShortcutDialog(root)).toBe(false);
});
it("archives only the first clean y press", () => {
const button = document.createElement("button");

View file

@ -19,7 +19,7 @@ export function isKeyboardShortcutTextInputTarget(target: EventTarget | null): b
}
export function hasBlockingShortcutDialog(root: ParentNode = document): boolean {
return !!root.querySelector("[role='dialog'], [aria-modal='true']");
return !!root.querySelector("[role='dialog'][aria-modal='true']");
}
export function isModifierOnlyKey(key: string): boolean {

View file

@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { looksLikeMarkdownPaste, normalizePastedMarkdown } from "./markdownPaste";
describe("markdownPaste", () => {
it("normalizes windows line endings", () => {
expect(normalizePastedMarkdown("a\r\nb\r\n")).toBe("a\nb\n");
});
it("normalizes old mac line endings", () => {
expect(normalizePastedMarkdown("a\rb\r")).toBe("a\nb\n");
});
it("treats markdown blocks as markdown paste", () => {
expect(looksLikeMarkdownPaste("# Title\n\n- item 1\n- item 2")).toBe(true);
});
it("treats a fenced code block as markdown paste", () => {
expect(looksLikeMarkdownPaste("```\nconst x = 1;\n```")).toBe(true);
});
it("treats a tilde fence as markdown paste", () => {
expect(looksLikeMarkdownPaste("~~~\nraw\n~~~")).toBe(true);
});
it("treats a blockquote as markdown paste", () => {
expect(looksLikeMarkdownPaste("> some quoted text")).toBe(true);
});
it("treats an ordered list as markdown paste", () => {
expect(looksLikeMarkdownPaste("1. first\n2. second")).toBe(true);
});
it("treats a table row as markdown paste", () => {
expect(looksLikeMarkdownPaste("| col1 | col2 |")).toBe(true);
});
it("treats horizontal rules as markdown paste", () => {
expect(looksLikeMarkdownPaste("---")).toBe(true);
expect(looksLikeMarkdownPaste("***")).toBe(true);
expect(looksLikeMarkdownPaste("___")).toBe(true);
});
it("leaves plain multi-line text on the native paste path", () => {
expect(looksLikeMarkdownPaste("first paragraph\nsecond paragraph")).toBe(false);
});
it("leaves single-line plain text on the native paste path", () => {
expect(looksLikeMarkdownPaste("just a sentence")).toBe(false);
});
});

View file

@ -0,0 +1,23 @@
const BLOCK_MARKER_PATTERNS = [
/^#{1,6}\s+/m,
/^>\s+/m,
/^[-*+]\s+/m,
/^\d+\.\s+/m,
/^```/m,
/^~~~/m,
/^\|.+\|$/m,
/^---$/m,
/^\*\*\*$/m,
/^___$/m,
];
export function normalizePastedMarkdown(text: string): string {
return text.replace(/\r\n?/g, "\n");
}
export function looksLikeMarkdownPaste(text: string): boolean {
const normalized = normalizePastedMarkdown(text).trim();
if (!normalized) return false;
return BLOCK_MARKER_PATTERNS.some((pattern) => pattern.test(normalized));
}

View file

@ -1,5 +1,5 @@
import type { CSSProperties } from "react";
import { parseAgentMentionHref, parseProjectMentionHref } from "@paperclipai/shared";
import { parseAgentMentionHref, parseProjectMentionHref, parseSkillMentionHref } from "@paperclipai/shared";
import { getAgentIcon } from "./agent-icons";
import { hexToRgb, pickTextColorForPillBg } from "./color-contrast";
@ -13,6 +13,11 @@ export type ParsedMentionChip =
kind: "project";
projectId: string;
color: string | null;
}
| {
kind: "skill";
skillId: string;
slug: string | null;
};
const iconMaskCache = new Map<string, string>();
@ -36,6 +41,15 @@ export function parseMentionChipHref(href: string): ParsedMentionChip | null {
};
}
const skill = parseSkillMentionHref(href);
if (skill) {
return {
kind: "skill",
skillId: skill.skillId,
slug: skill.slug,
};
}
return null;
}
@ -86,6 +100,7 @@ export function clearMentionChipDecoration(element: HTMLElement) {
"paperclip-mention-chip",
"paperclip-mention-chip--agent",
"paperclip-mention-chip--project",
"paperclip-mention-chip--skill",
"paperclip-project-mention-chip",
);
element.removeAttribute("contenteditable");

View file

@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import { normalizeMarkdown } from "./normalize-markdown";
describe("normalizeMarkdown", () => {
it("strips common leading whitespace (dedent)", () => {
const input = " # Title\n \n Some text\n - Item 1\n - Item 2";
const expected = "# Title\n\nSome text\n- Item 1\n- Item 2";
expect(normalizeMarkdown(input)).toBe(expected);
});
it("preserves relative indentation within dedented content", () => {
const input = " # Title\n \n Some text\n code block\n More text";
const expected = "# Title\n\nSome text\n code block\nMore text";
expect(normalizeMarkdown(input)).toBe(expected);
});
it("normalizes CRLF to LF", () => {
const input = "line one\r\nline two\r\nline three";
const expected = "line one\nline two\nline three";
expect(normalizeMarkdown(input)).toBe(expected);
});
it("normalizes bare CR to LF", () => {
const input = "line one\rline two\rline three";
const expected = "line one\nline two\nline three";
expect(normalizeMarkdown(input)).toBe(expected);
});
it("returns single-line input unchanged", () => {
const input = " just one line";
expect(normalizeMarkdown(input)).toBe(" just one line");
});
it("returns text unchanged when no common indent", () => {
const input = "# Title\n\nNo indent here\n- list item";
expect(normalizeMarkdown(input)).toBe(input);
});
it("handles empty lines in indented content", () => {
const input = " line one\n\n line two\n \n line three";
const expected = "line one\n\nline two\n\nline three";
expect(normalizeMarkdown(input)).toBe(expected);
});
it("returns empty string unchanged", () => {
expect(normalizeMarkdown("")).toBe("");
});
it("handles mixed indent levels correctly", () => {
const input = " base\n nested\n back\n deep";
const expected = "base\n nested\nback\n deep";
expect(normalizeMarkdown(input)).toBe(expected);
});
it("leaves mixed tab and space indentation unchanged", () => {
const input = "\t# Title\n body\n\t- item";
expect(normalizeMarkdown(input)).toBe(input);
});
});

View file

@ -0,0 +1,44 @@
/**
* Normalize pasted markdown by removing common leading whitespace (dedent)
* and normalizing line endings. This fixes formatting issues when pasting
* content from terminals/consoles that add uniform indentation.
*/
export function normalizeMarkdown(text: string): string {
// Normalize line endings
let result = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
const lines = result.split("\n");
if (lines.length <= 1) return result;
// Find minimum indentation across non-empty lines
let minIndent = Infinity;
let indentStyle: "spaces" | "tabs" | null = null;
for (const line of lines) {
if (line.trim() === "") continue;
const match = line.match(/^(\s+)/);
if (match) {
const leadingWhitespace = match[1];
const currentStyle = leadingWhitespace.includes("\t") ? "tabs" : "spaces";
if (indentStyle && indentStyle !== currentStyle) {
return result;
}
indentStyle = currentStyle;
minIndent = Math.min(minIndent, leadingWhitespace.length);
} else {
minIndent = 0;
break;
}
}
// Strip common indent and trim whitespace-only lines
if (minIndent > 0 && minIndent < Infinity) {
result = lines
.map((line) => {
if (line.trim() === "") return "";
return line.slice(minIndent);
})
.join("\n");
}
return result;
}

View file

@ -0,0 +1,66 @@
import { createRootEditorSubscription$, realmPlugin } from "@mdxeditor/editor";
import { COMMAND_PRIORITY_CRITICAL, PASTE_COMMAND } from "lexical";
import { looksLikeMarkdownPaste } from "./markdownPaste";
import { normalizeMarkdown } from "./normalize-markdown";
/**
* MDXEditor/Lexical plugin that intercepts paste events and normalizes
* markdown content before the editor processes it. Fixes issues with
* extra leading spaces when pasting from terminals or consoles.
*/
export const pasteNormalizationPlugin = realmPlugin({
init(realm) {
realm.pub(createRootEditorSubscription$, [
(editor) => {
let skipNext = false;
return editor.registerCommand(
PASTE_COMMAND,
(event) => {
if (skipNext) {
skipNext = false;
return false;
}
const clipboardData =
event instanceof ClipboardEvent ? event.clipboardData : null;
if (!clipboardData) return false;
const text = clipboardData.getData("text/plain");
if (!text) return false;
// If there's HTML content, the source app already formatted it —
// let the default paste handler deal with rich content as-is.
if (clipboardData.getData("text/html")) return false;
// Markdown-looking pastes are handled by MarkdownEditor.tsx via
// insertMarkdown(), so the plugin only owns the plain-text fallback.
if (looksLikeMarkdownPaste(text)) return false;
const cleaned = normalizeMarkdown(text);
if (cleaned === text) return false;
// Prevent the original paste from being processed
if (event instanceof ClipboardEvent) {
event.preventDefault();
}
// Re-dispatch with cleaned data so MDXEditor's handler processes it
const dt = new DataTransfer();
dt.setData("text/plain", cleaned);
const newEvent = new ClipboardEvent("paste", {
clipboardData: dt,
bubbles: true,
cancelable: true,
});
skipNext = true;
editor.dispatchCommand(PASTE_COMMAND, newEvent);
return true;
},
COMMAND_PRIORITY_CRITICAL,
);
},
]);
},
});

View file

@ -144,4 +144,7 @@ export const queryKeys = {
dashboard: (pluginId: string) => ["plugins", pluginId, "dashboard"] as const,
logs: (pluginId: string) => ["plugins", pluginId, "logs"] as const,
},
adapters: {
all: ["adapters"] as const,
},
};

View file

@ -11,6 +11,7 @@ import { BreadcrumbProvider } from "./context/BreadcrumbContext";
import { PanelProvider } from "./context/PanelContext";
import { SidebarProvider } from "./context/SidebarContext";
import { DialogProvider } from "./context/DialogContext";
import { EditorAutocompleteProvider } from "./context/EditorAutocompleteContext";
import { ToastProvider } from "./context/ToastContext";
import { ThemeProvider } from "./context/ThemeContext";
import { TooltipProvider } from "@/components/ui/tooltip";
@ -42,23 +43,25 @@ createRoot(document.getElementById("root")!).render(
<ThemeProvider>
<BrowserRouter>
<CompanyProvider>
<ToastProvider>
<LiveUpdatesProvider>
<TooltipProvider>
<BreadcrumbProvider>
<SidebarProvider>
<PanelProvider>
<PluginLauncherProvider>
<DialogProvider>
<App />
</DialogProvider>
</PluginLauncherProvider>
</PanelProvider>
</SidebarProvider>
</BreadcrumbProvider>
</TooltipProvider>
</LiveUpdatesProvider>
</ToastProvider>
<EditorAutocompleteProvider>
<ToastProvider>
<LiveUpdatesProvider>
<TooltipProvider>
<BreadcrumbProvider>
<SidebarProvider>
<PanelProvider>
<PluginLauncherProvider>
<DialogProvider>
<App />
</DialogProvider>
</PluginLauncherProvider>
</PanelProvider>
</SidebarProvider>
</BreadcrumbProvider>
</TooltipProvider>
</LiveUpdatesProvider>
</ToastProvider>
</EditorAutocompleteProvider>
</CompanyProvider>
</BrowserRouter>
</ThemeProvider>

View file

@ -0,0 +1,676 @@
/**
* @fileoverview Adapter Manager page install, view, and manage external adapters.
*
* Adapters are simpler than plugins: no workers, no events, no manifests.
* They just register a ServerAdapterModule that provides model discovery and execution.
*/
import { useEffect, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Cpu, Plus, Power, Trash2, FolderOpen, Package, RefreshCw, Download } from "lucide-react";
import { useCompany } from "@/context/CompanyContext";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { adaptersApi } from "@/api/adapters";
import type { AdapterInfo } from "@/api/adapters";
import { getAdapterLabel } from "@/adapters/adapter-display-registry";
import { queryKeys } from "@/lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useToast } from "@/context/ToastContext";
import { cn } from "@/lib/utils";
import { ChoosePathButton } from "@/components/PathInstructionsModal";
import { invalidateDynamicParser } from "@/adapters/dynamic-loader";
import { invalidateConfigSchemaCache } from "@/adapters/schema-config-fields";
function AdapterRow({
adapter,
canRemove,
onToggle,
onRemove,
onReload,
onReinstall,
isToggling,
isReloading,
isReinstalling,
overriddenBy,
/** Custom tooltip for the power button when adapter is enabled. */
toggleTitleEnabled,
/** Custom tooltip for the power button when adapter is disabled. */
toggleTitleDisabled,
/** Custom label for the disabled badge (defaults to "Hidden from menus"). */
disabledBadgeLabel,
}: {
adapter: AdapterInfo;
canRemove: boolean;
onToggle: (type: string, disabled: boolean) => void;
onRemove: (type: string) => void;
onReload?: (type: string) => void;
onReinstall?: (type: string) => void;
isToggling: boolean;
isReloading?: boolean;
isReinstalling?: boolean;
/** When set, shows an "Overridden by …" badge (used for builtin entries). */
overriddenBy?: string;
toggleTitleEnabled?: string;
toggleTitleDisabled?: string;
disabledBadgeLabel?: string;
}) {
return (
<li>
<div className="flex items-center gap-4 px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className={cn("font-medium", adapter.disabled && "text-muted-foreground line-through")}>
{adapter.label || getAdapterLabel(adapter.type)}
</span>
<Badge variant="outline">{adapter.source === "external" ? "External" : "Built-in"}</Badge>
{adapter.source === "external" && (
adapter.isLocalPath
? <span title="Installed from local path"><FolderOpen className="h-4 w-4 text-amber-500" /></span>
: <span title="Installed from npm"><Package className="h-4 w-4 text-red-500" /></span>
)}
{adapter.version && (
<Badge variant="secondary" className="font-mono text-[10px]">
v{adapter.version}
</Badge>
)}
{adapter.overriddenBuiltin && (
<Badge variant="secondary" className="text-blue-600 border-blue-400">
Overrides built-in
</Badge>
)}
{overriddenBy && (
<Badge variant="secondary" className="text-blue-600 border-blue-400">
Overridden by {overriddenBy}
</Badge>
)}
{adapter.disabled && (
<Badge variant="secondary" className="text-amber-600 border-amber-400">
{disabledBadgeLabel ?? "Hidden from menus"}
</Badge>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5">
{adapter.type}
{adapter.packageName && adapter.packageName !== adapter.type && (
<> · {adapter.packageName}</>
)}
{" · "}{adapter.modelsCount} models
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
{onReinstall && (
<Button
variant="outline"
size="icon-sm"
className="h-8 w-8"
title="Reinstall adapter (pull latest from npm)"
disabled={isReinstalling}
onClick={() => onReinstall(adapter.type)}
>
<Download className={cn("h-4 w-4", isReinstalling && "animate-bounce")} />
</Button>
)}
{onReload && (
<Button
variant="outline"
size="icon-sm"
className="h-8 w-8"
title="Reload adapter (hot-swap)"
disabled={isReloading}
onClick={() => onReload(adapter.type)}
>
<RefreshCw className={cn("h-4 w-4", isReloading && "animate-spin")} />
</Button>
)}
<Button
variant="outline"
size="icon-sm"
className="h-8 w-8"
title={adapter.disabled
? (toggleTitleEnabled ?? "Show in agent menus")
: (toggleTitleDisabled ?? "Hide from agent menus")}
disabled={isToggling}
onClick={() => onToggle(adapter.type, !adapter.disabled)}
>
<Power className={cn("h-4 w-4", !adapter.disabled ? "text-green-600" : "text-muted-foreground")} />
</Button>
{canRemove && (
<Button
variant="outline"
size="icon-sm"
className="h-8 w-8 text-destructive hover:text-destructive"
title="Remove adapter"
onClick={() => onRemove(adapter.type)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
</li>
);
}
function fetchNpmLatestVersion(packageName: string): Promise<string | null> {
return fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}/latest`, {
signal: AbortSignal.timeout(5000),
})
.then((res) => res.json())
.then((data) => (typeof data?.version === "string" ? (data.version as string) : null))
.catch(() => null);
}
function ReinstallDialog({
adapter,
open,
isReinstalling,
onConfirm,
onCancel,
}: {
adapter: AdapterInfo | null;
open: boolean;
isReinstalling: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
const { data: latestVersion, isLoading: isFetchingVersion } = useQuery({
queryKey: ["npm-latest-version", adapter?.packageName],
queryFn: () => {
if (!adapter?.packageName) return null;
return fetchNpmLatestVersion(adapter.packageName);
},
enabled: open && !!adapter?.packageName,
staleTime: 60_000,
});
const isUpToDate = adapter?.version && latestVersion && adapter.version === latestVersion;
return (
<Dialog open={open} onOpenChange={(o) => { if (!o) onCancel(); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reinstall Adapter</DialogTitle>
<DialogDescription>
This will pull the latest version of{" "}
<strong>{adapter?.packageName}</strong> from npm and hot-swap
the running adapter module. Existing agents will use the new
version on their next run.
</DialogDescription>
</DialogHeader>
<div className="rounded-md border bg-muted/50 px-4 py-3 text-sm space-y-1">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Package</span>
<span className="font-mono">{adapter?.packageName}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Current</span>
<span className="font-mono">
{adapter?.version ? `v${adapter.version}` : "unknown"}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Latest on npm</span>
<span className="font-mono">
{isFetchingVersion
? "checking..."
: latestVersion
? `v${latestVersion}`
: "unavailable"}
</span>
</div>
{isUpToDate && (
<p className="text-xs text-muted-foreground pt-1">
Already on the latest version.
</p>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={isReinstalling}>
Cancel
</Button>
<Button disabled={isReinstalling} onClick={onConfirm}>
{isReinstalling ? "Reinstalling..." : "Reinstall"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function AdapterManager() {
const { selectedCompany } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const { pushToast } = useToast();
const [installPackage, setInstallPackage] = useState("");
const [installVersion, setInstallVersion] = useState("");
const [isLocalPath, setIsLocalPath] = useState(false);
const [installDialogOpen, setInstallDialogOpen] = useState(false);
const [removeType, setRemoveType] = useState<string | null>(null);
const [reinstallTarget, setReinstallTarget] = useState<AdapterInfo | null>(null);
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Settings", href: "/instance/settings/general" },
{ label: "Adapters" },
]);
}, [selectedCompany?.name, setBreadcrumbs]);
const { data: adapters, isLoading } = useQuery({
queryKey: queryKeys.adapters.all,
queryFn: () => adaptersApi.list(),
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.adapters.all });
};
const installMutation = useMutation({
mutationFn: (params: { packageName: string; version?: string; isLocalPath?: boolean }) =>
adaptersApi.install(params),
onSuccess: (result) => {
invalidate();
setInstallDialogOpen(false);
setInstallPackage("");
setInstallVersion("");
setIsLocalPath(false);
pushToast({
title: "Adapter installed",
body: `Type "${result.type}" registered successfully.${result.version ? ` (v${result.version})` : ""}`,
tone: "success",
});
},
onError: (err: Error) => {
pushToast({ title: "Install failed", body: err.message, tone: "error" });
},
});
const removeMutation = useMutation({
mutationFn: (type: string) => adaptersApi.remove(type),
onSuccess: () => {
invalidate();
pushToast({ title: "Adapter removed", tone: "success" });
},
onError: (err: Error) => {
pushToast({ title: "Removal failed", body: err.message, tone: "error" });
},
});
const toggleMutation = useMutation({
mutationFn: ({ type, disabled }: { type: string; disabled: boolean }) =>
adaptersApi.setDisabled(type, disabled),
onSuccess: () => {
invalidate();
},
onError: (err: Error) => {
pushToast({ title: "Toggle failed", body: err.message, tone: "error" });
},
});
const overrideMutation = useMutation({
mutationFn: ({ type, paused }: { type: string; paused: boolean }) =>
adaptersApi.setOverridePaused(type, paused),
onSuccess: () => {
invalidate();
},
onError: (err: Error) => {
pushToast({ title: "Override toggle failed", body: err.message, tone: "error" });
},
});
const reloadMutation = useMutation({
mutationFn: (type: string) => adaptersApi.reload(type),
onSuccess: (result) => {
invalidate();
invalidateDynamicParser(result.type);
invalidateConfigSchemaCache(result.type);
pushToast({
title: "Adapter reloaded",
body: `Type "${result.type}" reloaded.${result.version ? ` (v${result.version})` : ""}`,
tone: "success",
});
},
onError: (err: Error) => {
pushToast({ title: "Reload failed", body: err.message, tone: "error" });
},
});
const reinstallMutation = useMutation({
mutationFn: (type: string) => adaptersApi.reinstall(type),
onSuccess: (result) => {
invalidate();
invalidateDynamicParser(result.type);
invalidateConfigSchemaCache(result.type);
pushToast({
title: "Adapter reinstalled",
body: `Type "${result.type}" updated from npm.${result.version ? ` (v${result.version})` : ""}`,
tone: "success",
});
},
onError: (err: Error) => {
pushToast({ title: "Reinstall failed", body: err.message, tone: "error" });
},
});
const builtinAdapters = (adapters ?? []).filter((a) => a.source === "builtin");
const externalAdapters = (adapters ?? []).filter((a) => a.source === "external");
// External adapters that override a builtin type. The server only returns
// one entry per type (the external), so we synthesize a builtin row for
// the builtins section so users can see which builtins are affected.
const overriddenBuiltins = (adapters ?? [])
.filter((a) => a.source === "external" && a.overriddenBuiltin)
.filter((a) => !builtinAdapters.some((b) => b.type === a.type))
.map((a) => ({
type: a.type,
label: getAdapterLabel(a.type),
overriddenBy: [
a.packageName,
a.version ? `v${a.version}` : undefined,
].filter(Boolean).join(" "),
overridePaused: !!a.overridePaused,
menuDisabled: !!a.disabled,
}));
if (isLoading) return <div className="p-4 text-sm text-muted-foreground">Loading adapters...</div>;
const isMutating = installMutation.isPending || removeMutation.isPending || toggleMutation.isPending || overrideMutation.isPending || reloadMutation.isPending || reinstallMutation.isPending;
return (
<div className="space-y-6 max-w-5xl">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Cpu className="h-6 w-6 text-muted-foreground" />
<h1 className="text-xl font-semibold">Adapters</h1>
<Badge variant="outline" className="text-amber-600 border-amber-400">
Alpha
</Badge>
</div>
<Dialog open={installDialogOpen} onOpenChange={setInstallDialogOpen}>
<DialogTrigger asChild>
<Button size="sm" className="gap-2">
<Plus className="h-4 w-4" />
Install Adapter
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Install External Adapter</DialogTitle>
<DialogDescription>
Add an adapter from npm or a local path. The adapter package must export <code className="text-xs bg-muted px-1 py-0.5 rounded">createServerAdapter()</code>.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{/* Source toggle */}
<div className="flex items-center gap-2">
<button
type="button"
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs transition-colors",
!isLocalPath
? "border-foreground bg-accent text-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:bg-accent/50"
)}
onClick={() => setIsLocalPath(false)}
>
<Package className="h-3.5 w-3.5" />
npm package
</button>
<button
type="button"
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs transition-colors",
isLocalPath
? "border-foreground bg-accent text-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:bg-accent/50"
)}
onClick={() => setIsLocalPath(true)}
>
<FolderOpen className="h-3.5 w-3.5" />
Local path
</button>
</div>
{isLocalPath ? (
/* Local path input */
<div className="grid gap-2">
<Label htmlFor="adapterLocalPath">Path to adapter package</Label>
<div className="flex gap-2">
<Input
id="adapterLocalPath"
className="flex-1 font-mono text-xs"
placeholder="/mnt/e/Projects/my-adapter or E:\Projects\my-adapter"
value={installPackage}
onChange={(e) => setInstallPackage(e.target.value)}
/>
<ChoosePathButton />
</div>
<p className="text-xs text-muted-foreground">
Accepts Linux, WSL, and Windows paths. Windows paths are auto-converted.
</p>
</div>
) : (
/* npm package input */
<>
<div className="grid gap-2">
<Label htmlFor="adapterPackageName">Package Name</Label>
<Input
id="adapterPackageName"
placeholder="my-paperclip-adapter"
value={installPackage}
onChange={(e) => setInstallPackage(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="adapterVersion">Version (optional)</Label>
<Input
id="adapterVersion"
placeholder="latest"
value={installVersion}
onChange={(e) => setInstallVersion(e.target.value)}
/>
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setInstallDialogOpen(false)}>Cancel</Button>
<Button
onClick={() =>
installMutation.mutate({
packageName: installPackage,
version: installVersion || undefined,
isLocalPath,
})
}
disabled={!installPackage || installMutation.isPending}
>
{installMutation.isPending ? "Installing..." : "Install"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Alpha notice */}
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-700" />
<div className="space-y-1 text-sm">
<p className="font-medium text-foreground">External adapters are alpha.</p>
<p className="text-muted-foreground">
The adapter plugin system is under active development. APIs and storage format may change.
Use the power icon to hide adapters from agent menus without removing them.
</p>
</div>
</div>
</div>
{/* External adapters */}
<section className="space-y-3">
<div className="flex items-center gap-2">
<Cpu className="h-5 w-5 text-muted-foreground" />
<h2 className="text-base font-semibold">External Adapters</h2>
</div>
{externalAdapters.length === 0 ? (
<Card className="bg-muted/30">
<CardContent className="flex flex-col items-center justify-center py-10">
<Cpu className="h-10 w-10 text-muted-foreground mb-4" />
<p className="text-sm font-medium">No external adapters installed</p>
<p className="text-xs text-muted-foreground mt-1">
Install an adapter package to extend model support.
</p>
</CardContent>
</Card>
) : (
<ul className="divide-y rounded-md border bg-card">
{externalAdapters.map((adapter) => {
const isBuiltinOverride = adapter.overriddenBuiltin;
const overridePaused = isBuiltinOverride && !!adapter.overridePaused;
// For overridden builtins, the power button controls the
// override pause state (not server menu visibility).
const effectiveAdapter: AdapterInfo = isBuiltinOverride
? { ...adapter, disabled: overridePaused ?? false }
: adapter;
return (
<AdapterRow
key={adapter.type}
adapter={effectiveAdapter}
canRemove={true}
onToggle={
isBuiltinOverride
? (type, disabled) => overrideMutation.mutate({ type, paused: disabled })
: (type, disabled) => toggleMutation.mutate({ type, disabled })
}
onRemove={(type) => setRemoveType(type)}
onReload={(type) => reloadMutation.mutate(type)}
onReinstall={!adapter.isLocalPath ? (type) => setReinstallTarget(adapter) : undefined}
isToggling={isBuiltinOverride ? overrideMutation.isPending : toggleMutation.isPending}
isReloading={reloadMutation.isPending}
isReinstalling={reinstallMutation.isPending}
toggleTitleDisabled={isBuiltinOverride ? "Pause external override" : undefined}
toggleTitleEnabled={isBuiltinOverride ? "Resume external override" : undefined}
disabledBadgeLabel={isBuiltinOverride ? "Override paused" : undefined}
/>
);
})}
</ul>
)}
</section>
{/* Built-in adapters */}
<section className="space-y-3">
<div className="flex items-center gap-2">
<Cpu className="h-5 w-5 text-muted-foreground" />
<h2 className="text-base font-semibold">Built-in Adapters</h2>
</div>
{builtinAdapters.length === 0 && overriddenBuiltins.length === 0 ? (
<div className="text-sm text-muted-foreground">No built-in adapters found.</div>
) : (
<ul className="divide-y rounded-md border bg-card">
{builtinAdapters.map((adapter) => (
<AdapterRow
key={adapter.type}
adapter={adapter}
canRemove={false}
onToggle={(type, disabled) => toggleMutation.mutate({ type, disabled })}
onRemove={() => {}}
isToggling={isMutating}
/>
))}
{overriddenBuiltins.map((virtual) => (
<AdapterRow
key={virtual.type}
adapter={{
type: virtual.type,
label: virtual.label,
source: "builtin",
modelsCount: 0,
loaded: true,
disabled: virtual.menuDisabled,
}}
canRemove={false}
onToggle={(type, disabled) => toggleMutation.mutate({ type, disabled })}
onRemove={() => {}}
isToggling={isMutating}
overriddenBy={virtual.overridePaused ? undefined : virtual.overriddenBy}
/>
))}
</ul>
)}
</section>
{/* Remove confirmation */}
<Dialog
open={removeType !== null}
onOpenChange={(open) => { if (!open) setRemoveType(null); }}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove Adapter</DialogTitle>
<DialogDescription>
Are you sure you want to remove the <strong>{removeType}</strong> adapter?
It will be unregistered and removed from the adapter store.
{removeType && adapters?.find((a) => a.type === removeType)?.packageName && (
<> npm packages will be cleaned up from disk.</>
)}
{" "}This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setRemoveType(null)}>Cancel</Button>
<Button
variant="destructive"
disabled={removeMutation.isPending}
onClick={() => {
if (removeType) {
removeMutation.mutate(removeType, {
onSettled: () => setRemoveType(null),
});
}
}}
>
{removeMutation.isPending ? "Removing..." : "Remove"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Reinstall confirmation */}
<ReinstallDialog
adapter={reinstallTarget}
open={reinstallTarget !== null}
isReinstalling={reinstallMutation.isPending}
onConfirm={() => {
if (reinstallTarget) {
reinstallMutation.mutate(reinstallTarget.type, {
onSettled: () => setReinstallTarget(null),
});
}
}}
onCancel={() => setReinstallTarget(null)}
/>
</div>
);
}

View file

@ -25,9 +25,10 @@ import { queryKeys } from "../lib/queryKeys";
import { AgentConfigForm } from "../components/AgentConfigForm";
import { PageTabBar } from "../components/PageTabBar";
import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { MarkdownEditor } from "../components/MarkdownEditor";
import { assetsApi } from "../api/assets";
import { getUIAdapter, buildTranscript } from "../adapters";
import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters";
import { StatusBadge } from "../components/StatusBadge";
import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
import { MarkdownBody } from "../components/MarkdownBody";
@ -263,12 +264,16 @@ function runMetrics(run: HeartbeatRun) {
);
const cost =
visibleRunCostUsd(usage, result);
const provider = asNonEmptyString(usage?.provider) ?? null;
const model = asNonEmptyString(usage?.model) ?? null;
return {
input,
output,
cached,
cost,
totalTokens: input + output,
provider,
model,
};
}
@ -285,6 +290,98 @@ function asNonEmptyString(value: unknown): string | null {
return trimmed.length > 0 ? trimmed : null;
}
export function RunInvocationCard({
payload,
censorUsernameInLogs,
}: {
payload: Record<string, unknown>;
censorUsernameInLogs: boolean;
}) {
const commandLine = [
typeof payload.command === "string" ? payload.command : null,
...(Array.isArray(payload.commandArgs)
? payload.commandArgs.filter((value): value is string => typeof value === "string")
: []),
]
.filter((value): value is string => Boolean(value))
.join(" ");
const hasAdvancedDetails =
commandLine.length > 0
|| (Array.isArray(payload.commandNotes) && payload.commandNotes.length > 0)
|| payload.prompt !== undefined
|| payload.context !== undefined
|| payload.env !== undefined;
return (
<div className="rounded-lg border border-border bg-background/60 p-3 space-y-2">
<div className="text-xs font-medium text-muted-foreground">Invocation</div>
{typeof payload.adapterType === "string" && (
<div className="text-xs"><span className="text-muted-foreground">Adapter: </span>{payload.adapterType}</div>
)}
{typeof payload.cwd === "string" && (
<div className="text-xs break-all"><span className="text-muted-foreground">Working dir: </span><span className="font-mono">{payload.cwd}</span></div>
)}
{hasAdvancedDetails && (
<Collapsible>
<CollapsibleTrigger className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors group">
<ChevronRight className="h-3 w-3 transition-transform group-data-[state=open]:rotate-90" />
Details
</CollapsibleTrigger>
<CollapsibleContent className="pt-2 space-y-2">
{commandLine && (
<div className="text-xs break-all">
<span className="text-muted-foreground">Command: </span>
<span className="font-mono">{commandLine}</span>
</div>
)}
{Array.isArray(payload.commandNotes) && payload.commandNotes.length > 0 && (
<div>
<div className="text-xs text-muted-foreground mb-1">Command notes</div>
<ul className="list-disc pl-5 space-y-1">
{payload.commandNotes
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
.map((note, idx) => (
<li key={`${idx}-${note}`} className="text-xs break-all font-mono">
{note}
</li>
))}
</ul>
</div>
)}
{payload.prompt !== undefined && (
<div>
<div className="text-xs text-muted-foreground mb-1">Prompt</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
{typeof payload.prompt === "string"
? redactPathText(payload.prompt, censorUsernameInLogs)
: JSON.stringify(redactPathValue(payload.prompt, censorUsernameInLogs), null, 2)}
</pre>
</div>
)}
{payload.context !== undefined && (
<div>
<div className="text-xs text-muted-foreground mb-1">Context</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(redactPathValue(payload.context, censorUsernameInLogs), null, 2)}
</pre>
</div>
)}
{payload.env !== undefined && (
<div>
<div className="text-xs text-muted-foreground mb-1">Environment</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap font-mono">
{formatEnvForDisplay(payload.env, censorUsernameInLogs)}
</pre>
</div>
)}
</CollapsibleContent>
</Collapsible>
)}
</div>
);
}
function parseStoredLogContent(content: string): RunLogChunk[] {
const parsed: RunLogChunk[] = [];
for (const line of content.split("\n")) {
@ -1035,6 +1132,7 @@ export function AgentDetail() {
agentRouteId={canonicalAgentRef}
selectedRunId={urlRunId ?? null}
adapterType={agent.adapterType}
adapterConfig={agent.adapterConfig}
/>
)}
@ -1530,30 +1628,16 @@ function ConfigurationTab({
Lets this agent create or hire agents and implicitly assign tasks.
</p>
</div>
<button
type="button"
role="switch"
data-slot="toggle"
aria-checked={canCreateAgents}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 disabled:cursor-not-allowed disabled:opacity-50",
canCreateAgents ? "bg-green-600" : "bg-muted",
)}
onClick={() =>
<ToggleSwitch
checked={canCreateAgents}
onCheckedChange={() =>
updatePermissions.mutate({
canCreateAgents: !canCreateAgents,
canAssignTasks: !canCreateAgents ? true : canAssignTasks,
})
}
disabled={updatePermissions.isPending}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
canCreateAgents ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
/>
</div>
<div className="flex items-center justify-between gap-4 text-sm">
<div className="space-y-1">
@ -1562,30 +1646,16 @@ function ConfigurationTab({
{taskAssignHint}
</p>
</div>
<button
type="button"
role="switch"
data-slot="toggle"
aria-checked={canAssignTasks}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors shrink-0 disabled:cursor-not-allowed disabled:opacity-50",
canAssignTasks ? "bg-green-600" : "bg-muted",
)}
onClick={() =>
<ToggleSwitch
checked={canAssignTasks}
onCheckedChange={() =>
updatePermissions.mutate({
canCreateAgents,
canAssignTasks: !canAssignTasks,
})
}
disabled={updatePermissions.isPending || taskAssignLocked}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
canAssignTasks ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
/>
</div>
</div>
</div>
@ -1917,7 +1987,7 @@ function PromptsTab({
}
return (
<div className="max-w-6xl space-y-6">
<div className="space-y-6">
{(bundle?.warnings ?? []).length > 0 && (
<div className="space-y-2">
{(bundle?.warnings ?? []).map((warning) => (
@ -2813,6 +2883,7 @@ function RunsTab({
agentRouteId,
selectedRunId,
adapterType,
adapterConfig,
}: {
runs: HeartbeatRun[];
companyId: string;
@ -2820,6 +2891,7 @@ function RunsTab({
agentRouteId: string;
selectedRunId: string | null;
adapterType: string;
adapterConfig: Record<string, unknown>;
}) {
const { isMobile } = useSidebar();
@ -2848,7 +2920,7 @@ function RunsTab({
<ArrowLeft className="h-3.5 w-3.5" />
Back to runs
</Link>
<RunDetail key={selectedRun.id} run={selectedRun} agentRouteId={agentRouteId} adapterType={adapterType} />
<RunDetail key={selectedRun.id} run={selectedRun} agentRouteId={agentRouteId} adapterType={adapterType} adapterConfig={adapterConfig} />
</div>
);
}
@ -2879,7 +2951,7 @@ function RunsTab({
{/* Right: run detail — natural height, page scrolls */}
{selectedRun && (
<div className="flex-1 min-w-0 pl-4">
<RunDetail key={selectedRun.id} run={selectedRun} agentRouteId={agentRouteId} adapterType={adapterType} />
<RunDetail key={selectedRun.id} run={selectedRun} agentRouteId={agentRouteId} adapterType={adapterType} adapterConfig={adapterConfig} />
</div>
)}
</div>
@ -2888,7 +2960,7 @@ function RunsTab({
/* ---- Run Detail (expanded) ---- */
function RunDetail({ run: initialRun, agentRouteId, adapterType }: { run: HeartbeatRun; agentRouteId: string; adapterType: string }) {
function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }: { run: HeartbeatRun; agentRouteId: string; adapterType: string; adapterConfig: Record<string, unknown> }) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { data: hydratedRun } = useQuery({
@ -3082,6 +3154,27 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType }: { run: Heartb
</Button>
)}
</div>
{/* Adapter type · provider · model */}
{(() => {
const displayProvider = metrics.provider
?? asNonEmptyString(adapterConfig?.provider);
const displayModel = metrics.model
?? asNonEmptyString(adapterConfig?.model);
if (!adapterType && !displayProvider && !displayModel) return null;
return (
<div className="text-[11px] text-muted-foreground font-mono flex items-center gap-1.5 flex-wrap">
{adapterType && (
<span className="bg-muted rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide">{adapterType.replace(/_/g, " ")}</span>
)}
{displayProvider && displayModel && (
<span>{displayProvider}/{displayModel}</span>
)}
{!displayProvider && displayModel && (
<span>{displayModel}</span>
)}
</div>
);
})()}
{resumeRun.isError && (
<div className="text-xs text-destructive">
{resumeRun.error instanceof Error ? resumeRun.error.message : "Failed to resume run"}
@ -3670,10 +3763,20 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return redactPathValue(asRecord(evt?.payload ?? null), censorUsernameInLogs);
}, [censorUsernameInLogs, events]);
const adapter = useMemo(() => getUIAdapter(adapterType), [adapterType]);
// NOTE: adapter is NOT memoized because external adapters replace their
// parseStdoutLine asynchronously after dynamic parser loading. Memoizing
// on adapterType alone would stale the transcript with the fallback parser.
// We subscribe to adapter registry changes to force transcript recomputation.
const [parserTick, setParserTick] = useState(0);
const adapter = getUIAdapter(adapterType);
useEffect(() => {
return onAdapterChange(() => setParserTick((t) => t + 1));
}, []);
const transcript = useMemo(
() => buildTranscript(logLines, adapter.parseStdoutLine, { censorUsernameInLogs }),
[adapter, censorUsernameInLogs, logLines],
() => buildTranscript(logLines, adapter, { censorUsernameInLogs }),
[adapter, censorUsernameInLogs, logLines, parserTick],
);
useEffect(() => {
@ -3707,68 +3810,7 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
censorUsernameInLogs={censorUsernameInLogs}
/>
{adapterInvokePayload && (
<div className="rounded-lg border border-border bg-background/60 p-3 space-y-2">
<div className="text-xs font-medium text-muted-foreground">Invocation</div>
{typeof adapterInvokePayload.adapterType === "string" && (
<div className="text-xs"><span className="text-muted-foreground">Adapter: </span>{adapterInvokePayload.adapterType}</div>
)}
{typeof adapterInvokePayload.cwd === "string" && (
<div className="text-xs break-all"><span className="text-muted-foreground">Working dir: </span><span className="font-mono">{adapterInvokePayload.cwd}</span></div>
)}
{typeof adapterInvokePayload.command === "string" && (
<div className="text-xs break-all">
<span className="text-muted-foreground">Command: </span>
<span className="font-mono">
{[
adapterInvokePayload.command,
...(Array.isArray(adapterInvokePayload.commandArgs)
? adapterInvokePayload.commandArgs.filter((v): v is string => typeof v === "string")
: []),
].join(" ")}
</span>
</div>
)}
{Array.isArray(adapterInvokePayload.commandNotes) && adapterInvokePayload.commandNotes.length > 0 && (
<div>
<div className="text-xs text-muted-foreground mb-1">Command notes</div>
<ul className="list-disc pl-5 space-y-1">
{adapterInvokePayload.commandNotes
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
.map((note, idx) => (
<li key={`${idx}-${note}`} className="text-xs break-all font-mono">
{note}
</li>
))}
</ul>
</div>
)}
{adapterInvokePayload.prompt !== undefined && (
<div>
<div className="text-xs text-muted-foreground mb-1">Prompt</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
{typeof adapterInvokePayload.prompt === "string"
? redactPathText(adapterInvokePayload.prompt, censorUsernameInLogs)
: JSON.stringify(redactPathValue(adapterInvokePayload.prompt, censorUsernameInLogs), null, 2)}
</pre>
</div>
)}
{adapterInvokePayload.context !== undefined && (
<div>
<div className="text-xs text-muted-foreground mb-1">Context</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(redactPathValue(adapterInvokePayload.context, censorUsernameInLogs), null, 2)}
</pre>
</div>
)}
{adapterInvokePayload.env !== undefined && (
<div>
<div className="text-xs text-muted-foreground mb-1">Environment</div>
<pre className="bg-neutral-100 dark:bg-neutral-950 rounded-md p-2 text-xs overflow-x-auto whitespace-pre-wrap font-mono">
{formatEnvForDisplay(adapterInvokePayload.env, censorUsernameInLogs)}
</pre>
</div>
)}
</div>
<RunInvocationCard payload={adapterInvokePayload} censorUsernameInLogs={censorUsernameInLogs} />
)}
<div className="flex items-center justify-between">

View file

@ -20,17 +20,7 @@ import { Button } from "@/components/ui/button";
import { Bot, Plus, List, GitBranch, SlidersHorizontal } from "lucide-react";
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
const adapterLabels: Record<string, string> = {
claude_local: "Claude",
codex_local: "Codex",
gemini_local: "Gemini",
opencode_local: "OpenCode",
cursor: "Cursor",
hermes_local: "Hermes",
openclaw_gateway: "OpenClaw Gateway",
process: "Process",
http: "HTTP",
};
import { getAdapterLabel } from "../adapters/adapter-display-registry";
const roleLabels = AGENT_ROLE_LABELS as Record<string, string>;
@ -263,7 +253,7 @@ export function Agents() {
/>
)}
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
{adapterLabels[agent.adapterType] ?? agent.adapterType}
{getAdapterLabel(agent.adapterType)}
</span>
<span className="text-xs text-muted-foreground w-16 text-right">
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}
@ -364,7 +354,7 @@ function OrgTreeNode({
{agent && (
<>
<span className="text-xs text-muted-foreground font-mono w-14 text-right">
{adapterLabels[agent.adapterType] ?? agent.adapterType}
{getAdapterLabel(agent.adapterType)}
</span>
<span className="text-xs text-muted-foreground w-16 text-right">
{agent.lastHeartbeatAt ? relativeTime(agent.lastHeartbeatAt) : "—"}

View file

@ -31,6 +31,7 @@ import {
Upload,
} from "lucide-react";
import { Field, adapterLabels } from "../components/agent-config-primitives";
import { getAdapterLabel } from "../adapters/adapter-display-registry";
import { defaultCreateValues } from "../components/agent-config-defaults";
import { getUIAdapter, listUIAdapters } from "../adapters";
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
@ -514,7 +515,7 @@ function ConflictResolutionList({
const IMPORT_ADAPTER_OPTIONS: { value: string; label: string }[] = listUIAdapters().map((adapter) => ({
value: adapter.type,
label: adapterLabels[adapter.type] ?? adapter.label,
label: adapterLabels[adapter.type] ?? getAdapterLabel(adapter.type),
}));
// ── Adapter picker for imported agents ───────────────────────────────

View file

@ -43,6 +43,10 @@ function readText(value: string | null | undefined) {
return value ?? "";
}
function hasActiveRuntimeServices(workspace: ExecutionWorkspace | null | undefined) {
return (workspace?.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running");
}
function formatJson(value: Record<string, unknown> | null | undefined) {
if (!value || Object.keys(value).length === 0) return "";
return JSON.stringify(value, null, 2);
@ -709,7 +713,7 @@ export function ExecutionWorkspaceDetail() {
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0}
disabled={controlRuntimeServices.isPending || !hasActiveRuntimeServices(workspace)}
onClick={() => controlRuntimeServices.mutate("stop")}
>
Stop

View file

@ -56,6 +56,7 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
labelIds: [],
myLastTouchAt: null,
lastExternalCommentAt: null,
lastActivityAt: new Date("2026-03-11T00:00:00.000Z"),
isUnreadForMe: false,
...overrides,
};

View file

@ -214,7 +214,7 @@ export function InboxIssueMetaLeading({
}
function issueActivityText(issue: Issue): string {
return `Updated ${timeAgo(issue.lastExternalCommentAt ?? issue.updatedAt)}`;
return `Updated ${timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt)}`;
}
function issueTrailingGridTemplate(columns: InboxIssueColumn[]): string {
@ -246,7 +246,7 @@ export function InboxIssueTrailingColumns({
assigneeName: string | null;
currentUserId: string | null;
}) {
const activityText = timeAgo(issue.lastExternalCommentAt ?? issue.updatedAt);
const activityText = timeAgo(issue.lastActivityAt ?? issue.lastExternalCommentAt ?? issue.updatedAt);
const userLabel = formatAssigneeUserLabel(issue.assigneeUserId, currentUserId) ?? "User";
return (

View file

@ -4,7 +4,7 @@ import { FlaskConical } from "lucide-react";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { cn } from "../lib/utils";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
export function InstanceExperimentalSettings() {
const { setBreadcrumbs } = useBreadcrumbs();
@ -82,24 +82,12 @@ export function InstanceExperimentalSettings() {
and existing issue runs.
</p>
</div>
<button
type="button"
data-slot="toggle"
aria-label="Toggle isolated workspaces experimental setting"
<ToggleSwitch
checked={enableIsolatedWorkspaces}
onCheckedChange={() => toggleMutation.mutate({ enableIsolatedWorkspaces: !enableIsolatedWorkspaces })}
disabled={toggleMutation.isPending}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
enableIsolatedWorkspaces ? "bg-green-600" : "bg-muted",
)}
onClick={() => toggleMutation.mutate({ enableIsolatedWorkspaces: !enableIsolatedWorkspaces })}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
enableIsolatedWorkspaces ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
aria-label="Toggle isolated workspaces experimental setting"
/>
</div>
</section>
@ -112,26 +100,12 @@ export function InstanceExperimentalSettings() {
automatically when backend changes or migrations make the current boot stale.
</p>
</div>
<button
type="button"
data-slot="toggle"
aria-label="Toggle guarded dev-server auto-restart"
<ToggleSwitch
checked={autoRestartDevServerWhenIdle}
onCheckedChange={() => toggleMutation.mutate({ autoRestartDevServerWhenIdle: !autoRestartDevServerWhenIdle })}
disabled={toggleMutation.isPending}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
autoRestartDevServerWhenIdle ? "bg-green-600" : "bg-muted",
)}
onClick={() =>
toggleMutation.mutate({ autoRestartDevServerWhenIdle: !autoRestartDevServerWhenIdle })
}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
autoRestartDevServerWhenIdle ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
aria-label="Toggle guarded dev-server auto-restart"
/>
</div>
</section>
</div>

View file

@ -1,10 +1,13 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { PatchInstanceGeneralSettings } from "@paperclipai/shared";
import { SlidersHorizontal } from "lucide-react";
import { LogOut, SlidersHorizontal } from "lucide-react";
import { authApi } from "@/api/auth";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { Button } from "../components/ui/button";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { cn } from "../lib/utils";
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
@ -14,6 +17,16 @@ export function InstanceGeneralSettings() {
const queryClient = useQueryClient();
const [actionError, setActionError] = useState<string | null>(null);
const signOutMutation = useMutation({
mutationFn: () => authApi.signOut(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
},
onError: (error) => {
setActionError(error instanceof Error ? error.message : "Failed to sign out.");
},
});
useEffect(() => {
setBreadcrumbs([
{ label: "Instance Settings" },
@ -83,28 +96,12 @@ export function InstanceGeneralSettings() {
default.
</p>
</div>
<button
type="button"
data-slot="toggle"
aria-label="Toggle username log censoring"
<ToggleSwitch
checked={censorUsernameInLogs}
onCheckedChange={() => updateGeneralMutation.mutate({ censorUsernameInLogs: !censorUsernameInLogs })}
disabled={updateGeneralMutation.isPending}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
censorUsernameInLogs ? "bg-green-600" : "bg-muted",
)}
onClick={() =>
updateGeneralMutation.mutate({
censorUsernameInLogs: !censorUsernameInLogs,
})
}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
censorUsernameInLogs ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
aria-label="Toggle username log censoring"
/>
</div>
</section>
@ -117,24 +114,12 @@ export function InstanceGeneralSettings() {
toggling panels. This is off by default.
</p>
</div>
<button
type="button"
data-slot="toggle"
aria-label="Toggle keyboard shortcuts"
<ToggleSwitch
checked={keyboardShortcuts}
onCheckedChange={() => updateGeneralMutation.mutate({ keyboardShortcuts: !keyboardShortcuts })}
disabled={updateGeneralMutation.isPending}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors disabled:cursor-not-allowed disabled:opacity-60",
keyboardShortcuts ? "bg-green-600" : "bg-muted",
)}
onClick={() => updateGeneralMutation.mutate({ keyboardShortcuts: !keyboardShortcuts })}
>
<span
className={cn(
"inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform",
keyboardShortcuts ? "translate-x-4.5" : "translate-x-0.5",
)}
/>
</button>
aria-label="Toggle keyboard shortcuts"
/>
</div>
</section>
@ -213,6 +198,26 @@ export function InstanceGeneralSettings() {
</p>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Sign out</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Sign out of this Paperclip instance. You will be redirected to the login page.
</p>
</div>
<Button
variant="outline"
size="sm"
disabled={signOutMutation.isPending}
onClick={() => signOutMutation.mutate()}
>
<LogOut className="size-4" />
{signOutMutation.isPending ? "Signing out..." : "Sign out"}
</Button>
</div>
</section>
</div>
);
}

View file

@ -12,20 +12,9 @@ import type { AgentAdapterType, JoinRequest } from "@paperclipai/shared";
type JoinType = "human" | "agent";
const joinAdapterOptions: AgentAdapterType[] = [...AGENT_ADAPTER_TYPES];
const adapterLabels: Record<string, string> = {
claude_local: "Claude (local)",
codex_local: "Codex (local)",
gemini_local: "Gemini CLI (local)",
opencode_local: "OpenCode (local)",
pi_local: "Pi (local)",
openclaw_gateway: "OpenClaw Gateway",
cursor: "Cursor (local)",
hermes_local: "Hermes Agent",
process: "Process",
http: "HTTP",
};
import { getAdapterLabel } from "../adapters/adapter-display-registry";
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor", "hermes_local"]);
const ENABLED_INVITE_ADAPTERS = new Set(["claude_local", "codex_local", "gemini_local", "opencode_local", "pi_local", "cursor"]);
function dateTime(value: string) {
return new Date(value).toLocaleString();
@ -279,7 +268,7 @@ export function InviteLandingPage() {
>
{joinAdapterOptions.map((type) => (
<option key={type} value={type} disabled={!ENABLED_INVITE_ADAPTERS.has(type)}>
{adapterLabels[type]}{!ENABLED_INVITE_ADAPTERS.has(type) ? " (Coming soon)" : ""}
{getAdapterLabel(type)}{!ENABLED_INVITE_ADAPTERS.has(type) ? " (Coming soon)" : ""}
</option>
))}
</select>

View file

@ -5,17 +5,23 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { issuesApi } from "../api/issues";
import { activityApi } from "../api/activity";
import { heartbeatsApi } from "../api/heartbeats";
import { instanceSettingsApi } from "../api/instanceSettings";
import { agentsApi } from "../api/agents";
import { authApi } from "../api/auth";
import { instanceSettingsApi } from "../api/instanceSettings";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { usePanel } from "../context/PanelContext";
import { useToast } from "../context/ToastContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { assigneeValueFromSelection, suggestedCommentAssigneeValue } from "../lib/assignees";
import { extractIssueTimelineEvents } from "../lib/issue-timeline-events";
import { queryKeys } from "../lib/queryKeys";
import { createIssueDetailPath, readIssueDetailBreadcrumb } from "../lib/issueDetailBreadcrumb";
import {
createIssueDetailPath,
readIssueDetailBreadcrumb,
shouldArmIssueDetailInboxQuickArchive,
} from "../lib/issueDetailBreadcrumb";
import { hasBlockingShortcutDialog, resolveInboxQuickArchiveKeyAction } from "../lib/keyboardShortcuts";
import {
applyOptimisticIssueCommentUpdate,
createOptimisticIssueComment,
@ -34,6 +40,7 @@ import { IssueProperties } from "../components/IssueProperties";
import { IssueWorkspaceCard } from "../components/IssueWorkspaceCard";
import { LiveRunWidget } from "../components/LiveRunWidget";
import type { MentionOption } from "../components/MarkdownEditor";
import { ImageGalleryModal } from "../components/ImageGalleryModal";
import { ScrollToBottom } from "../components/ScrollToBottom";
import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
@ -64,8 +71,16 @@ import {
SlidersHorizontal,
Trash2,
} from "lucide-react";
import type { ActivityEvent } from "@paperclipai/shared";
import type { Agent, FeedbackVote, Issue, IssueAttachment, IssueComment } from "@paperclipai/shared";
import {
getClosedIsolatedExecutionWorkspaceMessage,
isClosedIsolatedExecutionWorkspace,
type ActivityEvent,
type Agent,
type FeedbackVote,
type Issue,
type IssueAttachment,
type IssueComment,
} from "@paperclipai/shared";
type CommentReassignment = IssueCommentReassignment;
type IssueDetailComment = (IssueComment | OptimisticIssueComment) & {
@ -287,6 +302,8 @@ export function IssueDetail() {
});
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [attachmentDragActive, setAttachmentDragActive] = useState(false);
const [galleryOpen, setGalleryOpen] = useState(false);
const [galleryIndex, setGalleryIndex] = useState(0);
const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const lastMarkedReadIssueIdRef = useRef<string | null>(null);
@ -297,6 +314,12 @@ export function IssueDetail() {
enabled: !!issueId,
});
const resolvedCompanyId = issue?.companyId ?? selectedCompanyId;
const commentComposerDisabledReason = useMemo(() => {
if (!issue?.currentExecutionWorkspace || !isClosedIsolatedExecutionWorkspace(issue.currentExecutionWorkspace)) {
return null;
}
return getClosedIsolatedExecutionWorkspaceMessage(issue.currentExecutionWorkspace);
}, [issue?.currentExecutionWorkspace]);
const { data: comments } = useQuery({
queryKey: queryKeys.issues.comments(issueId!),
@ -400,6 +423,7 @@ export function IssueDetail() {
enabled: !!issueId,
retry: false,
});
const keyboardShortcutsEnabled = instanceGeneralSettings?.keyboardShortcuts === true;
const feedbackDataSharingPreference = instanceGeneralSettings?.feedbackDataSharingPreference ?? "prompt";
const { orderedProjects } = useProjectOrder({
projects: projects ?? [],
@ -545,6 +569,10 @@ export function IssueDetail() {
() => commentsWithRunMeta.filter((comment) => comment.queueState !== "queued"),
[commentsWithRunMeta],
);
const timelineEvents = useMemo(
() => extractIssueTimelineEvents(activity),
[activity],
);
const issueCostSummary = useMemo(() => {
let input = 0;
@ -913,6 +941,22 @@ export function IssueDetail() {
},
});
const archiveFromInbox = useMutation({
mutationFn: (id: string) => issuesApi.archiveFromInbox(id),
onSuccess: () => {
invalidateIssue();
navigate(sourceBreadcrumb.href.startsWith("/inbox") ? sourceBreadcrumb.href : "/inbox", { replace: true });
pushToast({ title: "Issue archived from inbox", tone: "success" });
},
onError: (err) => {
pushToast({
title: "Archive failed",
body: err instanceof Error ? err.message : "Unable to archive this issue from the inbox",
tone: "error",
});
},
});
useEffect(() => {
const titleLabel = issue?.title ?? issueId ?? "Issue";
setBreadcrumbs([
@ -947,6 +991,76 @@ export function IssueDetail() {
return () => closePanel();
}, [issue]); // eslint-disable-line react-hooks/exhaustive-deps
const inboxQuickArchiveArmedRef = useRef(false);
const canQuickArchiveFromInbox =
keyboardShortcutsEnabled &&
!issue?.hiddenAt &&
sourceBreadcrumb.href.startsWith("/inbox") &&
shouldArmIssueDetailInboxQuickArchive(location.state);
useEffect(() => {
if (!issue?.id || !canQuickArchiveFromInbox) {
inboxQuickArchiveArmedRef.current = false;
return;
}
inboxQuickArchiveArmedRef.current = true;
const disarm = () => {
inboxQuickArchiveArmedRef.current = false;
};
const handlePointerDown = () => {
disarm();
};
const handleFocusIn = (event: FocusEvent) => {
if (event.target instanceof HTMLElement && event.target !== document.body) {
disarm();
}
};
const handleSelectionChange = () => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || selection.toString().trim().length === 0) return;
disarm();
};
const handleKeyDown = (event: KeyboardEvent) => {
const action = resolveInboxQuickArchiveKeyAction({
armed: inboxQuickArchiveArmedRef.current,
defaultPrevented: event.defaultPrevented,
key: event.key,
metaKey: event.metaKey,
ctrlKey: event.ctrlKey,
altKey: event.altKey,
target: event.target,
hasOpenDialog: hasBlockingShortcutDialog(document),
});
if (action === "ignore") return;
disarm();
if (action !== "archive") return;
event.preventDefault();
if (!archiveFromInbox.isPending) {
archiveFromInbox.mutate(issue.id);
}
};
document.addEventListener("pointerdown", handlePointerDown, true);
document.addEventListener("focusin", handleFocusIn, true);
document.addEventListener("selectionchange", handleSelectionChange);
document.addEventListener("keydown", handleKeyDown, true);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
document.removeEventListener("focusin", handleFocusIn, true);
document.removeEventListener("selectionchange", handleSelectionChange);
document.removeEventListener("keydown", handleKeyDown, true);
};
}, [archiveFromInbox, canQuickArchiveFromInbox, issue?.id]);
const copyIssueToClipboard = async () => {
if (!issue) return;
const decodeEntities = (text: string) => {
@ -1000,6 +1114,7 @@ export function IssueDetail() {
const isImageAttachment = (attachment: IssueAttachment) => attachment.contentType.startsWith("image/");
const attachmentList = attachments ?? [];
const imageAttachments = attachmentList.filter(isImageAttachment);
const hasAttachments = attachmentList.length > 0;
const attachmentUploadButton = (
<>
@ -1338,14 +1453,22 @@ export function IssueDetail() {
{attachment.contentType} · {(attachment.byteSize / 1024).toFixed(1)} KB
</p>
{isImageAttachment(attachment) && (
<a href={attachment.contentPath} target="_blank" rel="noreferrer">
<button
type="button"
className="block w-full text-left"
onClick={() => {
const idx = imageAttachments.findIndex((a) => a.id === attachment.id);
setGalleryIndex(idx >= 0 ? idx : 0);
setGalleryOpen(true);
}}
>
<img
src={attachment.contentPath}
alt={attachment.originalFilename ?? "attachment"}
className="mt-2 max-h-56 rounded border border-border object-contain bg-accent/10"
className="mt-2 max-h-56 rounded border border-border object-contain bg-accent/10 cursor-pointer hover:opacity-80 transition-opacity"
loading="lazy"
/>
</a>
</button>
)}
</div>
))}
@ -1353,6 +1476,13 @@ export function IssueDetail() {
</div>
) : null}
<ImageGalleryModal
images={imageAttachments}
initialIndex={galleryIndex}
open={galleryOpen}
onOpenChange={setGalleryOpen}
/>
<IssueWorkspaceCard
issue={issue}
project={orderedProjects.find((p) => p.id === issue.projectId) ?? null}
@ -1390,10 +1520,12 @@ export function IssueDetail() {
feedbackDataSharingPreference={feedbackDataSharingPreference}
feedbackTermsUrl={FEEDBACK_TERMS_URL}
linkedRuns={timelineRuns}
timelineEvents={timelineEvents}
companyId={issue.companyId}
projectId={issue.projectId}
issueStatus={issue.status}
agentMap={agentMap}
currentUserId={currentUserId}
draftKey={`paperclip:issue-comment-draft:${issue.id}`}
enableReassign
reassignOptions={commentReassignOptions}
@ -1404,6 +1536,7 @@ export function IssueDetail() {
await interruptQueuedComment.mutateAsync(runId);
}}
interruptingQueuedRunId={interruptQueuedComment.isPending ? runningIssueRun?.id ?? null : null}
composerDisabledReason={commentComposerDisabledReason}
onVote={async (commentId, vote, options) => {
await feedbackVoteMutation.mutateAsync({
targetType: "issue_comment",

View file

@ -19,7 +19,9 @@ import { cn, agentUrl } from "../lib/utils";
import { roleLabels } from "../components/agent-config-primitives";
import { AgentConfigForm, type CreateConfigValues } from "../components/AgentConfigForm";
import { defaultCreateValues } from "../components/agent-config-defaults";
import { getUIAdapter } from "../adapters";
import { getUIAdapter, listUIAdapters } from "../adapters";
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
import { isValidAdapterType } from "../adapters/metadata";
import { ReportsToPicker } from "../components/ReportsToPicker";
import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
@ -28,17 +30,6 @@ import {
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set<CreateConfigValues["adapterType"]>([
"claude_local",
"codex_local",
"gemini_local",
"opencode_local",
"pi_local",
"cursor",
"hermes_local",
"openclaw_gateway",
]);
function createValuesForAdapterType(
adapterType: CreateConfigValues["adapterType"],
): CreateConfigValues {
@ -120,9 +111,7 @@ export function NewAgent() {
useEffect(() => {
const requested = presetAdapterType;
if (!requested) return;
if (!SUPPORTED_ADVANCED_ADAPTER_TYPES.has(requested as CreateConfigValues["adapterType"])) {
return;
}
if (!isValidAdapterType(requested)) return;
setConfigValues((prev) => {
if (prev.adapterType === requested) return prev;
return createValuesForAdapterType(requested as CreateConfigValues["adapterType"]);

View file

@ -116,17 +116,7 @@ function collectEdges(nodes: LayoutNode[]): Array<{ parent: LayoutNode; child: L
// ── Status dot colors (raw hex for SVG) ─────────────────────────────────
const adapterLabels: Record<string, string> = {
claude_local: "Claude",
codex_local: "Codex",
gemini_local: "Gemini",
opencode_local: "OpenCode",
cursor: "Cursor",
hermes_local: "Hermes",
openclaw_gateway: "OpenClaw Gateway",
process: "Process",
http: "HTTP",
};
import { getAdapterLabel } from "../adapters/adapter-display-registry";
const statusDotColor: Record<string, string> = {
running: "#22d3ee",
@ -426,7 +416,7 @@ export function OrgChart() {
</span>
{agent && (
<span className="text-[10px] text-muted-foreground/60 font-mono leading-tight mt-1">
{adapterLabels[agent.adapterType] ?? agent.adapterType}
{getAdapterLabel(agent.adapterType)}
</span>
)}
{agent && agent.capabilities && (

View file

@ -61,6 +61,10 @@ function readText(value: string | null | undefined) {
return value ?? "";
}
function hasActiveRuntimeServices(workspace: ProjectWorkspace | null | undefined) {
return (workspace?.runtimeServices ?? []).some((service) => service.status === "starting" || service.status === "running");
}
function formatJson(value: Record<string, unknown> | null | undefined) {
if (!value || Object.keys(value).length === 0) return "";
return JSON.stringify(value, null, 2);
@ -624,7 +628,7 @@ export function ProjectWorkspaceDetail() {
variant="outline"
size="sm"
className="w-full sm:w-auto"
disabled={controlRuntimeServices.isPending || (workspace.runtimeServices?.length ?? 0) === 0}
disabled={controlRuntimeServices.isPending || !hasActiveRuntimeServices(workspace)}
onClick={() => controlRuntimeServices.mutate("stop")}
>
Stop

View file

@ -27,6 +27,7 @@ import { useToast } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch";
import { timeAgo } from "../lib/timeAgo";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { AgentIcon } from "../components/AgentIconPicker";
@ -710,24 +711,13 @@ export function RoutineDetail() {
}}
disabled={runRoutine.isPending}
/>
<button
type="button"
role="switch"
data-slot="toggle"
aria-checked={automationEnabled}
aria-label={automationEnabled ? "Pause automatic triggers" : "Enable automatic triggers"}
<ToggleSwitch
size="lg"
checked={automationEnabled}
onCheckedChange={() => updateRoutineStatus.mutate(automationEnabled ? "paused" : "active")}
disabled={automationToggleDisabled}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
automationEnabled ? "bg-emerald-500" : "bg-muted"
} ${automationToggleDisabled ? "cursor-not-allowed opacity-50" : ""}`}
onClick={() => updateRoutineStatus.mutate(automationEnabled ? "paused" : "active")}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-background shadow-sm transition-transform ${
automationEnabled ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
aria-label={automationEnabled ? "Pause automatic triggers" : "Enable automatic triggers"}
/>
<span className={`min-w-[3.75rem] text-sm font-medium ${automationLabelClassName}`}>
{automationLabel}
</span>

View file

@ -0,0 +1,367 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Issue, RoutineListItem } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Routines, buildRoutineGroups } from "./Routines";
let currentSearch = "";
const navigateMock = vi.fn();
const routinesListMock = vi.fn<(companyId: string) => Promise<RoutineListItem[]>>();
const issuesListMock = vi.fn<(companyId: string, filters?: Record<string, unknown>) => Promise<Issue[]>>();
const issuesListRenderMock = vi.fn(({ issues }: { issues: Issue[] }) => (
<div data-testid="issues-list">{issues.map((issue) => issue.title).join(", ")}</div>
));
vi.mock("@/lib/router", () => ({
useNavigate: () => navigateMock,
useLocation: () => ({ pathname: "/routines", search: currentSearch ? `?${currentSearch}` : "", hash: "" }),
useSearchParams: () => [new URLSearchParams(currentSearch), vi.fn()],
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
}));
vi.mock("../context/ToastContext", () => ({
useToast: () => ({ pushToast: vi.fn() }),
}));
vi.mock("../api/routines", () => ({
routinesApi: {
list: (companyId: string) => routinesListMock(companyId),
create: vi.fn(),
update: vi.fn(),
run: vi.fn(),
},
}));
vi.mock("../api/issues", () => ({
issuesApi: {
list: (companyId: string, filters?: Record<string, unknown>) => issuesListMock(companyId, filters),
update: vi.fn(),
},
}));
vi.mock("../api/agents", () => ({
agentsApi: {
list: vi.fn(async () => [
{
id: "agent-1",
companyId: "company-1",
name: "Agent One",
role: "engineer",
title: null,
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "process",
adapterConfig: {},
contextMode: "thin",
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
lastHeartbeatAt: null,
icon: "code",
metadata: null,
createdAt: new Date("2026-04-01T00:00:00.000Z"),
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
urlKey: "agent-one",
pauseReason: null,
pausedAt: null,
permissions: null,
},
{
id: "agent-2",
companyId: "company-1",
name: "Agent Two",
role: "engineer",
title: null,
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "process",
adapterConfig: {},
contextMode: "thin",
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
lastHeartbeatAt: null,
icon: "code",
metadata: null,
createdAt: new Date("2026-04-01T00:00:00.000Z"),
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
urlKey: "agent-two",
pauseReason: null,
pausedAt: null,
permissions: null,
},
]),
},
}));
vi.mock("../api/projects", () => ({
projectsApi: {
list: vi.fn(async () => [
{
id: "project-1",
companyId: "company-1",
urlKey: "project-alpha",
goalId: null,
goalIds: [],
goals: [],
name: "Project Alpha",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#22c55e",
pauseReason: null,
pausedAt: null,
archivedAt: null,
executionWorkspacePolicy: null,
codebase: null,
workspaces: [],
primaryWorkspace: null,
createdAt: new Date("2026-04-01T00:00:00.000Z"),
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
},
{
id: "project-2",
companyId: "company-1",
urlKey: "project-beta",
goalId: null,
goalIds: [],
goals: [],
name: "Project Beta",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#38bdf8",
pauseReason: null,
pausedAt: null,
archivedAt: null,
executionWorkspacePolicy: null,
codebase: null,
workspaces: [],
primaryWorkspace: null,
createdAt: new Date("2026-04-01T00:00:00.000Z"),
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
},
]),
},
}));
vi.mock("../api/instanceSettings", () => ({
instanceSettingsApi: {
getExperimental: vi.fn(async () => ({ enableIsolatedWorkspaces: false })),
},
}));
vi.mock("../api/heartbeats", () => ({
heartbeatsApi: {
liveRunsForCompany: vi.fn(async () => []),
},
}));
vi.mock("../components/IssuesList", () => ({
IssuesList: (props: { issues: Issue[] }) => issuesListRenderMock(props),
}));
vi.mock("../components/PageTabBar", () => ({
PageTabBar: ({ items }: { items: Array<{ label: string }> }) => (
<div>{items.map((item) => item.label).join(", ")}</div>
),
}));
vi.mock("@/components/ui/tabs", () => ({
Tabs: ({ children }: { children: unknown }) => <div>{children as never}</div>,
TabsContent: ({ children }: { children: unknown }) => <div>{children as never}</div>,
}));
vi.mock("../components/MarkdownEditor", () => ({
MarkdownEditor: () => <div />,
}));
vi.mock("../components/InlineEntitySelector", () => ({
InlineEntitySelector: () => <button type="button">selector</button>,
}));
vi.mock("../components/RoutineRunVariablesDialog", () => ({
RoutineRunVariablesDialog: () => null,
routineRunNeedsConfiguration: () => false,
}));
vi.mock("../components/RoutineVariablesEditor", () => ({
RoutineVariablesEditor: () => null,
RoutineVariablesHint: () => null,
}));
vi.mock("../components/AgentIconPicker", () => ({
AgentIcon: () => <span data-testid="agent-icon" />,
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
function createRoutine(overrides: Partial<RoutineListItem>): RoutineListItem {
return {
id: "routine-1",
companyId: "company-1",
projectId: "project-1",
goalId: null,
parentIssueId: null,
title: "Routine title",
description: null,
assigneeAgentId: "agent-1",
priority: "medium",
status: "active",
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [],
createdByAgentId: null,
createdByUserId: null,
updatedByAgentId: null,
updatedByUserId: null,
lastTriggeredAt: null,
lastEnqueuedAt: null,
createdAt: new Date("2026-04-01T00:00:00.000Z"),
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
triggers: [],
lastRun: null,
activeIssue: null,
...overrides,
};
}
function createIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: "issue-1",
identifier: "PAP-1000",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Routine execution issue",
description: null,
status: "todo",
priority: "medium",
assigneeAgentId: "agent-1",
assigneeUserId: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 1000,
originKind: "routine_execution",
originId: "routine-1",
originRunId: null,
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
createdAt: new Date("2026-04-01T00:00:00.000Z"),
updatedAt: new Date("2026-04-01T00:00:00.000Z"),
labels: [],
labelIds: [],
myLastTouchAt: null,
lastExternalCommentAt: null,
lastActivityAt: new Date("2026-04-01T00:00:00.000Z"),
isUnreadForMe: false,
...overrides,
};
}
async function flush() {
await Promise.resolve();
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
describe("Routines page", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
currentSearch = "";
navigateMock.mockReset();
routinesListMock.mockReset();
issuesListMock.mockReset();
issuesListRenderMock.mockClear();
localStorage.clear();
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
});
it("groups routines by project using project names for the section labels", () => {
const groups = buildRoutineGroups(
[
createRoutine({ id: "routine-1", title: "Morning sync", projectId: "project-1" }),
createRoutine({ id: "routine-2", title: "Weekly digest", projectId: "project-2", assigneeAgentId: "agent-2" }),
],
"project",
new Map([
["project-1", { name: "Project Alpha" }],
["project-2", { name: "Project Beta" }],
]),
new Map([
["agent-1", { name: "Agent One" }],
["agent-2", { name: "Agent Two" }],
]),
);
expect(groups.map((group) => group.label)).toEqual(["Project Alpha", "Project Beta"]);
expect(groups[0]?.items.map((item) => item.title)).toEqual(["Morning sync"]);
expect(groups[1]?.items.map((item) => item.title)).toEqual(["Weekly digest"]);
});
it("shows recent runs through the issues list scoped to routine execution issues", async () => {
currentSearch = "tab=runs";
routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1" })]);
issuesListMock.mockResolvedValue([
createIssue({ id: "issue-1", title: "Routine execution A" }),
createIssue({ id: "issue-2", title: "Routine execution B", identifier: "PAP-1001", issueNumber: 1001 }),
]);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Routines />
</QueryClientProvider>,
);
await flush();
});
expect(issuesListMock).toHaveBeenCalledWith("company-1", { originKind: "routine_execution" });
await act(async () => {
root.unmount();
});
});
});

View file

@ -1,18 +1,25 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { startTransition, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@/lib/router";
import { ChevronDown, ChevronRight, MoreHorizontal, Play, Plus, Repeat } from "lucide-react";
import { useNavigate, useSearchParams } from "@/lib/router";
import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Plus, Repeat } from "lucide-react";
import { routinesApi } from "../api/routines";
import { instanceSettingsApi } from "../api/instanceSettings";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { issuesApi } from "../api/issues";
import { heartbeatsApi } from "../api/heartbeats";
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useToast } from "../context/ToastContext";
import { queryKeys } from "../lib/queryKeys";
import { groupBy } from "../lib/groupBy";
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { EmptyState } from "../components/EmptyState";
import { IssuesList } from "../components/IssuesList";
import { PageSkeleton } from "../components/PageSkeleton";
import { PageTabBar } from "../components/PageTabBar";
import { AgentIcon } from "../components/AgentIconPicker";
import { InlineEntitySelector, type InlineEntityOption } from "../components/InlineEntitySelector";
import { MarkdownEditor, type MarkdownEditorRef } from "../components/MarkdownEditor";
@ -33,6 +40,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
@ -40,6 +48,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import type { RoutineListItem, RoutineVariable } from "@paperclipai/shared";
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
@ -70,11 +79,203 @@ function nextRoutineStatus(currentStatus: string, enabled: boolean) {
return enabled ? "active" : "paused";
}
type RoutinesTab = "routines" | "runs";
type RoutineGroupBy = "none" | "project" | "assignee";
type RoutineViewState = {
groupBy: RoutineGroupBy;
collapsedGroups: string[];
};
type RoutineGroup = {
key: string;
label: string | null;
items: RoutineListItem[];
};
const defaultRoutineViewState: RoutineViewState = {
groupBy: "none",
collapsedGroups: [],
};
function getRoutineViewState(key: string): RoutineViewState {
try {
const raw = localStorage.getItem(key);
if (raw) return { ...defaultRoutineViewState, ...JSON.parse(raw) };
} catch {
// Ignore malformed local state and fall back to defaults.
}
return { ...defaultRoutineViewState };
}
function saveRoutineViewState(key: string, state: RoutineViewState) {
localStorage.setItem(key, JSON.stringify(state));
}
function formatRoutineRunStatus(value: string | null | undefined) {
if (!value) return null;
return value.replaceAll("_", " ");
}
export function buildRoutineGroups(
routines: RoutineListItem[],
groupByValue: RoutineGroupBy,
projectById: Map<string, { name: string }>,
agentById: Map<string, { name: string }>,
): RoutineGroup[] {
if (groupByValue === "none") {
return [{ key: "__all", label: null, items: routines }];
}
if (groupByValue === "project") {
const groups = groupBy(routines, (routine) => routine.projectId ?? "__no_project");
return Object.keys(groups)
.sort((left, right) => {
const leftLabel = left === "__no_project" ? "No project" : (projectById.get(left)?.name ?? "Unknown project");
const rightLabel = right === "__no_project" ? "No project" : (projectById.get(right)?.name ?? "Unknown project");
return leftLabel.localeCompare(rightLabel);
})
.map((key) => ({
key,
label: key === "__no_project" ? "No project" : (projectById.get(key)?.name ?? "Unknown project"),
items: groups[key]!,
}));
}
const groups = groupBy(routines, (routine) => routine.assigneeAgentId ?? "__unassigned");
return Object.keys(groups)
.sort((left, right) => {
const leftLabel = left === "__unassigned" ? "Unassigned" : (agentById.get(left)?.name ?? "Unknown agent");
const rightLabel = right === "__unassigned" ? "Unassigned" : (agentById.get(right)?.name ?? "Unknown agent");
return leftLabel.localeCompare(rightLabel);
})
.map((key) => ({
key,
label: key === "__unassigned" ? "Unassigned" : (agentById.get(key)?.name ?? "Unknown agent"),
items: groups[key]!,
}));
}
function buildRoutinesTabHref(tab: RoutinesTab) {
return tab === "runs" ? "/routines?tab=runs" : "/routines";
}
function RoutineListRow({
routine,
projectById,
agentById,
runningRoutineId,
statusMutationRoutineId,
onNavigate,
onRunNow,
onToggleEnabled,
onToggleArchived,
}: {
routine: RoutineListItem;
projectById: Map<string, { name: string; color?: string | null }>;
agentById: Map<string, { name: string; icon?: string | null }>;
runningRoutineId: string | null;
statusMutationRoutineId: string | null;
onNavigate: (routineId: string) => void;
onRunNow: (routine: RoutineListItem) => void;
onToggleEnabled: (routine: RoutineListItem, enabled: boolean) => void;
onToggleArchived: (routine: RoutineListItem) => void;
}) {
const enabled = routine.status === "active";
const isArchived = routine.status === "archived";
const isStatusPending = statusMutationRoutineId === routine.id;
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
const agent = routine.assigneeAgentId ? agentById.get(routine.assigneeAgentId) ?? null : null;
return (
<div
className="group flex cursor-pointer flex-col gap-3 border-b border-border px-3 py-3 transition-colors hover:bg-accent/50 last:border-b-0 sm:flex-row sm:items-center"
onClick={() => onNavigate(routine.id)}
>
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium">{routine.title}</span>
{(isArchived || routine.status === "paused") ? (
<span className="text-xs text-muted-foreground">
{isArchived ? "archived" : "paused"}
</span>
) : null}
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 shrink-0 rounded-sm"
style={{ backgroundColor: project?.color ?? "#64748b" }}
/>
<span>{project?.name ?? "Unknown project"}</span>
</span>
<span className="flex items-center gap-2">
{agent?.icon ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0" /> : null}
<span>{agent?.name ?? "Unknown agent"}</span>
</span>
<span>
{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}
{routine.lastRun ? ` · ${formatRoutineRunStatus(routine.lastRun.status)}` : ""}
</span>
</div>
</div>
<div className="flex items-center gap-3" onClick={(event) => event.stopPropagation()}>
<div className="flex items-center gap-3">
<ToggleSwitch
size="lg"
checked={enabled}
onCheckedChange={() => onToggleEnabled(routine, enabled)}
disabled={isStatusPending || isArchived}
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
/>
<span className="w-12 text-xs text-muted-foreground">
{isArchived ? "Archived" : enabled ? "On" : "Off"}
</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label={`More actions for ${routine.title}`}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onNavigate(routine.id)}>
Edit
</DropdownMenuItem>
<DropdownMenuItem
disabled={runningRoutineId === routine.id || isArchived}
onClick={() => onRunNow(routine)}
>
{runningRoutineId === routine.id ? "Running..." : "Run now"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onToggleEnabled(routine, enabled)}
disabled={isStatusPending || isArchived}
>
{enabled ? "Pause" : "Enable"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onToggleArchived(routine)}
disabled={isStatusPending}
>
{routine.status === "archived" ? "Restore" : "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
);
}
export function Routines() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { pushToast } = useToast();
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
@ -85,6 +286,7 @@ export function Routines() {
const [runDialogRoutine, setRunDialogRoutine] = useState<RoutineListItem | null>(null);
const [composerOpen, setComposerOpen] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const activeTab: RoutinesTab = searchParams.get("tab") === "runs" ? "runs" : "routines";
const [draft, setDraft] = useState<{
title: string;
description: string;
@ -104,11 +306,19 @@ export function Routines() {
catchUpPolicy: "skip_missed",
variables: [],
});
const routineViewStateKey = selectedCompanyId
? `paperclip:routines-view:${selectedCompanyId}`
: "paperclip:routines-view";
const [routineViewState, setRoutineViewState] = useState<RoutineViewState>(() => getRoutineViewState(routineViewStateKey));
useEffect(() => {
setBreadcrumbs([{ label: "Routines" }]);
}, [setBreadcrumbs]);
useEffect(() => {
setRoutineViewState(getRoutineViewState(routineViewStateKey));
}, [routineViewStateKey]);
const { data: routines, isLoading, error } = useQuery({
queryKey: queryKeys.routines.list(selectedCompanyId!),
queryFn: () => routinesApi.list(selectedCompanyId!),
@ -129,6 +339,17 @@ export function Routines() {
queryFn: () => instanceSettingsApi.getExperimental(),
retry: false,
});
const { data: routineExecutionIssues, isLoading: recentRunsLoading, error: recentRunsError } = useQuery({
queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"],
queryFn: () => issuesApi.list(selectedCompanyId!, { originKind: "routine_execution" }),
enabled: !!selectedCompanyId && activeTab === "runs",
});
const { data: liveRuns } = useQuery({
queryKey: queryKeys.liveRuns(selectedCompanyId!),
queryFn: () => heartbeatsApi.liveRunsForCompany(selectedCompanyId!),
enabled: !!selectedCompanyId && activeTab === "runs",
refetchInterval: 5000,
});
useEffect(() => {
autoResizeTextarea(titleInputRef.current);
@ -162,6 +383,13 @@ export function Routines() {
navigate(`/routines/${routine.id}?tab=triggers`);
},
});
const updateIssue = useMutation({
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
issuesApi.update(id, data),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [...queryKeys.issues.list(selectedCompanyId!), "routine-executions"] });
},
});
const updateRoutineStatus = useMutation({
mutationFn: ({ id, status }: { id: string; status: string }) => routinesApi.update(id, { status }),
@ -249,10 +477,45 @@ export function Routines() {
() => new Map((projects ?? []).map((project) => [project.id, project])),
[projects],
);
const liveIssueIds = useMemo(() => {
const ids = new Set<string>();
for (const run of liveRuns ?? []) {
if (run.issueId) ids.add(run.issueId);
}
return ids;
}, [liveRuns]);
const routineGroups = useMemo(
() => buildRoutineGroups(routines ?? [], routineViewState.groupBy, projectById, agentById),
[agentById, projectById, routineViewState.groupBy, routines],
);
const recentRunsIssueLinkState = useMemo(
() =>
createIssueDetailLocationState(
"Recent Runs",
buildRoutinesTabHref("runs"),
"issues",
),
[],
);
const runDialogProject = runDialogRoutine?.projectId ? projectById.get(runDialogRoutine.projectId) ?? null : null;
const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null;
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
function updateRoutineView(patch: Partial<RoutineViewState>) {
setRoutineViewState((current) => {
const next = { ...current, ...patch };
saveRoutineViewState(routineViewStateKey, next);
return next;
});
}
function handleTabChange(tab: string) {
const nextTab = tab === "runs" ? "runs" : "routines";
startTransition(() => {
navigate(buildRoutinesTabHref(nextTab));
});
}
function handleRunNow(routine: RoutineListItem) {
const project = routine.projectId ? projectById.get(routine.projectId) ?? null : null;
const needsConfiguration = routineRunNeedsConfiguration({
@ -267,6 +530,20 @@ export function Routines() {
runRoutine.mutate({ id: routine.id, data: {} });
}
function handleToggleEnabled(routine: RoutineListItem, enabled: boolean) {
updateRoutineStatus.mutate({
id: routine.id,
status: nextRoutineStatus(routine.status, !enabled),
});
}
function handleToggleArchived(routine: RoutineListItem) {
updateRoutineStatus.mutate({
id: routine.id,
status: routine.status === "archived" ? "active" : "archived",
});
}
if (!selectedCompanyId) {
return <EmptyState icon={Repeat} message="Select a company to view routines." />;
}
@ -293,6 +570,68 @@ export function Routines() {
</Button>
</div>
<Tabs value={activeTab} onValueChange={handleTabChange}>
<PageTabBar
align="start"
value={activeTab}
onValueChange={handleTabChange}
items={[
{ value: "routines", label: "Routines" },
{ value: "runs", label: "Recent Runs" },
]}
/>
<TabsContent value="routines" className="space-y-4">
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">
{(routines ?? []).length} routine{(routines ?? []).length === 1 ? "" : "s"}
</p>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="text-xs">
<Layers className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
<span className="hidden sm:inline">Group</span>
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-0">
<div className="p-2 space-y-0.5">
{([
["project", "Project"],
["assignee", "Agent"],
["none", "None"],
] as const).map(([value, label]) => (
<button
key={value}
className={`flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm ${
routineViewState.groupBy === value
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/50"
}`}
onClick={() => updateRoutineView({ groupBy: value, collapsedGroups: [] })}
>
<span>{label}</span>
{routineViewState.groupBy === value ? <Check className="h-3.5 w-3.5" /> : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
</TabsContent>
<TabsContent value="runs">
<IssuesList
issues={routineExecutionIssues ?? []}
isLoading={recentRunsLoading}
error={recentRunsError as Error | null}
agents={agents}
projects={projects}
liveIssueIds={liveIssueIds}
viewStateKey="paperclip:routine-recent-runs-view"
issueLinkState={recentRunsIssueLinkState}
onUpdateIssue={(id, data) => updateIssue.mutate({ id, data })}
/>
</TabsContent>
</Tabs>
<Dialog
open={composerOpen}
onOpenChange={(open) => {
@ -560,165 +899,64 @@ export function Routines() {
</Card>
) : null}
<div>
{(routines ?? []).length === 0 ? (
<div className="py-12">
<EmptyState
icon={Repeat}
message="No routines yet. Use Create routine to define the first recurring workflow."
/>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground border-b border-border">
<th className="px-3 py-2 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Project</th>
<th className="px-3 py-2 font-medium">Agent</th>
<th className="px-3 py-2 font-medium">Last run</th>
<th className="px-3 py-2 font-medium">Enabled</th>
<th className="w-12 px-3 py-2" />
</tr>
</thead>
<tbody>
{(routines ?? []).map((routine) => {
const enabled = routine.status === "active";
const isArchived = routine.status === "archived";
const isStatusPending = statusMutationRoutineId === routine.id;
return (
<tr
key={routine.id}
className="align-middle border-b border-border transition-colors hover:bg-accent/50 last:border-b-0 cursor-pointer"
onClick={() => navigate(`/routines/${routine.id}`)}
>
<td className="px-3 py-2.5">
<div className="min-w-[180px]">
<span className="font-medium">
{routine.title}
</span>
{(isArchived || routine.status === "paused") && (
<div className="mt-1 text-xs text-muted-foreground">
{isArchived ? "archived" : "paused"}
</div>
)}
</div>
</td>
<td className="px-3 py-2.5">
{routine.projectId ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span
className="shrink-0 h-3 w-3 rounded-sm"
style={{ backgroundColor: projectById.get(routine.projectId)?.color ?? "#6366f1" }}
/>
<span className="truncate">{projectById.get(routine.projectId)?.name ?? "Unknown"}</span>
</div>
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
<td className="px-3 py-2.5">
{routine.assigneeAgentId ? (() => {
const agent = agentById.get(routine.assigneeAgentId);
return agent ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AgentIcon icon={agent.icon} className="h-4 w-4 shrink-0" />
<span className="truncate">{agent.name}</span>
</div>
) : (
<span className="text-xs text-muted-foreground">Unknown</span>
);
})() : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
<td className="px-3 py-2.5 text-muted-foreground">
<div>{formatLastRunTimestamp(routine.lastRun?.triggeredAt)}</div>
{routine.lastRun ? (
<div className="mt-1 text-xs">{routine.lastRun.status.replaceAll("_", " ")}</div>
) : null}
</td>
<td className="px-3 py-2.5" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-3">
<button
type="button"
role="switch"
data-slot="toggle"
aria-checked={enabled}
aria-label={enabled ? `Disable ${routine.title}` : `Enable ${routine.title}`}
disabled={isStatusPending || isArchived}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? "bg-foreground" : "bg-muted"
} ${isStatusPending || isArchived ? "cursor-not-allowed opacity-50" : ""}`}
onClick={() =>
updateRoutineStatus.mutate({
id: routine.id,
status: nextRoutineStatus(routine.status, !enabled),
})
}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-background shadow-sm transition-transform ${
enabled ? "translate-x-5" : "translate-x-0.5"
}`}
/>
</button>
<span className="text-xs text-muted-foreground">
{isArchived ? "Archived" : enabled ? "On" : "Off"}
</span>
</div>
</td>
<td className="px-3 py-2.5 text-right" onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label={`More actions for ${routine.title}`}>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => navigate(`/routines/${routine.id}`)}>
Edit
</DropdownMenuItem>
<DropdownMenuItem
disabled={runningRoutineId === routine.id || isArchived}
onClick={() => handleRunNow(routine)}
>
{runningRoutineId === routine.id ? "Running..." : "Run now"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() =>
updateRoutineStatus.mutate({
id: routine.id,
status: enabled ? "paused" : "active",
})
}
disabled={isStatusPending || isArchived}
>
{enabled ? "Pause" : "Enable"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
updateRoutineStatus.mutate({
id: routine.id,
status: routine.status === "archived" ? "active" : "archived",
})
}
disabled={isStatusPending}
>
{routine.status === "archived" ? "Restore" : "Archive"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{activeTab === "routines" ? (
<div>
{(routines ?? []).length === 0 ? (
<div className="py-12">
<EmptyState
icon={Repeat}
message="No routines yet. Use Create routine to define the first recurring workflow."
/>
</div>
) : (
<div className="rounded-lg border border-border">
{routineGroups.map((group) => (
<Collapsible
key={group.key}
open={!routineViewState.collapsedGroups.includes(group.key)}
onOpenChange={(open) => {
updateRoutineView({
collapsedGroups: open
? routineViewState.collapsedGroups.filter((item) => item !== group.key)
: [...routineViewState.collapsedGroups, group.key],
});
}}
>
{group.label ? (
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
<CollapsibleTrigger className="flex items-center gap-1.5">
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform [[data-state=open]>&]:rotate-90" />
<span className="text-sm font-semibold uppercase tracking-wide">
{group.label}
</span>
</CollapsibleTrigger>
<span className="text-xs text-muted-foreground">
{group.items.length}
</span>
</div>
) : null}
<CollapsibleContent>
{group.items.map((routine) => (
<RoutineListRow
key={routine.id}
routine={routine}
projectById={projectById}
agentById={agentById}
runningRoutineId={runningRoutineId}
statusMutationRoutineId={statusMutationRoutineId}
onNavigate={(routineId) => navigate(`/routines/${routineId}`)}
onRunNow={handleRunNow}
onToggleEnabled={handleToggleEnabled}
onToggleArchived={handleToggleArchived}
/>
))}
</CollapsibleContent>
</Collapsible>
))}
</div>
)}
</div>
) : null}
<RoutineRunVariablesDialog
open={runDialogRoutine !== null}