anentrypoint-design 0.0.196 → 0.0.198
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/app-shell.css +185 -0
- package/chat.css +134 -0
- package/dist/247420.css +319 -0
- package/dist/247420.js +15 -15
- package/package.json +1 -1
- package/src/components/agent-chat.js +63 -10
- package/src/components/chat.js +19 -2
- package/src/components/context-pane.js +77 -0
- package/src/components/files.js +81 -2
- package/src/components/sessions.js +147 -0
- package/src/components/shell.js +189 -0
- package/src/components.js +8 -1
- package/src/kits/os/theme.css +40 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anentrypoint-design",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.198",
|
|
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",
|
|
@@ -13,14 +13,44 @@
|
|
|
13
13
|
import * as webjsx from '../../vendor/webjsx/index.js';
|
|
14
14
|
import { ChatComposer, ChatMessage, makeThreadAutoScroll } from './chat.js';
|
|
15
15
|
import { Select } from './content.js';
|
|
16
|
-
import { Btn } from './shell.js';
|
|
16
|
+
import { Btn, Icon } from './shell.js';
|
|
17
17
|
|
|
18
18
|
const h = webjsx.createElement;
|
|
19
19
|
|
|
20
20
|
// Auto-scroll behaviour is the shared chat helper; bind it to this thread's
|
|
21
21
|
// live message count. (`makeThreadAutoScroll` takes a getter so the observer
|
|
22
22
|
// always compares against current state, not a value captured at mount.)
|
|
23
|
-
const
|
|
23
|
+
const baseAutoScroll = (msgCount) => makeThreadAutoScroll(() => msgCount);
|
|
24
|
+
|
|
25
|
+
// Compose the auto-scroll ref with a scroll listener that reveals the
|
|
26
|
+
// jump-to-latest button when the user has scrolled away from the bottom. This
|
|
27
|
+
// is the scroll-anchoring fix: auto-scroll only pins when the user is already at
|
|
28
|
+
// the bottom (the IntersectionObserver gate), so reading back-history is no
|
|
29
|
+
// longer fought; the button is the explicit way back to the live edge.
|
|
30
|
+
const NEAR_BOTTOM_PX = 80;
|
|
31
|
+
const threadRef = (msgCount) => {
|
|
32
|
+
const auto = baseAutoScroll(msgCount);
|
|
33
|
+
return (el) => {
|
|
34
|
+
if (!el) return;
|
|
35
|
+
const disposeAuto = auto(el);
|
|
36
|
+
const jumpBtn = () => el.parentElement && el.parentElement.querySelector('.agentchat-jump');
|
|
37
|
+
const update = () => {
|
|
38
|
+
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < NEAR_BOTTOM_PX;
|
|
39
|
+
const btn = jumpBtn();
|
|
40
|
+
if (btn) btn.classList.toggle('show', !atBottom);
|
|
41
|
+
};
|
|
42
|
+
el.addEventListener('scroll', update, { passive: true });
|
|
43
|
+
requestAnimationFrame(update);
|
|
44
|
+
return () => { el.removeEventListener('scroll', update); if (typeof disposeAuto === 'function') disposeAuto(); };
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Scroll a thread to its live edge — used by the jump-to-latest button.
|
|
49
|
+
function scrollThreadToBottom(btn) {
|
|
50
|
+
const wrap = btn.closest('.agentchat-thread-wrap');
|
|
51
|
+
const thread = wrap && wrap.querySelector('.agentchat-thread');
|
|
52
|
+
if (thread) thread.scrollTop = thread.scrollHeight;
|
|
53
|
+
}
|
|
24
54
|
|
|
25
55
|
// The agent picker: agent-then-model, not a flat model list. Unavailable agents
|
|
26
56
|
// are disabled (unless installable via npx). Ordering is the host's concern.
|
|
@@ -93,6 +123,7 @@ export function AgentChat(props = {}) {
|
|
|
93
123
|
onCwdEdit, onCwdSave, onCwdCancel, onCwdClear, onCwdDraft,
|
|
94
124
|
canSend = true,
|
|
95
125
|
suggestions = [], onSuggestionClick,
|
|
126
|
+
onCopyMessage, onRetryMessage, onEditMessage,
|
|
96
127
|
} = props;
|
|
97
128
|
|
|
98
129
|
const name = agentName || (agents.find((a) => a.id === selectedAgent)?.name) || selectedAgent || 'agent';
|
|
@@ -142,6 +173,19 @@ export function AgentChat(props = {}) {
|
|
|
142
173
|
// carry prose, so a parts-driven turn isn't double-rendered.
|
|
143
174
|
const partsHaveProse = parts.some(p => p.kind === 'md' || p.kind === 'text');
|
|
144
175
|
if (m.content && !partsHaveProse) parts.unshift({ kind: isAssistant ? 'md' : 'text', text: m.content });
|
|
176
|
+
// The streaming caret rides the live assistant turn once it has body (the
|
|
177
|
+
// empty-shell turn already shows the inline typing dots).
|
|
178
|
+
const streaming = isStreaming && msgHasBody(m);
|
|
179
|
+
// Per-message actions: the host supplies onCopyMessage / onRetryMessage; we
|
|
180
|
+
// build the action row only for SETTLED messages (no actions mid-stream).
|
|
181
|
+
let actions;
|
|
182
|
+
if (!isStreaming && msgHasBody(m)) {
|
|
183
|
+
const built = [];
|
|
184
|
+
if (onCopyMessage) built.push({ label: 'copy', icon: 'page', title: 'copy message', onClick: () => onCopyMessage(m) });
|
|
185
|
+
if (isAssistant && onRetryMessage && i === lastIdx) built.push({ label: 'retry', icon: 'refresh', title: 'retry this turn', onClick: () => onRetryMessage(m) });
|
|
186
|
+
if (!isAssistant && onEditMessage) built.push({ label: 'edit', icon: 'pencil', title: 'edit and resend', onClick: () => onEditMessage(m) });
|
|
187
|
+
if (built.length) actions = built;
|
|
188
|
+
}
|
|
145
189
|
return ChatMessage({
|
|
146
190
|
key: m.id || String(i),
|
|
147
191
|
who: isAssistant ? 'them' : 'you',
|
|
@@ -149,6 +193,8 @@ export function AgentChat(props = {}) {
|
|
|
149
193
|
name: isAssistant ? name : 'you',
|
|
150
194
|
time: m.time || '',
|
|
151
195
|
typing: emptyStreaming,
|
|
196
|
+
streaming,
|
|
197
|
+
actions,
|
|
152
198
|
parts: emptyStreaming ? undefined : (parts.length ? parts : [{ kind: 'text', text: '' }]),
|
|
153
199
|
});
|
|
154
200
|
});
|
|
@@ -196,14 +242,21 @@ export function AgentChat(props = {}) {
|
|
|
196
242
|
// reconnecting-while-streaming state reads one word everywhere instead of
|
|
197
243
|
// the head saying "streaming…" while the controls say "reconnecting…".
|
|
198
244
|
busy ? (status || 'streaming…') : (messages.length ? messages.length + (messages.length === 1 ? ' message' : ' messages') : ''))),
|
|
199
|
-
h('div', { class: 'agentchat-thread
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
245
|
+
h('div', { class: 'agentchat-thread-wrap' },
|
|
246
|
+
h('div', { class: 'agentchat-thread', ref: threadRef(messages.length), role: 'log', 'aria-label': 'conversation' },
|
|
247
|
+
emptyState,
|
|
248
|
+
...rows.filter(Boolean),
|
|
249
|
+
showWorkingTail
|
|
250
|
+
? h('div', { key: '_working', class: 'agentchat-working', role: 'status', 'aria-live': 'polite' },
|
|
251
|
+
h('span', { class: 'chat-thinking-dots', 'aria-hidden': 'true' }, h('span'), h('span'), h('span')),
|
|
252
|
+
h('span', { class: 'agentchat-working-text' }, 'working…'))
|
|
253
|
+
: null),
|
|
254
|
+
// Jump-to-latest: hidden until the scroll listener adds .show (user scrolled
|
|
255
|
+
// up). Clicking returns to the live edge. Pure-DOM, like the kit's other
|
|
256
|
+
// stateless chrome, so the host needn't thread scroll state through state.
|
|
257
|
+
h('button', { class: 'agentchat-jump', type: 'button', 'aria-label': 'jump to latest', title: 'jump to latest',
|
|
258
|
+
onclick: (e) => scrollThreadToBottom(e.currentTarget) },
|
|
259
|
+
Icon('arrow-down', { size: 16 }), h('span', { class: 'agentchat-jump-label' }, 'latest'))),
|
|
207
260
|
composer,
|
|
208
261
|
);
|
|
209
262
|
}
|
package/src/components/chat.js
CHANGED
|
@@ -223,7 +223,7 @@ function renderPart(p, key) {
|
|
|
223
223
|
return node;
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typing, key, aicat, reactions, receipt, name }) {
|
|
226
|
+
export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typing, key, aicat, reactions, receipt, name, streaming, actions }) {
|
|
227
227
|
_stats.messages += 1;
|
|
228
228
|
// Support legacy 'who' prop, prefer 'role' with mapping:
|
|
229
229
|
// 'user' -> 'you' (right-aligned, accent bubble)
|
|
@@ -247,6 +247,11 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
|
|
|
247
247
|
if (typing) bodyNodes = [h('div', { class: 'chat-bubble', key: 'typb' }, h('span', { class: 'chat-typing' }, h('span'), h('span'), h('span')))];
|
|
248
248
|
else if (parts && parts.length) bodyNodes = parts.map((p, i) => renderPart(p, i));
|
|
249
249
|
else bodyNodes = [h('div', { class: 'chat-bubble', key: 't' }, ...renderInline(text || ''))];
|
|
250
|
+
// A blinking caret at the stream head: while an assistant turn is streaming
|
|
251
|
+
// AND already shows content (so the inline typing dots have stopped), append
|
|
252
|
+
// a thin caret so the live edge reads as "still writing", not "done". Drawn as
|
|
253
|
+
// a CSS element, not a glyph character.
|
|
254
|
+
if (streaming && !typing) bodyNodes = [...bodyNodes, h('span', { key: '_caret', class: 'chat-stream-caret', 'aria-hidden': 'true' })];
|
|
250
255
|
const reactionRow = reactions && reactions.length
|
|
251
256
|
? h('div', { class: 'chat-reactions' },
|
|
252
257
|
...reactions.map((r, i) => h('span', { class: 'rxn' + (r.you ? ' you' : ''), key: 'r' + i, 'aria-label': `${r.emoji} reaction (${String(r.count)} ${String(r.count) === '1' ? 'reaction' : 'reactions'})${r.you ? ' - you reacted' : ''}` },
|
|
@@ -260,7 +265,19 @@ export function ChatMessage({ role, who = 'them', avatar, text, parts, time, typ
|
|
|
260
265
|
if (time) metaItems.push(h('span', { class: 't', key: 'ti' }, time));
|
|
261
266
|
if (tickNode) metaItems.push(tickNode);
|
|
262
267
|
const meta = metaItems.length ? h('div', { class: 'chat-meta' }, ...metaItems) : null;
|
|
263
|
-
|
|
268
|
+
// Per-message actions (copy / retry / edit) — a hover-revealed control row
|
|
269
|
+
// below the bubble, the way Claude-Desktop surfaces message-level actions.
|
|
270
|
+
// Each action is { label, icon, onClick, title }. Kept icon-only with an
|
|
271
|
+
// accessible name; no decorative glyphs (the Icon set is line-SVG).
|
|
272
|
+
const actionRow = (actions && actions.length)
|
|
273
|
+
? h('div', { class: 'chat-msg-actions', role: 'group', 'aria-label': 'message actions' },
|
|
274
|
+
...actions.filter(Boolean).map((a, i) => h('button', {
|
|
275
|
+
key: 'ma' + i, type: 'button', class: 'chat-msg-action',
|
|
276
|
+
title: a.title || a.label, 'aria-label': a.label || a.title,
|
|
277
|
+
onclick: (e) => { e.preventDefault(); a.onClick && a.onClick(e); },
|
|
278
|
+
}, a.icon ? Icon(a.icon, { size: 14 }) : (a.label || ''))))
|
|
279
|
+
: null;
|
|
280
|
+
const stack = h('div', { class: 'chat-stack' }, ...bodyNodes, reactionRow, actionRow, meta);
|
|
264
281
|
// Centered roles (system/tool/thinking) skip the avatar column entirely so
|
|
265
282
|
// the bubble owns the full row — the chrome reads as out-of-band signal,
|
|
266
283
|
// not a participant turn.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// ContextPane — a compact right-hand context panel for the chat surface.
|
|
2
|
+
//
|
|
3
|
+
// Surfaces the current conversation's agent, model, working directory, and a
|
|
4
|
+
// live count of running tool calls in the in-flight turn. Built from the kit's
|
|
5
|
+
// Panel + Row primitives so it inherits the design tokens and rail semantics.
|
|
6
|
+
//
|
|
7
|
+
// Usage (consumer wires its own state):
|
|
8
|
+
// ContextPane({ agent, model, cwd, toolCount, onSetCwd })
|
|
9
|
+
//
|
|
10
|
+
// Props:
|
|
11
|
+
// agent : display name of the active agent (string) or falsy for "none"
|
|
12
|
+
// model : model id/name (string) or falsy
|
|
13
|
+
// cwd : the chat working directory (string) or falsy for server default
|
|
14
|
+
// toolCount : number of tool calls running in the current live turn (>=0)
|
|
15
|
+
// usage : OPTIONAL last-turn usage { inputTokens, outputTokens, costUsd, turns, durationMs }
|
|
16
|
+
// session : OPTIONAL session metadata { id, messages, startedAt } shown as a block
|
|
17
|
+
// onSetCwd : optional callback for the "set working directory" affordance
|
|
18
|
+
//
|
|
19
|
+
// No decorative glyphs — words + the kit's Icon SVGs only.
|
|
20
|
+
|
|
21
|
+
import * as webjsx from '../../vendor/webjsx/index.js';
|
|
22
|
+
import { Panel, Row } from './content.js';
|
|
23
|
+
import { Btn } from './shell.js';
|
|
24
|
+
|
|
25
|
+
const h = webjsx.createElement;
|
|
26
|
+
|
|
27
|
+
function fmtTok(n) {
|
|
28
|
+
if (n == null) return null;
|
|
29
|
+
if (n < 1000) return String(n);
|
|
30
|
+
if (n < 1000000) return (n / 1000).toFixed(n < 10000 ? 1 : 0) + 'k';
|
|
31
|
+
return (n / 1000000).toFixed(1) + 'M';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session, onSetCwd } = {}) {
|
|
35
|
+
const running = Number(toolCount) > 0;
|
|
36
|
+
// Each Panel's children array is all-unkeyed (no key prop on any sibling),
|
|
37
|
+
// so webjsx never sees a mixed keyed/unkeyed array here.
|
|
38
|
+
const panels = [
|
|
39
|
+
Panel({
|
|
40
|
+
title: 'context',
|
|
41
|
+
children: [
|
|
42
|
+
Row({ title: 'agent', meta: agent || 'none' }),
|
|
43
|
+
Row({ title: 'model', meta: model || '—' }),
|
|
44
|
+
Row({
|
|
45
|
+
title: 'working dir',
|
|
46
|
+
sub: cwd || 'server default',
|
|
47
|
+
// Use the rail tone consistently with the GUI-wide semantics:
|
|
48
|
+
// green = active/ok. A default cwd carries no rail (neutral).
|
|
49
|
+
rail: cwd ? 'green' : null,
|
|
50
|
+
}),
|
|
51
|
+
Row({
|
|
52
|
+
title: 'running tools',
|
|
53
|
+
meta: running ? String(toolCount) : 'idle',
|
|
54
|
+
rail: running ? 'purple' : null,
|
|
55
|
+
}),
|
|
56
|
+
],
|
|
57
|
+
}),
|
|
58
|
+
];
|
|
59
|
+
// Usage block: surface the last turn's token/cost/turn/duration so the
|
|
60
|
+
// result event is no longer silently dropped.
|
|
61
|
+
if (usage && (usage.inputTokens != null || usage.outputTokens != null || usage.costUsd != null)) {
|
|
62
|
+
const tokRows = [];
|
|
63
|
+
if (usage.inputTokens != null) tokRows.push(Row({ title: 'input', meta: fmtTok(usage.inputTokens) + ' tok' }));
|
|
64
|
+
if (usage.outputTokens != null) tokRows.push(Row({ title: 'output', meta: fmtTok(usage.outputTokens) + ' tok' }));
|
|
65
|
+
if (usage.costUsd != null) tokRows.push(Row({ title: 'cost', meta: '$' + usage.costUsd.toFixed(4) }));
|
|
66
|
+
if (usage.turns != null) tokRows.push(Row({ title: 'turns', meta: String(usage.turns) }));
|
|
67
|
+
if (usage.durationMs != null) tokRows.push(Row({ title: 'duration', meta: (usage.durationMs / 1000).toFixed(1) + 's' }));
|
|
68
|
+
panels.push(Panel({ title: 'last turn', children: tokRows }));
|
|
69
|
+
}
|
|
70
|
+
return h('div', { class: 'ds-context' },
|
|
71
|
+
...panels,
|
|
72
|
+
onSetCwd
|
|
73
|
+
? h('div', { class: 'ds-context-actions' },
|
|
74
|
+
Btn({ onClick: onSetCwd, children: 'set working dir' }))
|
|
75
|
+
: null,
|
|
76
|
+
);
|
|
77
|
+
}
|
package/src/components/files.js
CHANGED
|
@@ -77,7 +77,49 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
|
|
|
77
77
|
);
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
|
|
80
|
+
// FileSkeleton — placeholder shimmer rows shown while a directory loads, so the
|
|
81
|
+
// grid does not flash from a bare spinner to a full list (predictable perceived
|
|
82
|
+
// perf, the file-manager feel). `rows` controls how many ghost rows render.
|
|
83
|
+
export function FileSkeleton({ rows = 8 } = {}) {
|
|
84
|
+
return h('div', { class: 'ds-file-grid ds-file-skeleton', 'aria-hidden': 'true' },
|
|
85
|
+
...Array.from({ length: Math.max(1, rows) }, (_, i) => h('div', { key: 'sk' + i, class: 'ds-file-row ds-file-row-skeleton' },
|
|
86
|
+
h('span', { class: 'ds-skel ds-skel-icon' }),
|
|
87
|
+
h('span', { class: 'ds-skel ds-skel-title' }),
|
|
88
|
+
h('span', { class: 'ds-skel ds-skel-meta' })))
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Sort a file list by a key (name/size/modified/type), dirs-first always so the
|
|
93
|
+
// hierarchy reads top-down regardless of sort. `dir` is 'asc'|'desc'.
|
|
94
|
+
// `modifiedTs` (epoch ms) is used for the modified sort when present, since the
|
|
95
|
+
// `modified` field is a pre-formatted relative string the host passes for display.
|
|
96
|
+
export function sortFiles(files = [], sort = 'name', dir = 'asc') {
|
|
97
|
+
const mul = dir === 'desc' ? -1 : 1;
|
|
98
|
+
const cmp = (a, b) => {
|
|
99
|
+
// Directories always cluster before files; within a cluster, apply the sort.
|
|
100
|
+
const ad = a.type === 'dir' ? 0 : 1, bd = b.type === 'dir' ? 0 : 1;
|
|
101
|
+
if (ad !== bd) return ad - bd;
|
|
102
|
+
let r = 0;
|
|
103
|
+
if (sort === 'size') r = (a.size || 0) - (b.size || 0);
|
|
104
|
+
else if (sort === 'modified') r = (a.modifiedTs || 0) - (b.modifiedTs || 0);
|
|
105
|
+
else if (sort === 'type') r = String(a.type || '').localeCompare(String(b.type || ''));
|
|
106
|
+
else r = String(a.name || '').localeCompare(String(b.name || ''), undefined, { numeric: true, sensitivity: 'base' });
|
|
107
|
+
return r * mul || String(a.name || '').localeCompare(String(b.name || ''));
|
|
108
|
+
};
|
|
109
|
+
return files.slice().sort(cmp);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// FileGrid — the directory listing. Optional in-grid sort + filter make it a
|
|
113
|
+
// real file manager rather than a static dump:
|
|
114
|
+
// sort : { key, dir, onSort(key) } - clickable column headers (name/size/modified)
|
|
115
|
+
// filter : { value, onInput, placeholder } - a quick in-dir name filter
|
|
116
|
+
// onOpen(f) opens a row; onAction(act,f) wires the per-row download/rename/delete.
|
|
117
|
+
// Keyboard nav: the grid is a focusable listbox - ArrowUp/Down move the active
|
|
118
|
+
// row, Enter opens it, Backspace asks the host to go up (onUp). The host keeps no
|
|
119
|
+
// focus state; the grid tracks it on the DOM via roving tabindex.
|
|
120
|
+
export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'no files here yet',
|
|
121
|
+
columns = 'auto', sort, filter, loading = false } = {}) {
|
|
122
|
+
if (loading) return FileSkeleton({});
|
|
81
123
|
if (!files.length) return EmptyState({ text: emptyText });
|
|
82
124
|
const gridAttrs = {};
|
|
83
125
|
if (columns !== 'auto' && columns > 0) {
|
|
@@ -89,7 +131,27 @@ export function FileGrid({ files = [], onOpen, onAction, emptyText = 'no files h
|
|
|
89
131
|
gap: 'var(--space-3)'
|
|
90
132
|
};
|
|
91
133
|
}
|
|
92
|
-
|
|
134
|
+
// Keyboard: roving focus over the .ds-file-open buttons inside the grid.
|
|
135
|
+
const onKeyDown = (e) => {
|
|
136
|
+
const grid = e.currentTarget;
|
|
137
|
+
const opens = Array.from(grid.querySelectorAll('.ds-file-open:not([disabled])'));
|
|
138
|
+
if (!opens.length) return;
|
|
139
|
+
const cur = opens.indexOf(document.activeElement);
|
|
140
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); opens[Math.min(opens.length - 1, cur + 1)]?.focus(); }
|
|
141
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); (cur <= 0 ? opens[0] : opens[cur - 1])?.focus(); }
|
|
142
|
+
else if (e.key === 'Home') { e.preventDefault(); opens[0]?.focus(); }
|
|
143
|
+
else if (e.key === 'End') { e.preventDefault(); opens[opens.length - 1]?.focus(); }
|
|
144
|
+
else if (e.key === 'Backspace') { e.preventDefault(); onUp && onUp(); }
|
|
145
|
+
};
|
|
146
|
+
const head = sort ? FileSortHeader(sort) : null;
|
|
147
|
+
const filterBar = filter ? h('div', { class: 'ds-file-filter' },
|
|
148
|
+
h('input', {
|
|
149
|
+
class: 'ds-file-filter-input', type: 'search',
|
|
150
|
+
value: filter.value || '', placeholder: filter.placeholder || 'Filter files',
|
|
151
|
+
'aria-label': filter.placeholder || 'Filter files in this directory',
|
|
152
|
+
oninput: (e) => filter.onInput && filter.onInput(e.target.value),
|
|
153
|
+
})) : null;
|
|
154
|
+
const grid = h('div', { class: 'ds-file-grid', role: 'listbox', 'aria-label': 'files', tabindex: '0', onkeydown: onKeyDown, ...gridAttrs },
|
|
93
155
|
...files.map((f, i) => FileRow({
|
|
94
156
|
key: f.path || f.name + i,
|
|
95
157
|
name: f.name, type: f.type, size: f.size, modified: f.modified, code: f.code, active: f.active,
|
|
@@ -97,6 +159,23 @@ export function FileGrid({ files = [], onOpen, onAction, emptyText = 'no files h
|
|
|
97
159
|
onAction: onAction ? (act) => onAction(act, f) : null
|
|
98
160
|
}))
|
|
99
161
|
);
|
|
162
|
+
return (head || filterBar)
|
|
163
|
+
? h('div', { class: 'ds-file-listing' }, filterBar, head, grid)
|
|
164
|
+
: grid;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Clickable column headers for FileGrid sort. Active column shows its direction
|
|
168
|
+
// as an ASCII caret word (asc/desc) - never a glyph arrow.
|
|
169
|
+
function FileSortHeader({ key: active = 'name', dir = 'asc', onSort } = {}) {
|
|
170
|
+
const cols = [['name', 'name'], ['size', 'size'], ['modified', 'modified']];
|
|
171
|
+
return h('div', { class: 'ds-file-sort', role: 'group', 'aria-label': 'sort files' },
|
|
172
|
+
...cols.map(([k, label]) => h('button', {
|
|
173
|
+
key: k, type: 'button',
|
|
174
|
+
class: 'ds-file-sort-btn' + (active === k ? ' active' : ''),
|
|
175
|
+
'aria-pressed': active === k ? 'true' : 'false',
|
|
176
|
+
'aria-label': 'sort by ' + label + (active === k ? ' (' + (dir === 'asc' ? 'ascending' : 'descending') + ')' : ''),
|
|
177
|
+
onclick: () => onSort && onSort(k),
|
|
178
|
+
}, label + (active === k ? ' ' + (dir === 'asc' ? 'asc' : 'desc') : ''))));
|
|
100
179
|
}
|
|
101
180
|
|
|
102
181
|
export function FileToolbar({ left = [], right = [] } = {}) {
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Session surfaces — a persistent conversation list (left-rail "Chats") and a
|
|
2
|
+
// live multi-session dashboard. Pure factories: props in, webjsx vnode out, all
|
|
3
|
+
// interaction via host callbacks. Styling lives in chat.css (.ds-session*,
|
|
4
|
+
// .ds-dash*) using kit tokens; no transport, no decorative glyphs.
|
|
5
|
+
|
|
6
|
+
import * as webjsx from '../../vendor/webjsx/index.js';
|
|
7
|
+
import { Btn, Icon } from './shell.js';
|
|
8
|
+
const h = webjsx.createElement;
|
|
9
|
+
|
|
10
|
+
// ConversationList — the Claude-Desktop "Chats" column. Sessions grouped by a
|
|
11
|
+
// caller-supplied group label, each row showing title/project, relative time,
|
|
12
|
+
// agent badge, and a running/new-event indicator. Selecting a row switches the
|
|
13
|
+
// active conversation.
|
|
14
|
+
//
|
|
15
|
+
// sessions : [{ sid, title, project, agent, time, running, unread, rail }]
|
|
16
|
+
// selected : the active sid
|
|
17
|
+
// groups : OPTIONAL [{ label, sids:[...] }] to bucket rows; else one flat list
|
|
18
|
+
// search : { value, onInput, placeholder } inline filter (optional)
|
|
19
|
+
// onSelect(session), onNew() : intents
|
|
20
|
+
// emptyText, loading, error : explicit states
|
|
21
|
+
export function ConversationList({ sessions = [], selected, groups, search,
|
|
22
|
+
onSelect, onNew, newLabel = 'New chat',
|
|
23
|
+
emptyText = 'No conversations yet', loading = false, error = null } = {}) {
|
|
24
|
+
const rowFor = (s, i) => h('button', {
|
|
25
|
+
// Stable key: prefer sid, else position - a missing/duplicate sid would make
|
|
26
|
+
// key undefined and crash webjsx applyDiff ("reading 'key'" of undefined).
|
|
27
|
+
key: 'cs-' + (s.sid != null ? s.sid : 'i' + i),
|
|
28
|
+
type: 'button',
|
|
29
|
+
class: 'ds-session-row' + (s.sid === selected ? ' active' : '') + (s.rail ? ' rail-' + s.rail : ''),
|
|
30
|
+
'aria-current': s.sid === selected ? 'true' : null,
|
|
31
|
+
onclick: () => onSelect && onSelect(s),
|
|
32
|
+
},
|
|
33
|
+
// Positional children must NOT mix keyed VElements with null/strings (webjsx
|
|
34
|
+
// applyDiff crashes "reading 'key'"). Keep these unkeyed and filter nulls so
|
|
35
|
+
// each h() call gets a clean, consistent child list.
|
|
36
|
+
h('span', { class: 'ds-session-main' }, [
|
|
37
|
+
h('span', { class: 'ds-session-title' }, s.title || s.project || s.sid || ''),
|
|
38
|
+
(s.project || s.time) ? h('span', { class: 'ds-session-sub' },
|
|
39
|
+
[s.project, s.time].filter(Boolean).join(' · ')) : null,
|
|
40
|
+
].filter(Boolean)),
|
|
41
|
+
h('span', { class: 'ds-session-meta' }, [
|
|
42
|
+
s.agent ? h('span', { class: 'ds-session-agent' }, s.agent) : null,
|
|
43
|
+
s.running
|
|
44
|
+
? h('span', { class: 'status-dot-disc status-dot-live', 'aria-label': 'running', role: 'img' })
|
|
45
|
+
: (s.unread ? h('span', { class: 'ds-session-unread', 'aria-label': 'new activity', role: 'img' }) : null),
|
|
46
|
+
].filter(Boolean)));
|
|
47
|
+
|
|
48
|
+
// The body is ALWAYS a single keyed wrapper element of the same tag, so webjsx
|
|
49
|
+
// diffs its children across state transitions (loading -> empty -> populated)
|
|
50
|
+
// instead of swapping the container type - the swap is what triggered the
|
|
51
|
+
// applyDiff "reading 'key'" crash on the first populated mount. Row children
|
|
52
|
+
// are uniformly keyed; non-row states render a single unkeyed status line.
|
|
53
|
+
let inner;
|
|
54
|
+
if (loading) {
|
|
55
|
+
inner = [h('div', { key: 'st', class: 'ds-session-state', role: 'status', 'aria-live': 'polite' }, 'Loading conversations…')];
|
|
56
|
+
} else if (error) {
|
|
57
|
+
inner = [h('div', { key: 'st', class: 'ds-session-state ds-session-state-error', role: 'status' }, String(error))];
|
|
58
|
+
} else if (!sessions.length) {
|
|
59
|
+
inner = [h('div', { key: 'st', class: 'ds-session-state', role: 'status' }, emptyText)];
|
|
60
|
+
} else if (groups && groups.length) {
|
|
61
|
+
const bySid = new Map(sessions.map((s) => [s.sid, s]));
|
|
62
|
+
inner = groups.map((g) => h('div', { key: 'g-' + g.label, class: 'ds-session-group', role: 'group', 'aria-label': g.label },
|
|
63
|
+
h('div', { key: 'gl', class: 'ds-session-group-label' }, g.label),
|
|
64
|
+
h('div', { key: 'gr', class: 'ds-session-group-rows', role: 'list' }, ...g.sids.map((sid) => bySid.get(sid)).filter(Boolean).map(rowFor))));
|
|
65
|
+
} else {
|
|
66
|
+
inner = sessions.map(rowFor);
|
|
67
|
+
}
|
|
68
|
+
const body = h('div', { key: 'body', class: 'ds-session-list', role: 'list' }, ...inner);
|
|
69
|
+
|
|
70
|
+
return h('div', { class: 'ds-sessions' },
|
|
71
|
+
h('div', { key: 'head', class: 'ds-session-head' },
|
|
72
|
+
onNew ? h('button', { key: 'new', type: 'button', class: 'ds-session-new', onclick: onNew, 'aria-label': newLabel },
|
|
73
|
+
Icon('pencil'), h('span', { key: 'l' }, newLabel)) : null,
|
|
74
|
+
search ? h('input', {
|
|
75
|
+
key: 'search', type: 'search', class: 'ds-session-search',
|
|
76
|
+
value: search.value || '', placeholder: search.placeholder || 'Search conversations',
|
|
77
|
+
'aria-label': search.placeholder || 'Search conversations',
|
|
78
|
+
oninput: (e) => search.onInput && search.onInput(e.target.value),
|
|
79
|
+
}) : null),
|
|
80
|
+
body);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// SessionCard — one running session in the live dashboard. Status dot, agent /
|
|
84
|
+
// model / cwd, elapsed, live counter, last activity, and per-session controls
|
|
85
|
+
// that each act on this session's id independently.
|
|
86
|
+
//
|
|
87
|
+
// session : { sid, agent, model, cwd, elapsed, counter, lastActivity, currentTool, status }
|
|
88
|
+
// actions : { onStop, onOpen, onResume, onView } (any subset)
|
|
89
|
+
// `counter` carries the live activity tally (e.g. "12 ev · 3 tools"); `lastActivity`
|
|
90
|
+
// the relative time of the most-recent event ("4s ago"); `currentTool` the tool
|
|
91
|
+
// name a still-running turn is executing - together they distinguish a busy
|
|
92
|
+
// session from a stuck one (a frozen elapsed alone reads identically for both).
|
|
93
|
+
export function SessionCard({ session = {}, onStop, onOpen, onResume, onView } = {}) {
|
|
94
|
+
const s = session;
|
|
95
|
+
const statusTone = s.status === 'error' ? 'flame' : 'live';
|
|
96
|
+
// The stat line composes elapsed + live counter; the activity line carries the
|
|
97
|
+
// last-activity time and the current tool so a card shows MOTION, not just a
|
|
98
|
+
// start offset. Both are middot-joined (kept product separator).
|
|
99
|
+
const statBits = [s.elapsed != null ? s.elapsed : null, s.counter != null ? s.counter : null].filter((x) => x != null && x !== '');
|
|
100
|
+
const activityBits = [
|
|
101
|
+
s.currentTool ? 'running: ' + s.currentTool : null,
|
|
102
|
+
s.lastActivity ? 'last ' + s.lastActivity : null,
|
|
103
|
+
].filter(Boolean);
|
|
104
|
+
return h('div', { class: 'ds-dash-card' + (s.status === 'error' ? ' is-error' : ''), role: 'group', 'aria-label': 'session ' + (s.agent || s.sid) },
|
|
105
|
+
h('div', { class: 'ds-dash-card-head' },
|
|
106
|
+
h('span', { class: 'status-dot-disc ' + (statusTone === 'live' ? 'status-dot-live' : 'status-dot-error'), 'aria-hidden': 'true' }),
|
|
107
|
+
// Status is words + the disc, never colour alone (WCAG 1.4.1): the disc is
|
|
108
|
+
// aria-hidden, so the visible/AT status word carries the state.
|
|
109
|
+
h('span', { class: 'ds-dash-status ' + (s.status === 'error' ? 'is-error' : 'is-running') }, s.status === 'error' ? 'error' : 'running'),
|
|
110
|
+
h('span', { class: 'ds-dash-agent' }, s.agent || 'agent'),
|
|
111
|
+
s.model ? h('span', { class: 'ds-dash-model' }, s.model) : null),
|
|
112
|
+
h('div', { class: 'ds-dash-meta' },
|
|
113
|
+
s.cwd ? h('span', { class: 'ds-dash-cwd', title: s.cwd }, s.cwd) : null,
|
|
114
|
+
statBits.length ? h('span', { class: 'ds-dash-stat' }, statBits.join(' · ')) : null,
|
|
115
|
+
activityBits.length ? h('span', { class: 'ds-dash-activity' }, activityBits.join(' · ')) : null),
|
|
116
|
+
h('div', { class: 'ds-dash-actions', role: 'group', 'aria-label': 'session actions' },
|
|
117
|
+
onOpen ? Btn({ key: 'open', onClick: () => onOpen(s), children: 'open' }) : null,
|
|
118
|
+
onResume ? Btn({ key: 'resume', onClick: () => onResume(s), children: 'resume' }) : null,
|
|
119
|
+
onView ? Btn({ key: 'view', onClick: () => onView(s), children: 'events' }) : null,
|
|
120
|
+
onStop ? Btn({ key: 'stop', danger: true, onClick: () => onStop(s), children: 'stop' }) : null));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// SessionDashboard — grid of SessionCards for ALL live sessions, managed at once.
|
|
124
|
+
// sessions : [{ sid, agent, model, cwd, elapsed, counter, lastActivity, currentTool, status }]
|
|
125
|
+
// actions : { onStop, onOpen, onResume, onView } passed to each card
|
|
126
|
+
// onStopAll : OPTIONAL bulk control - stop every running session at once
|
|
127
|
+
// emptyText, offline : explicit states
|
|
128
|
+
// The bulk header is the "manage many at once" affordance: a live count plus a
|
|
129
|
+
// stop-all button, so a user running several agents does not have to hunt each
|
|
130
|
+
// card's stop. Rendered only when there are sessions AND onStopAll is wired.
|
|
131
|
+
export function SessionDashboard({ sessions = [], onStop, onOpen, onResume, onView, onStopAll,
|
|
132
|
+
emptyText = 'No live sessions', offline = false } = {}) {
|
|
133
|
+
if (offline) {
|
|
134
|
+
return h('div', { class: 'ds-dash-state ds-dash-state-error', role: 'status' }, 'Backend offline — live sessions unavailable');
|
|
135
|
+
}
|
|
136
|
+
if (!sessions.length) {
|
|
137
|
+
return h('div', { class: 'ds-dash-state', role: 'status' }, emptyText);
|
|
138
|
+
}
|
|
139
|
+
const header = h('div', { class: 'ds-dash-header', role: 'group', 'aria-label': 'live session controls' },
|
|
140
|
+
h('span', { class: 'ds-dash-count', role: 'status', 'aria-live': 'polite' },
|
|
141
|
+
sessions.length + ' running'),
|
|
142
|
+
onStopAll ? Btn({ key: 'stopall', danger: true, onClick: () => onStopAll(sessions), children: 'stop all' }) : null);
|
|
143
|
+
const grid = h('div', { class: 'ds-dash-grid', role: 'list', 'aria-label': 'live sessions' },
|
|
144
|
+
...sessions.map((s) => h('div', { key: s.sid, role: 'listitem' },
|
|
145
|
+
SessionCard({ session: s, onStop, onOpen, onResume, onView }))));
|
|
146
|
+
return h('div', { class: 'ds-dash' }, header, grid);
|
|
147
|
+
}
|