From 47f3cdc1bbaf2d339212b46620c04b6e0f84c710 Mon Sep 17 00:00:00 2001 From: HenkDz Date: Wed, 1 Apr 2026 21:56:19 +0100 Subject: [PATCH] fix(ui): external adapter selection, config field placement, and transcript parser freshness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix external adapters (hermes, droid) not auto-selected when navigating with ?adapterType= param — was using a stale module-level Set built before async adapter registration - Move SchemaConfigFields to render after thinking effort (same visual area as Claude's chrome toggle) instead of bottom of config section - Extract SelectField into its own component to fix React hooks order violation when schema fields change between renders - Add onAdapterChange() subscription in registry.ts so registerUIAdapter() notifies components when dynamic parsers load, fixing stale parser for old runs - Add parserTick to both RunTranscriptView and useLiveRunTranscripts to force recomputation on parser change --- packages/adapter-utils/src/log-redaction.ts | 1 + packages/adapter-utils/src/types.ts | 9 +- pnpm-lock.yaml | 20 -- server/src/adapters/plugin-loader.ts | 17 +- ui/src/adapters/index.ts | 1 + ui/src/adapters/metadata.ts | 12 + ui/src/adapters/registry.ts | 15 + ui/src/adapters/schema-config-fields.tsx | 284 ++++++++++++++++-- ui/src/components/AgentConfigForm.tsx | 4 +- .../transcript/RunTranscriptView.tsx | 131 ++++++++ .../transcript/useLiveRunTranscripts.ts | 9 +- ui/src/pages/AgentDetail.tsx | 16 +- ui/src/pages/NewAgent.tsx | 9 +- 13 files changed, 473 insertions(+), 55 deletions(-) diff --git a/packages/adapter-utils/src/log-redaction.ts b/packages/adapter-utils/src/log-redaction.ts index 6c5554e1..96cfba6d 100644 --- a/packages/adapter-utils/src/log-redaction.ts +++ b/packages/adapter-utils/src/log-redaction.ts @@ -68,6 +68,7 @@ export function redactTranscriptEntryPaths(entry: TranscriptEntry, opts?: HomePa case "stderr": case "system": case "stdout": + case "diff": return { ...entry, text: redactHomePathUserSegments(entry.text, opts) }; case "tool_call": return { diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 2dedc534..429143d5 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -268,17 +268,21 @@ export interface ProviderQuotaResult { export interface ConfigFieldOption { label: string; value: string; + /** Optional group key for categorizing options (e.g. provider name) */ + group?: string; } export interface ConfigFieldSchema { key: string; label: string; - type: "text" | "select" | "toggle" | "number" | "textarea"; + type: "text" | "select" | "toggle" | "number" | "textarea" | "combobox"; options?: ConfigFieldOption[]; default?: unknown; hint?: string; required?: boolean; group?: string; + /** Optional metadata — not rendered, but available to custom UI logic */ + meta?: Record; } export interface AdapterConfigSchema { @@ -340,7 +344,8 @@ export type TranscriptEntry = | { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] } | { kind: "stderr"; ts: string; text: string } | { kind: "system"; ts: string; text: string } - | { kind: "stdout"; ts: string; text: string }; + | { kind: "stdout"; ts: string; text: string } + | { kind: "diff"; ts: string; changeType: "add" | "remove" | "context" | "hunk" | "file_header" | "truncation"; text: string }; export type StdoutLineParser = (line: string, ts: string) => TranscriptEntry[]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19c9ffc4..e674ff24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -503,9 +503,6 @@ importers: express: specifier: ^5.1.0 version: 5.2.1 - hermes-paperclip-adapter: - specifier: ^0.2.0 - version: 0.2.0 jsdom: specifier: ^28.1.0 version: 28.1.0(@noble/hashes@2.0.1) @@ -639,9 +636,6 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - hermes-paperclip-adapter: - specifier: ^0.2.0 - version: 0.2.0 lexical: specifier: 0.35.0 version: 0.35.0 @@ -2043,9 +2037,6 @@ packages: '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} - '@paperclipai/adapter-utils@2026.325.0': - resolution: {integrity: sha512-YDVSAgjkeJ0PvxXDJVN9MZDX7oYRzidLtGHmGgRGd6gSk/bF2ygAKvND4FI1YxDc/cRLQjqAFCpCYaC/9wqIEA==} - '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} @@ -4471,10 +4462,6 @@ packages: help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} - hermes-paperclip-adapter@0.2.0: - resolution: {integrity: sha512-6CP5vxfvY4jY9XJK5zu4ZUL9aB7HHNtEMk6q7m1Pu9Gzoby1Vx5VNmVqte3NUO+1cvVK9Arj1f67xLagWkbo5Q==} - engines: {node: '>=20.0.0'} - html-encoding-sniffer@6.0.0: resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -7743,8 +7730,6 @@ snapshots: '@open-draft/deferred-promise@2.2.0': {} - '@paperclipai/adapter-utils@2026.325.0': {} - '@paralleldrive/cuid2@2.3.1': dependencies: '@noble/hashes': 1.8.0 @@ -10340,11 +10325,6 @@ snapshots: help-me@5.0.0: {} - hermes-paperclip-adapter@0.2.0: - dependencies: - '@paperclipai/adapter-utils': 2026.325.0 - picocolors: 1.1.1 - html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1): dependencies: '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) diff --git a/server/src/adapters/plugin-loader.ts b/server/src/adapters/plugin-loader.ts index e9faf312..a9f70463 100644 --- a/server/src/adapters/plugin-loader.ts +++ b/server/src/adapters/plugin-loader.ts @@ -212,8 +212,23 @@ export async function reloadExternalAdapter( const packageDir = resolvePackageDir(record); const entryPoint = resolvePackageEntryPoint(packageDir); const modulePath = path.resolve(packageDir, entryPoint); + const fileUrl = `file://${modulePath}`; - const cacheBustUrl = `file://${modulePath}?t=${Date.now()}`; + // Bust ESM module cache so re-import loads fresh code from disk. + // Query-string trick (?t=...) works in Node; Bun may need the file:// URL + // to be evicted from its internal registry first. + try { + // @ts-expect-error -- Bun internal module cache + const bunCache = globalThis.Bun?.__moduleCache as Map | undefined; + if (bunCache) { + bunCache.delete(fileUrl); + bunCache.delete(modulePath); + } + } catch { + // Ignore — query-string fallback still works in Node + } + + const cacheBustUrl = `${fileUrl}?t=${Date.now()}`; logger.info( { type, packageName: record.packageName, modulePath, cacheBustUrl }, diff --git a/ui/src/adapters/index.ts b/ui/src/adapters/index.ts index ec4f196b..5623cf8a 100644 --- a/ui/src/adapters/index.ts +++ b/ui/src/adapters/index.ts @@ -5,6 +5,7 @@ export { registerUIAdapter, unregisterUIAdapter, syncExternalAdapters, + onAdapterChange, } from "./registry"; export { buildTranscript } from "./transcript"; export type { diff --git a/ui/src/adapters/metadata.ts b/ui/src/adapters/metadata.ts index d8a9e1f1..3a35030a 100644 --- a/ui/src/adapters/metadata.ts +++ b/ui/src/adapters/metadata.ts @@ -26,6 +26,18 @@ export function listKnownAdapterTypes(): string[] { * Unknown types (external adapters) are always considered enabled. */ export function isEnabledAdapterType(type: string): boolean { + // Known external adapter — always valid + if (listUIAdapters().some((a) => a.type === type)) return true; + return !getAdapterDisplay(type).comingSoon; +} + +/** + * 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 (listUIAdapters().some((a) => a.type === type)) return true; return !getAdapterDisplay(type).comingSoon; } diff --git a/ui/src/adapters/registry.ts b/ui/src/adapters/registry.ts index 1e3a4452..89e15b1e 100644 --- a/ui/src/adapters/registry.ts +++ b/ui/src/adapters/registry.ts @@ -15,6 +15,20 @@ import { SchemaConfigFields, buildSchemaAdapterConfig } from "./schema-config-fi const uiAdapters: UIAdapterModule[] = []; const adaptersByType = new Map(); +// 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, @@ -40,6 +54,7 @@ export function registerUIAdapter(adapter: UIAdapterModule): void { uiAdapters.push(adapter); } adaptersByType.set(adapter.type, adapter); + notifyAdapterChange(); } export function unregisterUIAdapter(type: string): void { diff --git a/ui/src/adapters/schema-config-fields.tsx b/ui/src/adapters/schema-config-fields.tsx index 52a67fdc..7161f9e0 100644 --- a/ui/src/adapters/schema-config-fields.tsx +++ b/ui/src/adapters/schema-config-fields.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import type { AdapterConfigSchema, ConfigFieldSchema, CreateConfigValues } from "@paperclipai/adapter-utils"; @@ -10,15 +10,197 @@ import { 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 ( + + + + + + {options.map((opt) => ( + + ))} + + + ); +} 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 selectClass = - "w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono"; // --------------------------------------------------------------------------- -// Schema cache (module-level, survives re-renders) +// 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(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(); + 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 ( +
+
+ { + 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} + /> + 0} onOpenChange={setOpen}> + + + + e.preventDefault()} + > + {Array.from(grouped.entries()).map(([group, opts]) => ( +
+ {group && ( +
+ {group} +
+ )} + {opts.map((opt) => ( + + ))} +
+ ))} + {filter && filtered.length === 0 && ( +
+ Use "{filter}" as custom value (press Enter) +
+ )} +
+
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// SchemaConfigFields component // --------------------------------------------------------------------------- const schemaCache = new Map(); @@ -146,14 +328,42 @@ export function SchemaConfigFields({ function writeValue(field: ConfigFieldSchema, value: unknown): void { if (isCreate) { - set?.({ + 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; + 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; + const providerModels = modelsByProvider[String(value)] ?? []; + const currentModel = eff("adapterConfig", "model", ""); + if (currentModel && String(value) !== "auto" && !providerModels.includes(String(currentModel))) { + mark("adapterConfig", "model", ""); + } + } + } } } @@ -161,22 +371,18 @@ export function SchemaConfigFields({ <> {schema.fields.map((field) => { switch (field.type) { - case "select": + case "select": { + const currentVal = String(readValue(field) ?? ""); return ( - + writeValue(field, v)} + /> ); + } case "toggle": return ( @@ -212,6 +418,48 @@ export function SchemaConfigFields({ ); + 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; + 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 ( + + writeValue(field, v || undefined)} + placeholder={field.hint} + /> + + ); + } + case "text": default: return ( diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index bb3ac083..6b4f761b 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -693,8 +693,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { )} - {/* Adapter-specific fields */} - + {/* Adapter-specific fields are rendered inside Permissions & Configuration */} @@ -816,6 +815,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { {adapterType === "claude_local" && ( )} + ; }; function asRecord(value: unknown): Record | null { @@ -568,6 +579,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; @@ -1093,6 +1126,103 @@ function TranscriptEventRow({ ); } +function TranscriptDiffGroup({ + block, + density, +}: { + block: Extract; + 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 ( +
+
setOpen((v) => !v)} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setOpen((v) => !v); } }} + > + + + {shortFile} + + {hasChanges && ( + + +{addCount} + {" "} + -{removeCount} + + )} + {open ? : } +
+ {open && ( +
+          {block.hunks.map((hunk, i) => {
+            const key = `${i}-${hunk.changeType}`;
+            switch (hunk.changeType) {
+              case "remove":
+                return (
+                  
+                    -
+                    {hunk.text}
+                    {"\n"}
+                  
+                );
+              case "add":
+                return (
+                  
+                    +
+                    {hunk.text}
+                    {"\n"}
+                  
+                );
+              case "file_header":
+                return (
+                  
+                    {hunk.text}
+                    {"\n"}
+                  
+                );
+              case "truncation":
+                return (
+                  
+                    {hunk.text}
+                    {"\n"}
+                  
+                );
+              case "context":
+              default:
+                return (
+                  
+                    {" "}
+                    {hunk.text}
+                    {"\n"}
+                  
+                );
+            }
+          })}
+        
+ )} +
+ ); +} + function TranscriptStderrGroup({ block, density, @@ -1251,6 +1381,7 @@ export function RunTranscriptView({ {block.type === "tool" && } {block.type === "command_group" && } {block.type === "tool_group" && } + {block.type === "diff_group" && } {block.type === "stderr_group" && } {block.type === "stdout" && ( diff --git a/ui/src/components/transcript/useLiveRunTranscripts.ts b/ui/src/components/transcript/useLiveRunTranscripts.ts index fe6950f2..a0c9068d 100644 --- a/ui/src/components/transcript/useLiveRunTranscripts.ts +++ b/ui/src/components/transcript/useLiveRunTranscripts.ts @@ -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()); const pendingLogRowsByRunRef = useRef(new Map()); const logOffsetByRunRef = useRef(new Map()); + // 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(), @@ -285,7 +290,7 @@ export function useLiveRunTranscripts({ ); } return next; - }, [chunksByRun, generalSettings?.censorUsernameInLogs, runs]); + }, [chunksByRun, generalSettings?.censorUsernameInLogs, parserTick, runs]); return { transcriptByRun, diff --git a/ui/src/pages/AgentDetail.tsx b/ui/src/pages/AgentDetail.tsx index 734a5b17..9d1c5a18 100644 --- a/ui/src/pages/AgentDetail.tsx +++ b/ui/src/pages/AgentDetail.tsx @@ -27,7 +27,7 @@ import { PageTabBar } from "../components/PageTabBar"; import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives"; 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"; @@ -3762,10 +3762,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], + [adapter, censorUsernameInLogs, logLines, parserTick], ); useEffect(() => { diff --git a/ui/src/pages/NewAgent.tsx b/ui/src/pages/NewAgent.tsx index 7ca85a2f..9b1dd12c 100644 --- a/ui/src/pages/NewAgent.tsx +++ b/ui/src/pages/NewAgent.tsx @@ -21,6 +21,7 @@ import { AgentConfigForm, type CreateConfigValues } from "../components/AgentCon import { defaultCreateValues } from "../components/agent-config-defaults"; 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, @@ -29,10 +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( - listUIAdapters().map((adapter) => adapter.type as CreateConfigValues["adapterType"]), -); - function createValuesForAdapterType( adapterType: CreateConfigValues["adapterType"], ): CreateConfigValues { @@ -114,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"]);