mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 18:10:39 +09:00
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:
parent
69a1593ff8
commit
47f3cdc1bb
13 changed files with 473 additions and 55 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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[];
|
||||
|
||||
|
|
|
|||
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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<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(
|
||||
{ type, packageName: record.packageName, modulePath, cacheBustUrl },
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export {
|
|||
registerUIAdapter,
|
||||
unregisterUIAdapter,
|
||||
syncExternalAdapters,
|
||||
onAdapterChange,
|
||||
} from "./registry";
|
||||
export { buildTranscript } from "./transcript";
|
||||
export type {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,20 @@ import { SchemaConfigFields, buildSchemaAdapterConfig } from "./schema-config-fi
|
|||
const uiAdapters: 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() {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<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";
|
||||
|
||||
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 "{filter}" as custom value (press Enter)
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SchemaConfigFields component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const schemaCache = new Map<string, AdapterConfigSchema | null>();
|
||||
|
|
@ -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<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", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -161,22 +371,18 @@ export function SchemaConfigFields({
|
|||
<>
|
||||
{schema.fields.map((field) => {
|
||||
switch (field.type) {
|
||||
case "select":
|
||||
case "select": {
|
||||
const currentVal = String(readValue(field) ?? "");
|
||||
return (
|
||||
<Field key={field.key} label={field.label} hint={field.hint}>
|
||||
<select
|
||||
className={selectClass}
|
||||
value={String(readValue(field) ?? "")}
|
||||
onChange={(e) => writeValue(field, e.target.value)}
|
||||
>
|
||||
{field.options?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<SelectField
|
||||
value={currentVal}
|
||||
options={field.options ?? []}
|
||||
onChange={(v) => writeValue(field, v)}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
case "toggle":
|
||||
return (
|
||||
|
|
@ -212,6 +418,48 @@ export function SchemaConfigFields({
|
|||
</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 (
|
||||
|
|
|
|||
|
|
@ -693,8 +693,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* Adapter-specific fields */}
|
||||
<uiAdapter.ConfigFields {...adapterFieldProps} />
|
||||
{/* Adapter-specific fields are rendered inside Permissions & Configuration */}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
@ -816,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
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
GitCompare,
|
||||
TerminalSquare,
|
||||
User,
|
||||
Wrench,
|
||||
|
|
@ -104,6 +105,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 {
|
||||
|
|
@ -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<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,
|
||||
|
|
@ -1251,6 +1381,7 @@ 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 === "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(),
|
||||
|
|
@ -285,7 +290,7 @@ export function useLiveRunTranscripts({
|
|||
);
|
||||
}
|
||||
return next;
|
||||
}, [chunksByRun, generalSettings?.censorUsernameInLogs, runs]);
|
||||
}, [chunksByRun, generalSettings?.censorUsernameInLogs, parserTick, runs]);
|
||||
|
||||
return {
|
||||
transcriptByRun,
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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<CreateConfigValues["adapterType"]>(
|
||||
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"]);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue