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.
- package/dist/247420.css +33 -0
- package/dist/247420.js +53 -51
- package/package.json +1 -1
- package/src/components/agent-chat/controls.js +143 -0
- package/src/components/agent-chat/empty-state.js +63 -0
- package/src/components/agent-chat/message-rows.js +141 -0
- package/src/components/agent-chat/surface.js +206 -0
- package/src/components/agent-chat/thread-behaviour.js +50 -0
- package/src/components/agent-chat.js +6 -546
- package/src/components/chat/composer-affordances.js +109 -0
- package/src/components/chat/composer.js +256 -0
- package/src/components/chat/threads.js +105 -0
- package/src/components/chat-message-parts/agent-nodes.js +103 -0
- package/src/components/chat-message-parts/inline.js +106 -0
- package/src/components/chat-message-parts/prose-nodes.js +133 -0
- package/src/components/chat-message-parts/renderers.js +89 -0
- package/src/components/chat-message-parts.js +12 -408
- package/src/components/chat-minimap/minimap.js +167 -0
- package/src/components/chat-minimap/paint.js +73 -0
- package/src/components/chat-minimap/preview.js +41 -0
- package/src/components/chat-minimap.js +7 -263
- package/src/components/chat.js +20 -628
- package/src/components/community/chrome.js +63 -0
- package/src/components/community/navigation.js +167 -0
- package/src/components/community/presence.js +106 -0
- package/src/components/community/shell.js +17 -0
- package/src/components/community/views.js +131 -0
- package/src/components/community.js +18 -447
- package/src/components/files/chrome.js +140 -0
- package/src/components/files/entries.js +156 -0
- package/src/components/files/grid-controls.js +63 -0
- package/src/components/files/grid.js +177 -0
- package/src/components/files/types.js +69 -0
- package/src/components/files-modals/dialogs.js +118 -0
- package/src/components/files-modals/modal-shell.js +153 -0
- package/src/components/files-modals/preview-bodies.js +129 -0
- package/src/components/files-modals/preview-containers.js +96 -0
- package/src/components/files-modals.js +14 -475
- package/src/components/files.js +15 -564
- package/src/components/freddie/pages-config.js +3 -94
- package/src/components/freddie/pages-models.js +97 -0
- package/src/components/freddie.js +2 -1
- package/src/components/interaction-primitives/mobile.js +25 -0
- package/src/components/interaction-primitives/pointer.js +214 -0
- package/src/components/interaction-primitives/reorderable.js +47 -0
- package/src/components/interaction-primitives/shortcuts.js +119 -0
- package/src/components/interaction-primitives.js +16 -388
- package/src/components/sessions/conversation-list.js +230 -0
- package/src/components/sessions/dashboard.js +202 -0
- package/src/components/sessions/detail-bits.js +49 -0
- package/src/components/sessions/format.js +42 -0
- package/src/components/sessions/session-card.js +92 -0
- package/src/components/sessions.js +16 -579
- package/src/components/voice/audio-cue.js +39 -0
- package/src/components/voice/capture.js +90 -0
- package/src/components/voice/playback.js +75 -0
- package/src/components/voice/settings-modal.js +104 -0
- package/src/components/voice.js +16 -293
- package/src/css/app-shell/base.css +23 -0
- package/src/css/app-shell/states-interactions.css +10 -0
- package/src/kits/os/freddie/chat-protocol.js +32 -0
- package/src/kits/os/freddie/chat-transport.js +178 -0
- package/src/kits/os/freddie/pages-chat.js +10 -180
- package/src/kits/os/shell-chrome.js +163 -0
- package/src/kits/os/shell-geometry.js +59 -0
- package/src/kits/os/shell.js +178 -335
- package/src/page-html/client-script.js +151 -0
- package/src/page-html/head-tags.js +59 -0
- package/src/page-html/markdown.js +68 -0
- package/src/page-html/page-styles.js +44 -0
- 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.
|
|
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
|
+
}
|