mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
Merge branch 'master' into fix/clear-extra-args-config
This commit is contained in:
commit
23eea392c8
187 changed files with 13296 additions and 1694 deletions
|
|
@ -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 />} />
|
||||
|
|
|
|||
157
ui/src/adapters/adapter-display-registry.ts
Normal file
157
ui/src/adapters/adapter-display-registry.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
33
ui/src/adapters/disabled-store.ts
Normal file
33
ui/src/adapters/disabled-store.ts
Normal 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);
|
||||
}
|
||||
122
ui/src/adapters/dynamic-loader.ts
Normal file
122
ui/src/adapters/dynamic-loader.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
33
ui/src/adapters/metadata.test.ts
Normal file
33
ui/src/adapters/metadata.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
75
ui/src/adapters/metadata.ts
Normal file
75
ui/src/adapters/metadata.ts
Normal 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);
|
||||
}
|
||||
51
ui/src/adapters/registry.test.ts
Normal file
51
ui/src/adapters/registry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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[] {
|
||||
|
|
|
|||
507
ui/src/adapters/schema-config-fields.tsx
Normal file
507
ui/src/adapters/schema-config-fields.tsx
Normal 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 "{filter}" 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;
|
||||
}
|
||||
|
|
@ -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" }]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
54
ui/src/adapters/use-disabled-adapters.ts
Normal file
54
ui/src/adapters/use-disabled-adapters.ts
Normal 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
59
ui/src/api/adapters.ts
Normal 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`, {}),
|
||||
};
|
||||
|
|
@ -32,6 +32,7 @@ export interface DetectedAdapterModel {
|
|||
model: string;
|
||||
provider: string;
|
||||
source: string;
|
||||
candidates?: string[];
|
||||
}
|
||||
|
||||
export interface ClaudeLoginResult {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
147
ui/src/components/CommentThread.test.tsx
Normal file
147
ui/src/components/CommentThread.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 & 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<>
|
||||
|
|
|
|||
151
ui/src/components/ImageGalleryModal.tsx
Normal file
151
ui/src/components/ImageGalleryModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
ui/src/components/InlineEditor.test.tsx
Normal file
84
ui/src/components/InlineEditor.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
354
ui/src/components/IssueDocumentsSection.test.tsx
Normal file
354
ui/src/components/IssueDocumentsSection.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
189
ui/src/components/MarkdownEditor.test.tsx
Normal file
189
ui/src/components/MarkdownEditor.test.tsx
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
38
ui/src/components/RunInvocationCard.test.tsx
Normal file
38
ui/src/components/RunInvocationCard.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -24,7 +24,7 @@ export const defaultCreateValues: CreateConfigValues = {
|
|||
workspaceBranchTemplate: "",
|
||||
worktreeParentDir: "",
|
||||
runtimeServicesJson: "",
|
||||
maxTurnsPerRun: 300,
|
||||
maxTurnsPerRun: 1000,
|
||||
heartbeatEnabled: false,
|
||||
intervalSec: 300,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
59
ui/src/components/ui/toggle-switch.tsx
Normal file
59
ui/src/components/ui/toggle-switch.tsx
Normal 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";
|
||||
61
ui/src/context/EditorAutocompleteContext.tsx
Normal file
61
ui/src/context/EditorAutocompleteContext.tsx
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
153
ui/src/lib/issue-timeline-events.test.ts
Normal file
153
ui/src/lib/issue-timeline-events.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
105
ui/src/lib/issue-timeline-events.ts
Normal file
105
ui/src/lib/issue-timeline-events.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
50
ui/src/lib/markdownPaste.test.ts
Normal file
50
ui/src/lib/markdownPaste.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
23
ui/src/lib/markdownPaste.ts
Normal file
23
ui/src/lib/markdownPaste.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
59
ui/src/lib/normalize-markdown.test.ts
Normal file
59
ui/src/lib/normalize-markdown.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
44
ui/src/lib/normalize-markdown.ts
Normal file
44
ui/src/lib/normalize-markdown.ts
Normal 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;
|
||||
}
|
||||
66
ui/src/lib/paste-normalization.ts
Normal file
66
ui/src/lib/paste-normalization.ts
Normal 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,
|
||||
);
|
||||
},
|
||||
]);
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
676
ui/src/pages/AdapterManager.tsx
Normal file
676
ui/src/pages/AdapterManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) : "—"}
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
367
ui/src/pages/Routines.test.tsx
Normal file
367
ui/src/pages/Routines.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue