anentrypoint-design 0.0.385 → 0.0.387

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.
Files changed (71) hide show
  1. package/dist/247420.css +33 -0
  2. package/dist/247420.js +53 -51
  3. package/package.json +1 -1
  4. package/src/components/agent-chat/controls.js +143 -0
  5. package/src/components/agent-chat/empty-state.js +63 -0
  6. package/src/components/agent-chat/message-rows.js +141 -0
  7. package/src/components/agent-chat/surface.js +206 -0
  8. package/src/components/agent-chat/thread-behaviour.js +50 -0
  9. package/src/components/agent-chat.js +6 -546
  10. package/src/components/chat/composer-affordances.js +109 -0
  11. package/src/components/chat/composer.js +256 -0
  12. package/src/components/chat/threads.js +105 -0
  13. package/src/components/chat-message-parts/agent-nodes.js +103 -0
  14. package/src/components/chat-message-parts/inline.js +106 -0
  15. package/src/components/chat-message-parts/prose-nodes.js +133 -0
  16. package/src/components/chat-message-parts/renderers.js +89 -0
  17. package/src/components/chat-message-parts.js +12 -408
  18. package/src/components/chat-minimap/minimap.js +167 -0
  19. package/src/components/chat-minimap/paint.js +73 -0
  20. package/src/components/chat-minimap/preview.js +41 -0
  21. package/src/components/chat-minimap.js +7 -263
  22. package/src/components/chat.js +20 -628
  23. package/src/components/community/chrome.js +63 -0
  24. package/src/components/community/navigation.js +167 -0
  25. package/src/components/community/presence.js +106 -0
  26. package/src/components/community/shell.js +17 -0
  27. package/src/components/community/views.js +131 -0
  28. package/src/components/community.js +18 -447
  29. package/src/components/files/chrome.js +140 -0
  30. package/src/components/files/entries.js +156 -0
  31. package/src/components/files/grid-controls.js +63 -0
  32. package/src/components/files/grid.js +177 -0
  33. package/src/components/files/types.js +69 -0
  34. package/src/components/files-modals/dialogs.js +118 -0
  35. package/src/components/files-modals/modal-shell.js +153 -0
  36. package/src/components/files-modals/preview-bodies.js +129 -0
  37. package/src/components/files-modals/preview-containers.js +96 -0
  38. package/src/components/files-modals.js +14 -475
  39. package/src/components/files.js +15 -564
  40. package/src/components/freddie/pages-config.js +3 -94
  41. package/src/components/freddie/pages-models.js +97 -0
  42. package/src/components/freddie.js +2 -1
  43. package/src/components/interaction-primitives/mobile.js +25 -0
  44. package/src/components/interaction-primitives/pointer.js +214 -0
  45. package/src/components/interaction-primitives/reorderable.js +47 -0
  46. package/src/components/interaction-primitives/shortcuts.js +119 -0
  47. package/src/components/interaction-primitives.js +16 -388
  48. package/src/components/sessions/conversation-list.js +230 -0
  49. package/src/components/sessions/dashboard.js +202 -0
  50. package/src/components/sessions/detail-bits.js +49 -0
  51. package/src/components/sessions/format.js +42 -0
  52. package/src/components/sessions/session-card.js +92 -0
  53. package/src/components/sessions.js +16 -579
  54. package/src/components/voice/audio-cue.js +39 -0
  55. package/src/components/voice/capture.js +90 -0
  56. package/src/components/voice/playback.js +75 -0
  57. package/src/components/voice/settings-modal.js +104 -0
  58. package/src/components/voice.js +16 -293
  59. package/src/css/app-shell/base.css +23 -0
  60. package/src/css/app-shell/states-interactions.css +10 -0
  61. package/src/kits/os/freddie/chat-protocol.js +32 -0
  62. package/src/kits/os/freddie/chat-transport.js +178 -0
  63. package/src/kits/os/freddie/pages-chat.js +10 -180
  64. package/src/kits/os/shell-chrome.js +163 -0
  65. package/src/kits/os/shell-geometry.js +59 -0
  66. package/src/kits/os/shell.js +178 -335
  67. package/src/page-html/client-script.js +151 -0
  68. package/src/page-html/head-tags.js +59 -0
  69. package/src/page-html/markdown.js +68 -0
  70. package/src/page-html/page-styles.js +44 -0
  71. package/src/page-html.js +14 -291
@@ -9,552 +9,12 @@
9
9
  // reusable by any app, not just agentgui.
10
10
  //
11
11
  // The host owns state; AgentChat renders it and calls back on intent.
