anentrypoint-design 0.0.200 → 0.0.202

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.
@@ -13,7 +13,7 @@
13
13
  // cwd : the chat working directory (string) or falsy for server default
14
14
  // toolCount : number of tool calls running in the current live turn (>=0)
15
15
  // usage : OPTIONAL last-turn usage { inputTokens, outputTokens, costUsd, turns, durationMs }
16
- // session : OPTIONAL session metadata { id, messages, startedAt } shown as a block
16
+ // session : OPTIONAL whole-conversation totals { turns, cost } shown as a block
17
17
  // onSetCwd : optional callback for the "set working directory" affordance
18
18
  //
19
19
  // No decorative glyphs — words + the kit's Icon SVGs only.
@@ -21,6 +21,7 @@
21
21
  import * as webjsx from '../../vendor/webjsx/index.js';
22
22
  import { Panel, Row } from './content.js';
23
23
  import { Btn } from './shell.js';
24
+ import { fmtDuration } from './sessions.js';
24
25
 
25
26
  const h = webjsx.createElement;
26
27
 
@@ -34,10 +35,11 @@ function fmtTok(n) {
34
35
  export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session, onSetCwd } = {}) {
35
36
  const running = Number(toolCount) > 0;
36
37
  const hasUsage = usage && (usage.inputTokens != null || usage.outputTokens != null || usage.costUsd != null);
38
+ const hasSession = session && (session.turns != null || session.cost != null);
37
39
  // Empty state: before an agent is picked AND with no usage/session, four
38
40
  // placeholder rows (agent: none / model: dash / ...) read as a dead panel.
39
41
  // Show one honest line instead.
40
- if (!agent && !hasUsage && !session && !cwd) {
42
+ if (!agent && !hasUsage && !hasSession && !cwd) {
41
43
  return h('div', { class: 'ds-context' },
42
44
  h('div', { class: 'ds-context-empty', role: 'status' },
43
45
  'No active conversation — start a chat to see context here'),
@@ -66,6 +68,14 @@ export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session,
66
68
  ],
67
69
  }),
68
70
  ];
71
+ // Conversation block: whole-session totals (turn count + accumulated cost)
72
+ // between the context panel and the per-turn usage panel.
73
+ if (hasSession) {
74
+ const sesRows = [];
75
+ if (session.turns != null) sesRows.push(Row({ title: 'turns', meta: String(session.turns) }));
76
+ if (session.cost != null) sesRows.push(Row({ title: 'total cost', meta: '$' + Number(session.cost).toFixed(4) }));
77
+ panels.push(Panel({ title: 'conversation', children: sesRows }));
78
+ }
69
79
  // Usage block: surface the last turn's token/cost/turn/duration so the
70
80
  // result event is no longer silently dropped.
