fix(ui): external adapter selection, config field placement, and transcript parser freshness

- 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
This commit is contained in:
HenkDz 2026-04-01 21:56:19 +01:00
parent 69a1593ff8
commit 47f3cdc1bb
13 changed files with 473 additions and 55 deletions

View file

@ -68,6 +68,7 @@ export function redactTranscriptEntryPaths(entry: TranscriptEntry, opts?: HomePa
case "stderr": case "stderr":
case "system": case "system":
case "stdout": case "stdout":
case "diff":
return { ...entry, text: redactHomePathUserSegments(entry.text, opts) }; return { ...entry, text: redactHomePathUserSegments(entry.text, opts) };
case "tool_call": case "tool_call":
return { return {

View file

@ -268,17 +268,21 @@ export interface ProviderQuotaResult {
export interface ConfigFieldOption { export interface ConfigFieldOption {
label: string; label: string;
value: string; value: string;
/** Optional group key for categorizing options (e.g. provider name) */
group?: string;
} }
export interface ConfigFieldSchema { export interface ConfigFieldSchema {
key: string; key: string;
label: string; label: string;
type: "text" | "select" | "toggle" | "number" | "textarea"; type: "text" | "select" | "toggle" | "number" | "textarea" | "combobox";
options?: ConfigFieldOption[]; options?: ConfigFieldOption[];
default?: unknown; default?: unknown;
hint?: string; hint?: string;
required?: boolean; required?: boolean;
group?: string; group?: string;
/** Optional metadata — not rendered, but available to custom UI logic */
meta?: Record<string, unknown>;
} }
export interface AdapterConfigSchema { 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: "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: "stderr"; ts: string; text: string }
| { kind: "system"; 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[]; export type StdoutLineParser = (line: string, ts: string) => TranscriptEntry[];

20
pnpm-lock.yaml generated
View file

@ -503,9 +503,6 @@ importers:
express: express:
specifier: ^5.1.0 specifier: ^5.1.0
version: 5.2.1 version: 5.2.1
hermes-paperclip-adapter:
specifier: ^0.2.0
version: 0.2.0
jsdom: jsdom:
specifier: ^28.1.0 specifier: ^28.1.0
version: 28.1.0(@noble/hashes@2.0.1) version: 28.1.0(@noble/hashes@2.0.1)
@ -639,9 +636,6 @@ importers:
cmdk: cmdk:
specifier: ^1.1.1 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) 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: lexical:
specifier: 0.35.0 specifier: 0.35.0
version: 0.35.0 version: 0.35.0
@ -2043,9 +2037,6 @@ packages:
'@open-draft/deferred-promise@2.2.0': '@open-draft/deferred-promise@2.2.0':
resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==}
'@paperclipai/adapter-utils@2026.325.0':
resolution: {integrity: sha512-YDVSAgjkeJ0PvxXDJVN9MZDX7oYRzidLtGHmGgRGd6gSk/bF2ygAKvND4FI1YxDc/cRLQjqAFCpCYaC/9wqIEA==}
'@paralleldrive/cuid2@2.3.1': '@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
@ -4471,10 +4462,6 @@ packages:
help-me@5.0.0: help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} 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: html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@ -7743,8 +7730,6 @@ snapshots:
'@open-draft/deferred-promise@2.2.0': {} '@open-draft/deferred-promise@2.2.0': {}
'@paperclipai/adapter-utils@2026.325.0': {}
'@paralleldrive/cuid2@2.3.1': '@paralleldrive/cuid2@2.3.1':
dependencies: dependencies:
'@noble/hashes': 1.8.0 '@noble/hashes': 1.8.0
@ -10340,11 +10325,6 @@ snapshots:
help-me@5.0.0: {} 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): html-encoding-sniffer@6.0.0(@noble/hashes@2.0.1):
dependencies: dependencies:
'@exodus/bytes': 1.15.0(@noble/hashes@2.0.1) '@exodus/bytes': 1.15.0(@noble/hashes@2.0.1)

View file

@ -212,8 +212,23 @@ export async function reloadExternalAdapter(
const packageDir = resolvePackageDir(record); const packageDir = resolvePackageDir(record);
const entryPoint = resolvePackageEntryPoint(packageDir); const entryPoint = resolvePackageEntryPoint(packageDir);
const modulePath = path.resolve(packageDir, entryPoint); 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<string, unknown> | 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( logger.info(
{ type, packageName: record.packageName, modulePath, cacheBustUrl }, { type, packageName: record.packageName, modulePath, cacheBustUrl },

View file

@ -5,6 +5,7 @@ export {
registerUIAdapter, registerUIAdapter,
unregisterUIAdapter, unregisterUIAdapter,
syncExternalAdapters, syncExternalAdapters,
onAdapterChange,
} from "./registry"; } from "./registry";
export { buildTranscript } from "./transcript"; export { buildTranscript } from "./transcript";
export type { export type {

View file

@ -26,6 +26,18 @@ export function listKnownAdapterTypes(): string[] {
* Unknown types (external adapters) are always considered enabled. * Unknown types (external adapters) are always considered enabled.
*/ */
export function isEnabledAdapterType(type: string): boolean { 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; return !getAdapterDisplay(type).comingSoon;
} }

View file

@ -15,6 +15,20 @@ import { SchemaConfigFields, buildSchemaAdapterConfig } from "./schema-config-fi
const uiAdapters: UIAdapterModule[] = []; const uiAdapters: UIAdapterModule[] = [];
const adaptersByType = new Map<string, UIAdapterModule>(); const adaptersByType = new Map<string, UIAdapterModule>();
// 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() { function registerBuiltInUIAdapters() {
for (const adapter of [ for (const adapter of [
claudeLocalUIAdapter, claudeLocalUIAdapter,
@ -40,6 +54,7 @@ export function registerUIAdapter(adapter: UIAdapterModule): void {
uiAdapters.push(adapter); uiAdapters.push(adapter);
} }
adaptersByType.set(adapter.type, adapter); adaptersByType.set(adapter.type, adapter);
notifyAdapterChange();
} }
export function unregisterUIAdapter(type: string): void { export function unregisterUIAdapter(type: string): void {

View file

@ -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"; import type { AdapterConfigSchema, ConfigFieldSchema, CreateConfigValues } from "@paperclipai/adapter-utils";
@ -10,15 +10,197 @@ import {
DraftTextarea, DraftTextarea,
ToggleField, ToggleField,
} from "../components/agent-config-primitives"; } 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 = 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"; "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<HTMLInputElement>(null);
// Sync filter with external value when it changes (e.g. provider switch resets model)
useEffect(() => {
setFilter("");
}, [value]);
const filtered = options.filter((opt) => {
if (!filter) return true;
const q = filter.toLowerCase();
return (
opt.value.toLowerCase().includes(q) ||
opt.label.toLowerCase().includes(q) ||
(opt.group && opt.group.toLowerCase().includes(q))
);
});
const selectedOpt = options.find((o) => o.value === value);
const displayValue = filter || selectedOpt?.value || value || "";
// Group filtered options by `group` field if present
const grouped = new Map<string, typeof filtered>();
for (const opt of filtered) {
const g = opt.group ?? "";
if (!grouped.has(g)) grouped.set(g, []);
grouped.get(g)!.push(opt);
}
const select = useCallback(
(val: string) => {
onChange(val);
setOpen(false);
setFilter("");
inputRef.current?.blur();
},
[onChange],
);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
// If exactly one match, select it. Otherwise commit the typed value.
if (filtered.length === 1) {
select(filtered[0].value);
} else if (filter) {
select(filter);
}
} else if (e.key === "Escape") {
setOpen(false);
setFilter("");
} else if (e.key === "ArrowDown" && !open) {
e.preventDefault();
setOpen(true);
}
};
return (
<div className="relative">
<div className="flex items-center gap-0">
<input
ref={inputRef}
type="text"
className="flex-1 rounded-l-md border border-r-0 border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40 focus:z-10"
value={displayValue}
placeholder={placeholder ?? "Type or select..."}
onChange={(e) => {
setFilter(e.target.value);
if (!open) setOpen(true);
}}
onFocus={() => {
if (!open) setOpen(true);
}}
onBlur={() => {
// Delay close to allow click on option to register
setTimeout(() => setOpen(false), 150);
}}
onKeyDown={handleKeyDown}
/>
<Popover open={open && filtered.length > 0} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button className="rounded-r-md border border-border px-2 py-1.5 hover:bg-accent/50 transition-colors">
<ChevronDown className="h-3 w-3 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent
className="p-1 max-h-60 overflow-y-auto"
style={{ minWidth: 280 }}
align="start"
onOpenAutoFocus={(e) => e.preventDefault()}
>
{Array.from(grouped.entries()).map(([group, opts]) => (
<div key={group || "_ungrouped"}>
{group && (
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
{group}
</div>
)}
{opts.map((opt) => (
<button
key={opt.value}
className={`flex items-center w-full px-2 py-1.5 text-sm rounded hover:bg-accent/50 ${
opt.value === value ? "bg-accent" : ""
}`}
onMouseDown={(e) => {
e.preventDefault(); // prevent input blur
select(opt.value);
}}
>
<span className="truncate">{opt.label}</span>
</button>
))}
</div>
))}
{filter && filtered.length === 0 && (
<div className="px-2 py-1.5 text-sm text-muted-foreground">
Use &quot;{filter}&quot; as custom value (press Enter)
</div>
)}
</PopoverContent>
</Popover>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// SchemaConfigFields component
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const schemaCache = new Map<string, AdapterConfigSchema | null>(); const schemaCache = new Map<string, AdapterConfigSchema | null>();
@ -146,14 +328,42 @@ export function SchemaConfigFields({
function writeValue(field: ConfigFieldSchema, value: unknown): void { function writeValue(field: ConfigFieldSchema, value: unknown): void {
if (isCreate) { if (isCreate) {
set?.({ const next = {
adapterSchemaValues: { adapterSchemaValues: {
...values?.adapterSchemaValues, ...values?.adapterSchemaValues,
[field.key]: value, [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 { } else {
mark("adapterConfig", field.key, value); 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", "");
}
}
}
} }
} }
@ -161,22 +371,18 @@ export function SchemaConfigFields({
<> <>
{schema.fields.map((field) => { {schema.fields.map((field) => {
switch (field.type) { switch (field.type) {
case "select": case "select": {
const currentVal = String(readValue(field) ?? "");
return ( return (
<Field key={field.key} label={field.label} hint={field.hint}> <Field key={field.key} label={field.label} hint={field.hint}>
<select <SelectField
className={selectClass} value={currentVal}
value={String(readValue(field) ?? "")} options={field.options ?? []}
onChange={(e) => writeValue(field, e.target.value)} onChange={(v) => writeValue(field, v)}
> />
{field.options?.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</Field> </Field>
); );
}
case "toggle": case "toggle":
return ( return (
@ -212,6 +418,48 @@ export function SchemaConfigFields({
</Field> </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": case "text":
default: default:
return ( return (

View file

@ -693,8 +693,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</> </>
)} )}
{/* Adapter-specific fields */} {/* Adapter-specific fields are rendered inside Permissions & Configuration */}
<uiAdapter.ConfigFields {...adapterFieldProps} />
</div> </div>
</div> </div>
@ -816,6 +815,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
{adapterType === "claude_local" && ( {adapterType === "claude_local" && (
<ClaudeLocalAdvancedFields {...adapterFieldProps} /> <ClaudeLocalAdvancedFields {...adapterFieldProps} />
)} )}
<uiAdapter.ConfigFields {...adapterFieldProps} />
<Field label="Extra args (comma-separated)" hint={help.extraArgs}> <Field label="Extra args (comma-separated)" hint={help.extraArgs}>
<DraftInput <DraftInput

View file

@ -7,6 +7,7 @@ import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
CircleAlert, CircleAlert,
GitCompare,
TerminalSquare, TerminalSquare,
User, User,
Wrench, Wrench,
@ -104,6 +105,16 @@ type TranscriptBlock =
tone: "info" | "warn" | "error" | "neutral"; tone: "info" | "warn" | "error" | "neutral";
text: string; text: string;
detail?: 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 { function asRecord(value: unknown): Record<string, unknown> | null {
@ -568,6 +579,28 @@ export function normalizeTranscript(entries: TranscriptEntry[], streaming: boole
continue; 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") { if (previous?.type === "stdout") {
previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`; previous.text += previous.text.endsWith("\n") || entry.text.startsWith("\n") ? entry.text : `\n${entry.text}`;
previous.ts = entry.ts; previous.ts = entry.ts;
@ -1093,6 +1126,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({ function TranscriptStderrGroup({
block, block,
density, density,
@ -1251,6 +1381,7 @@ export function RunTranscriptView({
{block.type === "tool" && <TranscriptToolCard block={block} density={density} />} {block.type === "tool" && <TranscriptToolCard block={block} density={density} />}
{block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />} {block.type === "command_group" && <TranscriptCommandGroup block={block} density={density} />}
{block.type === "tool_group" && <TranscriptToolGroup 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 === "stderr_group" && <TranscriptStderrGroup block={block} density={density} />}
{block.type === "stdout" && ( {block.type === "stdout" && (
<TranscriptStdoutRow block={block} density={density} collapseByDefault={collapseStdout} /> <TranscriptStdoutRow block={block} density={density} collapseByDefault={collapseStdout} />

View file

@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import type { LiveEvent } from "@paperclipai/shared"; import type { LiveEvent } from "@paperclipai/shared";
import { instanceSettingsApi } from "../../api/instanceSettings"; import { instanceSettingsApi } from "../../api/instanceSettings";
import { heartbeatsApi, type LiveRunForIssue } from "../../api/heartbeats"; 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"; import { queryKeys } from "../../lib/queryKeys";
const LOG_POLL_INTERVAL_MS = 2000; const LOG_POLL_INTERVAL_MS = 2000;
@ -68,6 +68,11 @@ export function useLiveRunTranscripts({
const seenChunkKeysRef = useRef(new Set<string>()); const seenChunkKeysRef = useRef(new Set<string>());
const pendingLogRowsByRunRef = useRef(new Map<string, string>()); const pendingLogRowsByRunRef = useRef(new Map<string, string>());
const logOffsetByRunRef = useRef(new Map<string, number>()); 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({ const { data: generalSettings } = useQuery({
queryKey: queryKeys.instance.generalSettings, queryKey: queryKeys.instance.generalSettings,
queryFn: () => instanceSettingsApi.getGeneral(), queryFn: () => instanceSettingsApi.getGeneral(),
@ -285,7 +290,7 @@ export function useLiveRunTranscripts({
); );
} }
return next; return next;
}, [chunksByRun, generalSettings?.censorUsernameInLogs, runs]); }, [chunksByRun, generalSettings?.censorUsernameInLogs, parserTick, runs]);
return { return {
transcriptByRun, transcriptByRun,

View file

@ -27,7 +27,7 @@ import { PageTabBar } from "../components/PageTabBar";
import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives"; import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives";
import { MarkdownEditor } from "../components/MarkdownEditor"; import { MarkdownEditor } from "../components/MarkdownEditor";
import { assetsApi } from "../api/assets"; import { assetsApi } from "../api/assets";
import { getUIAdapter, buildTranscript } from "../adapters"; import { getUIAdapter, buildTranscript, onAdapterChange } from "../adapters";
import { StatusBadge } from "../components/StatusBadge"; import { StatusBadge } from "../components/StatusBadge";
import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors"; import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
import { MarkdownBody } from "../components/MarkdownBody"; import { MarkdownBody } from "../components/MarkdownBody";
@ -3762,10 +3762,20 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
return redactPathValue(asRecord(evt?.payload ?? null), censorUsernameInLogs); return redactPathValue(asRecord(evt?.payload ?? null), censorUsernameInLogs);
}, [censorUsernameInLogs, events]); }, [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( const transcript = useMemo(
() => buildTranscript(logLines, adapter.parseStdoutLine, { censorUsernameInLogs }), () => buildTranscript(logLines, adapter.parseStdoutLine, { censorUsernameInLogs }),
[adapter, censorUsernameInLogs, logLines], [adapter, censorUsernameInLogs, logLines, parserTick],
); );
useEffect(() => { useEffect(() => {

View file

@ -21,6 +21,7 @@ import { AgentConfigForm, type CreateConfigValues } from "../components/AgentCon
import { defaultCreateValues } from "../components/agent-config-defaults"; import { defaultCreateValues } from "../components/agent-config-defaults";
import { getUIAdapter, listUIAdapters } from "../adapters"; import { getUIAdapter, listUIAdapters } from "../adapters";
import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters"; import { useDisabledAdaptersSync } from "../adapters/use-disabled-adapters";
import { isValidAdapterType } from "../adapters/metadata";
import { ReportsToPicker } from "../components/ReportsToPicker"; import { ReportsToPicker } from "../components/ReportsToPicker";
import { import {
DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX, DEFAULT_CODEX_LOCAL_BYPASS_APPROVALS_AND_SANDBOX,
@ -29,10 +30,6 @@ import {
import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local"; import { DEFAULT_CURSOR_LOCAL_MODEL } from "@paperclipai/adapter-cursor-local";
import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local"; import { DEFAULT_GEMINI_LOCAL_MODEL } from "@paperclipai/adapter-gemini-local";
const SUPPORTED_ADVANCED_ADAPTER_TYPES = new Set<CreateConfigValues["adapterType"]>(
listUIAdapters().map((adapter) => adapter.type as CreateConfigValues["adapterType"]),
);
function createValuesForAdapterType( function createValuesForAdapterType(
adapterType: CreateConfigValues["adapterType"], adapterType: CreateConfigValues["adapterType"],
): CreateConfigValues { ): CreateConfigValues {
@ -114,9 +111,7 @@ export function NewAgent() {
useEffect(() => { useEffect(() => {
const requested = presetAdapterType; const requested = presetAdapterType;
if (!requested) return; if (!requested) return;
if (!SUPPORTED_ADVANCED_ADAPTER_TYPES.has(requested as CreateConfigValues["adapterType"])) { if (!isValidAdapterType(requested)) return;
return;
}
setConfigValues((prev) => { setConfigValues((prev) => {
if (prev.adapterType === requested) return prev; if (prev.adapterType === requested) return prev;
return createValuesForAdapterType(requested as CreateConfigValues["adapterType"]); return createValuesForAdapterType(requested as CreateConfigValues["adapterType"]);