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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anentrypoint-design",
3
- "version": "0.0.385",
3
+ "version": "0.0.387",
4
4
  "description": "247420 design system SDK — webjsx + modified ripple-ui, single-file ESM bundle for reproducible use of the AnEntrypoint design.",
5
5
  "type": "module",
6
6
  "main": "./dist/247420.js",
@@ -0,0 +1,143 @@
1
+ // AgentChat's two chrome bars: the agent-then-model picker (with stop/new,
2
+ // live status, and host-supplied transcript export actions), and the
3
+ // working-directory bar with its roots / recent / inline-browse affordances.
4
+
5
+ import * as webjsx from '../../../vendor/webjsx/index.js';
6
+ import { Select } from '../content.js';
7
+ import { Btn } from '../shell.js';
8
+ import { BreadcrumbPath } from '../files.js';
9
+
10
+ const h = webjsx.createElement;
11
+
12
+ // The agent picker: agent-then-model, not a flat model list. Unavailable agents
13
+ // are disabled (unless installable via npx). Ordering is the host's concern.
14
+ export function AgentControls({ agents, selectedAgent, models, selectedModel, busy, status, modelsLoading, agentsLoading,
15
+ onSelectAgent, onSelectModel, onNewChat, onStop, exportActions }) {
16
+ const agentOptions = (agents || []).map((a) => ({
17
+ value: a.id,
18
+ label: a.name + (a.available === false ? (a.npxInstallable ? ' (via npx)' : ' (not installed)') : ''),
19
+ disabled: a.available === false && !a.npxInstallable,
20
+ }));
21
+ const showModels = (models || []).length > 0;
22
+ return h('div', { class: 'agentchat-controls' },
23
+ // While agents load on first boot, show a disabled "loading…" placeholder
24
+ // instead of an empty options list, which is indistinguishable from "this
25
+ // app has no agents configured" (mirrors the models-loading branch below).
26
+ (agentsLoading && !agentOptions.length)
27
+ ? Select({ key: 'agentsel', value: '', placeholder: 'loading agents…', title: 'Loading agents', disabled: true, options: [] })
28
+ : Select({
29
+ key: 'agentsel', value: selectedAgent, placeholder: '— agent —',
30
+ title: 'Select agent', options: agentOptions,
31
+ onChange: (v) => onSelectAgent && onSelectAgent(v),
32
+ }),
33
+ // While models load for a freshly-picked agent, show a disabled "loading…"
34
+ // placeholder so the picker doesn't vanish then reappear (a layout flash).
35
+ showModels
36
+ ? Select({
37
+ key: 'modelsel', value: selectedModel, placeholder: '— model —',
38
+ title: 'Select model', options: (models || []).map((m) => ({ value: m.id, label: m.name || m.id })),
39
+ onChange: (v) => onSelectModel && onSelectModel(v),
40
+ })
41
+ : (modelsLoading
42
+ ? Select({ key: 'modelsel', value: '', placeholder: 'loading models…', title: 'Loading models', disabled: true, options: [] })
43
+ : null),
44
+ busy
45
+ ? Btn({ key: 'stop', onClick: () => onStop && onStop(), children: 'stop', title: 'Stop streaming' })
46
+ : Btn({ key: 'new', onClick: () => onNewChat && onNewChat(), children: 'new', title: 'New chat' }),
47
+ h('span', { key: 'st', class: 'agentchat-status', role: 'status', 'aria-live': 'polite', 'aria-atomic': 'true' },
48
+ h('span', { class: 'status-dot-disc ' + (busy ? 'status-dot-live' : ''), 'aria-hidden': 'true' }),
49
+ h('span', {}, status || (busy ? 'streaming…' : 'ready'))),
50
+ // Host-supplied transcript actions (copy-all / export-md / export-json):
51
+ // small text-labeled buttons riding the same controls row. All siblings in
52
+ // this h(...) call are keyed VElements or null — never bare strings.
53
+ ...(exportActions && exportActions.length
54
+ ? exportActions.map((a, i) => h('button', {
55
+ key: 'exp' + i, type: 'button', class: 'agentchat-export-act',
56
+ title: a.title || a.label,
57
+ onclick: () => a.onClick && a.onClick(),
58
+ }, a.label))
59
+ : []),
60
+ );
61
+ }
62
+
63
+ // A working-directory bar: shows where the agent will run, editable + clearable.
64
+ // `error`/`checking` give inline validation feedback while typing/blur (the host
65
+ // debounces its /api/stat probe and sets these): a plain-language line renders
66
+ // under the input (aria-describedby) and save stays disabled while either is set.
67
+ //
68
+ // Practicality upgrade: setting cwd by typing an exact absolute path from memory
69
+ // was the only path (a fresh user has no way to discover what's even browsable,
70
+ // and a regular user re-types/re-remembers the same handful of paths every
71
+ // session). Three additive affordances, all optional/host-driven so a host that
72
+ // doesn't wire them keeps the old text-only behavior:
73
+ // - `roots` (fsAllowRoots-equivalent): one-click starting points, always
74
+ // visible while editing, not buried in a separate Files-tab-only picker.
75
+ // - `recent` (host's own small MRU list, e.g. localStorage-backed): one-click
76
+ // chips for the last few cwds actually used, so switching between a
77
+ // regular handful of working directories needs no typing at all.
78
+ // - `browse` (host-driven inline directory listing): clicking "browse" asks
79
+ // the host (via onBrowse) to list a directory's subdirectories (reusing
80
+ // the same confined listing endpoint the Files tab already uses), and
81
+ // renders them as a breadcrumb + clickable dir list right in the composer
82
+ // — no round-trip through the Files tab and back required anymore.
83
+ export function CwdBar({ cwd, editing, draft, onEdit, onSave, onCancel, onClear, onDraft, error, checking,
84
+ roots, recent, browse, onBrowseCrumb, onBrowseEnter, onBrowsePick, onBrowseToggle }) {
85
+ if (editing) {
86
+ const hint = checking ? 'checking…' : (error || null);
87
+ const rootsRow = (roots && roots.length)
88
+ ? h('div', { key: 'roots', class: 'agentchat-cwd-roots', role: 'group', 'aria-label': 'accessible folders' },
89
+ ...roots.map((r, i) => h('button', {
90
+ key: 'root' + i, type: 'button', class: 'agentchat-cwd-chip',
91
+ onclick: () => onDraft && onDraft(r.path || r),
92
+ }, r.label || r.path || r)))
93
+ : null;
94
+ const recentRow = (recent && recent.length)
95
+ ? h('div', { key: 'recent', class: 'agentchat-cwd-recent', role: 'group', 'aria-label': 'recently used folders' },
96
+ h('span', { key: 'rlbl', class: 'agentchat-cwd-recent-label' }, 'recent:'),
97
+ ...recent.map((r, i) => h('button', {
98
+ key: 'rec' + i, type: 'button', class: 'agentchat-cwd-chip',
99
+ title: r, onclick: () => onDraft && onDraft(r),
100
+ }, r.split(/[/\\]/).filter(Boolean).slice(-1)[0] || r)))
101
+ : null;
102
+ const browseToggle = onBrowseToggle
103
+ ? h('button', { key: 'browsetoggle', type: 'button', class: 'agentchat-cwd-btn agentchat-cwd-browse-toggle',
104
+ 'aria-expanded': browse ? 'true' : 'false',
105
+ onclick: () => onBrowseToggle() }, browse ? 'hide browser' : 'browse…')
106
+ : null;
107
+ const browsePanel = (browse && browse.entries)
108
+ ? h('div', { key: 'browsepanel', class: 'agentchat-cwd-browse', role: 'group', 'aria-label': 'browse folders' },
109
+ browse.segments ? BreadcrumbPath({ segments: browse.segments, root: browse.rootLabel || 'root', onNav: (i) => onBrowseCrumb && onBrowseCrumb(i) }) : null,
110
+ h('div', { key: 'browselist', class: 'agentchat-cwd-browse-list', role: 'listbox', 'aria-label': 'subdirectories' },
111
+ browse.loading
112
+ ? h('div', { key: 'browseloading', class: 'agentchat-cwd-browse-loading', role: 'status' }, 'loading…')
113
+ : (browse.entries.length
114
+ ? browse.entries.map((e, i) => h('button', {
115
+ key: 'be' + i, type: 'button', class: 'agentchat-cwd-browse-item', role: 'option',
116
+ onclick: () => onBrowseEnter && onBrowseEnter(e.path || e.name),
117
+ }, e.name || e.path))
118
+ : h('div', { key: 'browseempty', class: 'agentchat-cwd-browse-empty' }, 'no subfolders here'))),
119
+ h('button', { key: 'browseuse', type: 'button', class: 'agentchat-cwd-btn', onclick: () => onBrowsePick && onBrowsePick(browse.current) }, 'use this folder'))
120
+ : null;
121
+ return h('div', { class: 'agentchat-cwd agentchat-cwd-editing', role: 'group', 'aria-label': 'Set working directory' },
122
+ h('div', { key: 'row1', class: 'agentchat-cwd-row' },
123
+ h('input', { class: 'agentchat-cwd-input', type: 'text', value: draft ?? cwd ?? '',
124
+ placeholder: 'absolute path (blank = server default)',
125
+ 'aria-describedby': hint ? 'agentchat-cwd-hint' : null,
126
+ 'aria-invalid': error ? 'true' : null,
127
+ 'aria-busy': checking ? 'true' : null,
128
+ oninput: (e) => onDraft && onDraft(e.target.value) }),
129
+ browseToggle,
130
+ Btn({ key: 'cancel', onClick: () => onCancel && onCancel(), children: 'cancel' }),
131
+ Btn({ key: 'save', variant: 'primary', disabled: !!(error || checking), onClick: () => onSave && onSave(), children: 'save' })),
132
+ rootsRow,
133
+ recentRow,
134
+ browsePanel,
135
+ hint ? h('span', { key: 'hint', id: 'agentchat-cwd-hint', role: 'status', 'aria-live': 'polite',
136
+ class: 'agentchat-cwd-hint' + (error ? ' is-error' : ' is-checking') }, hint) : null);
137
+ }
138
+ return h('div', { class: 'agentchat-cwd', role: 'group', 'aria-label': 'Working directory' },
139
+ h('span', { class: 'agentchat-cwd-text', title: cwd || 'server default working directory' },
140
+ 'cwd: ' + (cwd || 'server default')),
141
+ h('button', { type: 'button', class: 'agentchat-cwd-btn', onclick: () => onEdit && onEdit() }, cwd ? 'change' : 'set'),
142
+ cwd ? h('button', { type: 'button', class: 'agentchat-cwd-btn', onclick: () => onClear && onClear() }, 'use default') : null);
143
+ }
@@ -0,0 +1,63 @@
1
+ // AgentChat's zero-and-between-turn prompts: the fresh-thread empty state
2
+ // (with starter suggestions and the guided agent-install path) and the
3
+ // contextual follow-up chips shown under the last settled assistant turn.
4
+
5
+ import * as webjsx from '../../../vendor/webjsx/index.js';
6
+ import { Btn } from '../shell.js';
7
+
8
+ const h = webjsx.createElement;
9
+
10
+ // Empty state: a fresh thread is a void without this. Mirrors the kit's Chat
11
+ // empty surface (title, sub, optional starter prompts) with calm, factual
12
+ // copy rather than blank panel or invitational framing.
13
+ export function AgentEmptyState({ name, selectedAgent, suggestions, onSuggestionClick, installHint }) {
14
+ return h('div', { class: 'agentchat-empty', role: 'status' },
15
+ h('p', { class: 'agentchat-empty-title' }, selectedAgent ? name + ' is ready.' : 'Select an agent to start.'),
16
+ h('p', { class: 'agentchat-empty-sub' },
17
+ selectedAgent ? 'Type a message below.' : 'Pick an agent from the selector above, then send a message.'),
18
+ (suggestions && suggestions.length)
19
+ ? h('div', { class: 'agentchat-empty-suggestions' },
20
+ ...suggestions.map((s, i) => h('button', {
21
+ key: 'sug' + i, type: 'button', class: 'agentchat-empty-suggestion',
22
+ onclick: () => { const t = typeof s === 'string' ? s : (s.prompt || s.text || ''); if (onSuggestionClick) onSuggestionClick(t); },
23
+ }, typeof s === 'string' ? s : (s.label || s.text || s.prompt))))
24
+ : null,
25
+ // Guided install path for a brand-new user with zero installed agents:
26
+ // a plain copy line, a monospaced command per row (each with its own
27
+ // copy button, pure-DOM label flip like the code-block copy), and a
28
+ // recheck button so the user needn't reload after installing.
29
+ installHint
30
+ ? h('div', { class: 'agentchat-install', role: 'group', 'aria-label': 'install an agent' },
31
+ installHint.text ? h('p', { class: 'agentchat-install-text' }, installHint.text) : null,
32
+ (installHint.commands && installHint.commands.length)
33
+ ? h('ul', { class: 'agentchat-install-list' },
34
+ ...installHint.commands.map((c, i) => h('li', { key: 'inst' + i, class: 'agentchat-install-row' },
35
+ h('span', { class: 'agentchat-install-agent' }, c.agent),
36
+ h('code', { class: 'agentchat-install-cmd' }, c.command),
37
+ h('button', {
38
+ type: 'button', class: 'agentchat-install-copy',
39
+ 'aria-label': 'copy install command for ' + c.agent, title: 'copy command',
40
+ onclick: (e) => {
41
+ const btn = e.currentTarget;
42
+ navigator.clipboard && navigator.clipboard.writeText(c.command);
43
+ btn.textContent = 'copied';
44
+ setTimeout(() => { btn.textContent = 'copy'; }, 1200);
45
+ },
46
+ }, 'copy'))))
47
+ : null,
48
+ installHint.onRecheck
49
+ ? h('div', { class: 'agentchat-install-actions' },
50
+ Btn({ onClick: () => installHint.onRecheck(), children: 'recheck agents', title: 'Re-check installed agents' }))
51
+ : null)
52
+ : null);
53
+ }
54
+
55
+ // Contextual follow-up chips below the last SETTLED assistant turn (claude.ai/
56
+ // code / cowork surface these after a turn, not only on an empty thread).
57
+ export function FollowupRow({ followups, onFollowupClick, onSuggestionClick }) {
58
+ return h('div', { class: 'agentchat-followups', role: 'group', 'aria-label': 'suggested follow-ups' },
59
+ ...followups.map((s, i) => h('button', {
60
+ key: 'fu' + i, type: 'button', class: 'agentchat-empty-suggestion agentchat-followup',
61
+ onclick: () => { const t = typeof s === 'string' ? s : (s.prompt || s.text || ''); if (onFollowupClick) onFollowupClick(t); else if (onSuggestionClick) onSuggestionClick(t); },
62
+ }, typeof s === 'string' ? s : (s.label || s.text || s.prompt))));
63
+ }
@@ -0,0 +1,141 @@
1
+ // Turn-to-vnode translation for AgentChat: the windowed thread render, the
2
+ // mid-stream part downgrades that keep a long streaming turn O(tail) instead of
3
+ // O(turn^2), the streaming-caret placement, and the per-message action row.
4
+
5
+ import { ChatMessage } from '../chat.js';
6
+ import { STREAM_TAIL_THRESHOLD, STREAM_TAIL_WINDOW } from './thread-behaviour.js';
7
+
8
+ // A message carries content (text/parts) when it has a non-empty content
9
+ // string OR at least one part. Used for the empty-shell skip + working tail
10
+ // so an interleaved turn (parts-only, no m.content) is not treated as empty.
11
+ export const msgHasBody = (m) => !!(m.content || (Array.isArray(m.parts) && m.parts.length));
12
+
13
+ // Build the visible turn rows. `msgStart` is the window's absolute start index
14
+ // (rows keep their ABSOLUTE index so streaming/caret/actions logic keys off the
15
+ // real lastIdx, not the window offset).
16
+ export function buildMessageRows({ messages, msgStart, lastIdx, busy, name, avatar,
17
+ onCopyMessage, onRetryMessage, onEditMessage, confirmEdit, onArmEdit }) {
18
+ return messages.slice(msgStart).map((m, wi) => {
19
+ const i = wi + msgStart; // absolute index — streaming/caret/actions logic keys off the real lastIdx
20
+ const isAssistant = m.role === 'assistant';
21
+ const isStreaming = busy && i === lastIdx && isAssistant;
22
+ const hasParts = Array.isArray(m.parts) && m.parts.length > 0;
23
+ const emptyStreaming = isStreaming && !msgHasBody(m);
24
+ // A finished assistant message with no content and no parts is an empty
25
+ // shell (e.g. an aborted turn) — render nothing rather than a blank bubble.
26
+ if (!isStreaming && isAssistant && !msgHasBody(m)) return null;
27
+ // Render order follows m.parts so text and tool cards INTERLEAVE in arrival
28
+ // order (text -> tool -> text -> tool). A message's parts may be bare
29
+ // strings (legacy) OR structured {kind,...} objects (md/tool/tool_result/
30
+ // code/...) passed straight through to ChatMessage.renderPart — this is what
31
+ // lets an orchestration host render the kit's collapsible ToolCallNode
32
+ // inline instead of flattening tools to the end of the turn.
33
+ const parts = [];
34
+ if (hasParts) {
35
+ for (const p of m.parts) {
36
+ const part = (p && typeof p === 'object' && p.kind) ? p : { kind: 'text', text: String(p) };
37
+ // While a turn is still streaming, render its prose as cheap inline text
38
+ // rather than full markdown: MdNode re-parses + re-sanitizes the WHOLE
39
+ // accumulated source and swaps the entire bubble innerHTML on every frame
40
+ // (O(n^2) over the turn, with a visible reflow). Downgrade md -> text
41
+ // mid-stream; the settled turn below renders real markdown once.
42
+ // Carry a `mdShell` flag so the streaming-text bubble uses the same
43
+ // container shape (.chat-md padding/spacing) the settled markdown will
44
+ // use — only the inner content swaps on settle, so the bubble box does
45
+ // not reflow/jump when the turn finishes and renders real markdown.
46
+ if (isStreaming && part.kind === 'md') {
47
+ const txt = part.text || '';
48
+ // Giant streamed block: re-rendering the whole accumulated string per
49
+ // rAF is O(n^2) across the turn. Past the threshold, render a preShell
50
+ // bubble with a 'streaming · N KB so far' head plus only the last
51
+ // STREAM_TAIL_WINDOW chars; full markdown renders once on settle.
52
+ if (txt.length > STREAM_TAIL_THRESHOLD) {
53
+ parts.push({ kind: 'text', mdShell: true, preShell: true,
54
+ text: txt.slice(-STREAM_TAIL_WINDOW),
55
+ streamHead: 'streaming · ' + Math.round(txt.length / 1024) + ' KB so far' });
56
+ continue;
57
+ }
58
+ // If the streaming prose contains a code fence, the inline renderer
59
+ // (which has no triple-backtick handling) would show it as run-on text
60
+ // with literal ``` and no monospace, then snap into a styled <pre> on
61
+ // settle (a visible reflow during the most-watched moment). Detect a
62
+ // fence and render the bubble as a cheap monospaced <pre> shell instead
63
+ // (no Prism mid-stream, so no O(n^2)) so it does not reflow on settle.
64
+ if (part.text && part.text.indexOf('```') !== -1) parts.push({ kind: 'text', text: part.text, mdShell: true, preShell: true });
65
+ else parts.push({ kind: 'text', text: part.text, mdShell: true });
66
+ }
67
+ else if (!isStreaming && part.kind === 'thinking') parts.push({ kind: 'thinking', settled: true, text: part.text });
68
+ else parts.push(part);
69
+ }
70
+ }
71
+ // m.content is the legacy/simple path (user messages, hosts that don't build
72
+ // interleaved parts). Only prepend it when the parts array doesn't already
73
+ // carry prose, so a parts-driven turn isn't double-rendered.
74
+ const partsHaveProse = parts.some(p => p.kind === 'md' || p.kind === 'text');
75
+ if (m.content && !partsHaveProse) parts.unshift({ kind: isAssistant ? 'md' : 'text', text: m.content });
76
+ // The streaming caret rides the live assistant turn once it has body (the
77
+ // empty-shell turn already shows the inline typing dots).
78
+ const streaming = isStreaming && msgHasBody(m);
79
+ // Place the caret inline inside the last text/md part rather than as a
80
+ // sibling span (which renders as a block below the last bubble). Tag the
81
+ // last text part so PART_RENDERERS.text can append it as an inline child.
82
+ if (streaming && parts.length) {
83
+ const lastPart = parts[parts.length - 1];
84
+ if (lastPart && (lastPart.kind === 'text' || lastPart.kind === 'md')) {
85
+ parts[parts.length - 1] = { ...lastPart, streamingCaret: true };
86
+ }
87
+ }
88
+ // Per-message actions: the host supplies onCopyMessage / onRetryMessage; we
89
+ // build the action row only for SETTLED messages (no actions mid-stream).
90
+ let actions;
91
+ if (!isStreaming && msgHasBody(m)) {
92
+ const built = [];
93
+ if (onCopyMessage) built.push({ label: 'copy', icon: 'copy', title: 'copy message', onClick: () => onCopyMessage(m) });
94
+ // Mid-thread retry: EVERY settled assistant turn gets a retry action,
95
+ // not only the trailing one - the host truncates from that turn's
96
+ // position and resends (the same mechanism edit-and-resend uses for
97
+ // user messages), so any assistant reply the user was unhappy with can
98
+ // be redone without discarding turns that came after a LATER one.
99
+ if (isAssistant && onRetryMessage) built.push({ label: 'retry', icon: 'refresh', title: 'retry this turn', onClick: () => onRetryMessage(m) });
100
+ // A dangling user message (send failed / no reply arrived) can only be
101
+ // the LAST message when it has no assistant reply - retry here means
102
+ // "resend as-is", not "redo a specific turn", so stays lastIdx-gated.
103
+ if (!isAssistant && onRetryMessage && i === lastIdx) built.push({ label: 'retry', icon: 'refresh', title: 'retry', onClick: () => onRetryMessage(m) });
104
+ // With confirmEdit the host arms its own confirm affordance (onArmEdit)
105
+ // instead of resending immediately; the kit stays stateless either way.
106
+ if (!isAssistant && onEditMessage) built.push({ label: 'edit', icon: 'pencil', title: 'edit and resend',
107
+ onClick: () => (confirmEdit && onArmEdit) ? onArmEdit(m) : onEditMessage(m) });
108
+ if (built.length) actions = built;
109
+ }
110
+ return ChatMessage({
111
+ key: m.id || String(i),
112
+ role: isAssistant ? 'assistant' : 'user',
113
+ // Claude-Code-web layout: flat full-width turns (no avatar disc, no colored
114
+ // bubble), distinguished by a role label + a faint assistant background.
115
+ // aicat is left OFF so the mascot tint never reaches the agent surface.
116
+ flat: true,
117
+ aicat: false,
118
+ // A stable per-agent product mark (host passes a small line-SVG via
119
+ // `avatar`) instead of a per-agent letter initial that shifts identity.
120
+ avatar: isAssistant ? (m.avatar != null ? m.avatar : avatar) : undefined,
121
+ name: isAssistant ? name : 'you',
122
+ time: m.time || '',
123
+ typing: emptyStreaming,
124
+ streaming,
125
+ actions,
126
+ // Out-of-band notices (plain copy, neutral tone): m.stopped marks a
127
+ // cancelled turn; m.incomplete marks a turn whose stream dropped without
128
+ // replay. Retry rides the existing actions row.
129
+ stopped: m.stopped,
130
+ incomplete: m.incomplete,
131
+ // A failed trailing turn (the host's send/stream rejected, regardless
132
+ // of which role the placeholder message landed on) gets its error
133
+ // pinned to that specific turn, with retry right there — same pattern
134
+ // as stopped/incomplete, but destructive-toned since this is a genuine
135
+ // failure rather than a neutral "not finished" state.
136
+ error: i === lastIdx ? m.error : undefined,
137
+ onRetry: (i === lastIdx && m.error && onRetryMessage) ? () => onRetryMessage(m) : undefined,
138
+ parts: emptyStreaming ? undefined : (parts.length ? parts : [{ kind: 'text', text: '' }]),
139
+ });
140
+ });
141
+ }
@@ -0,0 +1,206 @@
1
+ // AgentChat — the composed surface: controls + cwd bar + banners, then the
2
+ // thread body (windowed rows, working tail, jump-to-latest, optional minimap)
3
+ // and the composer, optionally split against a host-supplied side preview pane.
4
+
5
+ import * as webjsx from '../../../vendor/webjsx/index.js';
6
+ import { ChatComposer } from '../chat.js';
7
+ import { Icon } from '../shell.js';
8
+ import { SplitPanel } from '../editor-primitives.js';
9
+ import { initializeCachesEagerly } from '../../markdown-cache.js';
10
+ import { ChatMinimap } from '../chat-minimap.js';
11
+ import { threadRef, scrollThreadToBottom, MESSAGE_CAP } from './thread-behaviour.js';
12
+ import { AgentControls, CwdBar } from './controls.js';
13
+ import { buildMessageRows, msgHasBody } from './message-rows.js';
14
+ import { AgentEmptyState, FollowupRow } from './empty-state.js';
15
+
16
+ const h = webjsx.createElement;
17
+
18
+ // AgentChat — the composed surface.
19
+ // agents, selectedAgent, models, selectedModel : picker state
20
+ // messages : [{ id, role:'user'|'assistant', content, time, parts:[string] }]
21
+ // busy, draft, status : stream + composer state
22
+ // cwd, cwdEditing, cwdDraft : working-directory bar
23
+ // banners : array of pre-built Alert vnodes (errors, resume, unavailable)
24
+ // onSelectAgent/onSelectModel/onSend/onStop/onNewChat/onInput
25
+ // onCwdEdit/onCwdSave/onCwdCancel/onCwdClear/onCwdDraft
26
+ export function AgentChat(props = {}) {
27
+ const {
28
+ agents = [], selectedAgent = '', models = [], selectedModel = '', modelsLoading = false, agentsLoading = false,
29
+ messages = [], busy = false, draft = '', status, banners = [],
30
+ cwd = '', cwdEditing = false, cwdDraft, cwdError, cwdChecking = false,
31
+ cwdRoots, cwdRecent, cwdBrowse,
32
+ agentName, placeholder,
33
+ onSelectAgent, onSelectModel, onSend, onStop, onNewChat, onInput,
34
+ onCwdEdit, onCwdSave, onCwdCancel, onCwdClear, onCwdDraft,
35
+ onCwdBrowseToggle, onCwdBrowseCrumb, onCwdBrowseEnter, onCwdBrowsePick,
36
+ canSend = true,
37
+ suggestions = [], onSuggestionClick,
38
+ onCopyMessage, onRetryMessage, onEditMessage,
39
+ confirmEdit = false, onArmEdit,
40
+ avatar, composerContext,
41
+ followups = [], onFollowupClick,
42
+ installHint, exportActions = [],
43
+ onPasteFiles, onDropFiles, onEmoji,
44
+ shownMessages, onShowEarlier,
45
+ streamingSince, detectAttachment,
46
+ // @-mention file autocomplete in the composer — a flat list of file paths
47
+ // the host already has (e.g. its Files tab data source). Purely forwarded
48
+ // to ChatComposer; omitting it keeps every existing caller unchanged (no
49
+ // mention affordance appears without it).
50
+ mentionFiles,
51
+ // Optional scroll-position minimap alongside the thread (ChatMinimap).
52
+ // false/omitted keeps every existing caller byte-identical (no minimap
53
+ // column at all). true renders the strip using this component's own
54
+ // thread ref via a shared getter — no extra DOM wiring needed from the
55
+ // host beyond passing showMinimap.
56
+ showMinimap = false,
57
+ // Optional inline content viewer beside the thread (a docstudio-cue
58
+ // addition: its chat view keeps a live document/PDF preview open next to
59
+ // the conversation instead of forcing a separate tab/window). The host
60
+ // supplies the actual preview vnode (FilePreviewPane/FileViewer or
61
+ // anything else) - this component only owns the split layout + a close
62
+ // affordance. Omitting sidePanel keeps every existing caller's output
63
+ // byte-identical (no SplitPanel wrapper at all when absent).
64
+ sidePanel, sidePanelTitle = 'preview', onCloseSidePanel,
65
+ } = props;
66
+
67
+ // Warm the markdown/Prism stack the moment the surface mounts so the CDN
68
+ // round-trip never starts mid-first-response. Self-idempotent (internal
69
+ // _initPromise), so the per-render call is free after the first.
70
+ initializeCachesEagerly().catch((err) => console.warn('[247420] cache init error:', err));
71
+
72
+ const name = agentName || (agents.find((a) => a.id === selectedAgent)?.name) || selectedAgent || 'agent';
73
+ const lastIdx = messages.length - 1;
74
+ const lastMsg = messages[lastIdx];
75
+ // Windowed thread render (mirrors FileGrid's cap): only the last `limit`
76
+ // turns build vnodes each frame; a keyed 'show N earlier turns' row at the
77
+ // top grows the window via onShowEarlier (host keeps state.chat.shownMessages
78
+ // and resets it on newChat/loadSession). A 500-turn conversation no longer
79
+ // rebuilds 500 ChatMessage vnodes per streaming rAF tick.
80
+ const msgLimit = shownMessages != null ? shownMessages : MESSAGE_CAP;
81
+ const msgStart = Math.max(0, messages.length - msgLimit);
82
+ // True when streaming but the live assistant turn already shows content/parts,
83
+ // so its inline typing dots have stopped — a long silent tool call would
84
+ // otherwise read as frozen. We append a standalone "working" indicator below.
85
+ const lastMsgLastPart = lastMsg && Array.isArray(lastMsg.parts) && lastMsg.parts.length ? lastMsg.parts[lastMsg.parts.length - 1] : null;
86
+ const showWorkingTail = busy && lastMsg && lastMsg.role === 'assistant' && msgHasBody(lastMsg)
87
+ && lastMsgLastPart && lastMsgLastPart.kind === 'tool' && lastMsgLastPart.status === 'running';
88
+ const rows = buildMessageRows({ messages, msgStart, lastIdx, busy, name, avatar,
89
+ onCopyMessage, onRetryMessage, onEditMessage, confirmEdit, onArmEdit });
90
+ // Keyed 'show N earlier turns' control at the top of the window. A keyed
91
+ // VElement like every row sibling (webjsx keying discipline).
92
+ const earlierRow = msgStart > 0
93
+ ? h('div', { key: '_earlier', class: 'agentchat-earlier' },
94
+ h('span', { class: 'agentchat-earlier-count', role: 'status', 'aria-live': 'polite' },
95
+ 'showing ' + (messages.length - msgStart) + ' of ' + messages.length + ' turns'),
96
+ onShowEarlier ? h('button', { type: 'button', class: 'agentchat-earlier-btn',
97
+ onclick: () => onShowEarlier(Math.min(messages.length, msgLimit + MESSAGE_CAP)) },
98
+ 'show ' + Math.min(MESSAGE_CAP, msgStart) + ' earlier turns') : null)
99
+ : null;
100
+
101
+ // While streaming, the composer's send button becomes an inline stop button
102
+ // (busy + onCancel) so the user can halt the turn from where their hands
103
+ // already are, not only from the controls cluster up top.
104
+ const composer = ChatComposer({
105
+ value: draft,
106
+ disabled: !canSend,
107
+ busy,
108
+ placeholder: placeholder || (selectedAgent ? 'message…' : 'choose an agent first'),
109
+ onInput: (v) => onInput && onInput(v),
110
+ onSend: (v) => onSend && onSend(v),
111
+ onCancel: busy && onStop ? () => onStop() : undefined,
112
+ // The active target (agent / model / cwd-basename) at the point of typing.
113
+ context: composerContext,
114
+ // Paste/drop file intents (image paste, file drop) — host-wired; the
115
+ // composer itself always preventDefaults the drop so the browser never
116
+ // navigates away from a live session.
117
+ onPasteFiles,
118
+ onDropFiles,
119
+ onEmoji,
120
+ streamingSince,
121
+ detectAttachment,
122
+ mentionFiles,
123
+ });
124
+
125
+ // Shown only when not busy and the last message is an assistant turn with body.
126
+ const followupRow = (!busy && followups && followups.length && lastMsg && lastMsg.role === 'assistant' && msgHasBody(lastMsg))
127
+ ? FollowupRow({ followups, onFollowupClick, onSuggestionClick })
128
+ : null;
129
+
130
+ const emptyState = (messages.length === 0)
131
+ ? AgentEmptyState({ name, selectedAgent, suggestions, onSuggestionClick, installHint })
132
+ : null;
133
+
134
+ // ChatMinimap needs a getter that resolves the live thread element lazily
135
+ // (it may mount before the thread's own ref fires). A holder object keeps
136
+ // the element across re-renders without introducing component state.
137
+ const threadElHolder = { el: null };
138
+ const combinedThreadRef = (el) => {
139
+ threadElHolder.el = el;
140
+ const dispose = threadRef(messages.length)(el);
141
+ return dispose;
142
+ };
143
+
144
+ const threadBody = h('div', { class: 'agentchat-thread-wrap' },
145
+ h('div', { class: 'agentchat-thread', ref: combinedThreadRef, role: 'log', 'aria-label': 'conversation', 'aria-live': 'polite', 'aria-relevant': 'additions' },
146
+ emptyState,
147
+ earlierRow,
148
+ ...rows.filter(Boolean),
149
+ showWorkingTail
150
+ ? h('div', { key: '_working', class: 'agentchat-working', role: 'status', 'aria-live': 'polite' },
151
+ h('span', { class: 'chat-thinking-dots', 'aria-hidden': 'true' }, h('span'), h('span'), h('span')),
152
+ h('span', { class: 'agentchat-working-text' }, 'working…'))
153
+ : null,
154
+ followupRow),
155
+ // Jump-to-latest: hidden until the scroll listener adds .show (user scrolled
156
+ // up). Clicking returns to the live edge. Pure-DOM, like the kit's other
157
+ // stateless chrome, so the host needn't thread scroll state through state.
158
+ h('button', { class: 'agentchat-jump', type: 'button', 'aria-label': 'jump to latest', title: 'jump to latest',
159
+ onclick: (e) => scrollThreadToBottom(e.currentTarget) },
160
+ Icon('arrow-down', { size: 16 }), h('span', { class: 'agentchat-jump-label' }, 'latest')),
161
+ // Optional scroll-position overview strip, sharing the same live thread
162
+ // element the auto-scroll/jump logic already resolves via threadElHolder.
163
+ showMinimap
164
+ ? ChatMinimap({ messages, getThreadEl: () => threadElHolder.el })
165
+ : null);
166
+
167
+ const mainColumn = h('div', { class: 'agentchat-main-col' },
168
+ h('div', { class: 'agentchat-head' },
169
+ h('h1', { class: 'agentchat-title' }, name + (selectedModel ? ' · ' + selectedModel : '')),
170
+ h('span', { class: 'agentchat-sub', 'aria-hidden': busy ? 'true' : null },
171
+ // Derive the busy label from the same status prop the controls use, so a
172
+ // reconnecting-while-streaming state reads one word everywhere instead of
173
+ // the head saying "streaming…" while the controls say "reconnecting…".
174
+ busy ? (status || 'streaming…') : (messages.length ? messages.length + (messages.length === 1 ? ' message' : ' messages') : ''))),
175
+ threadBody,
176
+ composer);
177
+
178
+ // sidePanel renders the caller's content vnode (a FilePreviewPane, a plain
179
+ // iframe/embed, anything) inside a resizable SplitPanel beside the thread -
180
+ // omitted entirely when sidePanel is falsy so every existing caller's DOM
181
+ // output is byte-unchanged.
182
+ const body = sidePanel
183
+ ? SplitPanel({ orientation: 'horizontal', initial: '55%', min: 320,
184
+ children: [
185
+ mainColumn,
186
+ h('div', { class: 'agentchat-side-panel' },
187
+ h('div', { class: 'agentchat-side-panel-head' },
188
+ h('span', { class: 'agentchat-side-panel-title' }, sidePanelTitle),
189
+ onCloseSidePanel
190
+ ? h('button', { type: 'button', class: 'agentchat-side-panel-close', 'aria-label': 'close preview', title: 'close preview', onclick: onCloseSidePanel }, Icon('x', { size: 14 }))
191
+ : null),
192
+ h('div', { class: 'agentchat-side-panel-body' }, sidePanel)),
193
+ ] })
194
+ : mainColumn;
195
+
196
+ return h('div', { class: 'agentchat' + (sidePanel ? ' has-side-panel' : '') },
197
+ AgentControls({ agents, selectedAgent, models, selectedModel, busy, status, modelsLoading, agentsLoading,
198
+ onSelectAgent, onSelectModel, onNewChat, onStop, exportActions }),
199
+ CwdBar({ cwd, editing: cwdEditing, draft: cwdDraft, error: cwdError, checking: cwdChecking,
200
+ roots: cwdRoots, recent: cwdRecent, browse: cwdBrowse,
201
+ onEdit: onCwdEdit, onSave: onCwdSave, onCancel: onCwdCancel, onClear: onCwdClear, onDraft: onCwdDraft,
202
+ onBrowseToggle: onCwdBrowseToggle, onBrowseCrumb: onCwdBrowseCrumb, onBrowseEnter: onCwdBrowseEnter, onBrowsePick: onCwdBrowsePick }),
203
+ ...(banners || []).filter(Boolean),
204
+ body,
205
+ );
206
+ }
@@ -0,0 +1,50 @@
1
+ // AgentChat thread scroll behaviour: the auto-scroll ref composed with a
2
+ // scroll listener that reveals the jump-to-latest button once the user has
3
+ // scrolled away from the live edge, plus the window/stream-tail constants the
4
+ // row builder renders against.
5
+
6
+ import { makeThreadAutoScroll } from '../chat.js';
7
+
8
+ // Auto-scroll behaviour is the shared chat helper; bind it to this thread's
9
+ // live message count. (`makeThreadAutoScroll` takes a getter so the observer
10
+ // always compares against current state, not a value captured at mount.)
11
+ const baseAutoScroll = (msgCount) => makeThreadAutoScroll(() => msgCount);
12
+
13
+ // Compose the auto-scroll ref with a scroll listener that reveals the
14
+ // jump-to-latest button when the user has scrolled away from the bottom. This
15
+ // is the scroll-anchoring fix: auto-scroll only pins when the user is already at
16
+ // the bottom (the IntersectionObserver gate), so reading back-history is no
17
+ // longer fought; the button is the explicit way back to the live edge.
18
+ const NEAR_BOTTOM_PX = 80;
19
+
20
+ // Thread window: how many trailing turns render by default (hosts override via
21
+ // shownMessages; grow with onShowEarlier).
22
+ export const MESSAGE_CAP = 100;
23
+ // A single streaming message beyond this many chars renders only a tail window
24
+ // per frame (O(tail), not O(turn)); the settled turn renders full markdown once.
25
+ export const STREAM_TAIL_THRESHOLD = 20000;
26
+ export const STREAM_TAIL_WINDOW = 4000;
27
+
28
+ export const threadRef = (msgCount) => {
29
+ const auto = baseAutoScroll(msgCount);
30
+ return (el) => {
31
+ if (!el) return;
32
+ const disposeAuto = auto(el);
33
+ const jumpBtn = () => el.parentElement && el.parentElement.querySelector('.agentchat-jump');
34
+ const update = () => {
35
+ const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX;
36
+ const btn = jumpBtn();
37
+ if (btn) btn.classList.toggle('show', !atBottom);
38
+ };
39
+ el.addEventListener('scroll', update, { passive: true });
40
+ requestAnimationFrame(update);
41
+ return () => { el.removeEventListener('scroll', update); if (typeof disposeAuto === 'function') disposeAuto(); };
42
+ };
43
+ };
44
+
45
+ // Scroll a thread to its live edge — used by the jump-to-latest button.
46
+ export function scrollThreadToBottom(btn) {
47
+ const wrap = btn.closest('.agentchat-thread-wrap');
48
+ const thread = wrap && wrap.querySelector('.agentchat-thread');
49
+ if (thread) thread.scrollTop = thread.scrollHeight;
50
+ }