@animalabs/connectome-host 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +20 -0
- package/.github/workflows/publish.yml +65 -0
- package/ARCHITECTURE.md +355 -0
- package/CHANGELOG.md +30 -0
- package/HEADLESS-FLEET-PLAN.md +330 -0
- package/README.md +189 -0
- package/UNIFIED-TREE-PLAN.md +242 -0
- package/bun.lock +288 -0
- package/docs/AGENT-MEMORY-GUIDE.md +214 -0
- package/docs/AGENT-ONBOARDING.md +541 -0
- package/docs/ATTENTION-AND-GATING.md +120 -0
- package/docs/DEPLOYMENTS.md +130 -0
- package/docs/DEV-ENVIRONMENT.md +215 -0
- package/docs/LIBRARY-PIPELINE.md +286 -0
- package/docs/LOCUS-ROUTING-DESIGN.md +115 -0
- package/docs/claudeai-evacuation.md +228 -0
- package/docs/debug-context-api.md +208 -0
- package/docs/webui-deployment.md +219 -0
- package/package.json +33 -0
- package/recipes/SETUP.md +308 -0
- package/recipes/TRIUMVIRATE-SETUP.md +467 -0
- package/recipes/claude-export-revive.json +19 -0
- package/recipes/clerk.json +157 -0
- package/recipes/knowledge-miner.json +174 -0
- package/recipes/knowledge-reviewer.json +76 -0
- package/recipes/mcpl-editor-test.json +38 -0
- package/recipes/prompts/transplant-addendum.md +17 -0
- package/recipes/triumvirate.json +63 -0
- package/recipes/webui-fleet-test.json +27 -0
- package/recipes/webui-test.json +16 -0
- package/recipes/zulip-miner.json +87 -0
- package/scripts/connectome-doctor +373 -0
- package/scripts/evacuator.ts +956 -0
- package/scripts/import-claudeai-export.ts +747 -0
- package/scripts/lib/line-reader.ts +53 -0
- package/scripts/test-historical-thinking.ts +148 -0
- package/scripts/warmup-session.ts +336 -0
- package/src/agent-name.ts +58 -0
- package/src/commands.ts +1074 -0
- package/src/headless.ts +528 -0
- package/src/index.ts +867 -0
- package/src/logging-adapter.ts +208 -0
- package/src/mcpl-config.ts +199 -0
- package/src/modules/activity-module.ts +270 -0
- package/src/modules/channel-mode-module.ts +260 -0
- package/src/modules/fleet-module.ts +1705 -0
- package/src/modules/fleet-types.ts +225 -0
- package/src/modules/lessons-module.ts +465 -0
- package/src/modules/mcpl-admin-module.ts +345 -0
- package/src/modules/retrieval-module.ts +327 -0
- package/src/modules/settings-module.ts +143 -0
- package/src/modules/subagent-module.ts +2074 -0
- package/src/modules/subscription-gc-module.ts +322 -0
- package/src/modules/time-module.ts +85 -0
- package/src/modules/tui-module.ts +76 -0
- package/src/modules/web-ui-curve-page.ts +249 -0
- package/src/modules/web-ui-module.ts +2595 -0
- package/src/recipe.ts +1003 -0
- package/src/session-manager.ts +278 -0
- package/src/state/agent-tree-reducer.ts +431 -0
- package/src/state/fleet-tree-aggregator.ts +195 -0
- package/src/strategies/frontdesk-strategy.ts +364 -0
- package/src/synesthete.ts +68 -0
- package/src/tui.ts +2200 -0
- package/src/types/bun-ffi.d.ts +6 -0
- package/src/web/protocol.ts +648 -0
- package/test/agent-name.test.ts +62 -0
- package/test/agent-tree-fleet-launch.test.ts +64 -0
- package/test/agent-tree-reducer-parity.test.ts +194 -0
- package/test/agent-tree-reducer.test.ts +443 -0
- package/test/agent-tree-rollup.test.ts +109 -0
- package/test/autobio-progress-snapshot.test.ts +40 -0
- package/test/claudeai-export-importer.test.ts +386 -0
- package/test/evacuator.test.ts +332 -0
- package/test/fleet-commands.test.ts +244 -0
- package/test/fleet-no-subfleets.test.ts +147 -0
- package/test/fleet-orchestration.test.ts +353 -0
- package/test/fleet-recipe.test.ts +124 -0
- package/test/fleet-route.test.ts +44 -0
- package/test/fleet-smoke.test.ts +479 -0
- package/test/fleet-subscribe-union-e2e.test.ts +123 -0
- package/test/fleet-subscribe-union.test.ts +85 -0
- package/test/fleet-tree-aggregator-e2e.test.ts +110 -0
- package/test/fleet-tree-aggregator.test.ts +215 -0
- package/test/frontdesk-strategy.test.ts +326 -0
- package/test/headless-describe.test.ts +182 -0
- package/test/headless-smoke.test.ts +190 -0
- package/test/logging-adapter.test.ts +87 -0
- package/test/mcpl-admin-module.test.ts +213 -0
- package/test/mcpl-agent-overlay.test.ts +126 -0
- package/test/mock-headless-child.ts +133 -0
- package/test/recipe-cache-ttl.test.ts +37 -0
- package/test/recipe-env.test.ts +157 -0
- package/test/recipe-path-resolution.test.ts +170 -0
- package/test/subagent-async-timeout.test.ts +381 -0
- package/test/subagent-fork-materialise.test.ts +241 -0
- package/test/subagent-peek-scoping.test.ts +180 -0
- package/test/subagent-peek-zombie.test.ts +148 -0
- package/test/subagent-reaper-in-flight.test.ts +229 -0
- package/test/subscription-gc-module.test.ts +136 -0
- package/test/warmup-handoff.test.ts +150 -0
- package/test/web-ui-bind.test.ts +51 -0
- package/test/web-ui-module.test.ts +246 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/tsconfig.json +14 -0
- package/web/bun.lock +357 -0
- package/web/index.html +12 -0
- package/web/package.json +24 -0
- package/web/src/App.tsx +1931 -0
- package/web/src/Context.tsx +158 -0
- package/web/src/ContextDocument.tsx +150 -0
- package/web/src/Files.tsx +271 -0
- package/web/src/Lessons.tsx +164 -0
- package/web/src/Mcpl.tsx +310 -0
- package/web/src/Stream.tsx +119 -0
- package/web/src/TreeSidebar.tsx +283 -0
- package/web/src/Usage.tsx +182 -0
- package/web/src/main.tsx +7 -0
- package/web/src/styles.css +26 -0
- package/web/src/tree.ts +268 -0
- package/web/src/wire.ts +120 -0
- package/web/tsconfig.json +21 -0
- package/web/vite.config.ts +32 -0
package/web/src/App.tsx
ADDED
|
@@ -0,0 +1,1931 @@
|
|
|
1
|
+
import { createMemo, createSignal, For, onCleanup, onMount, Show } from 'solid-js';
|
|
2
|
+
import { createStore, produce } from 'solid-js/store';
|
|
3
|
+
import { marked } from 'marked';
|
|
4
|
+
import DOMPurify from 'dompurify';
|
|
5
|
+
import { createWireClient, type WireClient } from './wire';
|
|
6
|
+
import { createTreeStore, type StreamSource, type UiNode } from './tree';
|
|
7
|
+
import { TreeSidebar } from './TreeSidebar';
|
|
8
|
+
import { StreamPanel, formatStreamEvent, type StreamLine } from './Stream';
|
|
9
|
+
import { UsagePanel } from './Usage';
|
|
10
|
+
import { LessonsPanel, type LessonRow } from './Lessons';
|
|
11
|
+
import { McplPanel, type McplServerRow } from './Mcpl';
|
|
12
|
+
import { FilesPanel, FileViewerModal, type Mount, type FlatEntry, type FileViewer } from './Files';
|
|
13
|
+
import { ContextPanel } from './Context';
|
|
14
|
+
import { ContextDocument } from './ContextDocument';
|
|
15
|
+
import {
|
|
16
|
+
WEB_PROTOCOL_VERSION,
|
|
17
|
+
type WebUiServerMessage,
|
|
18
|
+
type WelcomeMessage,
|
|
19
|
+
type WelcomeMessageEntry,
|
|
20
|
+
type HistoryPageMessage,
|
|
21
|
+
type MessageBlock,
|
|
22
|
+
type TokenUsage,
|
|
23
|
+
type PerAgentCost,
|
|
24
|
+
} from '@conhost/web/protocol';
|
|
25
|
+
|
|
26
|
+
/** Client-side block: the wire MessageBlock plus live-stream bookkeeping. */
|
|
27
|
+
type UiBlock =
|
|
28
|
+
| { kind: 'text'; text: string; truncated?: boolean; streaming?: boolean }
|
|
29
|
+
| { kind: 'thinking'; text: string; truncated?: boolean; streaming?: boolean }
|
|
30
|
+
| { kind: 'redacted_thinking'; bytes: number }
|
|
31
|
+
| {
|
|
32
|
+
kind: 'tool_use'; id: string; name: string; inputJson: string; truncated?: boolean;
|
|
33
|
+
/** Live lifecycle from tool:* traces; undefined for historical calls. */
|
|
34
|
+
status?: 'running' | 'done' | 'failed';
|
|
35
|
+
durationMs?: number;
|
|
36
|
+
}
|
|
37
|
+
| { kind: 'tool_result'; toolUseId: string; text: string; isError?: boolean; truncated?: boolean }
|
|
38
|
+
| { kind: 'media'; mediaType: string }
|
|
39
|
+
/** Client-only: a tool call being written live (raw partial JSON from
|
|
40
|
+
* blockType 'tool_call' token deltas). Replaced by the parsed tool_use
|
|
41
|
+
* blocks when inference:tool_calls_yielded lands. */
|
|
42
|
+
| { kind: 'tool_draft'; text: string; streaming?: boolean };
|
|
43
|
+
|
|
44
|
+
interface Message {
|
|
45
|
+
id: string;
|
|
46
|
+
participant: 'user' | 'assistant' | 'system' | 'tool' | 'command' | 'trigger';
|
|
47
|
+
text: string;
|
|
48
|
+
/** Ordered content blocks — present on server-sourced and streamed
|
|
49
|
+
* messages; absent on synthetic command/trigger rows. */
|
|
50
|
+
blocks?: UiBlock[];
|
|
51
|
+
/** Store slot index (paging cursor); absent on synthetic messages. */
|
|
52
|
+
index?: number;
|
|
53
|
+
/** Epoch millis. Server value for canonical entries; client clock for
|
|
54
|
+
* optimistic/streamed rows until the canonical entry replaces them. */
|
|
55
|
+
timestamp?: number;
|
|
56
|
+
/** Per-line style — only set for `command` messages from /slash output. */
|
|
57
|
+
lines?: Array<{ text: string; style?: 'user' | 'agent' | 'tool' | 'system' }>;
|
|
58
|
+
/** True if the message is mid-stream — render with a cursor cue. */
|
|
59
|
+
streaming?: boolean;
|
|
60
|
+
/** For 'trigger' participant: origin/source/author metadata for the box header. */
|
|
61
|
+
trigger?: {
|
|
62
|
+
origin: string;
|
|
63
|
+
source: string;
|
|
64
|
+
author?: string;
|
|
65
|
+
triggered: boolean;
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let messageCounter = 0;
|
|
70
|
+
const nextMessageId = () => `m${++messageCounter}`;
|
|
71
|
+
const isSyntheticId = (id: string): boolean => /^m\d+$/.test(id);
|
|
72
|
+
let streamIdSeq = 0;
|
|
73
|
+
const streamLineId = (): number => ++streamIdSeq;
|
|
74
|
+
|
|
75
|
+
function entryToMessage(e: WelcomeMessageEntry): Message {
|
|
76
|
+
return {
|
|
77
|
+
id: e.id ?? nextMessageId(),
|
|
78
|
+
participant: e.participant,
|
|
79
|
+
text: e.text,
|
|
80
|
+
blocks: (e.blocks ?? []) as UiBlock[],
|
|
81
|
+
index: e.index,
|
|
82
|
+
timestamp: e.timestamp,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function App() {
|
|
87
|
+
const wire = createWireClient();
|
|
88
|
+
const treeStore = createTreeStore();
|
|
89
|
+
|
|
90
|
+
// Use a store rather than a signal so the streaming token append can
|
|
91
|
+
// mutate the last message's `text` in place. Replacing the message object
|
|
92
|
+
// (as a plain signal would force) makes <For> remount the DOM, which
|
|
93
|
+
// replays the msg-enter fade — visible as a wholesale flicker on every
|
|
94
|
+
// token. Keyed-by-id mutation keeps the same DOM element throughout.
|
|
95
|
+
const [messages, setMessages] = createStore<Message[]>([]);
|
|
96
|
+
const [welcome, setWelcome] = createSignal<WelcomeMessage | null>(null);
|
|
97
|
+
/** History paging state: slot index of the earliest loaded message +
|
|
98
|
+
* store total. Null until the first welcome. */
|
|
99
|
+
const [historyInfo, setHistoryInfo] = createSignal<{ startIndex: number; totalCount: number } | null>(null);
|
|
100
|
+
const [historyLoading, setHistoryLoading] = createSignal(false);
|
|
101
|
+
/** Server protocol version when it doesn't match this bundle (stale
|
|
102
|
+
* dist/web or old host) — renders a persistent banner. */
|
|
103
|
+
const [protoMismatch, setProtoMismatch] = createSignal<number | null>(null);
|
|
104
|
+
/** Store ids of server-sourced messages currently rendered — dedupe set
|
|
105
|
+
* for message-appended / history-page / re-welcome overlap. */
|
|
106
|
+
let knownIds = new Set<string>();
|
|
107
|
+
/** session.id + '/' + branch.id of the last welcome; same key → soft
|
|
108
|
+
* merge (keep paged-in scrollback), changed key → hard reset. */
|
|
109
|
+
let welcomeKey: string | null = null;
|
|
110
|
+
/** Whether the operator is pinned to the bottom of the scroll pane.
|
|
111
|
+
* Autoscroll only fires when true, so reading history isn't yanked. */
|
|
112
|
+
let atBottom = true;
|
|
113
|
+
let historyCorrSeq = 0;
|
|
114
|
+
let pendingHistoryCorr: string | null = null;
|
|
115
|
+
let pendingHistoryTimer: number | undefined;
|
|
116
|
+
const [usage, setUsage] = createSignal<TokenUsage>({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
|
|
117
|
+
const [perAgentCost, setPerAgentCost] = createSignal<PerAgentCost[]>([]);
|
|
118
|
+
const [draft, setDraft] = createSignal('');
|
|
119
|
+
/** Currently-focused tree node + the panel mode rendered on its behalf.
|
|
120
|
+
* `mode` decides whether the side panel shows live stream events or a
|
|
121
|
+
* static usage breakdown; clicking the row opens 'stream', clicking the
|
|
122
|
+
* token badge opens 'usage'. Both share the same panel slot. */
|
|
123
|
+
type PanelMode = 'stream' | 'usage';
|
|
124
|
+
const [focusedId, setFocusedId] = createSignal<string | null>(null);
|
|
125
|
+
const [focusedNode, setFocusedNode] = createSignal<UiNode | null>(null);
|
|
126
|
+
const [panelMode, setPanelMode] = createSignal<PanelMode | null>(null);
|
|
127
|
+
const [streamLines, setStreamLines] = createSignal<StreamLine[]>([]);
|
|
128
|
+
/** Default-collapsed; the parent root and freshly-spawned fleet children
|
|
129
|
+
* auto-expand once on first sight, so users see structure without a click. */
|
|
130
|
+
const [expanded, setExpanded] = createSignal<Set<string>>(new Set(['process:local']));
|
|
131
|
+
const seenAutoExpand = new Set<string>(['process:local']);
|
|
132
|
+
/** Names of fleet children the server says are still running when /quit
|
|
133
|
+
* was invoked. Non-null = the quit-confirm modal is open. */
|
|
134
|
+
const [quitConfirm, setQuitConfirm] = createSignal<string[] | null>(null);
|
|
135
|
+
|
|
136
|
+
/** Right-sidebar tab selection. The Tree is the most-used surface so it's
|
|
137
|
+
* the default; lessons / mcp / files are operator-driven panels. */
|
|
138
|
+
type SidebarTab = 'tree' | 'lessons' | 'mcp' | 'files' | 'context';
|
|
139
|
+
const [sidebarTab, setSidebarTab] = createSignal<SidebarTab>('tree');
|
|
140
|
+
const [mainView, setMainView] = createSignal<'chat' | 'context'>('chat');
|
|
141
|
+
|
|
142
|
+
/** Scope shared by Lessons / Files / Recipe panels. 'local' means the
|
|
143
|
+
* parent process; otherwise the fleet child's name. The scope is shared
|
|
144
|
+
* so an operator can pin a child of interest and see all three views
|
|
145
|
+
* without re-selecting per panel. */
|
|
146
|
+
const [panelScope, setPanelScope] = createSignal<string>('local');
|
|
147
|
+
const availableScopes = (): Array<{ id: string; label: string }> => {
|
|
148
|
+
const w = welcome();
|
|
149
|
+
const local = { id: 'local', label: w?.recipe.name ?? 'parent' };
|
|
150
|
+
const children = (w?.childTrees ?? []).map(c => ({
|
|
151
|
+
id: c.name,
|
|
152
|
+
label: c.recipe?.name ? `${c.name} · ${c.recipe.name}` : c.name,
|
|
153
|
+
}));
|
|
154
|
+
return [local, ...children];
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** Lessons panel state — populated by 'lessons-list' responses to a
|
|
158
|
+
* 'request-lessons' message. */
|
|
159
|
+
const [lessons, setLessons] = createSignal<LessonRow[]>([]);
|
|
160
|
+
const [lessonsLoaded, setLessonsLoaded] = createSignal(false);
|
|
161
|
+
const [lessonsModuleLoaded, setLessonsModuleLoaded] = createSignal(false);
|
|
162
|
+
const refreshLessons = (): void => {
|
|
163
|
+
setLessonsLoaded(false);
|
|
164
|
+
wire.send({ type: 'request-lessons', scope: panelScope() });
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/** MCPL panel state — populated by 'mcpl-list' responses, which the server
|
|
168
|
+
* also re-sends after every mutation so the panel auto-refreshes. */
|
|
169
|
+
const [mcplServers, setMcplServers] = createSignal<McplServerRow[]>([]);
|
|
170
|
+
const [mcplLoaded, setMcplLoaded] = createSignal(false);
|
|
171
|
+
const [mcplConfigPath, setMcplConfigPath] = createSignal('');
|
|
172
|
+
const refreshMcpl = (): void => {
|
|
173
|
+
setMcplLoaded(false);
|
|
174
|
+
wire.send({ type: 'request-mcpl' });
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** Workspace files panel state — mounts list + per-mount tree cache. */
|
|
178
|
+
const [mounts, setMounts] = createSignal<Mount[]>([]);
|
|
179
|
+
const [mountsLoaded, setMountsLoaded] = createSignal(false);
|
|
180
|
+
const [workspaceModuleLoaded, setWorkspaceModuleLoaded] = createSignal(false);
|
|
181
|
+
const [treesByMount, setTreesByMount] = createSignal<Map<string, FlatEntry[]>>(new Map());
|
|
182
|
+
const [expandedMounts, setExpandedMounts] = createSignal<Set<string>>(new Set());
|
|
183
|
+
const [openFile, setOpenFile] = createSignal<FileViewer | null>(null);
|
|
184
|
+
const [fileLoading, setFileLoading] = createSignal(false);
|
|
185
|
+
const refreshMounts = (): void => {
|
|
186
|
+
setMountsLoaded(false);
|
|
187
|
+
setTreesByMount(new Map<string, FlatEntry[]>());
|
|
188
|
+
setExpandedMounts(new Set<string>());
|
|
189
|
+
wire.send({ type: 'request-workspace-mounts', scope: panelScope() });
|
|
190
|
+
};
|
|
191
|
+
const expandMount = (name: string): void => {
|
|
192
|
+
setExpandedMounts(prev => new Set(prev).add(name));
|
|
193
|
+
if (!treesByMount().has(name)) {
|
|
194
|
+
wire.send({ type: 'request-workspace-tree', mount: name, scope: panelScope() });
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
const collapseMount = (name: string): void => {
|
|
198
|
+
setExpandedMounts(prev => {
|
|
199
|
+
const next = new Set(prev);
|
|
200
|
+
next.delete(name);
|
|
201
|
+
return next;
|
|
202
|
+
});
|
|
203
|
+
};
|
|
204
|
+
const requestFile = (path: string): void => {
|
|
205
|
+
setOpenFile(null);
|
|
206
|
+
setFileLoading(true);
|
|
207
|
+
wire.send({ type: 'request-workspace-file', path, scope: panelScope() });
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** Switch the shared panel scope. Invalidates cached lessons/files so the
|
|
211
|
+
* new scope's data is re-fetched on next access. MCPL config is global,
|
|
212
|
+
* so it doesn't follow scope. */
|
|
213
|
+
const changePanelScope = (scope: string): void => {
|
|
214
|
+
if (scope === panelScope()) return;
|
|
215
|
+
setPanelScope(scope);
|
|
216
|
+
setLessonsLoaded(false);
|
|
217
|
+
setLessons([]);
|
|
218
|
+
setMountsLoaded(false);
|
|
219
|
+
setMounts([]);
|
|
220
|
+
setTreesByMount(new Map<string, FlatEntry[]>());
|
|
221
|
+
setExpandedMounts(new Set<string>());
|
|
222
|
+
// Re-request whichever tab the operator is currently looking at; the
|
|
223
|
+
// others will lazy-load when they're opened.
|
|
224
|
+
if (sidebarTab() === 'lessons') refreshLessons();
|
|
225
|
+
if (sidebarTab() === 'files') refreshMounts();
|
|
226
|
+
};
|
|
227
|
+
/** Pending-token buffer for the active stream; flushes on newline or non-token event. */
|
|
228
|
+
let streamTokenBuffer = '';
|
|
229
|
+
|
|
230
|
+
/** Per-childName ring buffer of recent child-event payloads. Used to
|
|
231
|
+
* backfill the stream pane when an operator opens a fleet child / agent
|
|
232
|
+
* view — events fan out to every connected client regardless of focus, so
|
|
233
|
+
* we capture them as they pass through. */
|
|
234
|
+
const FLEET_BACKFILL_LIMIT = 80;
|
|
235
|
+
const childEventBuffers = new Map<string, Array<{ type: string; [k: string]: unknown }>>();
|
|
236
|
+
const recordChildEvent = (childName: string, event: { type: string; [k: string]: unknown }): void => {
|
|
237
|
+
let buf = childEventBuffers.get(childName);
|
|
238
|
+
if (!buf) {
|
|
239
|
+
buf = [];
|
|
240
|
+
childEventBuffers.set(childName, buf);
|
|
241
|
+
}
|
|
242
|
+
buf.push(event);
|
|
243
|
+
if (buf.length > FLEET_BACKFILL_LIMIT) buf.splice(0, buf.length - FLEET_BACKFILL_LIMIT);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
let scrollPane: HTMLDivElement | undefined;
|
|
247
|
+
|
|
248
|
+
// Append-to-last-assistant streaming buffer. createStore lets us mutate
|
|
249
|
+
// the existing message's blocks in place — Solid's <For> sees the same
|
|
250
|
+
// item reference and only updates the changed text, instead of remounting
|
|
251
|
+
// the row (which would replay the entrance animation).
|
|
252
|
+
//
|
|
253
|
+
// Tokens are routed by trace blockType: 'text' → visible prose, 'thinking'
|
|
254
|
+
// → a live thinking block, 'tool_call' → a live "writing tool call" draft
|
|
255
|
+
// (raw partial JSON; replaced by the parsed call on tool_calls_yielded).
|
|
256
|
+
// (Previously ALL tokens were appended to the visible text, so enabling
|
|
257
|
+
// extended thinking polluted the markdown.)
|
|
258
|
+
const appendStreamToken = (token: string, blockType?: string): void => {
|
|
259
|
+
if (blockType === 'tool_result') return;
|
|
260
|
+
const kind: 'text' | 'thinking' | 'tool_draft' =
|
|
261
|
+
blockType === 'thinking' ? 'thinking'
|
|
262
|
+
: blockType === 'tool_call' ? 'tool_draft'
|
|
263
|
+
: 'text';
|
|
264
|
+
setMessages(produce((arr) => {
|
|
265
|
+
let last = arr[arr.length - 1];
|
|
266
|
+
if (!last || !last.streaming) {
|
|
267
|
+
arr.push({
|
|
268
|
+
id: nextMessageId(),
|
|
269
|
+
participant: 'assistant',
|
|
270
|
+
text: '',
|
|
271
|
+
blocks: [],
|
|
272
|
+
streaming: true,
|
|
273
|
+
timestamp: Date.now(),
|
|
274
|
+
});
|
|
275
|
+
last = arr[arr.length - 1]!;
|
|
276
|
+
}
|
|
277
|
+
const blocks = (last.blocks ??= []);
|
|
278
|
+
let blk = blocks[blocks.length - 1];
|
|
279
|
+
if (!blk || blk.kind !== kind || !(blk as { streaming?: boolean }).streaming) {
|
|
280
|
+
blk = { kind, text: '', streaming: true } as UiBlock;
|
|
281
|
+
blocks.push(blk);
|
|
282
|
+
}
|
|
283
|
+
(blk as { text: string }).text += token;
|
|
284
|
+
if (kind === 'text') last.text += token;
|
|
285
|
+
}));
|
|
286
|
+
queueScroll();
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/** block_complete → clear the open block's streaming flag (a finished
|
|
290
|
+
* thinking block auto-collapses to its header). */
|
|
291
|
+
const handleContentBlock = (phase: string): void => {
|
|
292
|
+
if (phase !== 'block_complete') return;
|
|
293
|
+
setMessages(produce((arr) => {
|
|
294
|
+
const last = arr[arr.length - 1];
|
|
295
|
+
if (!last?.streaming || !last.blocks) return;
|
|
296
|
+
for (let i = last.blocks.length - 1; i >= 0; i--) {
|
|
297
|
+
const b = last.blocks[i] as { streaming?: boolean };
|
|
298
|
+
if (b.streaming) { b.streaming = false; break; }
|
|
299
|
+
}
|
|
300
|
+
}));
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/** Attach yielded tool calls to the streaming assistant message as
|
|
304
|
+
* tool_use blocks (status: running). Results pair up later via the
|
|
305
|
+
* tool_result blocks arriving in message-appended frames. */
|
|
306
|
+
const appendToolUseBlocks = (calls: Array<{ id: string; name: string; input?: unknown }>): void => {
|
|
307
|
+
setMessages(produce((arr) => {
|
|
308
|
+
let last = arr[arr.length - 1];
|
|
309
|
+
if (!last || !last.streaming) {
|
|
310
|
+
arr.push({
|
|
311
|
+
id: nextMessageId(),
|
|
312
|
+
participant: 'assistant',
|
|
313
|
+
text: '',
|
|
314
|
+
blocks: [],
|
|
315
|
+
streaming: true,
|
|
316
|
+
timestamp: Date.now(),
|
|
317
|
+
});
|
|
318
|
+
last = arr[arr.length - 1]!;
|
|
319
|
+
}
|
|
320
|
+
let blocks = (last.blocks ??= []);
|
|
321
|
+
// The parsed calls supersede any live tool_draft scaffolding.
|
|
322
|
+
if (blocks.some((b) => b.kind === 'tool_draft')) {
|
|
323
|
+
last.blocks = blocks = blocks.filter((b) => b.kind !== 'tool_draft');
|
|
324
|
+
}
|
|
325
|
+
for (const b of blocks) {
|
|
326
|
+
if ((b as { streaming?: boolean }).streaming) (b as { streaming?: boolean }).streaming = false;
|
|
327
|
+
}
|
|
328
|
+
for (const c of calls) {
|
|
329
|
+
let inputJson: string;
|
|
330
|
+
try { inputJson = JSON.stringify(c.input, null, 2) ?? 'null'; } catch { inputJson = '[unserializable]'; }
|
|
331
|
+
blocks.push({ kind: 'tool_use', id: c.id, name: c.name, inputJson, status: 'running' });
|
|
332
|
+
}
|
|
333
|
+
}));
|
|
334
|
+
queueScroll();
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
/** Flip a tool_use block's lifecycle from tool:* traces, matched by callId
|
|
338
|
+
* over the most recent messages. */
|
|
339
|
+
const updateToolStatus = (callId: string, status: 'done' | 'failed', durationMs?: number): void => {
|
|
340
|
+
setMessages(produce((arr) => {
|
|
341
|
+
for (let i = arr.length - 1; i >= 0 && i >= arr.length - 12; i--) {
|
|
342
|
+
const blocks = arr[i]!.blocks;
|
|
343
|
+
if (!blocks) continue;
|
|
344
|
+
for (const b of blocks) {
|
|
345
|
+
if (b.kind === 'tool_use' && b.id === callId) {
|
|
346
|
+
b.status = status;
|
|
347
|
+
if (durationMs !== undefined) b.durationMs = durationMs;
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}));
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const finishStream = (): void => {
|
|
356
|
+
setMessages(produce((arr) => {
|
|
357
|
+
const last = arr[arr.length - 1];
|
|
358
|
+
if (last && last.streaming) {
|
|
359
|
+
last.streaming = false;
|
|
360
|
+
if (last.blocks) {
|
|
361
|
+
for (const b of last.blocks) {
|
|
362
|
+
if ((b as { streaming?: boolean }).streaming) (b as { streaming?: boolean }).streaming = false;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}));
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
const queueScroll = (): void => {
|
|
370
|
+
queueMicrotask(() => {
|
|
371
|
+
if (scrollPane && atBottom) scrollPane.scrollTop = scrollPane.scrollHeight;
|
|
372
|
+
});
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// -------------------------------------------------------------------------
|
|
376
|
+
// Windowed history: welcome merge, upward paging, live appends
|
|
377
|
+
// -------------------------------------------------------------------------
|
|
378
|
+
|
|
379
|
+
const applyWelcome = (msg: WelcomeMessage): void => {
|
|
380
|
+
if (msg.protocolVersion !== WEB_PROTOCOL_VERSION) {
|
|
381
|
+
setProtoMismatch(msg.protocolVersion);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
setProtoMismatch(null);
|
|
385
|
+
setWelcome(msg);
|
|
386
|
+
const key = `${msg.session.id}/${msg.branch.id}`;
|
|
387
|
+
const entries = msg.messages.map(entryToMessage);
|
|
388
|
+
|
|
389
|
+
if (key !== welcomeKey) {
|
|
390
|
+
// Session/branch changed (or first connect): hard reset.
|
|
391
|
+
welcomeKey = key;
|
|
392
|
+
knownIds = new Set(entries.map((m) => m.id).filter((id) => !isSyntheticId(id)));
|
|
393
|
+
setMessages(entries);
|
|
394
|
+
setHistoryInfo({ startIndex: msg.history.startIndex, totalCount: msg.history.totalCount });
|
|
395
|
+
atBottom = true;
|
|
396
|
+
queueScroll();
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Same session+branch (reconnect / setApp re-welcome): keep paged-in
|
|
401
|
+
// older scrollback, replace the tail with the canonical window. Synthetic
|
|
402
|
+
// rows (optimistic user msgs, command output, triggers) are dropped —
|
|
403
|
+
// the canonical entries carry the durable conversation.
|
|
404
|
+
setMessages(produce((arr) => {
|
|
405
|
+
const kept = arr.filter(
|
|
406
|
+
(m) => typeof m.index === 'number' && m.index < msg.history.startIndex && !isSyntheticId(m.id),
|
|
407
|
+
);
|
|
408
|
+
arr.length = 0;
|
|
409
|
+
arr.push(...kept, ...entries);
|
|
410
|
+
}));
|
|
411
|
+
knownIds = new Set(
|
|
412
|
+
messages.map((m) => m.id).filter((id) => !isSyntheticId(id)),
|
|
413
|
+
);
|
|
414
|
+
setHistoryInfo((prev) => ({
|
|
415
|
+
startIndex: Math.min(prev?.startIndex ?? msg.history.startIndex, msg.history.startIndex),
|
|
416
|
+
totalCount: msg.history.totalCount,
|
|
417
|
+
}));
|
|
418
|
+
queueScroll();
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const applyAppended = (entry: WelcomeMessageEntry): void => {
|
|
422
|
+
if (entry.id && knownIds.has(entry.id)) return;
|
|
423
|
+
if (entry.id) knownIds.add(entry.id);
|
|
424
|
+
const incoming = entryToMessage(entry);
|
|
425
|
+
setMessages(produce((arr) => {
|
|
426
|
+
const last = arr[arr.length - 1];
|
|
427
|
+
// Canonical assistant message replaces the streamed scaffold (brings
|
|
428
|
+
// authoritative block order, signatures, redacted blocks).
|
|
429
|
+
if (incoming.participant === 'assistant' && last?.participant === 'assistant' && last.streaming) {
|
|
430
|
+
// Preserve live tool statuses the trace stream already delivered.
|
|
431
|
+
const statusById = new Map<string, { status?: 'running' | 'done' | 'failed'; durationMs?: number }>();
|
|
432
|
+
for (const b of last.blocks ?? []) {
|
|
433
|
+
if (b.kind === 'tool_use') statusById.set(b.id, { status: b.status, durationMs: b.durationMs });
|
|
434
|
+
}
|
|
435
|
+
for (const b of incoming.blocks ?? []) {
|
|
436
|
+
if (b.kind === 'tool_use') {
|
|
437
|
+
const s = statusById.get(b.id);
|
|
438
|
+
if (s?.status) { b.status = s.status; b.durationMs = s.durationMs; }
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
arr[arr.length - 1] = incoming;
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
// Server echo of a message typed here: adopt into the optimistic row.
|
|
445
|
+
if (incoming.participant === 'user') {
|
|
446
|
+
for (let i = arr.length - 1; i >= Math.max(0, arr.length - 3); i--) {
|
|
447
|
+
const m = arr[i]!;
|
|
448
|
+
if (m.participant === 'user' && isSyntheticId(m.id) && m.text === incoming.text) {
|
|
449
|
+
arr[i] = incoming;
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
arr.push(incoming);
|
|
455
|
+
}));
|
|
456
|
+
setHistoryInfo((prev) => prev
|
|
457
|
+
? { ...prev, totalCount: Math.max(prev.totalCount, (entry.index ?? prev.totalCount) + 1) }
|
|
458
|
+
: prev);
|
|
459
|
+
queueScroll();
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const requestOlder = (): void => {
|
|
463
|
+
const info = historyInfo();
|
|
464
|
+
if (!info || info.startIndex <= 0 || historyLoading() || protoMismatch() !== null) return;
|
|
465
|
+
const corrId = `h${++historyCorrSeq}`;
|
|
466
|
+
pendingHistoryCorr = corrId;
|
|
467
|
+
setHistoryLoading(true);
|
|
468
|
+
wire.send({ type: 'request-history', corrId, beforeIndex: info.startIndex, limit: 200 });
|
|
469
|
+
window.clearTimeout(pendingHistoryTimer);
|
|
470
|
+
pendingHistoryTimer = window.setTimeout(() => {
|
|
471
|
+
if (pendingHistoryCorr === corrId) {
|
|
472
|
+
pendingHistoryCorr = null;
|
|
473
|
+
setHistoryLoading(false);
|
|
474
|
+
}
|
|
475
|
+
}, 10_000);
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const applyHistoryPage = (msg: HistoryPageMessage): void => {
|
|
479
|
+
if (msg.corrId !== pendingHistoryCorr) return;
|
|
480
|
+
pendingHistoryCorr = null;
|
|
481
|
+
window.clearTimeout(pendingHistoryTimer);
|
|
482
|
+
setHistoryLoading(false);
|
|
483
|
+
const fresh = msg.entries.filter((e) => !(e.id && knownIds.has(e.id)));
|
|
484
|
+
for (const e of fresh) if (e.id) knownIds.add(e.id);
|
|
485
|
+
// Prepending grows the pane upward; restore the operator's viewport by
|
|
486
|
+
// offsetting scrollTop by the height delta after the DOM settles.
|
|
487
|
+
const prevScrollHeight = scrollPane?.scrollHeight ?? 0;
|
|
488
|
+
const prevScrollTop = scrollPane?.scrollTop ?? 0;
|
|
489
|
+
setMessages(produce((arr) => arr.unshift(...fresh.map(entryToMessage))));
|
|
490
|
+
setHistoryInfo((prev) => ({
|
|
491
|
+
startIndex: msg.startIndex,
|
|
492
|
+
totalCount: msg.totalCount > 0 ? msg.totalCount : prev?.totalCount ?? 0,
|
|
493
|
+
}));
|
|
494
|
+
requestAnimationFrame(() => {
|
|
495
|
+
if (scrollPane) scrollPane.scrollTop = scrollPane.scrollHeight - prevScrollHeight + prevScrollTop;
|
|
496
|
+
});
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
const onScrollPane = (): void => {
|
|
500
|
+
if (!scrollPane) return;
|
|
501
|
+
atBottom = scrollPane.scrollHeight - scrollPane.scrollTop - scrollPane.clientHeight < 80;
|
|
502
|
+
if (scrollPane.scrollTop < 400) requestOlder();
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
/** toolUseId → result block, across all loaded messages. Rendered inline
|
|
506
|
+
* under the matching tool_use; standalone all-paired result rows hide. */
|
|
507
|
+
const toolResults = createMemo(() => {
|
|
508
|
+
const map = new Map<string, { text: string; isError?: boolean; truncated?: boolean }>();
|
|
509
|
+
for (const m of messages) {
|
|
510
|
+
for (const b of m.blocks ?? []) {
|
|
511
|
+
if (b.kind === 'tool_result') map.set(b.toolUseId, b);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return map;
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
/** ids of all tool_use blocks currently loaded — lets a pure-tool_result
|
|
518
|
+
* message know its content is already displayed inline. */
|
|
519
|
+
const toolUseIds = createMemo(() => {
|
|
520
|
+
const set = new Set<string>();
|
|
521
|
+
for (const m of messages) {
|
|
522
|
+
for (const b of m.blocks ?? []) {
|
|
523
|
+
if (b.kind === 'tool_use') set.add(b.id);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return set;
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
const appendStreamPaneToken = (text: string): void => {
|
|
530
|
+
streamTokenBuffer += text;
|
|
531
|
+
const newlineIdx = streamTokenBuffer.lastIndexOf('\n');
|
|
532
|
+
if (newlineIdx < 0) return;
|
|
533
|
+
const completed = streamTokenBuffer.slice(0, newlineIdx);
|
|
534
|
+
streamTokenBuffer = streamTokenBuffer.slice(newlineIdx + 1);
|
|
535
|
+
if (completed) {
|
|
536
|
+
const lines = completed.split('\n').filter(s => s.length > 0);
|
|
537
|
+
setStreamLines((prev) => [...prev, ...lines.map(s => ({
|
|
538
|
+
id: streamLineId(),
|
|
539
|
+
kind: 'token' as const,
|
|
540
|
+
text: s,
|
|
541
|
+
color: 'text-cyan-200',
|
|
542
|
+
}))]);
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
const flushStreamBuffer = (): void => {
|
|
547
|
+
if (streamTokenBuffer.length === 0) return;
|
|
548
|
+
const text = streamTokenBuffer;
|
|
549
|
+
streamTokenBuffer = '';
|
|
550
|
+
setStreamLines((prev) => [...prev, {
|
|
551
|
+
id: streamLineId(),
|
|
552
|
+
kind: 'token' as const,
|
|
553
|
+
text,
|
|
554
|
+
color: 'text-cyan-200',
|
|
555
|
+
}]);
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
/** Tear down any peek subscription tied to the currently focused node.
|
|
559
|
+
* Idempotent — safe to call when nothing's focused or when the focused
|
|
560
|
+
* node was a non-peek source. */
|
|
561
|
+
const teardownPeek = (): void => {
|
|
562
|
+
const node = focusedNode();
|
|
563
|
+
if (node && panelMode() === 'stream' && node.streamSource.kind === 'peek') {
|
|
564
|
+
wire.send({ type: 'subscribe-peek', scope: node.streamSource.scope, active: false });
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const closePanel = (): void => {
|
|
569
|
+
teardownPeek();
|
|
570
|
+
setFocusedId(null);
|
|
571
|
+
setFocusedNode(null);
|
|
572
|
+
setPanelMode(null);
|
|
573
|
+
setStreamLines([]);
|
|
574
|
+
streamTokenBuffer = '';
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
const openStream = (node: UiNode): void => {
|
|
578
|
+
if (node.streamSource.kind === 'none') return;
|
|
579
|
+
if (focusedId() === node.id && panelMode() === 'stream') {
|
|
580
|
+
// Re-clicking same node in stream mode closes the panel.
|
|
581
|
+
closePanel();
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
teardownPeek();
|
|
585
|
+
setFocusedId(node.id);
|
|
586
|
+
setFocusedNode(node);
|
|
587
|
+
setPanelMode('stream');
|
|
588
|
+
setStreamLines([]);
|
|
589
|
+
streamTokenBuffer = '';
|
|
590
|
+
if (node.streamSource.kind === 'peek') {
|
|
591
|
+
wire.send({ type: 'subscribe-peek', scope: node.streamSource.scope, active: true });
|
|
592
|
+
} else if (node.streamSource.kind === 'child-event-all') {
|
|
593
|
+
// Replay buffered child-events for this child so the panel isn't blank.
|
|
594
|
+
const buf = childEventBuffers.get(node.streamSource.childName);
|
|
595
|
+
if (buf) for (const ev of buf) ingestStreamEvent(ev);
|
|
596
|
+
} else if (node.streamSource.kind === 'child-event-agent') {
|
|
597
|
+
// Replay only events attributable to this agent inside the child.
|
|
598
|
+
const buf = childEventBuffers.get(node.streamSource.childName);
|
|
599
|
+
if (buf) {
|
|
600
|
+
for (const ev of buf) {
|
|
601
|
+
if ((ev as { agentName?: string }).agentName === node.streamSource.agentName) {
|
|
602
|
+
ingestStreamEvent(ev);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
const openUsage = (node: UiNode): void => {
|
|
610
|
+
if (focusedId() === node.id && panelMode() === 'usage') {
|
|
611
|
+
// Re-clicking the same usage badge toggles closed.
|
|
612
|
+
closePanel();
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
teardownPeek();
|
|
616
|
+
setFocusedId(node.id);
|
|
617
|
+
setFocusedNode(node);
|
|
618
|
+
setPanelMode('usage');
|
|
619
|
+
setStreamLines([]);
|
|
620
|
+
streamTokenBuffer = '';
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
const toggleExpand = (id: string): void => {
|
|
624
|
+
setExpanded((prev) => {
|
|
625
|
+
const next = new Set(prev);
|
|
626
|
+
if (next.has(id)) next.delete(id);
|
|
627
|
+
else next.add(id);
|
|
628
|
+
return next;
|
|
629
|
+
});
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
const cancelSubagent = (name: string, childName?: string): void => {
|
|
633
|
+
wire.send({ type: 'cancel-subagent', name, ...(childName ? { childName } : {}) });
|
|
634
|
+
};
|
|
635
|
+
|
|
636
|
+
const fleetStop = (name: string): void => {
|
|
637
|
+
wire.send({ type: 'fleet-stop', name });
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
const fleetRestart = (name: string): void => {
|
|
641
|
+
wire.send({ type: 'fleet-restart', name });
|
|
642
|
+
};
|
|
643
|
+
|
|
644
|
+
/** Stop the focused stream's underlying node, dispatched by stream source kind. */
|
|
645
|
+
const stopFocusedNode = (): void => {
|
|
646
|
+
const node = focusedNode();
|
|
647
|
+
if (!node) return;
|
|
648
|
+
const src = node.streamSource;
|
|
649
|
+
if (src.kind === 'peek') cancelSubagent(src.scope);
|
|
650
|
+
else if (src.kind === 'child-event-all') fleetStop(src.childName);
|
|
651
|
+
else if (src.kind === 'child-event-agent') {
|
|
652
|
+
// For a subagent inside a fleet child, route the cancel to that
|
|
653
|
+
// child's SubagentModule rather than killing the whole child. The
|
|
654
|
+
// main framework agent of a child is not a subagent — for that we
|
|
655
|
+
// still have no per-agent kill verb, so fall back to fleetStop.
|
|
656
|
+
if (node.kind === 'subagent') cancelSubagent(src.agentName, src.childName);
|
|
657
|
+
else fleetStop(src.childName);
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
/** Whether the current focused node has a sensible stop affordance. */
|
|
662
|
+
const canStopFocused = (): boolean => {
|
|
663
|
+
const node = focusedNode();
|
|
664
|
+
if (!node) return false;
|
|
665
|
+
const src = node.streamSource;
|
|
666
|
+
if (src.kind === 'peek') {
|
|
667
|
+
return node.agent?.status === 'running';
|
|
668
|
+
}
|
|
669
|
+
return src.kind === 'child-event-all' || src.kind === 'child-event-agent';
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
/** Auto-expand newly-discovered fleet-child folders the first time they
|
|
673
|
+
* appear, so the user sees their agents without an extra click. */
|
|
674
|
+
const autoExpandNewFleetChildren = (): void => {
|
|
675
|
+
const roots = treeStore.build();
|
|
676
|
+
const visit = (node: UiNode): void => {
|
|
677
|
+
if (node.kind === 'fleet-child' && !seenAutoExpand.has(node.id)) {
|
|
678
|
+
seenAutoExpand.add(node.id);
|
|
679
|
+
setExpanded((prev) => {
|
|
680
|
+
const next = new Set(prev);
|
|
681
|
+
next.add(node.id);
|
|
682
|
+
return next;
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
for (const c of node.children) visit(c);
|
|
686
|
+
};
|
|
687
|
+
for (const r of roots) visit(r);
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
onMount(() => {
|
|
691
|
+
const detach = wire.onMessage((msg) => {
|
|
692
|
+
treeStore.ingest(msg);
|
|
693
|
+
autoExpandNewFleetChildren();
|
|
694
|
+
// Capture child-events into the per-childName ring buffer so a future
|
|
695
|
+
// openStream() can backfill the panel from local history.
|
|
696
|
+
if (msg.type === 'child-event') {
|
|
697
|
+
recordChildEvent(msg.childName, msg.event);
|
|
698
|
+
}
|
|
699
|
+
handleStreamMessage(msg);
|
|
700
|
+
handleServerMessage(msg, wire, {
|
|
701
|
+
applyWelcome,
|
|
702
|
+
applyAppended,
|
|
703
|
+
applyHistoryPage,
|
|
704
|
+
appendMessage: (m) => setMessages(produce(arr => arr.push(m))),
|
|
705
|
+
setUsage,
|
|
706
|
+
setPerAgentCost,
|
|
707
|
+
appendStreamToken,
|
|
708
|
+
appendToolUseBlocks,
|
|
709
|
+
updateToolStatus,
|
|
710
|
+
handleContentBlock,
|
|
711
|
+
finishStream,
|
|
712
|
+
queueScroll,
|
|
713
|
+
openQuitConfirm: (children) => setQuitConfirm(children),
|
|
714
|
+
setLessons: (loaded, moduleLoaded, list) => {
|
|
715
|
+
setLessonsLoaded(loaded);
|
|
716
|
+
setLessonsModuleLoaded(moduleLoaded);
|
|
717
|
+
setLessons(list);
|
|
718
|
+
},
|
|
719
|
+
setMcpl: (configPath, servers) => {
|
|
720
|
+
setMcplLoaded(true);
|
|
721
|
+
setMcplConfigPath(configPath);
|
|
722
|
+
setMcplServers(servers);
|
|
723
|
+
},
|
|
724
|
+
setMounts: (loaded, moduleLoaded, list) => {
|
|
725
|
+
setMountsLoaded(loaded);
|
|
726
|
+
setWorkspaceModuleLoaded(moduleLoaded);
|
|
727
|
+
setMounts(list);
|
|
728
|
+
},
|
|
729
|
+
setMountTree: (mountName, entries) => {
|
|
730
|
+
setTreesByMount(prev => {
|
|
731
|
+
const next = new Map(prev);
|
|
732
|
+
next.set(mountName, entries);
|
|
733
|
+
return next;
|
|
734
|
+
});
|
|
735
|
+
},
|
|
736
|
+
setOpenFile: (file) => {
|
|
737
|
+
setOpenFile(file);
|
|
738
|
+
setFileLoading(false);
|
|
739
|
+
},
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
onCleanup(() => {
|
|
743
|
+
detach();
|
|
744
|
+
wire.close();
|
|
745
|
+
});
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
/** Route a server message into the stream pane *if* it matches the focused
|
|
749
|
+
* node's StreamSource. Each kind has its own match rule. */
|
|
750
|
+
const handleStreamMessage = (msg: WebUiServerMessage): void => {
|
|
751
|
+
if (panelMode() !== 'stream') return;
|
|
752
|
+
const node = focusedNode();
|
|
753
|
+
if (!node) return;
|
|
754
|
+
const src = node.streamSource;
|
|
755
|
+
if (src.kind === 'none') return;
|
|
756
|
+
|
|
757
|
+
if (src.kind === 'peek' && msg.type === 'peek' && msg.scope === src.scope) {
|
|
758
|
+
ingestStreamEvent(msg.event);
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (msg.type !== 'child-event') return;
|
|
763
|
+
|
|
764
|
+
if (src.kind === 'child-event-all' && msg.childName === src.childName) {
|
|
765
|
+
ingestStreamEvent(msg.event);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (
|
|
770
|
+
src.kind === 'child-event-agent'
|
|
771
|
+
&& msg.childName === src.childName
|
|
772
|
+
&& (msg.event as { agentName?: string }).agentName === src.agentName
|
|
773
|
+
) {
|
|
774
|
+
ingestStreamEvent(msg.event);
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
const ingestStreamEvent = (event: { type: string; [k: string]: unknown }): void => {
|
|
780
|
+
if ((event.type === 'inference:tokens' || event.type === 'tokens') && typeof event.content === 'string') {
|
|
781
|
+
appendStreamPaneToken(event.content);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
flushStreamBuffer();
|
|
785
|
+
const line = formatStreamEvent(event);
|
|
786
|
+
if (line) setStreamLines((prev) => [...prev, line]);
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
const submit = (): void => {
|
|
790
|
+
const text = draft().trim();
|
|
791
|
+
if (!text) return;
|
|
792
|
+
setDraft('');
|
|
793
|
+
if (text.startsWith('/')) {
|
|
794
|
+
wire.send({ type: 'command', command: text });
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
const route = parseRoute(text);
|
|
798
|
+
if (route) {
|
|
799
|
+
setMessages(produce((arr) => arr.push({
|
|
800
|
+
id: nextMessageId(),
|
|
801
|
+
participant: 'user',
|
|
802
|
+
text: `→ @${route.childName}: ${route.content}`,
|
|
803
|
+
timestamp: Date.now(),
|
|
804
|
+
})));
|
|
805
|
+
queueScroll();
|
|
806
|
+
wire.send({ type: 'route-to-child', childName: route.childName, content: route.content });
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
setMessages(produce((arr) => arr.push({
|
|
810
|
+
id: nextMessageId(),
|
|
811
|
+
participant: 'user',
|
|
812
|
+
text,
|
|
813
|
+
timestamp: Date.now(),
|
|
814
|
+
})));
|
|
815
|
+
queueScroll();
|
|
816
|
+
wire.send({ type: 'user-message', content: text });
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
const interrupt = (): void => {
|
|
820
|
+
wire.send({ type: 'interrupt' });
|
|
821
|
+
};
|
|
822
|
+
|
|
823
|
+
const respondQuit = (action: 'kill-children' | 'detach' | 'cancel'): void => {
|
|
824
|
+
wire.send({ type: 'quit-confirm', action });
|
|
825
|
+
setQuitConfirm(null);
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
const onKey = (e: KeyboardEvent): void => {
|
|
829
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
830
|
+
e.preventDefault();
|
|
831
|
+
submit();
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
return (
|
|
836
|
+
<div class="flex flex-col h-screen">
|
|
837
|
+
<Header welcome={welcome()} usage={usage()} status={wire.status()} />
|
|
838
|
+
<ReconnectBanner status={wire.status()} />
|
|
839
|
+
<Show when={protoMismatch() !== null}>
|
|
840
|
+
<div class="bg-amber-950/60 border-b border-amber-900 px-4 py-1.5 text-xs text-amber-200 flex items-center gap-2">
|
|
841
|
+
<span class="w-2 h-2 rounded-full bg-amber-500" />
|
|
842
|
+
<span>
|
|
843
|
+
Protocol mismatch — server speaks v{protoMismatch()}, this bundle expects v{WEB_PROTOCOL_VERSION}.
|
|
844
|
+
Rebuild the SPA (<code class="font-mono">bun run build:web</code>) and/or restart the host, then reload.
|
|
845
|
+
</span>
|
|
846
|
+
</div>
|
|
847
|
+
</Show>
|
|
848
|
+
<Show when={quitConfirm()}>
|
|
849
|
+
{(list) => (
|
|
850
|
+
<QuitConfirmModal
|
|
851
|
+
childNames={list()}
|
|
852
|
+
onAction={respondQuit}
|
|
853
|
+
/>
|
|
854
|
+
)}
|
|
855
|
+
</Show>
|
|
856
|
+
<FileViewerModal
|
|
857
|
+
file={openFile()}
|
|
858
|
+
loading={fileLoading()}
|
|
859
|
+
onClose={() => { setOpenFile(null); setFileLoading(false); }}
|
|
860
|
+
/>
|
|
861
|
+
|
|
862
|
+
<div class="flex flex-1 min-h-0">
|
|
863
|
+
<main class="flex-1 flex flex-col min-w-0">
|
|
864
|
+
<div class="flex border-b border-neutral-800 bg-neutral-900/40 text-[11px] font-mono">
|
|
865
|
+
<button type="button" class={`px-3 py-1.5 ${mainView() === 'chat' ? 'text-neutral-100 bg-neutral-900 border-b border-cyan-700' : 'text-neutral-500 hover:text-neutral-300'}`} onClick={() => setMainView('chat')}>Chat</button>
|
|
866
|
+
<button type="button" class={`px-3 py-1.5 ${mainView() === 'context' ? 'text-neutral-100 bg-neutral-900 border-b border-cyan-700' : 'text-neutral-500 hover:text-neutral-300'}`} onClick={() => setMainView('context')}>Context</button>
|
|
867
|
+
</div>
|
|
868
|
+
<div
|
|
869
|
+
ref={scrollPane}
|
|
870
|
+
class="flex-1 overflow-y-auto px-4 py-3 space-y-3"
|
|
871
|
+
onScroll={onScrollPane}
|
|
872
|
+
>
|
|
873
|
+
<Show when={mainView() === 'context'} fallback={<>
|
|
874
|
+
<Show when={(historyInfo()?.startIndex ?? 0) > 0}>
|
|
875
|
+
<button
|
|
876
|
+
type="button"
|
|
877
|
+
class="w-full text-center text-xs font-mono text-neutral-500 hover:text-neutral-300 py-1.5 border border-dashed border-neutral-800 rounded"
|
|
878
|
+
onClick={requestOlder}
|
|
879
|
+
disabled={historyLoading()}
|
|
880
|
+
>
|
|
881
|
+
{historyLoading()
|
|
882
|
+
? 'loading…'
|
|
883
|
+
: `▲ ${historyInfo()!.startIndex} older message${historyInfo()!.startIndex === 1 ? '' : 's'} — scroll or click to load`}
|
|
884
|
+
</button>
|
|
885
|
+
</Show>
|
|
886
|
+
<Show when={messages.length === 0}>
|
|
887
|
+
<div class="text-neutral-500 text-sm italic">
|
|
888
|
+
Connected. Type a message or /help to begin.
|
|
889
|
+
</div>
|
|
890
|
+
</Show>
|
|
891
|
+
<For each={messages}>{(m) => (
|
|
892
|
+
<MessageView msg={m} results={toolResults()} toolUseIds={toolUseIds()} />
|
|
893
|
+
)}</For>
|
|
894
|
+
</>}>
|
|
895
|
+
<ContextDocument agent={panelScope() === 'local' ? undefined : panelScope()} />
|
|
896
|
+
</Show>
|
|
897
|
+
</div>
|
|
898
|
+
|
|
899
|
+
<div class="border-t border-neutral-800 px-4 py-3 bg-neutral-950 relative">
|
|
900
|
+
<CommandSuggestions
|
|
901
|
+
draft={draft()}
|
|
902
|
+
onPick={(cmd) => setDraft(cmd + ' ')}
|
|
903
|
+
/>
|
|
904
|
+
<div class="flex gap-2">
|
|
905
|
+
<textarea
|
|
906
|
+
class="flex-1 bg-neutral-900 text-neutral-100 border border-neutral-800 rounded px-3 py-2 resize-none focus:outline-none focus:ring-1 focus:ring-neutral-600 font-mono text-sm"
|
|
907
|
+
rows="2"
|
|
908
|
+
placeholder="Type a message, /help, or @childname"
|
|
909
|
+
value={draft()}
|
|
910
|
+
onInput={(e) => setDraft(e.currentTarget.value)}
|
|
911
|
+
onKeyDown={onKey}
|
|
912
|
+
/>
|
|
913
|
+
<div class="flex flex-col gap-1">
|
|
914
|
+
<button
|
|
915
|
+
type="button"
|
|
916
|
+
class="px-4 py-1.5 bg-neutral-800 hover:bg-neutral-700 rounded text-sm"
|
|
917
|
+
onClick={submit}
|
|
918
|
+
>
|
|
919
|
+
Send
|
|
920
|
+
</button>
|
|
921
|
+
<button
|
|
922
|
+
type="button"
|
|
923
|
+
class="px-4 py-1.5 bg-rose-900/40 hover:bg-rose-900/60 text-rose-200 rounded text-xs font-mono"
|
|
924
|
+
onClick={interrupt}
|
|
925
|
+
title="Cancel any in-flight inference (Esc parity)"
|
|
926
|
+
>
|
|
927
|
+
Stop
|
|
928
|
+
</button>
|
|
929
|
+
</div>
|
|
930
|
+
</div>
|
|
931
|
+
</div>
|
|
932
|
+
</main>
|
|
933
|
+
|
|
934
|
+
<Show when={focusedNode() && panelMode() === 'stream'}>
|
|
935
|
+
{(_) => {
|
|
936
|
+
const node = focusedNode()!;
|
|
937
|
+
return (
|
|
938
|
+
<StreamPanel
|
|
939
|
+
label={node.label}
|
|
940
|
+
scopeHint={streamHint(node.streamSource)}
|
|
941
|
+
lines={streamLines()}
|
|
942
|
+
onClose={closePanel}
|
|
943
|
+
onStop={stopFocusedNode}
|
|
944
|
+
canStop={canStopFocused()}
|
|
945
|
+
/>
|
|
946
|
+
);
|
|
947
|
+
}}
|
|
948
|
+
</Show>
|
|
949
|
+
<Show when={focusedNode() && panelMode() === 'usage'}>
|
|
950
|
+
{(_) => (
|
|
951
|
+
<UsagePanel
|
|
952
|
+
node={focusedNode()!}
|
|
953
|
+
sessionUsage={usage()}
|
|
954
|
+
perAgentCost={perAgentCost()}
|
|
955
|
+
onClose={closePanel}
|
|
956
|
+
/>
|
|
957
|
+
)}
|
|
958
|
+
</Show>
|
|
959
|
+
<aside class="w-72 border-l border-neutral-800 bg-neutral-950 shrink-0 flex flex-col">
|
|
960
|
+
<SidebarTabs
|
|
961
|
+
current={sidebarTab()}
|
|
962
|
+
onSelect={(tab) => {
|
|
963
|
+
setSidebarTab(tab);
|
|
964
|
+
// Lazy-load tab data on first open. Mutations re-broadcast a
|
|
965
|
+
// fresh list so we don't need to re-fetch after every change.
|
|
966
|
+
if (tab === 'lessons' && !lessonsLoaded()) refreshLessons();
|
|
967
|
+
if (tab === 'mcp' && !mcplLoaded()) refreshMcpl();
|
|
968
|
+
if (tab === 'files' && !mountsLoaded()) refreshMounts();
|
|
969
|
+
}}
|
|
970
|
+
/>
|
|
971
|
+
<div class="flex-1 min-h-0">
|
|
972
|
+
<Show when={sidebarTab() === 'tree'}>
|
|
973
|
+
<TreeSidebar
|
|
974
|
+
roots={treeStore.build()}
|
|
975
|
+
selectedId={focusedId()}
|
|
976
|
+
expanded={expanded()}
|
|
977
|
+
onToggleExpand={toggleExpand}
|
|
978
|
+
onSelectStream={openStream}
|
|
979
|
+
onSelectUsage={openUsage}
|
|
980
|
+
onCancelSubagent={cancelSubagent}
|
|
981
|
+
onFleetStop={fleetStop}
|
|
982
|
+
onFleetRestart={fleetRestart}
|
|
983
|
+
/>
|
|
984
|
+
</Show>
|
|
985
|
+
<Show when={sidebarTab() === 'lessons'}>
|
|
986
|
+
<LessonsPanel
|
|
987
|
+
loaded={lessonsLoaded()}
|
|
988
|
+
moduleLoaded={lessonsModuleLoaded()}
|
|
989
|
+
lessons={lessons()}
|
|
990
|
+
scope={panelScope()}
|
|
991
|
+
scopes={availableScopes()}
|
|
992
|
+
onScopeChange={changePanelScope}
|
|
993
|
+
onRefresh={refreshLessons}
|
|
994
|
+
/>
|
|
995
|
+
</Show>
|
|
996
|
+
<Show when={sidebarTab() === 'mcp'}>
|
|
997
|
+
<McplPanel
|
|
998
|
+
loaded={mcplLoaded()}
|
|
999
|
+
configPath={mcplConfigPath()}
|
|
1000
|
+
servers={mcplServers()}
|
|
1001
|
+
onRefresh={refreshMcpl}
|
|
1002
|
+
onAdd={(input) => wire.send({ type: 'mcpl-add', ...input })}
|
|
1003
|
+
onRemove={(id) => wire.send({ type: 'mcpl-remove', id })}
|
|
1004
|
+
onSetEnv={(id, env) => wire.send({ type: 'mcpl-set-env', id, env })}
|
|
1005
|
+
/>
|
|
1006
|
+
</Show>
|
|
1007
|
+
<Show when={sidebarTab() === 'files'}>
|
|
1008
|
+
<FilesPanel
|
|
1009
|
+
loaded={mountsLoaded()}
|
|
1010
|
+
moduleLoaded={workspaceModuleLoaded()}
|
|
1011
|
+
mounts={mounts()}
|
|
1012
|
+
treesByMount={treesByMount()}
|
|
1013
|
+
expandedMounts={expandedMounts()}
|
|
1014
|
+
scope={panelScope()}
|
|
1015
|
+
scopes={availableScopes()}
|
|
1016
|
+
onScopeChange={changePanelScope}
|
|
1017
|
+
onRefreshMounts={refreshMounts}
|
|
1018
|
+
onExpandMount={expandMount}
|
|
1019
|
+
onCollapseMount={collapseMount}
|
|
1020
|
+
onOpenFile={requestFile}
|
|
1021
|
+
/>
|
|
1022
|
+
</Show>
|
|
1023
|
+
<Show when={sidebarTab() === 'context'}>
|
|
1024
|
+
<ContextPanel agent={panelScope() === 'local' ? undefined : panelScope()} />
|
|
1025
|
+
</Show>
|
|
1026
|
+
</div>
|
|
1027
|
+
<RecipePane welcome={welcome()} scope={panelScope()} />
|
|
1028
|
+
</aside>
|
|
1029
|
+
</div>
|
|
1030
|
+
</div>
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function streamHint(src: StreamSource): string | undefined {
|
|
1035
|
+
switch (src.kind) {
|
|
1036
|
+
case 'peek': return 'subagent';
|
|
1037
|
+
case 'child-event-all': return 'fleet child';
|
|
1038
|
+
case 'child-event-agent': return `agent in ${src.childName}`;
|
|
1039
|
+
default: return undefined;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
interface HandlerHooks {
|
|
1044
|
+
/** Merge (or hard-reset on session/branch change) a welcome frame. */
|
|
1045
|
+
applyWelcome: (w: WelcomeMessage) => void;
|
|
1046
|
+
/** Fold a message-appended push into the timeline (id-deduped). */
|
|
1047
|
+
applyAppended: (entry: WelcomeMessageEntry) => void;
|
|
1048
|
+
/** Prepend a history page with scroll-anchor preservation. */
|
|
1049
|
+
applyHistoryPage: (msg: HistoryPageMessage) => void;
|
|
1050
|
+
/** Append a single message at the end (used for command-result, errors, etc.). */
|
|
1051
|
+
appendMessage: (msg: Message) => void;
|
|
1052
|
+
setUsage: (u: TokenUsage) => void;
|
|
1053
|
+
setPerAgentCost: (c: PerAgentCost[]) => void;
|
|
1054
|
+
appendStreamToken: (token: string, blockType?: string) => void;
|
|
1055
|
+
/** Attach yielded tool calls to the streaming assistant message. */
|
|
1056
|
+
appendToolUseBlocks: (calls: Array<{ id: string; name: string; input?: unknown }>) => void;
|
|
1057
|
+
/** Flip a tool_use block's live status from tool:* traces. */
|
|
1058
|
+
updateToolStatus: (callId: string, status: 'done' | 'failed', durationMs?: number) => void;
|
|
1059
|
+
/** Handle inference:content_block phases (auto-collapse finished blocks). */
|
|
1060
|
+
handleContentBlock: (phase: string) => void;
|
|
1061
|
+
finishStream: () => void;
|
|
1062
|
+
queueScroll: () => void;
|
|
1063
|
+
/** Show the quit-confirm modal with the given list of running children. */
|
|
1064
|
+
openQuitConfirm: (children: string[]) => void;
|
|
1065
|
+
/** Apply a lessons-list response from the server. */
|
|
1066
|
+
setLessons: (loaded: boolean, moduleLoaded: boolean, lessons: LessonRow[]) => void;
|
|
1067
|
+
/** Apply an mcpl-list response from the server. */
|
|
1068
|
+
setMcpl: (configPath: string, servers: McplServerRow[]) => void;
|
|
1069
|
+
/** Apply a workspace-mounts response. */
|
|
1070
|
+
setMounts: (loaded: boolean, moduleLoaded: boolean, mounts: Mount[]) => void;
|
|
1071
|
+
/** Apply a workspace-tree response for one mount. */
|
|
1072
|
+
setMountTree: (mount: string, entries: FlatEntry[]) => void;
|
|
1073
|
+
/** Apply a workspace-file response. */
|
|
1074
|
+
setOpenFile: (file: FileViewer) => void;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function handleServerMessage(
|
|
1078
|
+
msg: WebUiServerMessage,
|
|
1079
|
+
_wire: WireClient,
|
|
1080
|
+
hooks: HandlerHooks,
|
|
1081
|
+
): void {
|
|
1082
|
+
switch (msg.type) {
|
|
1083
|
+
case 'welcome': {
|
|
1084
|
+
hooks.applyWelcome(msg);
|
|
1085
|
+
hooks.setUsage(msg.usage);
|
|
1086
|
+
hooks.setPerAgentCost(msg.perAgentCost ?? []);
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
case 'message-appended':
|
|
1090
|
+
hooks.applyAppended(msg.entry);
|
|
1091
|
+
return;
|
|
1092
|
+
case 'history-page':
|
|
1093
|
+
hooks.applyHistoryPage(msg);
|
|
1094
|
+
return;
|
|
1095
|
+
case 'usage':
|
|
1096
|
+
hooks.setUsage(msg.usage);
|
|
1097
|
+
if (msg.perAgentCost) hooks.setPerAgentCost(msg.perAgentCost);
|
|
1098
|
+
return;
|
|
1099
|
+
case 'trace': {
|
|
1100
|
+
const e = msg.event;
|
|
1101
|
+
switch (e.type) {
|
|
1102
|
+
case 'inference:tokens': {
|
|
1103
|
+
const content = e.content;
|
|
1104
|
+
if (typeof content === 'string') {
|
|
1105
|
+
hooks.appendStreamToken(content, typeof e.blockType === 'string' ? e.blockType : undefined);
|
|
1106
|
+
}
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
case 'inference:content_block': {
|
|
1110
|
+
if (typeof e.phase === 'string') hooks.handleContentBlock(e.phase);
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
case 'inference:completed':
|
|
1114
|
+
case 'inference:failed':
|
|
1115
|
+
hooks.finishStream();
|
|
1116
|
+
return;
|
|
1117
|
+
case 'inference:tool_calls_yielded': {
|
|
1118
|
+
const calls = (e.calls as Array<{ id: string; name: string; input?: unknown }> | undefined) ?? [];
|
|
1119
|
+
if (calls.length === 0) return;
|
|
1120
|
+
hooks.appendToolUseBlocks(calls);
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
case 'tool:completed': {
|
|
1124
|
+
if (typeof e.callId === 'string') {
|
|
1125
|
+
hooks.updateToolStatus(e.callId, 'done',
|
|
1126
|
+
typeof e.durationMs === 'number' ? e.durationMs : undefined);
|
|
1127
|
+
}
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
case 'tool:failed': {
|
|
1131
|
+
if (typeof e.callId === 'string') {
|
|
1132
|
+
hooks.updateToolStatus(e.callId, 'failed',
|
|
1133
|
+
typeof e.durationMs === 'number' ? e.durationMs : undefined);
|
|
1134
|
+
}
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
default:
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
case 'command-result': {
|
|
1142
|
+
hooks.appendMessage({
|
|
1143
|
+
id: nextMessageId(),
|
|
1144
|
+
participant: 'command',
|
|
1145
|
+
text: msg.lines.map(l => l.text).join('\n'),
|
|
1146
|
+
lines: msg.lines,
|
|
1147
|
+
});
|
|
1148
|
+
hooks.queueScroll();
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
case 'branch-changed':
|
|
1152
|
+
return;
|
|
1153
|
+
case 'session-changed':
|
|
1154
|
+
return;
|
|
1155
|
+
case 'inbound-trigger': {
|
|
1156
|
+
hooks.appendMessage({
|
|
1157
|
+
id: nextMessageId(),
|
|
1158
|
+
participant: 'trigger',
|
|
1159
|
+
text: msg.text,
|
|
1160
|
+
timestamp: msg.timestamp,
|
|
1161
|
+
trigger: {
|
|
1162
|
+
origin: msg.origin,
|
|
1163
|
+
source: msg.source,
|
|
1164
|
+
author: msg.author,
|
|
1165
|
+
triggered: msg.triggered,
|
|
1166
|
+
},
|
|
1167
|
+
});
|
|
1168
|
+
hooks.queueScroll();
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
case 'quit-confirm-required':
|
|
1172
|
+
hooks.openQuitConfirm(msg.children);
|
|
1173
|
+
return;
|
|
1174
|
+
case 'lessons-list':
|
|
1175
|
+
hooks.setLessons(true, msg.loaded, msg.lessons);
|
|
1176
|
+
return;
|
|
1177
|
+
case 'mcpl-list':
|
|
1178
|
+
hooks.setMcpl(msg.configPath, msg.servers);
|
|
1179
|
+
return;
|
|
1180
|
+
case 'workspace-mounts':
|
|
1181
|
+
hooks.setMounts(true, msg.loaded, msg.mounts);
|
|
1182
|
+
return;
|
|
1183
|
+
case 'workspace-tree':
|
|
1184
|
+
hooks.setMountTree(msg.mount, msg.entries);
|
|
1185
|
+
return;
|
|
1186
|
+
case 'workspace-file':
|
|
1187
|
+
hooks.setOpenFile(msg);
|
|
1188
|
+
return;
|
|
1189
|
+
case 'error':
|
|
1190
|
+
console.warn('[server error]', msg.message);
|
|
1191
|
+
hooks.appendMessage({
|
|
1192
|
+
id: nextMessageId(),
|
|
1193
|
+
participant: 'system',
|
|
1194
|
+
text: `Error: ${msg.message}`,
|
|
1195
|
+
});
|
|
1196
|
+
hooks.queueScroll();
|
|
1197
|
+
return;
|
|
1198
|
+
default:
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function Header(props: {
|
|
1204
|
+
welcome: WelcomeMessage | null;
|
|
1205
|
+
usage: TokenUsage;
|
|
1206
|
+
status: string;
|
|
1207
|
+
}) {
|
|
1208
|
+
const fmt = (n: number): string => {
|
|
1209
|
+
if (n < 1000) return String(n);
|
|
1210
|
+
if (n < 1_000_000) return (n / 1000).toFixed(1) + 'k';
|
|
1211
|
+
return (n / 1_000_000).toFixed(2) + 'M';
|
|
1212
|
+
};
|
|
1213
|
+
|
|
1214
|
+
const statusColor = (): string => {
|
|
1215
|
+
switch (props.status) {
|
|
1216
|
+
case 'open': return 'bg-emerald-500';
|
|
1217
|
+
case 'connecting':
|
|
1218
|
+
case 'reconnecting': return 'bg-amber-500';
|
|
1219
|
+
default: return 'bg-rose-500';
|
|
1220
|
+
}
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1223
|
+
return (
|
|
1224
|
+
<div class="border-b border-neutral-800 bg-neutral-950 px-4 py-2 flex items-center gap-3 text-sm">
|
|
1225
|
+
<div class={`w-2 h-2 rounded-full ${statusColor()}`} title={props.status} />
|
|
1226
|
+
<div class="font-semibold text-neutral-200">
|
|
1227
|
+
{props.welcome?.recipe.name ?? 'connectome-host'}
|
|
1228
|
+
</div>
|
|
1229
|
+
<Show when={props.welcome?.session.name}>
|
|
1230
|
+
<div class="text-neutral-500">
|
|
1231
|
+
· {props.welcome!.session.name}
|
|
1232
|
+
</div>
|
|
1233
|
+
</Show>
|
|
1234
|
+
<Show when={props.welcome?.branch.name && props.welcome.branch.name !== 'main'}>
|
|
1235
|
+
<div class="text-amber-400 text-xs font-mono px-1.5 py-0.5 bg-amber-950/30 rounded">
|
|
1236
|
+
{props.welcome!.branch.name}
|
|
1237
|
+
</div>
|
|
1238
|
+
</Show>
|
|
1239
|
+
<div class="ml-auto text-xs font-mono text-neutral-500">
|
|
1240
|
+
{fmt(props.usage.input)} in
|
|
1241
|
+
<span class="ml-2">{fmt(props.usage.output)} out</span>
|
|
1242
|
+
<Show when={props.usage.cacheRead > 0}>
|
|
1243
|
+
<span class="ml-2">{fmt(props.usage.cacheRead)} cache</span>
|
|
1244
|
+
</Show>
|
|
1245
|
+
<Show when={props.usage.cost && props.usage.cost.total > 0}>
|
|
1246
|
+
<span
|
|
1247
|
+
class="ml-3 text-emerald-300"
|
|
1248
|
+
title={`Estimated cost (${props.usage.cost!.currency})`}
|
|
1249
|
+
>
|
|
1250
|
+
${props.usage.cost!.total.toFixed(props.usage.cost!.total < 1 ? 4 : 2)}
|
|
1251
|
+
</span>
|
|
1252
|
+
</Show>
|
|
1253
|
+
</div>
|
|
1254
|
+
</div>
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
type ToolResultInfo = { text: string; isError?: boolean; truncated?: boolean };
|
|
1259
|
+
|
|
1260
|
+
function MessageView(props: {
|
|
1261
|
+
msg: Message;
|
|
1262
|
+
results: Map<string, ToolResultInfo>;
|
|
1263
|
+
toolUseIds: Set<string>;
|
|
1264
|
+
}) {
|
|
1265
|
+
// Access fields via props.msg.X directly (not a destructured local) so Solid
|
|
1266
|
+
// tracks reactive reads against the store. Otherwise the message would
|
|
1267
|
+
// capture a snapshot at render time and never update mid-stream.
|
|
1268
|
+
|
|
1269
|
+
if (props.msg.participant === 'user') {
|
|
1270
|
+
return (
|
|
1271
|
+
<div class="msg-enter">
|
|
1272
|
+
<div class="text-xs text-neutral-500 mb-1">you<TimeChip ts={props.msg.timestamp} /></div>
|
|
1273
|
+
<div class="font-mono text-sm whitespace-pre-wrap text-neutral-200">{props.msg.text}</div>
|
|
1274
|
+
<MediaChips blocks={props.msg.blocks} />
|
|
1275
|
+
</div>
|
|
1276
|
+
);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
if (props.msg.participant === 'assistant') {
|
|
1280
|
+
return (
|
|
1281
|
+
<div class="msg-enter">
|
|
1282
|
+
<div class="text-xs text-neutral-500 mb-1">
|
|
1283
|
+
assistant
|
|
1284
|
+
<TimeChip ts={props.msg.timestamp} />
|
|
1285
|
+
<Show when={props.msg.streaming}>
|
|
1286
|
+
<span class="animate-pulse ml-2">▍</span>
|
|
1287
|
+
</Show>
|
|
1288
|
+
</div>
|
|
1289
|
+
<Show
|
|
1290
|
+
when={(props.msg.blocks?.length ?? 0) > 0}
|
|
1291
|
+
fallback={<div class="prose-mini text-neutral-100" innerHTML={renderMarkdown(props.msg.text)} />}
|
|
1292
|
+
>
|
|
1293
|
+
<For each={props.msg.blocks}>{(block, idx) => (
|
|
1294
|
+
<BlockView block={block} msgId={props.msg.id} idx={idx()} results={props.results} />
|
|
1295
|
+
)}</For>
|
|
1296
|
+
</Show>
|
|
1297
|
+
</div>
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
if (props.msg.participant === 'tool') {
|
|
1302
|
+
// A message whose payload is entirely tool_results that are already
|
|
1303
|
+
// rendered inline under their tool_use blocks carries no new
|
|
1304
|
+
// information — hide it. Unpaired results render standalone.
|
|
1305
|
+
const allPaired = (): boolean => {
|
|
1306
|
+
const blocks = props.msg.blocks ?? [];
|
|
1307
|
+
if (blocks.length === 0) return false;
|
|
1308
|
+
return blocks.every((b) =>
|
|
1309
|
+
b.kind === 'tool_result' ? props.toolUseIds.has(b.toolUseId) : b.kind === 'media',
|
|
1310
|
+
);
|
|
1311
|
+
};
|
|
1312
|
+
return (
|
|
1313
|
+
<Show when={!allPaired()}>
|
|
1314
|
+
<div class="msg-enter">
|
|
1315
|
+
<div class="text-xs text-neutral-500 mb-1">tool<TimeChip ts={props.msg.timestamp} /></div>
|
|
1316
|
+
<For each={props.msg.blocks ?? []}>{(block, idx) => (
|
|
1317
|
+
<BlockView block={block} msgId={props.msg.id} idx={idx()} results={props.results} />
|
|
1318
|
+
)}</For>
|
|
1319
|
+
</div>
|
|
1320
|
+
</Show>
|
|
1321
|
+
);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
if (props.msg.participant === 'command') {
|
|
1325
|
+
return (
|
|
1326
|
+
<div class="msg-enter font-mono text-xs bg-neutral-900/60 border border-neutral-800 rounded px-3 py-2 whitespace-pre-wrap">
|
|
1327
|
+
<For each={props.msg.lines ?? [{ text: props.msg.text }]}>{(line) => (
|
|
1328
|
+
<div class={lineStyleClass(line.style)}>{line.text || ' '}</div>
|
|
1329
|
+
)}</For>
|
|
1330
|
+
</div>
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
if (props.msg.participant === 'trigger') {
|
|
1335
|
+
const t = props.msg.trigger!;
|
|
1336
|
+
// Bordered box with an origin chip — emphasises that the agent didn't
|
|
1337
|
+
// generate this; the world did. Dim when the gate filtered it (no wake).
|
|
1338
|
+
const tone = t.triggered
|
|
1339
|
+
? 'border-amber-700/60 bg-amber-950/20'
|
|
1340
|
+
: 'border-neutral-800 bg-neutral-900/40';
|
|
1341
|
+
return (
|
|
1342
|
+
<div class={`msg-enter rounded border px-3 py-2 ${tone}`}>
|
|
1343
|
+
<div class="flex items-center gap-2 text-[10px] uppercase tracking-wider mb-1">
|
|
1344
|
+
<span class="text-amber-300 font-semibold">incoming</span>
|
|
1345
|
+
<span class="font-mono text-neutral-300">{t.origin}</span>
|
|
1346
|
+
<Show when={t.author}>
|
|
1347
|
+
<span class="text-neutral-500">· {t.author}</span>
|
|
1348
|
+
</Show>
|
|
1349
|
+
<TimeChip ts={props.msg.timestamp} />
|
|
1350
|
+
<Show when={!t.triggered}>
|
|
1351
|
+
<span class="ml-auto text-neutral-600 italic">(gated, no wake)</span>
|
|
1352
|
+
</Show>
|
|
1353
|
+
</div>
|
|
1354
|
+
<div class="font-mono text-sm whitespace-pre-wrap text-neutral-200">{props.msg.text}</div>
|
|
1355
|
+
</div>
|
|
1356
|
+
);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
return (
|
|
1360
|
+
<div class="msg-enter text-xs text-neutral-500 font-mono whitespace-pre-wrap">
|
|
1361
|
+
{props.msg.text}
|
|
1362
|
+
</div>
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// ---------------------------------------------------------------------------
|
|
1367
|
+
// Block rendering — the interiority layer
|
|
1368
|
+
// ---------------------------------------------------------------------------
|
|
1369
|
+
|
|
1370
|
+
/** Compact timestamp chip for message headers. Time-only for today,
|
|
1371
|
+
* "Jul 5 14:22:07" otherwise; full ISO on hover. */
|
|
1372
|
+
function TimeChip(props: { ts?: number }) {
|
|
1373
|
+
const label = (): string => {
|
|
1374
|
+
const d = new Date(props.ts!);
|
|
1375
|
+
const now = new Date();
|
|
1376
|
+
const hms = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
|
|
1377
|
+
return d.toDateString() === now.toDateString()
|
|
1378
|
+
? hms
|
|
1379
|
+
: `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${hms}`;
|
|
1380
|
+
};
|
|
1381
|
+
return (
|
|
1382
|
+
<Show when={props.ts}>
|
|
1383
|
+
<span
|
|
1384
|
+
class="ml-2 text-[10px] font-mono text-neutral-600"
|
|
1385
|
+
title={new Date(props.ts!).toISOString()}
|
|
1386
|
+
>{label()}</span>
|
|
1387
|
+
</Show>
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
/**
|
|
1392
|
+
* Readable rendering for tool inputs/results. Raw payloads are JSON (tool
|
|
1393
|
+
* inputs always; shell/MCPL results usually — often DOUBLE-encoded), which
|
|
1394
|
+
* renders as escape-soup. Strategy:
|
|
1395
|
+
* - parse; unwrap one level of double-encoding ("\"{...}\"")
|
|
1396
|
+
* - object → per-field rows: long/multiline strings as real pre-wrapped
|
|
1397
|
+
* text (the `think` tool's `thought`, shell `command`s, file contents),
|
|
1398
|
+
* short values inline
|
|
1399
|
+
* - bare string → pre-wrapped text
|
|
1400
|
+
* - arrays/other/unparseable → pretty JSON / raw fallback
|
|
1401
|
+
*/
|
|
1402
|
+
function ToolPayload(props: { raw: string; isError?: boolean }) {
|
|
1403
|
+
const parsed = createMemo<
|
|
1404
|
+
| { kind: 'fields'; entries: Array<[string, unknown]> }
|
|
1405
|
+
| { kind: 'text'; text: string }
|
|
1406
|
+
| { kind: 'raw'; text: string }
|
|
1407
|
+
>(() => {
|
|
1408
|
+
const t = props.raw.trim();
|
|
1409
|
+
if (!t) return { kind: 'raw', text: props.raw };
|
|
1410
|
+
let v: unknown;
|
|
1411
|
+
try { v = JSON.parse(t); } catch { return { kind: 'raw', text: props.raw }; }
|
|
1412
|
+
// Double-encoded payload: a JSON string that itself contains JSON.
|
|
1413
|
+
if (typeof v === 'string') {
|
|
1414
|
+
const s: string = v;
|
|
1415
|
+
const inner = s.trim();
|
|
1416
|
+
if (/^[[{]/.test(inner)) {
|
|
1417
|
+
try { v = JSON.parse(inner); } catch { return { kind: 'text', text: s }; }
|
|
1418
|
+
} else {
|
|
1419
|
+
return { kind: 'text', text: s };
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
1423
|
+
return { kind: 'fields', entries: Object.entries(v as Record<string, unknown>) };
|
|
1424
|
+
}
|
|
1425
|
+
try { return { kind: 'raw', text: JSON.stringify(v, null, 2) }; }
|
|
1426
|
+
catch { return { kind: 'raw', text: props.raw }; }
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
const tone = (): string => props.isError
|
|
1430
|
+
? 'text-rose-300 bg-rose-950/20 border-rose-900/40'
|
|
1431
|
+
: 'text-neutral-300 bg-neutral-900/70 border-neutral-800';
|
|
1432
|
+
|
|
1433
|
+
const fieldIsBlock = (val: unknown): boolean =>
|
|
1434
|
+
typeof val === 'string' && (val.includes('\n') || val.length > 80);
|
|
1435
|
+
|
|
1436
|
+
const inlineValue = (val: unknown): string => {
|
|
1437
|
+
if (typeof val === 'string') return val;
|
|
1438
|
+
try { return JSON.stringify(val); } catch { return String(val); }
|
|
1439
|
+
};
|
|
1440
|
+
|
|
1441
|
+
return (
|
|
1442
|
+
<div class={`font-mono text-[11px] rounded px-2 py-1.5 overflow-x-auto border ${tone()}`}>
|
|
1443
|
+
<Show when={parsed().kind === 'fields'} fallback={
|
|
1444
|
+
<div class="whitespace-pre-wrap">{(parsed() as { text: string }).text}</div>
|
|
1445
|
+
}>
|
|
1446
|
+
<For each={(parsed() as { entries: Array<[string, unknown]> }).entries}>{([k, val]) => (
|
|
1447
|
+
<Show when={fieldIsBlock(val)} fallback={
|
|
1448
|
+
<div class="leading-relaxed">
|
|
1449
|
+
<span class="text-neutral-500">{k}:</span> <span class="whitespace-pre-wrap">{inlineValue(val)}</span>
|
|
1450
|
+
</div>
|
|
1451
|
+
}>
|
|
1452
|
+
<div class="mt-1 first:mt-0">
|
|
1453
|
+
<div class="text-[10px] uppercase tracking-wider text-neutral-500">{k}</div>
|
|
1454
|
+
<div class="whitespace-pre-wrap">{val as string}</div>
|
|
1455
|
+
</div>
|
|
1456
|
+
</Show>
|
|
1457
|
+
)}</For>
|
|
1458
|
+
</Show>
|
|
1459
|
+
</div>
|
|
1460
|
+
);
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/** Expand/collapse state for thinking + tool blocks, keyed `${msgId}:${idx}`.
|
|
1464
|
+
* Module-scope signal: one timeline per page, and survival across <For>
|
|
1465
|
+
* row recycling is exactly what we want. */
|
|
1466
|
+
const [expandedBlocks, setExpandedBlocks] = createSignal<Set<string>>(new Set());
|
|
1467
|
+
const toggleBlock = (key: string): void => {
|
|
1468
|
+
setExpandedBlocks((prev) => {
|
|
1469
|
+
const next = new Set(prev);
|
|
1470
|
+
if (next.has(key)) next.delete(key);
|
|
1471
|
+
else next.add(key);
|
|
1472
|
+
return next;
|
|
1473
|
+
});
|
|
1474
|
+
};
|
|
1475
|
+
|
|
1476
|
+
function BlockView(props: {
|
|
1477
|
+
block: UiBlock;
|
|
1478
|
+
msgId: string;
|
|
1479
|
+
idx: number;
|
|
1480
|
+
results: Map<string, ToolResultInfo>;
|
|
1481
|
+
}) {
|
|
1482
|
+
const key = (): string => `${props.msgId}:${props.idx}`;
|
|
1483
|
+
const b = props.block;
|
|
1484
|
+
|
|
1485
|
+
if (b.kind === 'text') {
|
|
1486
|
+
return <TextBlockView block={b} />;
|
|
1487
|
+
}
|
|
1488
|
+
if (b.kind === 'thinking') {
|
|
1489
|
+
return <ThinkingBlockView block={b} blockKey={key()} />;
|
|
1490
|
+
}
|
|
1491
|
+
if (b.kind === 'redacted_thinking') {
|
|
1492
|
+
return (
|
|
1493
|
+
<div class="my-1 inline-block text-[11px] font-mono text-violet-400/70 bg-violet-950/20 border border-violet-900/40 rounded px-2 py-0.5">
|
|
1494
|
+
🔒 redacted thinking ({b.bytes.toLocaleString()} bytes)
|
|
1495
|
+
</div>
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
if (b.kind === 'tool_use') {
|
|
1499
|
+
return <ToolBlockView block={b} blockKey={key()} result={props.results.get(b.id)} />;
|
|
1500
|
+
}
|
|
1501
|
+
if (b.kind === 'tool_result') {
|
|
1502
|
+
// Standalone (unpaired) result — its tool_use isn't loaded.
|
|
1503
|
+
return (
|
|
1504
|
+
<div class="my-1">
|
|
1505
|
+
<div class="text-[10px] uppercase tracking-wider mb-0.5 text-neutral-600">
|
|
1506
|
+
tool result{b.isError ? ' · error' : ''}{b.truncated ? ' · truncated' : ''}
|
|
1507
|
+
</div>
|
|
1508
|
+
<ToolPayload raw={b.text} isError={b.isError} />
|
|
1509
|
+
</div>
|
|
1510
|
+
);
|
|
1511
|
+
}
|
|
1512
|
+
if (b.kind === 'tool_draft') {
|
|
1513
|
+
return (
|
|
1514
|
+
<div class="my-1 border-l-2 border-amber-800/60 pl-2">
|
|
1515
|
+
<div class="text-[11px] font-mono text-amber-400/80 flex items-center gap-1.5">
|
|
1516
|
+
<span class="w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
|
|
1517
|
+
<span>writing tool call…</span>
|
|
1518
|
+
</div>
|
|
1519
|
+
<div class="mt-0.5 font-mono text-[11px] text-amber-200/50 whitespace-pre-wrap">{b.text}</div>
|
|
1520
|
+
</div>
|
|
1521
|
+
);
|
|
1522
|
+
}
|
|
1523
|
+
// media
|
|
1524
|
+
return (
|
|
1525
|
+
<div class="my-1 inline-block text-[11px] font-mono text-sky-400/80 bg-sky-950/20 border border-sky-900/40 rounded px-2 py-0.5">
|
|
1526
|
+
📎 {b.mediaType}
|
|
1527
|
+
</div>
|
|
1528
|
+
);
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
function TextBlockView(props: { block: Extract<UiBlock, { kind: 'text' }> }) {
|
|
1532
|
+
// While streaming: plain pre-wrap text — no markdown reparse per token.
|
|
1533
|
+
// Once final: parse once via memo (recomputes only if text changes again).
|
|
1534
|
+
const html = createMemo(() => renderMarkdown(props.block.text));
|
|
1535
|
+
return (
|
|
1536
|
+
<Show
|
|
1537
|
+
when={!props.block.streaming}
|
|
1538
|
+
fallback={
|
|
1539
|
+
<div class="font-mono text-sm whitespace-pre-wrap text-neutral-100">{props.block.text}</div>
|
|
1540
|
+
}
|
|
1541
|
+
>
|
|
1542
|
+
<div class="prose-mini text-neutral-100" innerHTML={html()} />
|
|
1543
|
+
<Show when={props.block.truncated}>
|
|
1544
|
+
<div class="text-[10px] font-mono text-neutral-600 italic">· truncated for display ·</div>
|
|
1545
|
+
</Show>
|
|
1546
|
+
</Show>
|
|
1547
|
+
);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
function ThinkingBlockView(props: {
|
|
1551
|
+
block: Extract<UiBlock, { kind: 'thinking' }>;
|
|
1552
|
+
blockKey: string;
|
|
1553
|
+
}) {
|
|
1554
|
+
// Expanded while streaming (watch the mind move), collapsed once done —
|
|
1555
|
+
// unless the operator pinned it open.
|
|
1556
|
+
const open = (): boolean => props.block.streaming === true || expandedBlocks().has(props.blockKey);
|
|
1557
|
+
return (
|
|
1558
|
+
<div class="my-1.5 border-l-2 border-violet-800/60 pl-2">
|
|
1559
|
+
<button
|
|
1560
|
+
type="button"
|
|
1561
|
+
class="text-[11px] font-mono text-violet-400/80 hover:text-violet-300 flex items-center gap-1.5"
|
|
1562
|
+
onClick={() => toggleBlock(props.blockKey)}
|
|
1563
|
+
>
|
|
1564
|
+
<span>{open() ? '▾' : '▸'}</span>
|
|
1565
|
+
<span>💭 thinking · {props.block.text.length.toLocaleString()} chars</span>
|
|
1566
|
+
<Show when={props.block.streaming}>
|
|
1567
|
+
<span class="animate-pulse text-violet-300">▍</span>
|
|
1568
|
+
</Show>
|
|
1569
|
+
<Show when={props.block.truncated}>
|
|
1570
|
+
<span class="text-neutral-600 italic">truncated</span>
|
|
1571
|
+
</Show>
|
|
1572
|
+
</button>
|
|
1573
|
+
<Show when={open()}>
|
|
1574
|
+
<div class="mt-1 text-[13px] leading-relaxed text-violet-200/60 italic whitespace-pre-wrap">
|
|
1575
|
+
{props.block.text}
|
|
1576
|
+
</div>
|
|
1577
|
+
</Show>
|
|
1578
|
+
</div>
|
|
1579
|
+
);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
function ToolBlockView(props: {
|
|
1583
|
+
block: Extract<UiBlock, { kind: 'tool_use' }>;
|
|
1584
|
+
blockKey: string;
|
|
1585
|
+
result?: ToolResultInfo;
|
|
1586
|
+
}) {
|
|
1587
|
+
const open = (): boolean => expandedBlocks().has(props.blockKey);
|
|
1588
|
+
const dot = (): string => {
|
|
1589
|
+
if (props.block.status === 'running') return 'bg-amber-400 animate-pulse';
|
|
1590
|
+
if (props.block.status === 'failed' || props.result?.isError) return 'bg-rose-500';
|
|
1591
|
+
if (props.block.status === 'done' || props.result) return 'bg-emerald-500';
|
|
1592
|
+
return 'bg-neutral-600';
|
|
1593
|
+
};
|
|
1594
|
+
const duration = (): string => {
|
|
1595
|
+
const ms = props.block.durationMs;
|
|
1596
|
+
if (ms === undefined) return '';
|
|
1597
|
+
return ms < 1000 ? ` · ${ms}ms` : ` · ${(ms / 1000).toFixed(1)}s`;
|
|
1598
|
+
};
|
|
1599
|
+
return (
|
|
1600
|
+
<div class="my-1">
|
|
1601
|
+
<button
|
|
1602
|
+
type="button"
|
|
1603
|
+
class="font-mono text-xs text-amber-400 bg-amber-950/20 hover:bg-amber-950/40 border border-amber-900/30 px-2 py-1 rounded flex items-center gap-2"
|
|
1604
|
+
onClick={() => toggleBlock(props.blockKey)}
|
|
1605
|
+
>
|
|
1606
|
+
<span class={`w-1.5 h-1.5 rounded-full ${dot()}`} />
|
|
1607
|
+
<span>{open() ? '▾' : '▸'} {props.block.name}{duration()}</span>
|
|
1608
|
+
<Show when={props.result?.isError}>
|
|
1609
|
+
<span class="text-rose-400">error</span>
|
|
1610
|
+
</Show>
|
|
1611
|
+
</button>
|
|
1612
|
+
<Show when={open()}>
|
|
1613
|
+
<div class="mt-1 ml-3 space-y-1">
|
|
1614
|
+
<div class="text-[10px] uppercase tracking-wider text-neutral-600">
|
|
1615
|
+
input{props.block.truncated ? ' · truncated' : ''}
|
|
1616
|
+
</div>
|
|
1617
|
+
<ToolPayload raw={props.block.inputJson} />
|
|
1618
|
+
<Show when={props.result} fallback={
|
|
1619
|
+
<div class="text-[11px] font-mono text-neutral-600 italic">
|
|
1620
|
+
{props.block.status === 'running' ? 'running…' : 'no result loaded'}
|
|
1621
|
+
</div>
|
|
1622
|
+
}>
|
|
1623
|
+
<div class="text-[10px] uppercase tracking-wider text-neutral-600">
|
|
1624
|
+
result{props.result!.isError ? ' · error' : ''}{props.result!.truncated ? ' · truncated' : ''}
|
|
1625
|
+
</div>
|
|
1626
|
+
<ToolPayload raw={props.result!.text} isError={props.result!.isError} />
|
|
1627
|
+
</Show>
|
|
1628
|
+
</div>
|
|
1629
|
+
</Show>
|
|
1630
|
+
</div>
|
|
1631
|
+
);
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
function MediaChips(props: { blocks?: UiBlock[] }) {
|
|
1635
|
+
return (
|
|
1636
|
+
<For each={(props.blocks ?? []).filter((b) => b.kind === 'media')}>{(b) => (
|
|
1637
|
+
<div class="mt-1 inline-block mr-1 text-[11px] font-mono text-sky-400/80 bg-sky-950/20 border border-sky-900/40 rounded px-2 py-0.5">
|
|
1638
|
+
📎 {(b as Extract<UiBlock, { kind: 'media' }>).mediaType}
|
|
1639
|
+
</div>
|
|
1640
|
+
)}</For>
|
|
1641
|
+
);
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
interface CommandHint {
|
|
1645
|
+
name: string;
|
|
1646
|
+
blurb: string;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
const COMMANDS: CommandHint[] = [
|
|
1650
|
+
{ name: '/help', blurb: 'list commands' },
|
|
1651
|
+
{ name: '/status', blurb: 'agent + branch status' },
|
|
1652
|
+
{ name: '/recipe', blurb: 'current recipe info' },
|
|
1653
|
+
{ name: '/usage', blurb: 'session token usage' },
|
|
1654
|
+
{ name: '/budget', blurb: 'show or set stream token budget' },
|
|
1655
|
+
{ name: '/branches', blurb: 'list Chronicle branches' },
|
|
1656
|
+
{ name: '/checkout', blurb: 'switch to named branch' },
|
|
1657
|
+
{ name: '/checkpoint', blurb: 'save current state as named checkpoint' },
|
|
1658
|
+
{ name: '/restore', blurb: 'switch to checkpoint' },
|
|
1659
|
+
{ name: '/undo', blurb: 'revert before last agent turn' },
|
|
1660
|
+
{ name: '/redo', blurb: 're-apply last undone action' },
|
|
1661
|
+
{ name: '/history', blurb: 'recent messages' },
|
|
1662
|
+
{ name: '/lessons', blurb: 'list active lessons' },
|
|
1663
|
+
{ name: '/export', blurb: 'export lessons to ./output/' },
|
|
1664
|
+
{ name: '/session', blurb: 'list/new/switch/rename/delete sessions' },
|
|
1665
|
+
{ name: '/newtopic', blurb: 'reset head window with summary' },
|
|
1666
|
+
{ name: '/mcp', blurb: 'list/add/remove/env MCPL servers' },
|
|
1667
|
+
{ name: '/fleet', blurb: 'list/peek/stop/restart fleet children' },
|
|
1668
|
+
{ name: '/clear', blurb: 'clear conversation display' },
|
|
1669
|
+
{ name: '/quit', blurb: 'export lessons + exit' },
|
|
1670
|
+
];
|
|
1671
|
+
|
|
1672
|
+
function CommandSuggestions(props: { draft: string; onPick: (cmd: string) => void }) {
|
|
1673
|
+
const filtered = (): CommandHint[] => {
|
|
1674
|
+
const d = props.draft.trim();
|
|
1675
|
+
if (!d.startsWith('/')) return [];
|
|
1676
|
+
const head = d.split(/\s/)[0]!.toLowerCase();
|
|
1677
|
+
if (d.includes(' ')) return [];
|
|
1678
|
+
return COMMANDS.filter(c => c.name.startsWith(head)).slice(0, 8);
|
|
1679
|
+
};
|
|
1680
|
+
|
|
1681
|
+
return (
|
|
1682
|
+
<Show when={filtered().length > 0}>
|
|
1683
|
+
<div class="absolute bottom-full left-0 right-0 mx-4 mb-1 bg-neutral-900 border border-neutral-800 rounded shadow-lg max-h-60 overflow-y-auto">
|
|
1684
|
+
<For each={filtered()}>{(c) => (
|
|
1685
|
+
<button
|
|
1686
|
+
type="button"
|
|
1687
|
+
class="w-full text-left px-3 py-1.5 hover:bg-neutral-800 flex items-center gap-3 text-xs"
|
|
1688
|
+
onClick={() => props.onPick(c.name)}
|
|
1689
|
+
>
|
|
1690
|
+
<span class="font-mono text-neutral-200 w-24">{c.name}</span>
|
|
1691
|
+
<span class="text-neutral-500">{c.blurb}</span>
|
|
1692
|
+
</button>
|
|
1693
|
+
)}</For>
|
|
1694
|
+
</div>
|
|
1695
|
+
</Show>
|
|
1696
|
+
);
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
function SidebarTabs(props: {
|
|
1700
|
+
current: 'tree' | 'lessons' | 'mcp' | 'files' | 'context';
|
|
1701
|
+
onSelect: (tab: 'tree' | 'lessons' | 'mcp' | 'files' | 'context') => void;
|
|
1702
|
+
}) {
|
|
1703
|
+
const tabs: Array<{ id: 'tree' | 'lessons' | 'mcp' | 'files' | 'context'; label: string; title: string }> = [
|
|
1704
|
+
{ id: 'tree', label: 'Tree', title: 'Agent + fleet tree' },
|
|
1705
|
+
{ id: 'lessons', label: 'Lessons', title: 'Lesson library' },
|
|
1706
|
+
{ id: 'mcp', label: 'MCP', title: 'MCPL servers' },
|
|
1707
|
+
{ id: 'files', label: 'Files', title: 'Workspace mounts + files' },
|
|
1708
|
+
{ id: 'context', label: 'Context', title: 'Compiled context makeup' },
|
|
1709
|
+
];
|
|
1710
|
+
return (
|
|
1711
|
+
<div class="flex border-b border-neutral-800 bg-neutral-900/40 text-[11px]">
|
|
1712
|
+
<For each={tabs}>{(t) => (
|
|
1713
|
+
<button
|
|
1714
|
+
type="button"
|
|
1715
|
+
class={`flex-1 px-2 py-1.5 font-mono ${
|
|
1716
|
+
props.current === t.id
|
|
1717
|
+
? 'text-neutral-100 bg-neutral-900 border-b border-cyan-700'
|
|
1718
|
+
: 'text-neutral-500 hover:text-neutral-300 hover:bg-neutral-900/60'
|
|
1719
|
+
}`}
|
|
1720
|
+
title={t.title}
|
|
1721
|
+
onClick={() => props.onSelect(t.id)}
|
|
1722
|
+
>
|
|
1723
|
+
{t.label}
|
|
1724
|
+
</button>
|
|
1725
|
+
)}</For>
|
|
1726
|
+
</div>
|
|
1727
|
+
);
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
/** Stub used while a tab's full content isn't shipped yet. Replaced as each
|
|
1731
|
+
* panel lands. */
|
|
1732
|
+
function PlaceholderPanel(props: { label: string }) {
|
|
1733
|
+
return (
|
|
1734
|
+
<div class="px-3 py-3 text-xs text-neutral-600 italic">
|
|
1735
|
+
{props.label} — coming soon.
|
|
1736
|
+
</div>
|
|
1737
|
+
);
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
function QuitConfirmModal(props: {
|
|
1741
|
+
childNames: string[];
|
|
1742
|
+
onAction: (action: 'kill-children' | 'detach' | 'cancel') => void;
|
|
1743
|
+
}) {
|
|
1744
|
+
// Centered overlay rather than a corner banner — quit is a deliberate
|
|
1745
|
+
// action, so the dialog should command attention.
|
|
1746
|
+
return (
|
|
1747
|
+
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
|
1748
|
+
<div class="bg-neutral-900 border border-neutral-700 rounded-lg shadow-2xl max-w-md w-full mx-4 p-5">
|
|
1749
|
+
<div class="text-amber-300 text-sm font-semibold mb-2">
|
|
1750
|
+
Quit requested
|
|
1751
|
+
</div>
|
|
1752
|
+
<p class="text-neutral-300 text-sm mb-3">
|
|
1753
|
+
{props.childNames.length === 1
|
|
1754
|
+
? `1 fleet child is still running:`
|
|
1755
|
+
: `${props.childNames.length} fleet children are still running:`}
|
|
1756
|
+
</p>
|
|
1757
|
+
<ul class="text-xs font-mono text-neutral-400 mb-4 space-y-0.5">
|
|
1758
|
+
<For each={props.childNames}>{(n) => <li>· {n}</li>}</For>
|
|
1759
|
+
</ul>
|
|
1760
|
+
<div class="grid grid-cols-1 gap-2">
|
|
1761
|
+
<button
|
|
1762
|
+
type="button"
|
|
1763
|
+
class="px-3 py-1.5 rounded bg-rose-900/40 hover:bg-rose-900/60 text-rose-100 text-sm text-left"
|
|
1764
|
+
onClick={() => props.onAction('kill-children')}
|
|
1765
|
+
title="Stop each child gracefully, then exit."
|
|
1766
|
+
>
|
|
1767
|
+
<span class="font-semibold">Stop children & exit</span>
|
|
1768
|
+
<span class="text-rose-200/70 text-xs ml-2">graceful kill, then host shuts down</span>
|
|
1769
|
+
</button>
|
|
1770
|
+
<button
|
|
1771
|
+
type="button"
|
|
1772
|
+
class="px-3 py-1.5 rounded bg-amber-900/30 hover:bg-amber-900/50 text-amber-100 text-sm text-left"
|
|
1773
|
+
onClick={() => props.onAction('detach')}
|
|
1774
|
+
title="Leave the children running; the host process exits anyway."
|
|
1775
|
+
>
|
|
1776
|
+
<span class="font-semibold">Detach & exit</span>
|
|
1777
|
+
<span class="text-amber-200/70 text-xs ml-2">children orphan, host exits</span>
|
|
1778
|
+
</button>
|
|
1779
|
+
<button
|
|
1780
|
+
type="button"
|
|
1781
|
+
class="px-3 py-1.5 rounded bg-neutral-800 hover:bg-neutral-700 text-neutral-200 text-sm text-left"
|
|
1782
|
+
onClick={() => props.onAction('cancel')}
|
|
1783
|
+
title="Cancel the quit request."
|
|
1784
|
+
>
|
|
1785
|
+
<span class="font-semibold">Cancel</span>
|
|
1786
|
+
<span class="text-neutral-500 text-xs ml-2">stay running</span>
|
|
1787
|
+
</button>
|
|
1788
|
+
</div>
|
|
1789
|
+
</div>
|
|
1790
|
+
</div>
|
|
1791
|
+
);
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
function ReconnectBanner(props: { status: string }) {
|
|
1795
|
+
const [droppedSince, setDroppedSince] = createSignal<number | null>(null);
|
|
1796
|
+
const [now, setNow] = createSignal(Date.now());
|
|
1797
|
+
|
|
1798
|
+
const updateDropTime = (): void => {
|
|
1799
|
+
if (props.status === 'open' || props.status === 'connecting') {
|
|
1800
|
+
setDroppedSince(null);
|
|
1801
|
+
} else if (droppedSince() === null) {
|
|
1802
|
+
setDroppedSince(Date.now());
|
|
1803
|
+
}
|
|
1804
|
+
};
|
|
1805
|
+
const trackStatus = (): void => { void props.status; updateDropTime(); };
|
|
1806
|
+
|
|
1807
|
+
onMount(() => {
|
|
1808
|
+
trackStatus();
|
|
1809
|
+
const interval = setInterval(() => {
|
|
1810
|
+
trackStatus();
|
|
1811
|
+
setNow(Date.now());
|
|
1812
|
+
}, 1000);
|
|
1813
|
+
onCleanup(() => clearInterval(interval));
|
|
1814
|
+
});
|
|
1815
|
+
|
|
1816
|
+
const elapsed = (): string => {
|
|
1817
|
+
const dropped = droppedSince();
|
|
1818
|
+
if (dropped === null) return '';
|
|
1819
|
+
const sec = Math.floor((now() - dropped) / 1000);
|
|
1820
|
+
if (sec < 60) return `${sec}s`;
|
|
1821
|
+
const min = Math.floor(sec / 60);
|
|
1822
|
+
if (min < 60) return `${min}m ${sec % 60}s`;
|
|
1823
|
+
return `${Math.floor(min / 60)}h ${min % 60}m`;
|
|
1824
|
+
};
|
|
1825
|
+
|
|
1826
|
+
return (
|
|
1827
|
+
<Show when={droppedSince() !== null}>
|
|
1828
|
+
<div class="bg-rose-950/60 border-b border-rose-900 px-4 py-1.5 text-xs text-rose-200 flex items-center gap-2">
|
|
1829
|
+
<span class="w-2 h-2 rounded-full bg-rose-500 animate-pulse" />
|
|
1830
|
+
<span>Disconnected — retrying ({elapsed()} elapsed). Check the host process.</span>
|
|
1831
|
+
</div>
|
|
1832
|
+
</Show>
|
|
1833
|
+
);
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
function RecipePane(props: { welcome: WelcomeMessage | null; scope: string }) {
|
|
1837
|
+
/** Pick the displayed recipe based on the current panel scope: parent
|
|
1838
|
+
* process when scope is 'local', otherwise the matching child entry
|
|
1839
|
+
* from welcome.childTrees (recipe summary loaded by the host). */
|
|
1840
|
+
const displayed = (): { name: string; description?: string; agentModel?: string; scopeLabel: string } | null => {
|
|
1841
|
+
if (!props.welcome) return null;
|
|
1842
|
+
if (props.scope === 'local') {
|
|
1843
|
+
return {
|
|
1844
|
+
name: props.welcome.recipe.name,
|
|
1845
|
+
...(props.welcome.recipe.description ? { description: props.welcome.recipe.description } : {}),
|
|
1846
|
+
...(props.welcome.agents[0]?.model ? { agentModel: props.welcome.agents[0].model } : {}),
|
|
1847
|
+
scopeLabel: 'parent',
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1850
|
+
const child = props.welcome.childTrees.find(c => c.name === props.scope);
|
|
1851
|
+
if (!child) return null;
|
|
1852
|
+
if (!child.recipe) {
|
|
1853
|
+
return { name: child.name, scopeLabel: 'child' };
|
|
1854
|
+
}
|
|
1855
|
+
return {
|
|
1856
|
+
name: child.recipe.name,
|
|
1857
|
+
...(child.recipe.description ? { description: child.recipe.description } : {}),
|
|
1858
|
+
...(child.recipe.agentModel ? { agentModel: child.recipe.agentModel } : {}),
|
|
1859
|
+
scopeLabel: `child · ${child.name}`,
|
|
1860
|
+
};
|
|
1861
|
+
};
|
|
1862
|
+
|
|
1863
|
+
return (
|
|
1864
|
+
<div class="border-t border-neutral-800 px-3 py-2 text-[11px] space-y-0.5">
|
|
1865
|
+
<div class="text-neutral-500 uppercase tracking-wider text-[10px] font-semibold flex items-center gap-2">
|
|
1866
|
+
<span>recipe</span>
|
|
1867
|
+
<Show when={displayed()}>
|
|
1868
|
+
<span class="text-neutral-600 normal-case tracking-normal">· {displayed()!.scopeLabel}</span>
|
|
1869
|
+
</Show>
|
|
1870
|
+
</div>
|
|
1871
|
+
<Show when={displayed()} fallback={<div class="text-neutral-600 italic">…</div>}>
|
|
1872
|
+
<div class="text-neutral-300">{displayed()!.name}</div>
|
|
1873
|
+
<Show when={displayed()!.description}>
|
|
1874
|
+
<div class="text-neutral-500 truncate" title={displayed()!.description}>
|
|
1875
|
+
{displayed()!.description}
|
|
1876
|
+
</div>
|
|
1877
|
+
</Show>
|
|
1878
|
+
<Show when={displayed()!.agentModel}>
|
|
1879
|
+
<div class="text-neutral-600 font-mono">
|
|
1880
|
+
{displayed()!.agentModel}
|
|
1881
|
+
</div>
|
|
1882
|
+
</Show>
|
|
1883
|
+
</Show>
|
|
1884
|
+
</div>
|
|
1885
|
+
);
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
function lineStyleClass(style?: string): string {
|
|
1889
|
+
switch (style) {
|
|
1890
|
+
case 'user': return 'text-emerald-400';
|
|
1891
|
+
case 'agent': return 'text-neutral-100';
|
|
1892
|
+
case 'tool': return 'text-amber-400';
|
|
1893
|
+
case 'system': return 'text-neutral-300';
|
|
1894
|
+
default: return 'text-neutral-300';
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
function parseRoute(input: string): { childName: string; content: string } | null {
|
|
1899
|
+
const trimmed = input.trimStart();
|
|
1900
|
+
if (!trimmed.startsWith('@') || trimmed.startsWith('@@')) return null;
|
|
1901
|
+
const m = /^@([a-zA-Z0-9_.-]+)(?::|\s)\s*([\s\S]+)$/.exec(trimmed);
|
|
1902
|
+
if (!m) return null;
|
|
1903
|
+
const content = m[2]!.trim();
|
|
1904
|
+
if (!content) return null;
|
|
1905
|
+
return { childName: m[1]!, content };
|
|
1906
|
+
}
|
|
1907
|
+
|
|
1908
|
+
marked.setOptions({ async: false, breaks: false, gfm: true });
|
|
1909
|
+
|
|
1910
|
+
// marked v14 has no built-in sanitizer (the `sanitize` option was removed),
|
|
1911
|
+
// and assistant output is the *primary* untrusted input on this admin
|
|
1912
|
+
// surface — a tool result, mined-knowledge ingestion, or peeked subagent
|
|
1913
|
+
// line that contains literal HTML lands here. Pipe through DOMPurify before
|
|
1914
|
+
// innerHTML so <script>, <img onerror=...>, javascript: hrefs, etc. are
|
|
1915
|
+
// stripped. The default DOMPurify config already drops <script>, on*
|
|
1916
|
+
// handlers, and dangerous URL schemes.
|
|
1917
|
+
function renderMarkdown(src: string): string {
|
|
1918
|
+
if (!src) return '';
|
|
1919
|
+
try {
|
|
1920
|
+
const html = marked.parse(src) as string;
|
|
1921
|
+
return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
|
|
1922
|
+
} catch {
|
|
1923
|
+
return escapeHtml(src);
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
function escapeHtml(s: string): string {
|
|
1928
|
+
return s.replace(/[&<>"']/g, (c) => ({
|
|
1929
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
1930
|
+
})[c]!);
|
|
1931
|
+
}
|