71
81
  if (hasUsage) {
@@ -74,7 +84,8 @@ export function ContextPane({ agent, model, cwd, toolCount = 0, usage, session,
74
84
  if (usage.outputTokens != null) tokRows.push(Row({ title: 'output', meta: fmtTok(usage.outputTokens) + ' tok' }));
75
85
  if (usage.costUsd != null) tokRows.push(Row({ title: 'cost', meta: '$' + usage.costUsd.toFixed(4) }));
76
86
  if (usage.turns != null) tokRows.push(Row({ title: 'turns', meta: String(usage.turns) }));
77
- if (usage.durationMs != null) tokRows.push(Row({ title: 'duration', meta: (usage.durationMs / 1000).toFixed(1) + 's' }));
87
+ // One duration vocabulary kit-wide: shared fmtDuration (s -> m -> h).
88
+ if (usage.durationMs != null) tokRows.push(Row({ title: 'duration', meta: fmtDuration(usage.durationMs) }));
78
89
  panels.push(Panel({ title: 'last turn', children: tokRows }));
79
90
  }
80
91
  return h('div', { class: 'ds-context' },
@@ -8,7 +8,7 @@ const h = webjsx.createElement;
8
8
  // Monotonic id source for aria-labelledby wiring between a modal and its head.
9
9
  let _modalSeq = 0;
10
10
 
11
- function Backdrop({ onClose, children, kind = '', labelledBy } = {}) {
11
+ function Backdrop({ onClose, children, kind = '', labelledBy, busy = false } = {}) {
12
12
  // webjsx invokes a ref callback with the element on mount and with null on
13
13
  // unmount. We stash the per-element keydown teardown on the node itself so
14
14
  // the null branch can run it — otherwise the document/element listener leaks
@@ -26,9 +26,12 @@ function Backdrop({ onClose, children, kind = '', labelledBy } = {}) {
26
26
  const lastFocusable = focusables[focusables.length - 1];
27
27
 
28
28
  const handleKeydown = (e) => {
29
- // Escape closes the modal
29
+ // Escape closes the modal — unless a mutation is in flight (the live
30
+ // busy state is read off the data-busy attribute, which re-renders;
31
+ // this handler's closure is bound once at mount).
30
32
  if (e.key === 'Escape') {
31
33
  e.preventDefault();
34
+ if (el.dataset.busy === '1') return;
32
35
  if (onClose) onClose();
33
36
  return;
34
37
  }
@@ -52,22 +55,61 @@ function Backdrop({ onClose, children, kind = '', labelledBy } = {}) {
52
55
  }
53
56
  };
54
57
 
55
- el.addEventListener('keydown', handleKeydown);
56
- el._dsModalTeardown = () => el.removeEventListener('keydown', handleKeydown);
57
- // Auto-focus on open — always, not only when focus sits on <body>.
58
- // Prefer an element explicitly marked [autofocus].
59
- const preferred = modal.querySelector('[autofocus]') || firstFocusable;
60
- if (preferred) preferred.focus();
58
+ // Escape must close the modal no matter where focus sits (re-renders
59
+ // can bounce focus out of the dialog), so listen at document level
60
+ // for the modal's lifetime.
61
+ document.addEventListener('keydown', handleKeydown, true);
62
+ // Record the invoker BEFORE the modal steals focus, so close (confirm,
63
+ // cancel, Escape, backdrop click) restores keyboard/AT focus to where
64
+ // the user was (e.g. the FileGrid row button) instead of <body>.
65
+ // Re-mounts mid-lifetime (every app render re-runs this ref) keep the
66
+ // ORIGINAL invoker and never re-steal focus from the user.
67
+ const invoker = el.contains(document.activeElement) ? (Backdrop._invoker || document.activeElement) : document.activeElement;
68
+ if (!Backdrop._invoker) Backdrop._invoker = invoker;
69
+ el._dsModalTeardown = (removed) => {
70
+ document.removeEventListener('keydown', handleKeydown, true);
71
+ // Only restore focus when the modal is genuinely going away (not a
72
+ // re-render remount) and focus is not already somewhere useful.
73
+ if (removed && Backdrop._invoker && Backdrop._invoker.focus && Backdrop._invoker.isConnected) {
74
+ try { Backdrop._invoker.focus(); } catch {}
75
+ }
76
+ if (removed) Backdrop._invoker = null;
77
+ };
78
+ // Auto-focus on open - only when focus is not already inside the modal
79
+ // (re-renders must not yank the caret around).
80
+ if (!el.contains(document.activeElement)) {
81
+ const preferred = modal.querySelector('[autofocus]') || firstFocusable;
82
+ if (preferred) preferred.focus();
83
+ }
61
84
  };
62
85
 
63
86
  return h('div', {
64
87
  class: 'ds-modal-backdrop',
88
+ // Live busy flag read by the mount-bound Escape handler + backdrop click.
89
+ 'data-busy': busy ? '1' : '0',
65
90
  ref: (el) => {
66
- if (el) backdropRef(el);
67
- else if (Backdrop._last && Backdrop._last._dsModalTeardown) { Backdrop._last._dsModalTeardown(); Backdrop._last = null; }
68
- if (el) Backdrop._last = el;
91
+ if (el) {
92
+ // A remount in the same tick (render churn) is not a close:
93
+ // cancel the pending removal teardown before re-binding.
94
+ Backdrop._pendingRemoval = false;
95
+ backdropRef(el);
96
+ Backdrop._last = el;
97
+ } else if (Backdrop._last && Backdrop._last._dsModalTeardown) {
98
+ const t = Backdrop._last._dsModalTeardown;
99
+ Backdrop._last = null;
100
+ Backdrop._pendingRemoval = true;
101
+ t(false); // always unhook the document listener now
102
+ queueMicrotask(() => {
103
+ // Still gone next microtask -> genuine close: restore focus.
104
+ if (Backdrop._pendingRemoval) { t(true); Backdrop._pendingRemoval = false; }
105
+ });
106
+ }
69
107
  },
70
- onclick: (e) => { if (e.target === e.currentTarget && onClose) onClose(); }
108
+ onclick: (e) => {
109
+ if (e.target !== e.currentTarget) return;
110
+ if (e.currentTarget.dataset.busy === '1') return; // no mid-flight close
111
+ if (onClose) onClose();
112
+ }
71
113
  },
72
114
  h('div', {
73
115
  class: 'ds-modal' + (kind ? ' ds-modal-' + kind : ''),
@@ -81,13 +123,14 @@ function Backdrop({ onClose, children, kind = '', labelledBy } = {}) {
81
123
  // FileViewer all funnel through this so the ds-modal markup is authored once.
82
124
  // `actions` is an array of vnodes (already using the Btn primitive). Any of the
83
125
  // slots may be omitted.
84
- function Modal({ onClose, kind = '', head, headClass = '', headAttrs = {}, body, bodyClass = 'ds-modal-body', bodyAttrs = {}, actions } = {}) {
126
+ function Modal({ onClose, kind = '', head, headClass = '', headAttrs = {}, body, bodyClass = 'ds-modal-body', bodyAttrs = {}, actions, busy = false } = {}) {
85
127
  // Give the head a stable id so the dialog can point aria-labelledby at it,
86
128
  // exposing the title as the dialog's accessible name to screen readers.
87
129
  const headId = head != null ? ('ds-modal-head-' + (++_modalSeq)) : null;
88
130
  return Backdrop({
89
131
  onClose,
90
132
  kind,
133
+ busy,
91
134
  labelledBy: headId,
92
135
  children: [
93
136
  head != null ? h('div', { id: headId, class: ('ds-modal-head' + (headClass ? ' ' + headClass : '')), ...headAttrs }, ...(Array.isArray(head) ? head : [head])) : null,
@@ -97,40 +140,56 @@ function Modal({ onClose, kind = '', head, headClass = '', headAttrs = {}, body,
97
140
  });
98
141
  }
99
142
 
100
- export function ConfirmDialog({ title = 'confirm', message, confirmLabel = 'confirm', cancelLabel = 'cancel', destructive, onConfirm, onCancel } = {}) {
143
+ // A role=alert error line rendered INSIDE the modal body (so a 409/403 from a
144
+ // mutation is visible at the point of action, inside the focus trap — not a
145
+ // sibling stuck in page flow behind the fixed backdrop).
146
+ function modalError(error) {
147
+ return error ? h('p', { class: 'ds-modal-error field-error', role: 'alert' }, String(error)) : null;
148
+ }
149
+
150
+ // `error` renders inside .ds-modal-body (role=alert, error tone). `busy`
151
+ // disables both action buttons AND the Escape/backdrop close paths; the confirm
152
+ // label flips to `busyLabel` (default 'working…') so the in-flight state reads.
153
+ export function ConfirmDialog({ title = 'confirm', message, confirmLabel = 'confirm', cancelLabel = 'cancel', destructive, onConfirm, onCancel, error, busy = false, busyLabel = 'working…' } = {}) {
101
154
  return Modal({
102
155
  onClose: onCancel,
103
156
  kind: 'small',
157
+ busy,
104
158
  head: title,
105
- body: message || '',
159
+ body: [message || '', modalError(error)].filter(Boolean),
106
160
  actions: [
107
- Btn({ onClick: onCancel, children: cancelLabel }),
108
- Btn({ primary: true, danger: !!destructive, onClick: onConfirm, children: confirmLabel })
161
+ Btn({ onClick: onCancel, disabled: busy, children: cancelLabel }),
162
+ Btn({ primary: true, danger: !!destructive, disabled: busy, onClick: onConfirm, children: busy ? busyLabel : confirmLabel })
109
163
  ]
110
164
  });
111
165
  }
112
166
 
113
- export function PromptDialog({ title = 'name', value = '', placeholder = '', confirmLabel = 'ok', cancelLabel = 'cancel', onConfirm, onCancel, onInput } = {}) {
167
+ export function PromptDialog({ title = 'name', value = '', placeholder = '', confirmLabel = 'ok', cancelLabel = 'cancel', onConfirm, onCancel, onInput, error, busy = false, busyLabel = 'working…' } = {}) {
114
168
  return Modal({
115
169
  onClose: onCancel,
116
170
  kind: 'small',
171
+ busy,
117
172
  head: title,
118
- body: h('input', {
173
+ body: [h('input', {
119
174
  class: 'input ds-modal-input',
120
175
  type: 'text',
121
176
  value,
122
177
  placeholder,
123
178
  autofocus: true,
179
+ disabled: busy ? true : null,
180
+ 'aria-invalid': error ? 'true' : null,
124
181
  oninput: (e) => onInput && onInput(e.target.value),
125
182
  onkeydown: (e) => {
126
- if (e.key === 'Enter') { e.preventDefault(); onConfirm && onConfirm(e.target.value); }
127
- if (e.key === 'Escape') { e.preventDefault(); onCancel && onCancel(); }
183
+ // IME guard: the Enter that commits a CJK composition must not confirm.
184
+ if (e.key === 'Enter' && !e.isComposing && e.keyCode !== 229) { e.preventDefault(); if (!busy) onConfirm && onConfirm(e.target.value); }
185
+ if (e.key === 'Escape') { e.preventDefault(); if (!busy) onCancel && onCancel(); }
128
186
  }
129
- }),
187
+ }), modalError(error)].filter(Boolean),
130
188
  actions: [
131
- Btn({ onClick: onCancel, children: cancelLabel }),
189
+ Btn({ onClick: onCancel, disabled: busy, children: cancelLabel }),
132
190
  Btn({
133
191
  primary: true,
192
+ disabled: busy,
134
193
  // Read the live input value, not the closed-over `value` prop:
135
194
  // consumers update their state in oninput without re-rendering
136
195
  // (to avoid caret jump), so the prop is stale at click time.
@@ -139,7 +198,7 @@ export function PromptDialog({ title = 'name', value = '', placeholder = '', con
139
198
  const inp = e.currentTarget.closest('.ds-modal')?.querySelector('.ds-modal-input');
140
199
  onConfirm(inp ? inp.value : value);
141
200
  },
142
- children: confirmLabel
201
+ children: busy ? busyLabel : confirmLabel
143
202
  })
144
203
  ]
145
204
  });
@@ -31,8 +31,11 @@ export function fileGlyph(type) {
31
31
  return TYPE_ICON[type] || TYPE_ICON.other;
32
32
  }
33
33
 
34
+ // The canonical kit byte formatter (chat.js re-exports it as fmtBytes). One
35
+ // format everywhere: '0 B' for zero; the em-dash means unknown/null ONLY.
34
36
  export function fmtFileSize(bytes) {
35
- if (bytes == null || bytes === 0) return '—';
37
+ if (bytes == null) return '—';
38
+ if (bytes === 0) return '0 B';
36
39
  const u = ['B', 'KB', 'MB', 'GB', 'TB'];
37
40
  let i = 0, n = bytes;
38
41
  while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
@@ -43,7 +46,12 @@ export function FileIcon({ type = 'other' } = {}) {
43
46
  return h('span', { class: 'ds-file-icon', 'data-file-type': type, 'aria-label': TYPE_LABELS[type] || 'file', role: 'img' }, Icon(fileGlyph(type)));
44
47
  }
45
48
 
46
- export function FileRow({ name, type = 'other', size, modified, code, onOpen, onAction, active, key, permissions, locked } = {}) {
49
+ // Default action set for FileRow. A host without mutation endpoints passes a
50
+ // narrower `actions` list (e.g. ['download']) so the row renders no dead controls.
51
+ const FILE_ROW_ACTIONS = ['download', 'rename', 'delete'];
52
+
53
+ export function FileRow({ name, type = 'other', size, modified, code, onOpen, onAction, active, key, permissions, locked,
54
+ actions = FILE_ROW_ACTIONS, busy = false } = {}) {
47
55
  // permissions: ['read','write'] | ['read'] | 'EACCES'. A no-access entry can
48
56
  // be listed (the dir stat saw it) but not opened — show an ASCII tag and
49
57
  // disable the open button so the row reads honestly instead of silently
@@ -54,7 +62,29 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
54
62
  const meta = [type === 'dir' ? null : fmtFileSize(size), modified || null, permTag].filter(Boolean).join(' · ');
55
63
  const typeLabel = TYPE_LABELS[type] || 'file';
56
64
  const accessibleLabel = `${typeLabel}: ${name}${meta ? ` (${meta})` : ''}`;
57
- const canOpen = onOpen && !noAccess;
65
+ const canOpen = onOpen && !noAccess && !busy;
66
+ // Mutation actions on a read-only/no-access row render disabled (with a
67
+ // 'read-only' title) instead of vanishing, so the affordance reads honestly.
68
+ // `busy` (in-flight mutation) disables every control on the row.
69
+ const mutateDisabled = busy || readOnly || noAccess;
70
+ const actBtn = (act, title, ariaLabel, icon, warn) => h('button', {
71
+ key: 'act-' + act,
72
+ type: 'button',
73
+ class: 'ds-file-act' + (warn ? ' ds-file-act-warn' : ''),
74
+ title: mutateDisabled && act !== 'download' ? 'read-only' : title,
75
+ 'aria-label': ariaLabel,
76
+ disabled: (act === 'download' ? busy : mutateDisabled) ? true : null,
77
+ 'aria-disabled': (act === 'download' ? busy : mutateDisabled) ? 'true' : null,
78
+ onclick: () => onAction(act),
79
+ }, Icon(icon));
80
+ const actionBtns = onAction ? [
81
+ actions.indexOf('download') !== -1 && type !== 'dir'
82
+ ? actBtn('download', 'download', `download ${name}`, 'arrow-down', false) : null,
83
+ actions.indexOf('rename') !== -1
84
+ ? actBtn('rename', 'rename', `rename ${name}`, 'pencil', false) : null,
85
+ actions.indexOf('delete') !== -1
86
+ ? actBtn('delete', 'delete', `delete ${name}`, 'x', true) : null,
87
+ ].filter(Boolean) : [];
58
88
  // A role=button row containing real <button> action controls is invalid
59
89
  // HTML (interactive nesting). Instead the row is a plain container and the
60
90
  // primary "open" affordance is itself a real <button> (native keyboard +
@@ -63,6 +93,7 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
63
93
  key,
64
94
  class: 'ds-file-row row' + (active ? ' active' : '') + (noAccess ? ' is-locked' : ''),
65
95
  'data-file-type': type,
96
+ 'aria-busy': busy ? 'true' : null,
66
97
  },
67
98
  h('button', {
68
99
  type: 'button',
@@ -77,10 +108,8 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
77
108
  h('span', { class: 'title' }, name),
78
109
  h('span', { class: 'ds-file-meta meta', 'aria-label': meta ? `metadata: ${meta}` : null }, meta || '—')
79
110
  ),
80
- onAction ? h('span', { class: 'ds-file-actions', role: 'group', 'aria-label': `actions for ${name}` },
81
- h('button', { class: 'ds-file-act', title: 'download', 'aria-label': `download ${name}`, onclick: () => onAction('download') }, Icon('arrow-down')),
82
- h('button', { class: 'ds-file-act', title: 'rename', 'aria-label': `rename ${name}`, onclick: () => onAction('rename') }, Icon('pencil')),
83
- h('button', { class: 'ds-file-act ds-file-act-warn', title: 'delete', 'aria-label': `delete ${name}`, onclick: () => onAction('delete') }, Icon('x'))
111
+ actionBtns.length ? h('span', { class: 'ds-file-actions', role: 'group', 'aria-label': `actions for ${name}` },
112
+ ...actionBtns
84
113
  ) : null
85
114
  );
86
115
  }
@@ -133,7 +162,7 @@ const FILE_GRID_CAP = 200;
133
162
 
134
163
  export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No files here yet',
135
164
  columns = 'auto', sort, filter, loading = false,
136
- shown, onShowMore } = {}) {
165
+ shown, onShowMore, actions, busy } = {}) {
137
166
  if (loading) return FileSkeleton({});
138
167
  if (!files.length) return EmptyState({ text: emptyText });
139
168
  // Cap the rendered rows. `shown` (host-controlled) overrides the default cap
@@ -179,6 +208,8 @@ export function FileGrid({ files = [], onOpen, onAction, onUp, emptyText = 'No f
179
208
  key: f.path || f.name + i,
180
209
  name: f.name, type: f.type, size: f.size, modified: f.modified, code: f.code, active: f.active,
181
210
  permissions: f.permissions, locked: f.locked,
211
+ actions: actions != null ? actions : undefined,
212
+ busy: busy != null ? !!busy : !!f.busy,
182
213
  onOpen: onOpen ? () => onOpen(f) : null,
183
214
  onAction: onAction ? (act) => onAction(act, f) : null
184
215
  }))
@@ -250,11 +281,27 @@ export function DropZone({ children, dragover, onDrop, onDragOver, onDragLeave,
250
281
  );
251
282
  }
252
283
 
253
- export function UploadProgress({ items = [] } = {}) {
284
+ // UploadProgress per-file upload rows. Error rows are recoverable, not dead
285
+ // ends: each item may carry `actions` ([{ label, onClick }], e.g. 'replace' on
286
+ // a 409 collision) and the host may wire `onDismiss(item, index)` so error rows
287
+ // can be cleared without waiting for the next successful batch.
288
+ export function UploadProgress({ items = [], onDismiss } = {}) {
254
289
  if (!items.length) return null;
255
290
  return h('div', { class: 'ds-upload-progress' },
256
291
  ...items.map((it, i) => {
257
292
  const status = it.error ? 'error' : (it.done ? 'complete' : `uploading ${it.pct || 0}%`);
293
+ const rowActions = [
294
+ ...((it.actions || []).map((a, ai) => h('button', {
295
+ key: 'ua' + ai, type: 'button', class: 'ds-upload-act',
296
+ 'aria-label': `${a.label} ${it.name}`,
297
+ onclick: () => a.onClick && a.onClick(it, i),
298
+ }, a.label))),
299
+ (it.error && onDismiss) ? h('button', {
300
+ key: 'ud', type: 'button', class: 'ds-upload-act',
301
+ 'aria-label': `dismiss ${it.name}`,
302
+ onclick: () => onDismiss(it, i),
303
+ }, 'dismiss') : null,
304
+ ].filter(Boolean);
258
305
  return h('div', {
259
306
  key: it.name + i,
260
307
  class: 'ds-upload-item' + (it.done ? ' done' : '') + (it.error ? ' error' : ''),
@@ -269,7 +316,8 @@ export function UploadProgress({ items = [] } = {}) {
269
316
  h('span', { class: 'ds-upload-bar' },
270
317
  h('span', { class: 'ds-upload-fill', 'data-pct': String(Math.max(0, Math.min(100, it.pct || 0))), 'aria-hidden': 'true' })
271
318
  ),
272
- h('span', { class: 'ds-upload-pct', 'aria-hidden': 'true' }, (it.error ? 'err' : (it.done ? 'ok' : (it.pct || 0) + '%')))
319
+ h('span', { class: 'ds-upload-pct', 'aria-hidden': 'true' }, (it.error ? 'err' : (it.done ? 'ok' : (it.pct || 0) + '%'))),
320
+ rowActions.length ? h('span', { class: 'ds-upload-actions', role: 'group', 'aria-label': `actions for ${it.name}` }, ...rowActions) : null
273
321
  );
274
322
  })
275
323
  );
@@ -8,6 +8,19 @@ import { Btn, Icon } from './shell.js';
8
8
  import { Select, SearchInput } from './content.js';
9
9
  const h = webjsx.createElement;
10
10
 
11
+ // ONE duration format for every surface (live cards, running panel, session
12
+ // meta, context pane): <60s -> 'Ns', <1h -> 'Nm Ss', else 'Nh Nm'. Durations
13
+ // roll s -> m -> h instead of an hour-long run reading '3712s'.
14
+ export function fmtDuration(ms) {
15
+ if (ms == null || !isFinite(ms) || ms < 0) return '';
16
+ const s = Math.round(ms / 1000);
17
+ if (s < 60) return s + 's';
18
+ const m = Math.floor(s / 60);
19
+ if (m < 60) return m + 'm ' + (s % 60) + 's';
20
+ const hrs = Math.floor(m / 60);
21
+ return hrs + 'h ' + (m % 60) + 'm';
22
+ }
23
+
11
24
  // ConversationList — the Claude-Desktop "Chats" column. Sessions grouped by a
12
25
  // caller-supplied group label, each row showing title/project, relative time,
13
26
  // agent badge, and a running/new-event indicator. Selecting a row switches the
@@ -21,7 +34,8 @@ const h = webjsx.createElement;
21
34
  // emptyText, loading, error : explicit states
22
35
  export function ConversationList({ sessions = [], selected, groups, search, caption,
23
36
  onSelect, onNew, newLabel = 'New chat',
24
- emptyText = 'No conversations yet', loading = false, error = null } = {}) {
37
+ emptyText = 'No conversations yet', loading = false, error = null,
38
+ loadingText = 'Loading conversations…' } = {}) {
25
39
  const rowFor = (s, i) => h('button', {
26
40
  // Stable key: prefer sid, else position - a missing/duplicate sid would make
27
41
  // key undefined and crash webjsx applyDiff ("reading 'key'" of undefined).
@@ -35,8 +49,10 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
35
49
  // applyDiff crashes "reading 'key'"). Keep these unkeyed and filter nulls so
36
50
  // each h() call gets a clean, consistent child list.
37
51
  h('span', { class: 'ds-session-main' }, [
38
- h('span', { class: 'ds-session-title' }, s.title || s.project || s.sid || ''),
39
- (s.project || s.time) ? h('span', { class: 'ds-session-sub' },
52
+ // Two-sided truncation: the CSS ellipsis is paired with a title= carrying
53
+ // the full string, so a long title/project is recoverable on hover.
54
+ h('span', { class: 'ds-session-title', title: s.title || s.project || s.sid || null }, s.title || s.project || s.sid || ''),
55
+ (s.project || s.time) ? h('span', { class: 'ds-session-sub', title: s.project || null },
40
56
  [s.project, s.time].filter(Boolean).join(' · ')) : null,
41
57
  ].filter(Boolean)),
42
58
  h('span', { class: 'ds-session-meta' }, [
@@ -53,7 +69,7 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
53
69
  // are uniformly keyed; non-row states render a single unkeyed status line.
54
70
  let inner;
55
71
  if (loading) {
56
- inner = [h('div', { key: 'st', class: 'ds-session-state', role: 'status', 'aria-live': 'polite' }, 'Loading conversations…')];
72
+ inner = [h('div', { key: 'st', class: 'ds-session-state', role: 'status', 'aria-live': 'polite' }, loadingText)];
57
73
  } else if (error) {
58
74
  inner = [h('div', { key: 'st', class: 'ds-session-state ds-session-state-error', role: 'status' }, String(error))];
59
75
  } else if (!sessions.length) {
@@ -85,6 +101,30 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
85
101
  body);
86
102
  }
87
103
 
104
+ // SessionMeta — a middot-separated metadata strip for a session detail surface.
105
+ // items : [{ label, value, title, onCopy }]
106
+ // Each item is a span (label dimmed, value mono) with an optional per-item copy
107
+ // button; the strip flex-wraps at narrow widths. Class is .ds-session-meta-strip
108
+ // (the bare .ds-session-meta is already taken by ConversationList row meta).
109
+ export function SessionMeta({ items = [] } = {}) {
110
+ if (!items.length) return null;
111
+ return h('div', { class: 'ds-session-meta-strip', role: 'group', 'aria-label': 'session metadata' },
112
+ ...items.map((it, i) => h('span', {
113
+ key: 'sm-' + (it.label != null ? it.label : i),
114
+ class: 'ds-session-meta-item',
115
+ title: it.title || null,
116
+ },
117
+ [
118
+ it.label != null ? h('span', { key: 'l', class: 'ds-session-meta-label' }, it.label) : null,
119
+ h('span', { key: 'v', class: 'ds-session-meta-value' }, it.value != null ? String(it.value) : ''),
120
+ it.onCopy ? h('button', {
121
+ key: 'c', type: 'button', class: 'ds-session-meta-copy',
122
+ 'aria-label': 'copy ' + (it.title || it.label || 'value'),
123
+ onclick: () => it.onCopy(it.value),
124
+ }, 'copy') : null,
125
+ ].filter(Boolean))));
126
+ }
127
+
88
128
  // SessionCard — one running session in the live dashboard. Status dot, agent /
89
129
  // model / cwd, elapsed, live counter, last activity, and per-session controls
90
130
  // that each act on this session's id independently.
@@ -100,36 +140,50 @@ export function ConversationList({ sessions = [], selected, groups, search, capt
100
140
  // no current tool) — it reads as `idle` with a NON-pulsing disc so a stuck agent
101
141
  // is visually distinct from a busy one (a frozen elapsed alone reads identically
102
142
  // for both, which is the high-severity oversight gap this closes).
103
- const STATUS_WORD = { error: 'error', stale: 'idle', running: 'running' };
104
- const STATUS_DISC = { error: 'status-dot-error', stale: 'status-dot-stale', running: 'status-dot-live' };
143
+ // `session.stopping` is the in-flight cancel state: the stop button disables
144
+ // with label 'stopping…' and the status word flips to 'stopping', so the click
145
+ // visibly took and cannot re-fire while the host waits for the active poll.
146
+ // `session.external` marks a session we observe (ccsniff stream) but do not own
147
+ // (no process to kill): the stop button is suppressed, an 'external' tag renders
148
+ // in the head, and the host wires onView to open it in history instead.
149
+ // `session.title` is the SAME string the conversation rails use, rendered as
150
+ // the card heading so the rail row and its dashboard card share one identity.
151
+ // `session.elapsedMs` (raw ms) is formatted internally via fmtDuration; the
152
+ // pre-formatted `elapsed` string remains as a legacy fallback.
153
+ const STATUS_WORD = { error: 'error', stale: 'idle', running: 'running', stopping: 'stopping' };
154
+ const STATUS_DISC = { error: 'status-dot-error', stale: 'status-dot-stale', running: 'status-dot-live', stopping: 'status-dot-connecting' };
105
155
 
106
156
  export function SessionCard({ session = {}, onStop, onOpen, onView, active = false,
107
157
  selectable = false, selected = false, onToggleSelect } = {}) {
108
158
  const s = session;
109
- const st = s.status === 'error' ? 'error' : (s.status === 'stale' ? 'stale' : 'running');
159
+ const st = s.stopping ? 'stopping' : (s.status === 'error' ? 'error' : (s.status === 'stale' ? 'stale' : 'running'));
110
160
  // The stat line composes elapsed + live counter; the activity line carries the
111
161
  // last-activity time and the current tool so a card shows MOTION, not just a
112
162
  // start offset. Both are middot-joined (kept product separator).
113
- const statBits = [s.elapsed != null ? s.elapsed : null, s.counter != null ? s.counter : null].filter((x) => x != null && x !== '');
163
+ const elapsedText = s.elapsedMs != null ? fmtDuration(s.elapsedMs) : (s.elapsed != null ? s.elapsed : null);
164
+ const statBits = [elapsedText, s.counter != null ? s.counter : null].filter((x) => x != null && x !== '');
114
165
  const activityBits = [
115
166
  s.currentTool ? 'running: ' + s.currentTool : null,
116
167
  s.lastActivity ? 'last ' + s.lastActivity : null,
117
168
  ].filter(Boolean);
118
- const cls = 'ds-dash-card is-' + st + (active ? ' is-active' : '') + (selected ? ' is-selected' : '');
119
- return h('div', { class: cls, role: 'group', 'aria-label': 'session ' + (s.agent || s.sid), 'aria-current': active ? 'true' : null },
169
+ const cls = 'ds-dash-card is-' + st + (active ? ' is-active' : '') + (selected ? ' is-selected' : '') + (s.external ? ' is-external' : '');
170
+ return h('div', { class: cls, role: 'group', 'aria-label': 'session ' + (s.title || s.agent || s.sid), 'aria-current': active ? 'true' : null },
171
+ // Shared session identity: the same title the conversation rails show.
172
+ s.title ? h('div', { class: 'ds-dash-title', title: s.title }, s.title) : null,
120
173
  h('div', { class: 'ds-dash-card-head' },
121
174
  selectable ? h('button', {
122
175
  type: 'button', class: 'ds-dash-select', role: 'checkbox',
123
176
  'aria-checked': selected ? 'true' : 'false',
124
- 'aria-label': (selected ? 'deselect' : 'select') + ' session ' + (s.agent || s.sid),
177
+ 'aria-label': (selected ? 'deselect' : 'select') + ' session ' + (s.title || s.agent || s.sid),
125
178
  onclick: () => onToggleSelect && onToggleSelect(s),
126
179
  }, selected ? '[x]' : '[ ]') : null,
127
180
  h('span', { class: 'status-dot-disc ' + STATUS_DISC[st], 'aria-hidden': 'true' }),
128
181
  // Status is words + the disc, never colour alone (WCAG 1.4.1): the disc is
129
182
  // aria-hidden, so the visible/AT status word carries the state.
130
183
  h('span', { class: 'ds-dash-status is-' + st }, STATUS_WORD[st]),
131
- h('span', { class: 'ds-dash-agent' }, s.agent || 'agent'),
132
- s.model ? h('span', { class: 'ds-dash-model' }, s.model) : null),
184
+ s.external ? h('span', { class: 'ds-dash-external' }, 'external') : null,
185
+ h('span', { class: 'ds-dash-agent', title: s.agent || null }, s.agent || 'agent'),
186
+ s.model ? h('span', { class: 'ds-dash-model', title: s.model }, s.model) : null),
133
187
  h('div', { class: 'ds-dash-meta' },
134
188
  s.cwd ? h('span', { class: 'ds-dash-cwd', title: s.cwd }, s.cwd) : null,
135
189
  statBits.length ? h('span', { class: 'ds-dash-stat' }, statBits.join(' · ')) : null,
@@ -138,8 +192,10 @@ export function SessionCard({ session = {}, onStop, onOpen, onView, active = fal
138
192
  // open and resume collapsed into one 'open' action (they both just reopen
139
193
  // the session in chat); 'events' kept for the read-only event view.
140
194
  onOpen ? Btn({ key: 'open', onClick: () => onOpen(s), children: 'open' }) : null,
141
- onView ? Btn({ key: 'view', onClick: () => onView(s), children: 'events' }) : null,
142
- onStop ? Btn({ key: 'stop', danger: true, onClick: () => onStop(s), children: 'stop' }) : null));
195
+ onView ? Btn({ key: 'view', onClick: () => onView(s), children: s.external ? 'open in history' : 'events' }) : null,
196
+ // External sessions get no stop control: we own no process to kill.
197
+ (onStop && !s.external) ? Btn({ key: 'stop', danger: true, disabled: !!s.stopping,
198
+ onClick: () => !s.stopping && onStop(s), children: s.stopping ? 'stopping…' : 'stop' }) : null));
143
199
  }
144
200
 
145
201
  // SessionDashboard — grid of SessionCards for ALL live sessions, managed at once.
@@ -152,9 +208,23 @@ export function SessionCard({ session = {}, onStop, onOpen, onView, active = fal
152
208
  // card's stop. Rendered only when there are sessions AND onStopAll is wired.
153
209
  // Streamstate words: the live-stream health signal so "connected, zero running"
154
210
  // still tells the user the dashboard is listening (vs a dropped stream).
155
- const STREAM_WORD = { connected: 'listening for activity', connecting: 'connecting to live stream…', lost: 'live stream lost — retrying…' };
211
+ // One connection vocabulary across the crumb, settings chip, and the dashboard
212
+ // stream line: connected / connecting / offline ('lost' kept as a legacy alias).
213
+ const STREAM_WORD = {
214
+ connected: 'listening for activity',
215
+ connecting: 'connecting to live stream…',
216
+ offline: 'live stream offline — retrying…',
217
+ lost: 'live stream offline — retrying…',
218
+ };
156
219
 
220
+ // The stop-all / stop-selected danger buttons are two-step (host-driven, the kit
221
+ // is stateless): the first click fires onArmStop* so the host flips confirming*
222
+ // true and re-renders; the armed button reads 'stop N sessions - press again'
223
+ // and only THAT click fires the real onStopAll/onStopSelected. Hosts that wire
224
+ // no onArmStop* keep the old single-click behavior.
157
225
  export function SessionDashboard({ sessions = [], onStop, onOpen, onView, onStopAll, onStopSelected,
226
+ confirmingStopAll = false, confirmingStopSelected = false,
227
+ onArmStopAll, onArmStopSelected,
158
228
  sort, filter, errorsOnly = false, onErrorsOnly,
159
229
  selectable = false, selected, onToggleSelect,
160
230
  activeSid, streamState,
@@ -164,6 +234,9 @@ export function SessionDashboard({ sessions = [], onStop, onOpen, onView, onStop
164
234
  }
165
235
  const selSet = selected instanceof Set ? selected : new Set(selected || []);
166
236
  const selCount = selSet.size;
237
+ // While any session is mid-cancel the bulk control reads disabled
238
+ // 'stopping N…' so a bulk stop visibly takes instead of staying re-firable.
239
+ const stoppingCount = sessions.filter((s) => s.stopping).length;
167
240
  // The stream-state line always renders (even with zero sessions) so a
168
241
  // connected-but-idle dashboard reads differently from an offline one.
169
242
  const streamLine = streamState
@@ -193,9 +266,19 @@ export function SessionDashboard({ sessions = [], onStop, onOpen, onView, onStop
193
266
  selectable && selCount ? selCount + ' selected' : sessions.length + ' running'),
194
267
  streamLine,
195
268
  h('span', { class: 'spread' }),
196
- selectable && selCount && onStopSelected
197
- ? Btn({ key: 'stopsel', danger: true, onClick: () => onStopSelected([...selSet]), children: 'stop selected' })
198
- : (onStopAll ? Btn({ key: 'stopall', danger: true, onClick: () => onStopAll(sessions), children: 'stop all' }) : null),
269
+ stoppingCount > 0 && (onStopSelected || onStopAll)
270
+ ? Btn({ key: 'stopbusy', danger: true, disabled: true, children: 'stopping ' + stoppingCount + '…' })
271
+ : (selectable && selCount && onStopSelected
272
+ ? (onArmStopSelected && !confirmingStopSelected
273
+ ? Btn({ key: 'stopsel', danger: true, onClick: () => onArmStopSelected([...selSet]), children: 'stop selected' })
274
+ : Btn({ key: 'stopsel', danger: true, onClick: () => onStopSelected([...selSet]),
275
+ children: confirmingStopSelected ? 'stop ' + selCount + ' sessions - press again' : 'stop selected' }))
276
+ : (onStopAll
277
+ ? (onArmStopAll && !confirmingStopAll
278
+ ? Btn({ key: 'stopall', danger: true, onClick: () => onArmStopAll(sessions), children: 'stop all' })
279
+ : Btn({ key: 'stopall', danger: true, onClick: () => onStopAll(sessions),
280
+ children: confirmingStopAll ? 'stop ' + sessions.length + ' sessions - press again' : 'stop all' }))
281
+ : null)),
199
282
  toolbar);
200
283
  const grid = h('div', { class: 'ds-dash-grid', role: 'list', 'aria-label': 'live sessions' },
201
284
  ...sessions.map((s) => h('div', { key: s.sid, role: 'listitem' },
package/src/components.js CHANGED
@@ -16,19 +16,19 @@ export {
16
16
  WorksList, WritingList, Manifesto, Section, PageHeader,
17
17
  Kpi, Table, SearchInput, TextField, Select, EventList,
18
18
  HomeView, ProjectView, Form,
19
- Spinner, Skeleton, Alert
19
+ Spinner, Skeleton, Alert, FilterPills
20
20
  } from './components/content.js';
21
21
 
22
22
  export {
23
- fmtBytes, renderInline,
23
+ fmtBytes, renderInline, hasSelectionInside,
24
24
  ChatMessage, ChatComposer, Chat,
25
25
  AICAT_FACE, AICatPortrait, AICat
26
26
  } from './components/chat.js';
27
27
 
28
- export { AgentChat } from './components/agent-chat.js';
28
+ export { AgentChat, MESSAGE_CAP } from './components/agent-chat.js';
29
29
 
30
30
  export {
31
- ConversationList, SessionCard, SessionDashboard
31
+ ConversationList, SessionCard, SessionDashboard, SessionMeta, fmtDuration
32
32
  } from './components/sessions.js';
33
33
 
34
34
  export { ContextPane } from './components/context-pane.js';