12
-
13
- import * as webjsx from '../../vendor/webjsx/index.js';
14
- import { ChatComposer, ChatMessage, makeThreadAutoScroll } from './chat.js';
15
- import { Select } from './content.js';
16
- import { Btn, Icon } from './shell.js';
17
- import { BreadcrumbPath } from './files.js';
18
- import { SplitPanel } from './editor-primitives.js';
19
- import { initializeCachesEagerly } from '../markdown-cache.js';
20
- import { ChatMinimap } from './chat-minimap.js';
21
-
22
- const h = webjsx.createElement;
23
-
24
- // Auto-scroll behaviour is the shared chat helper; bind it to this thread's
25
- // live message count. (`makeThreadAutoScroll` takes a getter so the observer
26
- // always compares against current state, not a value captured at mount.)
27
- const baseAutoScroll = (msgCount) => makeThreadAutoScroll(() => msgCount);
28
-
29
- // Compose the auto-scroll ref with a scroll listener that reveals the
30
- // jump-to-latest button when the user has scrolled away from the bottom. This
31
- // is the scroll-anchoring fix: auto-scroll only pins when the user is already at
32
- // the bottom (the IntersectionObserver gate), so reading back-history is no
33
- // longer fought; the button is the explicit way back to the live edge.
34
- const NEAR_BOTTOM_PX = 80;
35
-
36
- // Thread window: how many trailing turns render by default (hosts override via
37
- // shownMessages; grow with onShowEarlier).
38
- export const MESSAGE_CAP = 100;
39
- // A single streaming message beyond this many chars renders only a tail window
40
- // per frame (O(tail), not O(turn)); the settled turn renders full markdown once.
41
- const STREAM_TAIL_THRESHOLD = 20000;
42
- const STREAM_TAIL_WINDOW = 4000;
43
- const threadRef = (msgCount) => {
44
- const auto = baseAutoScroll(msgCount);
45
- return (el) => {
46
- if (!el) return;
47
- const disposeAuto = auto(el);
48
- const jumpBtn = () => el.parentElement && el.parentElement.querySelector('.agentchat-jump');
49
- const update = () => {
50
- const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX;
51
- const btn = jumpBtn();
52
- if (btn) btn.classList.toggle('show', !atBottom);
53
- };
54
- el.addEventListener('scroll', update, { passive: true });
55
- requestAnimationFrame(update);
56
- return () => { el.removeEventListener('scroll', update); if (typeof disposeAuto === 'function') disposeAuto(); };
57
- };
58
- };
59
-
60
- // Scroll a thread to its live edge — used by the jump-to-latest button.
61
- function scrollThreadToBottom(btn) {
62
- const wrap = btn.closest('.agentchat-thread-wrap');
63
- const thread = wrap && wrap.querySelector('.agentchat-thread');
64
- if (thread) thread.scrollTop = thread.scrollHeight;
65
- }
66
-
67
- // The agent picker: agent-then-model, not a flat model list. Unavailable agents
68
- // are disabled (unless installable via npx). Ordering is the host's concern.
69
- function AgentControls({ agents, selectedAgent, models, selectedModel, busy, status, modelsLoading, agentsLoading,
70
- onSelectAgent, onSelectModel, onNewChat, onStop, exportActions }) {
71
- const agentOptions = (agents || []).map((a) => ({
72
- value: a.id,
73
- label: a.name + (a.available === false ? (a.npxInstallable ? ' (via npx)' : ' (not installed)') : ''),
74
- disabled: a.available === false && !a.npxInstallable,
75
- }));
76
- const showModels = (models || []).length > 0;
77
- return h('div', { class: 'agentchat-controls' },
78
- // While agents load on first boot, show a disabled "loading…" placeholder
79
- // instead of an empty options list, which is indistinguishable from "this
80
- // app has no agents configured" (mirrors the models-loading branch below).
81
- (agentsLoading && !agentOptions.length)
82
- ? Select({ key: 'agentsel', value: '', placeholder: 'loading agents…', title: 'Loading agents', disabled: true, options: [] })
83
- : Select({
84
- key: 'agentsel', value: selectedAgent, placeholder: '— agent —',
85
- title: 'Select agent', options: agentOptions,
86
- onChange: (v) => onSelectAgent && onSelectAgent(v),
87
- }),
88
- // While models load for a freshly-picked agent, show a disabled "loading…"
89
- // placeholder so the picker doesn't vanish then reappear (a layout flash).
90
- showModels
91
- ? Select({
92
- key: 'modelsel', value: selectedModel, placeholder: '— model —',
93
- title: 'Select model', options: (models || []).map((m) => ({ value: m.id, label: m.name || m.id })),
94
- onChange: (v) => onSelectModel && onSelectModel(v),
95
- })
96
- : (modelsLoading
97
- ? Select({ key: 'modelsel', value: '', placeholder: 'loading models…', title: 'Loading models', disabled: true, options: [] })
98
- : null),
99
- busy
100
- ? Btn({ key: 'stop', onClick: () => onStop && onStop(), children: 'stop', title: 'Stop streaming' })
101
- : Btn({ key: 'new', onClick: () => onNewChat && onNewChat(), children: 'new', title: 'New chat' }),
102
- h('span', { key: 'st', class: 'agentchat-status', role: 'status', 'aria-live': 'polite', 'aria-atomic': 'true' },
103
- h('span', { class: 'status-dot-disc ' + (busy ? 'status-dot-live' : ''), 'aria-hidden': 'true' }),
104
- h('span', {}, status || (busy ? 'streaming…' : 'ready'))),
105
- // Host-supplied transcript actions (copy-all / export-md / export-json):
106
- // small text-labeled buttons riding the same controls row. All siblings in
107
- // this h(...) call are keyed VElements or null — never bare strings.
108
- ...(exportActions && exportActions.length
109
- ? exportActions.map((a, i) => h('button', {
110
- key: 'exp' + i, type: 'button', class: 'agentchat-export-act',
111
- title: a.title || a.label,
112
- onclick: () => a.onClick && a.onClick(),
113
- }, a.label))
114
- : []),
115
- );
116
- }
117
-
118
- // A working-directory bar: shows where the agent will run, editable + clearable.
119
- // `error`/`checking` give inline validation feedback while typing/blur (the host
120
- // debounces its /api/stat probe and sets these): a plain-language line renders
121
- // under the input (aria-describedby) and save stays disabled while either is set.
122
12
  //
123
- // Practicality upgrade: setting cwd by typing an exact absolute path from memory
124
- // was the only path (a fresh user has no way to discover what's even browsable,
125
- // and a regular user re-types/re-remembers the same handful of paths every
126
- // session). Three additive affordances, all optional/host-driven so a host that
127
- // doesn't wire them keeps the old text-only behavior:
128
- // - `roots` (fsAllowRoots-equivalent): one-click starting points, always
129
- // visible while editing, not buried in a separate Files-tab-only picker.
130
- // - `recent` (host's own small MRU list, e.g. localStorage-backed): one-click
131
- // chips for the last few cwds actually used, so switching between a
132
- // regular handful of working directories needs no typing at all.
133
- // - `browse` (host-driven inline directory listing): clicking "browse" asks
134
- // the host (via onBrowse) to list a directory's subdirectories (reusing
135
- // the same confined listing endpoint the Files tab already uses), and
136
- // renders them as a breadcrumb + clickable dir list right in the composer
137
- // — no round-trip through the Files tab and back required anymore.
138
- function CwdBar({ cwd, editing, draft, onEdit, onSave, onCancel, onClear, onDraft, error, checking,
139
- roots, recent, browse, onBrowseCrumb, onBrowseEnter, onBrowsePick, onBrowseToggle }) {
140
- if (editing) {
141
- const hint = checking ? 'checking…' : (error || null);
142
- const rootsRow = (roots && roots.length)
143
- ? h('div', { key: 'roots', class: 'agentchat-cwd-roots', role: 'group', 'aria-label': 'accessible folders' },
144
- ...roots.map((r, i) => h('button', {
145
- key: 'root' + i, type: 'button', class: 'agentchat-cwd-chip',
146
- onclick: () => onDraft && onDraft(r.path || r),
147
- }, r.label || r.path || r)))
148
- : null;
149
- const recentRow = (recent && recent.length)
150
- ? h('div', { key: 'recent', class: 'agentchat-cwd-recent', role: 'group', 'aria-label': 'recently used folders' },
151
- h('span', { key: 'rlbl', class: 'agentchat-cwd-recent-label' }, 'recent:'),
152
- ...recent.map((r, i) => h('button', {
153
- key: 'rec' + i, type: 'button', class: 'agentchat-cwd-chip',
154
- title: r, onclick: () => onDraft && onDraft(r),
155
- }, r.split(/[/\\]/).filter(Boolean).slice(-1)[0] || r)))
156
- : null;
157
- const browseToggle = onBrowseToggle
158
- ? h('button', { key: 'browsetoggle', type: 'button', class: 'agentchat-cwd-btn agentchat-cwd-browse-toggle',
159
- 'aria-expanded': browse ? 'true' : 'false',
160
- onclick: () => onBrowseToggle() }, browse ? 'hide browser' : 'browse…')
161
- : null;
162
- const browsePanel = (browse && browse.entries)
163
- ? h('div', { key: 'browsepanel', class: 'agentchat-cwd-browse', role: 'group', 'aria-label': 'browse folders' },
164
- browse.segments ? BreadcrumbPath({ segments: browse.segments, root: browse.rootLabel || 'root', onNav: (i) => onBrowseCrumb && onBrowseCrumb(i) }) : null,
165
- h('div', { key: 'browselist', class: 'agentchat-cwd-browse-list', role: 'listbox', 'aria-label': 'subdirectories' },
166
- browse.loading
167
- ? h('div', { key: 'browseloading', class: 'agentchat-cwd-browse-loading', role: 'status' }, 'loading…')
168
- : (browse.entries.length
169
- ? browse.entries.map((e, i) => h('button', {
170
- key: 'be' + i, type: 'button', class: 'agentchat-cwd-browse-item', role: 'option',
171
- onclick: () => onBrowseEnter && onBrowseEnter(e.path || e.name),
172
- }, e.name || e.path))
173
- : h('div', { key: 'browseempty', class: 'agentchat-cwd-browse-empty' }, 'no subfolders here'))),
174
- h('button', { key: 'browseuse', type: 'button', class: 'agentchat-cwd-btn', onclick: () => onBrowsePick && onBrowsePick(browse.current) }, 'use this folder'))
175
- : null;
176
- return h('div', { class: 'agentchat-cwd agentchat-cwd-editing', role: 'group', 'aria-label': 'Set working directory' },
177
- h('div', { key: 'row1', class: 'agentchat-cwd-row' },
178
- h('input', { class: 'agentchat-cwd-input', type: 'text', value: draft ?? cwd ?? '',
179
- placeholder: 'absolute path (blank = server default)',
180
- 'aria-describedby': hint ? 'agentchat-cwd-hint' : null,
181
- 'aria-invalid': error ? 'true' : null,
182
- 'aria-busy': checking ? 'true' : null,
183
- oninput: (e) => onDraft && onDraft(e.target.value) }),
184
- browseToggle,
185
- Btn({ key: 'cancel', onClick: () => onCancel && onCancel(), children: 'cancel' }),
186
- Btn({ key: 'save', variant: 'primary', disabled: !!(error || checking), onClick: () => onSave && onSave(), children: 'save' })),
187
- rootsRow,
188
- recentRow,
189
- browsePanel,
190
- hint ? h('span', { key: 'hint', id: 'agentchat-cwd-hint', role: 'status', 'aria-live': 'polite',
191
- class: 'agentchat-cwd-hint' + (error ? ' is-error' : ' is-checking') }, hint) : null);
192
- }
193
- return h('div', { class: 'agentchat-cwd', role: 'group', 'aria-label': 'Working directory' },
194
- h('span', { class: 'agentchat-cwd-text', title: cwd || 'server default working directory' },
195
- 'cwd: ' + (cwd || 'server default')),
196
- h('button', { type: 'button', class: 'agentchat-cwd-btn', onclick: () => onEdit && onEdit() }, cwd ? 'change' : 'set'),
197
- cwd ? h('button', { type: 'button', class: 'agentchat-cwd-btn', onclick: () => onClear && onClear() }, 'use default') : null);
198
- }
199
-
200
- // AgentChat — the composed surface.
201
- // agents, selectedAgent, models, selectedModel : picker state
202
- // messages : [{ id, role:'user'|'assistant', content, time, parts:[string] }]
203
- // busy, draft, status : stream + composer state
204
- // cwd, cwdEditing, cwdDraft : working-directory bar
205
- // banners : array of pre-built Alert vnodes (errors, resume, unavailable)
206
- // onSelectAgent/onSelectModel/onSend/onStop/onNewChat/onInput
207
- // onCwdEdit/onCwdSave/onCwdCancel/onCwdClear/onCwdDraft
208
- export function AgentChat(props = {}) {
209
- const {
210
- agents = [], selectedAgent = '', models = [], selectedModel = '', modelsLoading = false, agentsLoading = false,
211
- messages = [], busy = false, draft = '', status, banners = [],
212
- cwd = '', cwdEditing = false, cwdDraft, cwdError, cwdChecking = false,
213
- cwdRoots, cwdRecent, cwdBrowse,
214
- agentName, placeholder,
215
- onSelectAgent, onSelectModel, onSend, onStop, onNewChat, onInput,
216
- onCwdEdit, onCwdSave, onCwdCancel, onCwdClear, onCwdDraft,
217
- onCwdBrowseToggle, onCwdBrowseCrumb, onCwdBrowseEnter, onCwdBrowsePick,
218
- canSend = true,
219
- suggestions = [], onSuggestionClick,
220
- onCopyMessage, onRetryMessage, onEditMessage,
221
- confirmEdit = false, onArmEdit,
222
- avatar, composerContext,
223
- followups = [], onFollowupClick,
224
- installHint, exportActions = [],
225
- onPasteFiles, onDropFiles, onEmoji,
226
- shownMessages, onShowEarlier,
227
- streamingSince, detectAttachment,
228
- // @-mention file autocomplete in the composer — a flat list of file paths
229
- // the host already has (e.g. its Files tab data source). Purely forwarded
230
- // to ChatComposer; omitting it keeps every existing caller unchanged (no
231
- // mention affordance appears without it).
232
- mentionFiles,
233
- // Optional scroll-position minimap alongside the thread (ChatMinimap).
234
- // false/omitted keeps every existing caller byte-identical (no minimap
235
- // column at all). true renders the strip using this component's own
236
- // thread ref via a shared getter — no extra DOM wiring needed from the
237
- // host beyond passing showMinimap.
238
- showMinimap = false,
239
- // Optional inline content viewer beside the thread (a docstudio-cue
240
- // addition: its chat view keeps a live document/PDF preview open next to
241
- // the conversation instead of forcing a separate tab/window). The host
242
- // supplies the actual preview vnode (FilePreviewPane/FileViewer or
243
- // anything else) - this component only owns the split layout + a close
244
- // affordance. Omitting sidePanel keeps every existing caller's output
245
- // byte-identical (no SplitPanel wrapper at all when absent).
246
- sidePanel, sidePanelTitle = 'preview', onCloseSidePanel,
247
- } = props;
248
-
249
- // Warm the markdown/Prism stack the moment the surface mounts so the CDN
250
- // round-trip never starts mid-first-response. Self-idempotent (internal
251
- // _initPromise), so the per-render call is free after the first.
252
- initializeCachesEagerly().catch((err) => console.warn('[247420] cache init error:', err));
253
-
254
- const name = agentName || (agents.find((a) => a.id === selectedAgent)?.name) || selectedAgent || 'agent';
255
- const lastIdx = messages.length - 1;
256
- const lastMsg = messages[lastIdx];
257
- // Windowed thread render (mirrors FileGrid's cap): only the last `limit`
258
- // turns build vnodes each frame; a keyed 'show N earlier turns' row at the
259
- // top grows the window via onShowEarlier (host keeps state.chat.shownMessages
260
- // and resets it on newChat/loadSession). A 500-turn conversation no longer
261
- // rebuilds 500 ChatMessage vnodes per streaming rAF tick.
262
- const msgLimit = shownMessages != null ? shownMessages : MESSAGE_CAP;
263
- const msgStart = Math.max(0, messages.length - msgLimit);
264
- // True when streaming but the live assistant turn already shows content/parts,
265
- // so its inline typing dots have stopped — a long silent tool call would
266
- // otherwise read as frozen. We append a standalone "working" indicator below.
267
- // A message carries content (text/parts) when it has a non-empty content
268
- // string OR at least one part. Used for the empty-shell skip + working tail
269
- // so an interleaved turn (parts-only, no m.content) is not treated as empty.
270
- const msgHasBody = (m) => !!(m.content || (Array.isArray(m.parts) && m.parts.length));
271
- const lastMsgLastPart = lastMsg && Array.isArray(lastMsg.parts) && lastMsg.parts.length ? lastMsg.parts[lastMsg.parts.length - 1] : null;
272
- const showWorkingTail = busy && lastMsg && lastMsg.role === 'assistant' && msgHasBody(lastMsg)
273
- && lastMsgLastPart && lastMsgLastPart.kind === 'tool' && lastMsgLastPart.status === 'running';
274
- const rows = messages.slice(msgStart).map((m, wi) => {
275
- const i = wi + msgStart; // absolute index — streaming/caret/actions logic keys off the real lastIdx
276
- const isAssistant = m.role === 'assistant';
277
- const isStreaming = busy && i === lastIdx && isAssistant;
278
- const hasParts = Array.isArray(m.parts) && m.parts.length > 0;
279
- const emptyStreaming = isStreaming && !msgHasBody(m);
280
- // A finished assistant message with no content and no parts is an empty
281
- // shell (e.g. an aborted turn) — render nothing rather than a blank bubble.
282
- if (!isStreaming && isAssistant && !msgHasBody(m)) return null;
283
- // Render order follows m.parts so text and tool cards INTERLEAVE in arrival
284
- // order (text -> tool -> text -> tool). A message's parts may be bare
285
- // strings (legacy) OR structured {kind,...} objects (md/tool/tool_result/
286
- // code/...) passed straight through to ChatMessage.renderPart — this is what
287
- // lets an orchestration host render the kit's collapsible ToolCallNode
288
- // inline instead of flattening tools to the end of the turn.
289
- const parts = [];
290
- if (hasParts) {
291
- for (const p of m.parts) {
292
- const part = (p && typeof p === 'object' && p.kind) ? p : { kind: 'text', text: String(p) };
293
- // While a turn is still streaming, render its prose as cheap inline text
294
- // rather than full markdown: MdNode re-parses + re-sanitizes the WHOLE
295
- // accumulated source and swaps the entire bubble innerHTML on every frame
296
- // (O(n^2) over the turn, with a visible reflow). Downgrade md -> text
297
- // mid-stream; the settled turn below renders real markdown once.
298
- // Carry a `mdShell` flag so the streaming-text bubble uses the same
299
- // container shape (.chat-md padding/spacing) the settled markdown will
300
- // use — only the inner content swaps on settle, so the bubble box does
301
- // not reflow/jump when the turn finishes and renders real markdown.
302
- if (isStreaming && part.kind === 'md') {
303
- const txt = part.text || '';
304
- // Giant streamed block: re-rendering the whole accumulated string per
305
- // rAF is O(n^2) across the turn. Past the threshold, render a preShell
306
- // bubble with a 'streaming · N KB so far' head plus only the last
307
- // STREAM_TAIL_WINDOW chars; full markdown renders once on settle.
308
- if (txt.length > STREAM_TAIL_THRESHOLD) {
309
- parts.push({ kind: 'text', mdShell: true, preShell: true,
310
- text: txt.slice(-STREAM_TAIL_WINDOW),
311
- streamHead: 'streaming · ' + Math.round(txt.length / 1024) + ' KB so far' });
312
- continue;
313
- }
314
- // If the streaming prose contains a code fence, the inline renderer
315
- // (which has no triple-backtick handling) would show it as run-on text
316
- // with literal ``` and no monospace, then snap into a styled <pre> on
317
- // settle (a visible reflow during the most-watched moment). Detect a
318
- // fence and render the bubble as a cheap monospaced <pre> shell instead
319
- // (no Prism mid-stream, so no O(n^2)) so it does not reflow on settle.
320
- if (part.text && part.text.indexOf('```') !== -1) parts.push({ kind: 'text', text: part.text, mdShell: true, preShell: true });
321
- else parts.push({ kind: 'text', text: part.text, mdShell: true });
322
- }
323
- else if (!isStreaming && part.kind === 'thinking') parts.push({ kind: 'thinking', settled: true, text: part.text });
324
- else parts.push(part);
325
- }
326
- }
327
- // m.content is the legacy/simple path (user messages, hosts that don't build
328
- // interleaved parts). Only prepend it when the parts array doesn't already
329
- // carry prose, so a parts-driven turn isn't double-rendered.
330
- const partsHaveProse = parts.some(p => p.kind === 'md' || p.kind === 'text');
331
- if (m.content && !partsHaveProse) parts.unshift({ kind: isAssistant ? 'md' : 'text', text: m.content });
332
- // The streaming caret rides the live assistant turn once it has body (the
333
- // empty-shell turn already shows the inline typing dots).
334
- const streaming = isStreaming && msgHasBody(m);
335
- // Place the caret inline inside the last text/md part rather than as a
336
- // sibling span (which renders as a block below the last bubble). Tag the
337
- // last text part so PART_RENDERERS.text can append it as an inline child.
338
- if (streaming && parts.length) {
339
- const lastPart = parts[parts.length - 1];
340
- if (lastPart && (lastPart.kind === 'text' || lastPart.kind === 'md')) {
341
- parts[parts.length - 1] = { ...lastPart, streamingCaret: true };
342
- }
343
- }
344
- // Per-message actions: the host supplies onCopyMessage / onRetryMessage; we
345
- // build the action row only for SETTLED messages (no actions mid-stream).
346
- let actions;
347
- if (!isStreaming && msgHasBody(m)) {
348
- const built = [];
349
- if (onCopyMessage) built.push({ label: 'copy', icon: 'copy', title: 'copy message', onClick: () => onCopyMessage(m) });
350
- // Mid-thread retry: EVERY settled assistant turn gets a retry action,
351
- // not only the trailing one - the host truncates from that turn's
352
- // position and resends (the same mechanism edit-and-resend uses for
353
- // user messages), so any assistant reply the user was unhappy with can
354
- // be redone without discarding turns that came after a LATER one.
355
- if (isAssistant && onRetryMessage) built.push({ label: 'retry', icon: 'refresh', title: 'retry this turn', onClick: () => onRetryMessage(m) });
356
- // A dangling user message (send failed / no reply arrived) can only be
357
- // the LAST message when it has no assistant reply - retry here means
358
- // "resend as-is", not "redo a specific turn", so stays lastIdx-gated.
359
- if (!isAssistant && onRetryMessage && i === lastIdx) built.push({ label: 'retry', icon: 'refresh', title: 'retry', onClick: () => onRetryMessage(m) });
360
- // With confirmEdit the host arms its own confirm affordance (onArmEdit)
361
- // instead of resending immediately; the kit stays stateless either way.
362
- if (!isAssistant && onEditMessage) built.push({ label: 'edit', icon: 'pencil', title: 'edit and resend',
363
- onClick: () => (confirmEdit && onArmEdit) ? onArmEdit(m) : onEditMessage(m) });
364
- if (built.length) actions = built;
365
- }
366
- return ChatMessage({
367
- key: m.id || String(i),
368
- role: isAssistant ? 'assistant' : 'user',
369
- // Claude-Code-web layout: flat full-width turns (no avatar disc, no colored
370
- // bubble), distinguished by a role label + a faint assistant background.
371
- // aicat is left OFF so the mascot tint never reaches the agent surface.
372
- flat: true,
373
- aicat: false,
374
- // A stable per-agent product mark (host passes a small line-SVG via
375
- // `avatar`) instead of a per-agent letter initial that shifts identity.
376
- avatar: isAssistant ? (m.avatar != null ? m.avatar : avatar) : undefined,
377
- name: isAssistant ? name : 'you',
378
- time: m.time || '',
379
- typing: emptyStreaming,
380
- streaming,
381
- actions,
382
- // Out-of-band notices (plain copy, neutral tone): m.stopped marks a
383
- // cancelled turn; m.incomplete marks a turn whose stream dropped without
384
- // replay. Retry rides the existing actions row.
385
- stopped: m.stopped,
386
- incomplete: m.incomplete,
387
- // A failed trailing turn (the host's send/stream rejected, regardless
388
- // of which role the placeholder message landed on) gets its error
389
- // pinned to that specific turn, with retry right there — same pattern
390
- // as stopped/incomplete, but destructive-toned since this is a genuine
391
- // failure rather than a neutral "not finished" state.
392
- error: i === lastIdx ? m.error : undefined,
393
- onRetry: (i === lastIdx && m.error && onRetryMessage) ? () => onRetryMessage(m) : undefined,
394
- parts: emptyStreaming ? undefined : (parts.length ? parts : [{ kind: 'text', text: '' }]),
395
- });
396
- });
397
- // Keyed 'show N earlier turns' control at the top of the window. A keyed
398
- // VElement like every row sibling (webjsx keying discipline).
399
- const earlierRow = msgStart > 0
400
- ? h('div', { key: '_earlier', class: 'agentchat-earlier' },
401
- h('span', { class: 'agentchat-earlier-count', role: 'status', 'aria-live': 'polite' },
402
- 'showing ' + (messages.length - msgStart) + ' of ' + messages.length + ' turns'),
403
- onShowEarlier ? h('button', { type: 'button', class: 'agentchat-earlier-btn',
404
- onclick: () => onShowEarlier(Math.min(messages.length, msgLimit + MESSAGE_CAP)) },
405
- 'show ' + Math.min(MESSAGE_CAP, msgStart) + ' earlier turns') : null)
406
- : null;
407
-
408
- // While streaming, the composer's send button becomes an inline stop button
409
- // (busy + onCancel) so the user can halt the turn from where their hands
410
- // already are, not only from the controls cluster up top.
411
- const composer = ChatComposer({
412
- value: draft,
413
- disabled: !canSend,
414
- busy,
415
- placeholder: placeholder || (selectedAgent ? 'message…' : 'choose an agent first'),
416
- onInput: (v) => onInput && onInput(v),
417
- onSend: (v) => onSend && onSend(v),
418
- onCancel: busy && onStop ? () => onStop() : undefined,
419
- // The active target (agent / model / cwd-basename) at the point of typing.
420
- context: composerContext,
421
- // Paste/drop file intents (image paste, file drop) — host-wired; the
422
- // composer itself always preventDefaults the drop so the browser never
423
- // navigates away from a live session.
424
- onPasteFiles,
425
- onDropFiles,
426
- onEmoji,
427
- streamingSince,
428
- detectAttachment,
429
- mentionFiles,
430
- });
431
-
432
- // Contextual follow-up chips below the last SETTLED assistant turn (claude.ai/
433
- // code / cowork surface these after a turn, not only on an empty thread). Shown
434
- // only when not busy and the last message is an assistant turn with body.
435
- const followupRow = (!busy && followups && followups.length && lastMsg && lastMsg.role === 'assistant' && msgHasBody(lastMsg))
436
- ? h('div', { class: 'agentchat-followups', role: 'group', 'aria-label': 'suggested follow-ups' },
437
- ...followups.map((s, i) => h('button', {
438
- key: 'fu' + i, type: 'button', class: 'agentchat-empty-suggestion agentchat-followup',
439
- onclick: () => { const t = typeof s === 'string' ? s : (s.prompt || s.text || ''); if (onFollowupClick) onFollowupClick(t); else if (onSuggestionClick) onSuggestionClick(t); },
440
- }, typeof s === 'string' ? s : (s.label || s.text || s.prompt))))
441
- : null;
442
-
443
- // Empty state: a fresh thread is a void without this. Mirrors the kit's Chat
444
- // empty surface (title, sub, optional starter prompts) with calm, factual
445
- // copy rather than blank panel or invitational framing.
446
- const emptyState = (messages.length === 0)
447
- ? h('div', { class: 'agentchat-empty', role: 'status' },
448
- h('p', { class: 'agentchat-empty-title' }, selectedAgent ? name + ' is ready.' : 'Select an agent to start.'),
449
- h('p', { class: 'agentchat-empty-sub' },
450
- selectedAgent ? 'Type a message below.' : 'Pick an agent from the selector above, then send a message.'),
451
- (suggestions && suggestions.length)
452
- ? h('div', { class: 'agentchat-empty-suggestions' },
453
- ...suggestions.map((s, i) => h('button', {
454
- key: 'sug' + i, type: 'button', class: 'agentchat-empty-suggestion',
455
- onclick: () => { const t = typeof s === 'string' ? s : (s.prompt || s.text || ''); if (onSuggestionClick) onSuggestionClick(t); },
456
- }, typeof s === 'string' ? s : (s.label || s.text || s.prompt))))
457
- : null,
458
- // Guided install path for a brand-new user with zero installed agents:
459
- // a plain copy line, a monospaced command per row (each with its own
460
- // copy button, pure-DOM label flip like the code-block copy), and a
461
- // recheck button so the user needn't reload after installing.
462
- installHint
463
- ? h('div', { class: 'agentchat-install', role: 'group', 'aria-label': 'install an agent' },
464
- installHint.text ? h('p', { class: 'agentchat-install-text' }, installHint.text) : null,
465
- (installHint.commands && installHint.commands.length)
466
- ? h('ul', { class: 'agentchat-install-list' },
467
- ...installHint.commands.map((c, i) => h('li', { key: 'inst' + i, class: 'agentchat-install-row' },
468
- h('span', { class: 'agentchat-install-agent' }, c.agent),
469
- h('code', { class: 'agentchat-install-cmd' }, c.command),
470
- h('button', {
471
- type: 'button', class: 'agentchat-install-copy',
472
- 'aria-label': 'copy install command for ' + c.agent, title: 'copy command',
473
- onclick: (e) => {
474
- const btn = e.currentTarget;
475
- navigator.clipboard && navigator.clipboard.writeText(c.command);
476
- btn.textContent = 'copied';
477
- setTimeout(() => { btn.textContent = 'copy'; }, 1200);
478
- },
479
- }, 'copy'))))
480
- : null,
481
- installHint.onRecheck
482
- ? h('div', { class: 'agentchat-install-actions' },
483
- Btn({ onClick: () => installHint.onRecheck(), children: 'recheck agents', title: 'Re-check installed agents' }))
484
- : null)
485
- : null)
486
- : null;
487
-
488
- // ChatMinimap needs a getter that resolves the live thread element lazily
489
- // (it may mount before the thread's own ref fires). A holder object keeps
490
- // the element across re-renders without introducing component state.
491
- const threadElHolder = { el: null };
492
- const combinedThreadRef = (el) => {
493
- threadElHolder.el = el;
494
- const dispose = threadRef(messages.length)(el);
495
- return dispose;
496
- };
497
-
498
- const threadBody = h('div', { class: 'agentchat-thread-wrap' },
499
- h('div', { class: 'agentchat-thread', ref: combinedThreadRef, role: 'log', 'aria-label': 'conversation', 'aria-live': 'polite', 'aria-relevant': 'additions' },
500
- emptyState,
501
- earlierRow,
502
- ...rows.filter(Boolean),
503
- showWorkingTail
504
- ? h('div', { key: '_working', class: 'agentchat-working', role: 'status', 'aria-live': 'polite' },
505
- h('span', { class: 'chat-thinking-dots', 'aria-hidden': 'true' }, h('span'), h('span'), h('span')),
506
- h('span', { class: 'agentchat-working-text' }, 'working…'))
507
- : null,
508
- followupRow),
509
- // Jump-to-latest: hidden until the scroll listener adds .show (user scrolled
510
- // up). Clicking returns to the live edge. Pure-DOM, like the kit's other
511
- // stateless chrome, so the host needn't thread scroll state through state.
512
- h('button', { class: 'agentchat-jump', type: 'button', 'aria-label': 'jump to latest', title: 'jump to latest',
513
- onclick: (e) => scrollThreadToBottom(e.currentTarget) },
514
- Icon('arrow-down', { size: 16 }), h('span', { class: 'agentchat-jump-label' }, 'latest')),
515
- // Optional scroll-position overview strip, sharing the same live thread
516
- // element the auto-scroll/jump logic already resolves via threadElHolder.
517
- showMinimap
518
- ? ChatMinimap({ messages, getThreadEl: () => threadElHolder.el })
519
- : null);
520
-
521
- const mainColumn = h('div', { class: 'agentchat-main-col' },
522
- h('div', { class: 'agentchat-head' },
523
- h('h1', { class: 'agentchat-title' }, name + (selectedModel ? ' · ' + selectedModel : '')),
524
- h('span', { class: 'agentchat-sub', 'aria-hidden': busy ? 'true' : null },
525
- // Derive the busy label from the same status prop the controls use, so a
526
- // reconnecting-while-streaming state reads one word everywhere instead of
527
- // the head saying "streaming…" while the controls say "reconnecting…".
528
- busy ? (status || 'streaming…') : (messages.length ? messages.length + (messages.length === 1 ? ' message' : ' messages') : ''))),
529
- threadBody,
530
- composer);
13
+ // This module is a barrel: the surface and its parts live in
14
+ // single-responsibility submodules under ./agent-chat/, and the public export
15
+ // surface here is unchanged no consumer import needs to move.
531
16
 
532
- // sidePanel renders the caller's content vnode (a FilePreviewPane, a plain
533
- // iframe/embed, anything) inside a resizable SplitPanel beside the thread -
534
- // omitted entirely when sidePanel is falsy so every existing caller's DOM
535
- // output is byte-unchanged.
536
- const body = sidePanel
537
- ? SplitPanel({ orientation: 'horizontal', initial: '55%', min: 320,
538
- children: [
539
- mainColumn,
540
- h('div', { class: 'agentchat-side-panel' },
541
- h('div', { class: 'agentchat-side-panel-head' },
542
- h('span', { class: 'agentchat-side-panel-title' }, sidePanelTitle),
543
- onCloseSidePanel
544
- ? h('button', { type: 'button', class: 'agentchat-side-panel-close', 'aria-label': 'close preview', title: 'close preview', onclick: onCloseSidePanel }, Icon('x', { size: 14 }))
545
- : null),
546
- h('div', { class: 'agentchat-side-panel-body' }, sidePanel)),
547
- ] })
548
- : mainColumn;
17
+ import { MESSAGE_CAP } from './agent-chat/thread-behaviour.js';
18
+ import { AgentChat } from './agent-chat/surface.js';
549
19
 
550
- return h('div', { class: 'agentchat' + (sidePanel ? ' has-side-panel' : '') },
551
- AgentControls({ agents, selectedAgent, models, selectedModel, busy, status, modelsLoading, agentsLoading,
552
- onSelectAgent, onSelectModel, onNewChat, onStop, exportActions }),
553
- CwdBar({ cwd, editing: cwdEditing, draft: cwdDraft, error: cwdError, checking: cwdChecking,
554
- roots: cwdRoots, recent: cwdRecent, browse: cwdBrowse,
555
- onEdit: onCwdEdit, onSave: onCwdSave, onCancel: onCwdCancel, onClear: onCwdClear, onDraft: onCwdDraft,
556
- onBrowseToggle: onCwdBrowseToggle, onBrowseCrumb: onCwdBrowseCrumb, onBrowseEnter: onCwdBrowseEnter, onBrowsePick: onCwdBrowsePick }),
557
- ...(banners || []).filter(Boolean),
558
- body,
559
- );
560
- }
20
+ export { MESSAGE_CAP, AgentChat };