anentrypoint-design 0.0.385 → 0.0.387

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/247420.css +33 -0
  2. package/dist/247420.js +53 -51
  3. package/package.json +1 -1
  4. package/src/components/agent-chat/controls.js +143 -0
  5. package/src/components/agent-chat/empty-state.js +63 -0
  6. package/src/components/agent-chat/message-rows.js +141 -0
  7. package/src/components/agent-chat/surface.js +206 -0
  8. package/src/components/agent-chat/thread-behaviour.js +50 -0
  9. package/src/components/agent-chat.js +6 -546
  10. package/src/components/chat/composer-affordances.js +109 -0
  11. package/src/components/chat/composer.js +256 -0
  12. package/src/components/chat/threads.js +105 -0
  13. package/src/components/chat-message-parts/agent-nodes.js +103 -0
  14. package/src/components/chat-message-parts/inline.js +106 -0
  15. package/src/components/chat-message-parts/prose-nodes.js +133 -0
  16. package/src/components/chat-message-parts/renderers.js +89 -0
  17. package/src/components/chat-message-parts.js +12 -408
  18. package/src/components/chat-minimap/minimap.js +167 -0
  19. package/src/components/chat-minimap/paint.js +73 -0
  20. package/src/components/chat-minimap/preview.js +41 -0
  21. package/src/components/chat-minimap.js +7 -263
  22. package/src/components/chat.js +20 -628
  23. package/src/components/community/chrome.js +63 -0
  24. package/src/components/community/navigation.js +167 -0
  25. package/src/components/community/presence.js +106 -0
  26. package/src/components/community/shell.js +17 -0
  27. package/src/components/community/views.js +131 -0
  28. package/src/components/community.js +18 -447
  29. package/src/components/files/chrome.js +140 -0
  30. package/src/components/files/entries.js +156 -0
  31. package/src/components/files/grid-controls.js +63 -0
  32. package/src/components/files/grid.js +177 -0
  33. package/src/components/files/types.js +69 -0
  34. package/src/components/files-modals/dialogs.js +118 -0
  35. package/src/components/files-modals/modal-shell.js +153 -0
  36. package/src/components/files-modals/preview-bodies.js +129 -0
  37. package/src/components/files-modals/preview-containers.js +96 -0
  38. package/src/components/files-modals.js +14 -475
  39. package/src/components/files.js +15 -564
  40. package/src/components/freddie/pages-config.js +3 -94
  41. package/src/components/freddie/pages-models.js +97 -0
  42. package/src/components/freddie.js +2 -1
  43. package/src/components/interaction-primitives/mobile.js +25 -0
  44. package/src/components/interaction-primitives/pointer.js +214 -0
  45. package/src/components/interaction-primitives/reorderable.js +47 -0
  46. package/src/components/interaction-primitives/shortcuts.js +119 -0
  47. package/src/components/interaction-primitives.js +16 -388
  48. package/src/components/sessions/conversation-list.js +230 -0
  49. package/src/components/sessions/dashboard.js +202 -0
  50. package/src/components/sessions/detail-bits.js +49 -0
  51. package/src/components/sessions/format.js +42 -0
  52. package/src/components/sessions/session-card.js +92 -0
  53. package/src/components/sessions.js +16 -579
  54. package/src/components/voice/audio-cue.js +39 -0
  55. package/src/components/voice/capture.js +90 -0
  56. package/src/components/voice/playback.js +75 -0
  57. package/src/components/voice/settings-modal.js +104 -0
  58. package/src/components/voice.js +16 -293
  59. package/src/css/app-shell/base.css +23 -0
  60. package/src/css/app-shell/states-interactions.css +10 -0
  61. package/src/kits/os/freddie/chat-protocol.js +32 -0
  62. package/src/kits/os/freddie/chat-transport.js +178 -0
  63. package/src/kits/os/freddie/pages-chat.js +10 -180
  64. package/src/kits/os/shell-chrome.js +163 -0
  65. package/src/kits/os/shell-geometry.js +59 -0
  66. package/src/kits/os/shell.js +178 -335
  67. package/src/page-html/client-script.js +151 -0
  68. package/src/page-html/head-tags.js +59 -0
  69. package/src/page-html/markdown.js +68 -0
  70. package/src/page-html/page-styles.js +44 -0
  71. package/src/page-html.js +14 -291
@@ -1,390 +1,18 @@
1
1
  // Interaction primitives — pointer drag/drop + keyboard shortcuts.
2
2
  // Pointer Events only (touch+mouse). Visuals via editor-primitives.css.
3
- import * as webjsx from '../../vendor/webjsx/index.js';
4
- import { Icon } from './shell.js';
5
- const h = webjsx.createElement;
6
- const DRAG_THRESHOLD = 5;
7
- const IS_MAC = (typeof navigator !== 'undefined') && /Mac|iPhone|iPad/.test(navigator.platform || '');
8
- const SHORTCUT_REGISTRY = new Set();
9
- function dispatchDrag(el, detail) {
10
- el.dispatchEvent(new CustomEvent('ds-drag', { detail, bubbles: true, composed: true }));
11
- }
12
-
13
- export function useDraggable(el, { data, kind, onDragStart, onDragEnd } = {}) {
14
- if (!el) return { destroy() {} };
15
- let startX = 0, startY = 0, active = false, started = false, pid = null;
16
- let kbMode = false, kbTargets = [], kbIdx = 0;
17
-
18
- const onMove = (e) => {
19
- if (!active) return;
20
- if (!started) {
21
- const dx = e.clientX - startX, dy = e.clientY - startY;
22
- if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
23
- started = true;
24
- el.setAttribute('data-dragging', 'true');
25
- if (onDragStart) onDragStart({ data, kind, pointerEvent: e });
26
- }
27
- const hit = document.elementFromPoint(e.clientX, e.clientY);
28
- const target = hit && hit.closest('[data-drop-target]');
29
- dispatchDrag(el, { phase: 'move', data, kind, pointerEvent: e, target });
30
- };
31
- const onUp = (e) => {
32
- if (!active) return;
33
- const wasStarted = started;
34
- active = false; started = false;
35
- try { if (pid != null) el.releasePointerCapture(pid); } catch { /* swallow: pointer capture may already be released, drag end still proceeds */ }
36
- pid = null;
37
- el.removeAttribute('data-dragging');
38
- const hit = document.elementFromPoint(e.clientX, e.clientY);
39
- const target = hit && hit.closest('[data-drop-target]');
40
- dispatchDrag(el, { phase: 'end', data, kind, pointerEvent: e, target });
41
- window.removeEventListener('pointermove', onMove);
42
- window.removeEventListener('pointerup', onUp);
43
- window.removeEventListener('pointercancel', onUp);
44
- if (wasStarted && onDragEnd) onDragEnd({ drop: target, data, kind });
45
- };
46
- const onDown = (e) => {
47
- if (e.button != null && e.button !== 0) return;
48
- active = true; started = false;
49
- startX = e.clientX; startY = e.clientY; pid = e.pointerId;
50
- try { el.setPointerCapture(e.pointerId); } catch { /* swallow: pointer capture unsupported/denied, drag still tracks via listeners */ }
51
- window.addEventListener('pointermove', onMove);
52
- window.addEventListener('pointerup', onUp);
53
- window.addEventListener('pointercancel', onUp);
54
- };
55
- const clearActive = () => kbTargets.forEach(n => n.removeAttribute('data-drop-target-active'));
56
- const onKey = (e) => {
57
- if (e.key === ' ' || e.code === 'Space') {
58
- e.preventDefault();
59
- if (!kbMode) {
60
- kbMode = true; kbIdx = 0;
61
- kbTargets = Array.from(document.querySelectorAll('[data-drop-target]'));
62
- el.setAttribute('data-dragging', 'true');
63
- if (onDragStart) onDragStart({ data, kind, pointerEvent: null });
64
- if (kbTargets[0]) kbTargets[0].setAttribute('data-drop-target-active', 'true');
65
- } else {
66
- const t = kbTargets[kbIdx];
67
- kbMode = false; el.removeAttribute('data-dragging'); clearActive();
68
- if (t) dispatchDrag(el, { phase: 'end', data, kind, pointerEvent: null, target: t });
69
- if (onDragEnd) onDragEnd({ drop: t, data, kind });
70
- }
71
- } else if (kbMode && /^Arrow/.test(e.key)) {
72
- e.preventDefault();
73
- const dir = (e.key === 'ArrowDown' || e.key === 'ArrowRight') ? 1 : -1;
74
- clearActive();
75
- kbIdx = (kbIdx + dir + kbTargets.length) % kbTargets.length;
76
- if (kbTargets[kbIdx]) kbTargets[kbIdx].setAttribute('data-drop-target-active', 'true');
77
- } else if (kbMode && e.key === 'Escape') {
78
- e.preventDefault(); kbMode = false; el.removeAttribute('data-dragging'); clearActive();
79
- }
80
- };
81
- el.addEventListener('pointerdown', onDown);
82
- el.addEventListener('keydown', onKey);
83
- return { destroy() {
84
- el.removeEventListener('pointerdown', onDown);
85
- el.removeEventListener('keydown', onKey);
86
- window.removeEventListener('pointermove', onMove);
87
- window.removeEventListener('pointerup', onUp);
88
- window.removeEventListener('pointercancel', onUp);
89
- }};
90
- }
91
-
92
- // useNumberScrub — pointer-event horizontal drag-to-scrub on a numeric input.
93
- // Pointer Events (mouse+touch+pen+XR), touch-action:none so a vertical page
94
- // scroll never steals the gesture. Click-to-edit is preserved: a press that
95
- // does not cross SCRUB_THRESHOLD leaves the input focusable for typing.
96
- export function useNumberScrub(el, { getValue, onChange, step = 0.01, threshold = 3 } = {}) {
97
- if (!el) return { destroy() {} };
98
- el.style.touchAction = 'none';
99
- let pid = null, startX = 0, startV = 0, moved = false;
100
- const onMove = (e) => {
101
- if (pid == null) return;
102
- const dx = e.clientX - startX;
103
- if (!moved) {
104
- if (Math.abs(dx) < threshold) return;
105
- moved = true;
106
- el.setAttribute('data-scrubbing', 'true');
107
- if (document.activeElement === el) el.blur();
108
- }
109
- const v = startV + dx * step;
110
- if (onChange) onChange(v);
111
- };
112
- const onUp = () => {
113
- if (pid == null) return;
114
- try { el.releasePointerCapture(pid); } catch { /* swallow: pointer capture may already be released, drag end still proceeds */ }
115
- pid = null;
116
- el.removeAttribute('data-scrubbing');
117
- window.removeEventListener('pointermove', onMove);
118
- window.removeEventListener('pointerup', onUp);
119
- window.removeEventListener('pointercancel', onUp);
120
- };
121
- const onDown = (e) => {
122
- if (e.button != null && e.button !== 0) return;
123
- // Let a focused input handle caret placement / text selection instead.
124
- if (document.activeElement === el) return;
125
- pid = e.pointerId; startX = e.clientX; moved = false;
126
- const cur = getValue ? getValue() : parseFloat(el.value);
127
- startV = Number.isFinite(cur) ? cur : 0;
128
- try { el.setPointerCapture(pid); } catch { /* swallow: pointer capture unsupported/denied, drag still tracks via listeners */ }
129
- window.addEventListener('pointermove', onMove);
130
- window.addEventListener('pointerup', onUp);
131
- window.addEventListener('pointercancel', onUp);
132
- };
133
- el.addEventListener('pointerdown', onDown);
134
- return { destroy() {
135
- el.removeEventListener('pointerdown', onDown);
136
- window.removeEventListener('pointermove', onMove);
137
- window.removeEventListener('pointerup', onUp);
138
- window.removeEventListener('pointercancel', onUp);
139
- }};
140
- }
141
-
142
- // usePointerDrag — free 2D pointer drag with caller-supplied onMove, for surfaces
143
- // that need raw pointer coordinates each frame (a 3D viewport gizmo, a canvas
144
- // handle) rather than the data-transfer DnD model of useDraggable. Pointer Events
145
- // only, so mouse+touch+pen+XR-controller all drive it. The primary pointer is
146
- // captured on the element (drag keeps tracking when it leaves the element or
147
- // crosses a panel) and released on pointerup/pointercancel; a non-primary pointer
148
- // (second finger) is ignored mid-drag so multi-touch never makes the drag jump.
149
- // onStart returns false to decline the drag (e.g. a miss in the gizmo raycast),
150
- // leaving the pointerdown to propagate to other handlers.
151
- export function usePointerDrag(el, { onStart, onMove, onEnd, button = 0 } = {}) {
152
- if (!el) return { destroy() {} };
153
- let pid = null;
154
- const onMoveEv = (e) => {
155
- if (pid == null || e.pointerId !== pid) return;
156
- if (onMove) onMove(e);
157
- };
158
- const finish = (e, cancelled) => {
159
- if (pid == null) return;
160
- try { el.releasePointerCapture(pid); } catch { /* swallow: pointer capture may already be released, drag end still proceeds */ }
161
- pid = null;
162
- el.removeAttribute('data-pointer-dragging');
163
- window.removeEventListener('pointermove', onMoveEv);
164
- window.removeEventListener('pointerup', onUpEv);
165
- window.removeEventListener('pointercancel', onCancelEv);
166
- if (onEnd) onEnd(e, cancelled);
167
- };
168
- const onUpEv = (e) => { if (e.pointerId === pid) finish(e, false); };
169
- const onCancelEv = (e) => { if (e.pointerId === pid) finish(e, true); };
170
- const onDown = (e) => {
171
- if (button != null && e.button != null && e.button !== button) return;
172
- if (pid != null) return; // already dragging with the primary pointer
173
- if (onStart && onStart(e) === false) return; // caller declined (e.g. raycast miss)
174
- pid = e.pointerId;
175
- el.setAttribute('data-pointer-dragging', 'true');
176
- try { el.setPointerCapture(pid); } catch { /* swallow: pointer capture unsupported/denied, drag still tracks via listeners */ }
177
- window.addEventListener('pointermove', onMoveEv);
178
- window.addEventListener('pointerup', onUpEv);
179
- window.addEventListener('pointercancel', onCancelEv);
180
- };
181
- el.addEventListener('pointerdown', onDown);
182
- return { destroy() {
183
- el.removeEventListener('pointerdown', onDown);
184
- window.removeEventListener('pointermove', onMoveEv);
185
- window.removeEventListener('pointerup', onUpEv);
186
- window.removeEventListener('pointercancel', onCancelEv);
187
- }, get dragging() { return pid != null; } };
188
- }
189
-
190
- export function useDropTarget(el, { accepts = [], onDrop, onDragOver } = {}) {
191
- if (!el) return { destroy() {} };
192
- el.setAttribute('data-drop-target', '');
193
- const handler = (e) => {
194
- const d = e.detail; if (!d) return;
195
- if (accepts.length && !accepts.includes(d.kind)) return;
196
- if (d.target !== el) {
197
- el.removeAttribute('data-drop-target-active');
198
- return;
199
- }
200
- if (d.phase === 'move') {
201
- el.setAttribute('data-drop-target-active', 'true');
202
- if (onDragOver) onDragOver({ data: d.data, kind: d.kind, pointerEvent: d.pointerEvent });
203
- } else if (d.phase === 'end') {
204
- el.removeAttribute('data-drop-target-active');
205
- if (onDrop) onDrop({ data: d.data, kind: d.kind, pointerEvent: d.pointerEvent });
206
- }
207
- };
208
- document.addEventListener('ds-drag', handler, true);
209
- return { destroy() {
210
- document.removeEventListener('ds-drag', handler, true);
211
- el.removeAttribute('data-drop-target');
212
- el.removeAttribute('data-drop-target-active');
213
- }};
214
- }
215
-
216
- export function Reorderable({ items = [], getKey, renderItem, onReorder, axis = 'vertical', kind = 'reorder' } = {}) {
217
- const order = items.map((_, i) => i);
218
- const cls = 'ds-reorderable ds-reorderable-' + axis;
219
- return h('div', { class: cls, role: 'list' },
220
- ...items.map((item, i) => {
221
- const key = getKey ? getKey(item, i) : i;
222
- const onRef = (el) => {
223
- if (!el || el._dsReorder) return;
224
- el._dsReorder = true;
225
- const handle = el.querySelector('.ds-reorder-handle') || el;
226
- const drag = useDraggable(handle, {
227
- data: { index: i }, kind,
228
- onDragEnd: ({ drop }) => {
229
- if (!drop) return;
230
- const toIdx = Number(drop.getAttribute('data-reorder-index'));
231
- if (Number.isNaN(toIdx) || toIdx === i) return;
232
- const next = order.slice();
233
- const [m] = next.splice(i, 1);
234
- next.splice(toIdx, 0, m);
235
- if (onReorder) onReorder(next);
236
- },
237
- });
238
- const drop = useDropTarget(el, { accepts: [kind] });
239
- el._dsReorderDestroy = () => { drag.destroy(); drop.destroy(); };
240
- };
241
- return h('div', {
242
- key, ref: onRef, class: 'ds-reorder-item',
243
- 'data-reorder-index': String(i), role: 'listitem',
244
- },
245
- h('button', {
246
- type: 'button', class: 'ds-reorder-handle',
247
- 'aria-label': 'Reorder', tabindex: '0',
248
- }, Icon('more-horizontal')),
249
- renderItem ? renderItem(item, i) : null
250
- );
251
- })
252
- );
253
- }
254
-
255
- function parseCombo(combo) {
256
- const parts = combo.split('+').map(s => s.trim());
257
- const key = parts.pop();
258
- const mods = new Set(parts.map(s => s.toLowerCase()));
259
- return { key: key.length === 1 ? key.toLowerCase() : key, mod: mods.has('mod'), shift: mods.has('shift'), alt: mods.has('alt'), ctrl: mods.has('ctrl') };
260
- }
261
- function matchEvent(e, spec) {
262
- const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
263
- if (k !== spec.key) return false;
264
- const modOk = spec.mod ? (IS_MAC ? e.metaKey : e.ctrlKey) : true;
265
- if (!modOk) return false;
266
- if (spec.shift !== !!e.shiftKey) return false;
267
- if (spec.alt !== !!e.altKey) return false;
268
- if (spec.ctrl && !e.ctrlKey) return false;
269
- return true;
270
- }
271
-
272
- export function formatShortcut(combo) {
273
- const s = parseCombo(combo);
274
- const mod = s.mod ? (IS_MAC ? 'Cmd+' : 'Ctrl+') : '';
275
- const shift = s.shift ? 'Shift+' : '';
276
- const alt = s.alt ? 'Alt+' : '';
277
- const key = s.key.length === 1 ? s.key.toUpperCase() : s.key;
278
- return mod + alt + shift + key;
279
- }
280
-
281
- export function useKeyboardShortcut(map = {}, { scope = 'global', enabled = true } = {}) {
282
- if (!enabled) return { destroy() {}, trigger() {} };
283
- const target = scope === 'global' ? (typeof document !== 'undefined' ? document : null) : scope;
284
- if (!target) return { destroy() {}, trigger() {} };
285
- const specs = Object.entries(map).map(([combo, fn]) => ({ combo, spec: parseCombo(combo), fn }));
286
- specs.forEach(s => SHORTCUT_REGISTRY.add({ combo: s.combo, scope: scope === 'global' ? 'global' : 'local' }));
287
- const onKey = (e) => {
288
- for (const s of specs) if (matchEvent(e, s.spec)) { e.preventDefault(); s.fn(e); return; }
289
- };
290
- target.addEventListener('keydown', onKey);
291
- return {
292
- destroy() { target.removeEventListener('keydown', onKey); },
293
- trigger(combo) { const s = specs.find(x => x.combo === combo); if (s) s.fn(); },
294
- };
295
- }
296
-
297
- export function ShortcutHint({ combo, kind = 'kbd' } = {}) { return h('kbd', { class: 'ds-kbd ds-kbd-' + kind }, formatShortcut(combo || '')); }
298
-
299
- function shortcutCaps(keys) {
300
- const caps = [];
301
- let n = 0;
302
- const alts = String(keys || '').split(' / ');
303
- alts.forEach((alt, ai) => {
304
- if (ai > 0) caps.push(h('span', { key: 'sep-alt-' + (n++), class: 'ds-kbd-sep' }, ' / '));
305
- const steps = alt.split(' then ');
306
- steps.forEach((step, si) => {
307
- if (si > 0) caps.push(h('span', { key: 'sep-then-' + (n++), class: 'ds-kbd-sep' }, ' then '));
308
- caps.push(h('kbd', { key: 'cap-' + (n++), class: 'ds-kbd' }, step));
309
- });
310
- });
311
- return caps;
312
- }
313
-
314
- export function ShortcutList({ shortcuts = [] } = {}) {
315
- return h('div', { class: 'ds-shortcuts-hint' },
316
- ...shortcuts.map(s => h('div', { class: 'ds-shortcut-row' },
317
- h('span', { class: 'ds-kbd-caps' }, ...shortcutCaps(s.keys || s.combo || '')),
318
- h('span', { class: 'ds-kbd-label' }, s.desc || s.description || s.label || ''))));
319
- }
320
-
321
- // ---------------------------------------------------------------------------
322
- // Mobile-breakpoint detection — subscribe-style (webjsx has no hooks/
323
- // useSyncExternalStore equivalent; consumers re-render on the callback).
324
- // Builds on editor-primitives.js's BP_SM (480px) rather than pi-web's
325
- // hardcoded 640px so it stays one source of truth with the Grid/GridItem
326
- // breakpoint tiers already in this design system.
327
- // ---------------------------------------------------------------------------
328
- const MOBILE_QUERY = '(max-width: 480px)';
329
-
330
- export function isMobileNow() {
331
- if (typeof window === 'undefined' || !window.matchMedia) return false;
332
- return window.matchMedia(MOBILE_QUERY).matches;
333
- }
334
-
335
- // Returns an unsubscribe function, mirroring theme.js's onThemeChange shape.
336
- export function onMobileChange(cb) {
337
- if (typeof window === 'undefined' || !window.matchMedia) return () => {};
338
- const mql = window.matchMedia(MOBILE_QUERY);
339
- const handler = () => cb(mql.matches);
340
- if (mql.addEventListener) mql.addEventListener('change', handler);
341
- else mql.addListener(handler);
342
- return () => {
343
- if (mql.removeEventListener) mql.removeEventListener('change', handler);
344
- else mql.removeListener(handler);
345
- };
346
- }
347
-
348
- export function useKeyboardShortcutHelp() { return { registry: Array.from(SHORTCUT_REGISTRY) }; }
349
- export function ShortcutHelpDialog({ open = false, onClose, registry } = {}) {
350
- if (!open) return null;
351
- const list = registry || Array.from(SHORTCUT_REGISTRY);
352
- const groups = {};
353
- list.forEach(r => { (groups[r.scope] = groups[r.scope] || []).push(r); });
354
- // Escape-to-close, Tab focus trap, and autofocus on open — wired through a
355
- // ref so teardown runs on the webjsx ref(null) unmount branch.
356
- const dialogRef = (el) => {
357
- if (!el) { if (ShortcutHelpDialog._teardown) { ShortcutHelpDialog._teardown(); ShortcutHelpDialog._teardown = null; } return; }
358
- const focusables = () => el.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])');
359
- const onKey = (e) => {
360
- if (e.key === 'Escape') { e.preventDefault(); if (onClose) onClose(); return; }
361
- if (e.key === 'Tab') {
362
- const f = focusables();
363
- if (!f.length) { e.preventDefault(); return; }
364
- const first = f[0], last = f[f.length - 1];
365
- if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
366
- else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
367
- }
368
- };
369
- el.addEventListener('keydown', onKey);
370
- ShortcutHelpDialog._teardown = () => el.removeEventListener('keydown', onKey);
371
- // The dialog itself is focusable (tabindex=-1) so it always has a home
372
- // for focus even when it contains no interactive controls.
373
- const f = focusables();
374
- (f[0] || el).focus();
375
- };
376
- return h('div', { class: 'ds-ep-dialog-backdrop', onmousedown: (e) => { if (e.target === e.currentTarget && onClose) onClose(); } },
377
- h('div', { class: 'ds-ep-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-label': 'Keyboard shortcuts', tabindex: '-1', ref: dialogRef },
378
- h('h2', null, 'Keyboard shortcuts'),
379
- ...Object.entries(groups).map(([scope, rows]) =>
380
- h('section', { class: 'ds-kbd-group' },
381
- h('h3', null, scope),
382
- h('ul', null, ...rows.map(r => h('li', { class: 'ds-kbd-row' },
383
- h(ShortcutHint, { combo: r.combo }),
384
- (r.label || r.description) ? h('span', { class: 'ds-kbd-label' }, r.label || r.description) : null
385
- )))
386
- )
387
- )
388
- )
389
- );
390
- }
3
+ //
4
+ // This module is a barrel: every primitive lives in a single-responsibility
5
+ // submodule under ./interaction-primitives/, and the public export surface here
6
+ // is unchanged — no consumer import needs to move.
7
+
8
+ import { useDraggable, useNumberScrub, usePointerDrag, useDropTarget } from './interaction-primitives/pointer.js';
9
+ import { Reorderable } from './interaction-primitives/reorderable.js';
10
+ import { formatShortcut, useKeyboardShortcut, ShortcutHint, ShortcutList, useKeyboardShortcutHelp, ShortcutHelpDialog } from './interaction-primitives/shortcuts.js';
11
+ import { isMobileNow, onMobileChange } from './interaction-primitives/mobile.js';
12
+
13
+ export {
14
+ useDraggable, useNumberScrub, usePointerDrag, useDropTarget,
15
+ Reorderable,
16
+ formatShortcut, useKeyboardShortcut, ShortcutHint, ShortcutList, useKeyboardShortcutHelp, ShortcutHelpDialog,
17
+ isMobileNow, onMobileChange,
18
+ };
@@ -0,0 +1,230 @@
1
+ // ConversationList — the persistent left-rail "Chats" column: grouped, flat,
2
+ // or parentSid-nested fork-tree rows, with inline rename, arm-then-confirm
3
+ // delete, search, and a load-more tail. Host-driven throughout (the kit stays
4
+ // stateless); styling lives in chat.css (.ds-session*).
5
+
6
+ import * as webjsx from '../../../vendor/webjsx/index.js';
7
+ import { Icon } from '../shell.js';
8
+ import { SearchInput } from '../content.js';
9
+ import { STATUS_WORD, STATUS_DISC } from './format.js';
10
+ const h = webjsx.createElement;
11
+
12
+ /**
13
+ * The Claude-Desktop "Chats" column. Sessions grouped by a caller-supplied
14
+ * group label, each row showing title/project, relative time, agent badge,
15
+ * and a running/new-event indicator. Selecting a row switches the active
16
+ * conversation.
17
+ *
18
+ * @param {Object} [props]
19
+ * @param {Array<{sid:*, title?:string, project?:string, agent?:string, time?:string, running?:boolean, unread?:boolean, rail?:string, parentSid?:*}>} [props.sessions=[]]
20
+ * @param {*} [props.selected] - the active sid.
21
+ * @param {Array<{label:string, sids:Array<*>}>} [props.groups] - OPTIONAL buckets for the rows; else one flat list.
22
+ * @param {{value:string, onInput:Function, placeholder?:string}} [props.search] - inline filter (optional).
23
+ * @param {Function} [props.onSelect] - onSelect(session).
24
+ * @param {Function} [props.onNew] - onNew().
25
+ * @param {string} [props.emptyText='No conversations yet']
26
+ * @param {boolean} [props.loading=false]
27
+ * @param {*} [props.error=null]
28
+ * @param {boolean} [props.tree=false] - OPTIONAL: nest rows whose `parentSid` matches
29
+ * another row's `sid` under that row (fork/branch tree), with an indent guide,
30
+ * a branch glyph, and a per-node collapse toggle. Ignored when `groups` is set
31
+ * (grouping and tree-nesting are mutually exclusive row layouts). `expanded`/
32
+ * `onToggleExpand` are host-driven (kit stays stateless): a `sid` NOT present
33
+ * in the `expanded` Set renders collapsed once it has children.
34
+ * @param {Set<*>|Array<*>} [props.expanded] - sids whose children are shown, when `tree`.
35
+ * @param {Function} [props.onToggleExpand] - onToggleExpand(sid), when `tree`.
36
+ * @param {Function} [props.onRename] - onRename(session, newTitle). Presence enables the
37
+ * hover-revealed rename action (row becomes an inline text input while active).
38
+ * @param {*} [props.renaming] - sid of the row currently in rename-edit mode (host-driven).
39
+ * @param {Function} [props.onStartRename] - onStartRename(session) - fired by the rename
40
+ * button click, before `onRename` commits; host flips `renaming` to this sid.
41
+ * @param {Function} [props.onCancelRename] - onCancelRename() - Escape / blur-without-change.
42
+ * @param {Function} [props.onDelete] - onDelete(session). Presence enables the hover-revealed
43
+ * delete action; clicking it arms an inline two-button confirm row (same height, no modal),
44
+ * mirroring SessionDashboard's arm-then-confirm stop control.
45
+ * @param {*} [props.confirmingDelete] - sid currently showing the armed delete-confirm state.
46
+ * @param {Function} [props.onArmDelete] - onArmDelete(session) - first delete click.
47
+ * @param {Function} [props.onCancelDelete] - onCancelDelete() - confirm-row Cancel click.
48
+ * @returns {*} webjsx vnode
49
+ */
50
+ export function ConversationList({ sessions = [], selected, groups, search, caption,
51
+ onSelect, onNew, newLabel = 'New chat',
52
+ emptyText = 'No conversations yet', loading = false, error = null,
53
+ loadingText = 'Loading conversations…',
54
+ // hasMore/onLoadMore: the rail's host truncates the underlying
55
+ // session list at some limit (a 200+ conversation user would
56
+ // otherwise never reach older sessions) - mirrors the History
57
+ // tab's existing "load N older" EventList pattern.
58
+ hasMore = false, onLoadMore, loadMoreLabel = 'load more conversations',
59
+ // resultCount: forwarded straight through to the inner SearchInput's
60
+ // aria-live region, so a real "N results" string (computed by the
61
+ // host from its filtered session list) reaches AT users instead of
62
+ // the region sitting permanently empty.
63
+ resultCount,
64
+ // Fork/branch tree nesting (parentSid-driven), inline rename,
65
+ // inline delete — all host-driven, kit stays stateless.
66
+ tree = false, expanded, onToggleExpand,
67
+ onRename, renaming, onStartRename, onCancelRename,
68
+ onDelete, confirmingDelete, onArmDelete, onCancelDelete } = {}) {
69
+ const expSet = expanded instanceof Set ? expanded : new Set(expanded || []);
70
+ // childrenBySid: only consulted when `tree` is on - a flat caller pays nothing.
71
+ const childrenBySid = new Map();
72
+ if (tree) {
73
+ for (const s of sessions) {
74
+ if (s.parentSid == null) continue;
75
+ if (!childrenBySid.has(s.parentSid)) childrenBySid.set(s.parentSid, []);
76
+ childrenBySid.get(s.parentSid).push(s);
77
+ }
78
+ }
79
+ const isRenaming = (s) => renaming != null && s.sid === renaming;
80
+ const isConfirmingDelete = (s) => confirmingDelete != null && s.sid === confirmingDelete;
81
+
82
+ const rowFor = (s, i, depth = 0) => {
83
+ const hasKids = tree && childrenBySid.has(s.sid) && childrenBySid.get(s.sid).length > 0;
84
+ const kidsOpen = hasKids && expSet.has(s.sid);
85
+ const renamingThis = onRename && isRenaming(s);
86
+ const confirmingThis = onDelete && isConfirmingDelete(s);
87
+ let content;
88
+ if (confirmingThis) {
89
+ // Inline confirm: same row height, no modal - mirrors SessionDashboard's
90
+ // arm-then-confirm bulk-stop control so delete has one consistent shape
91
+ // across the kit.
92
+ content = [
93
+ h('span', { key: 'cd-msg', class: 'ds-session-confirm-msg' }, 'Delete "' + (s.title || s.project || s.sid || '') + '"?'),
94
+ h('span', { key: 'cd-acts', class: 'ds-session-confirm-actions' }, [
95
+ h('button', { key: 'cd-yes', type: 'button', class: 'ds-session-confirm-delete',
96
+ onclick: (e) => { e.stopPropagation(); onDelete(s); } }, 'delete'),
97
+ h('button', { key: 'cd-no', type: 'button', class: 'ds-session-confirm-cancel',
98
+ onclick: (e) => { e.stopPropagation(); onCancelDelete && onCancelDelete(); } }, 'cancel'),
99
+ ]),
100
+ ];
101
+ } else if (renamingThis) {
102
+ content = [
103
+ h('input', {
104
+ key: 'rn-input', class: 'ds-session-rename-input', type: 'text',
105
+ value: s.title || '', autofocus: true,
106
+ onclick: (e) => e.stopPropagation(),
107
+ onkeydown: (e) => {
108
+ if (e.key === 'Enter') { e.preventDefault(); onRename(s, e.target.value); }
109
+ if (e.key === 'Escape') { e.preventDefault(); onCancelRename && onCancelRename(); }
110
+ },
111
+ onblur: (e) => onRename(s, e.target.value),
112
+ }),
113
+ ];
114
+ } else {
115
+ content = [
116
+ hasKids ? h('button', {
117
+ key: 'tog', type: 'button', class: 'ds-session-tree-toggle' + (kidsOpen ? ' open' : ''),
118
+ 'aria-label': (kidsOpen ? 'collapse' : 'expand') + ' forks', 'aria-expanded': kidsOpen ? 'true' : 'false',
119
+ onclick: (e) => { e.stopPropagation(); onToggleExpand && onToggleExpand(s.sid); },
120
+ }, Icon('chevron-right', { size: 10 })) : null,
121
+ depth > 0 ? h('span', { key: 'fork', class: 'ds-session-fork-icon', 'aria-hidden': 'true' }, Icon('corner-up-left', { size: 10 })) : null,
122
+ h('span', { key: 'main', class: 'ds-session-main' }, [
123
+ // Two-sided truncation: the CSS ellipsis is paired with a title= carrying
124
+ // the full string, so a long title/project is recoverable on hover.
125
+ h('span', { class: 'ds-session-title', title: s.title || s.project || s.sid || null }, s.title || s.project || s.sid || ''),
126
+ (s.project || s.time) ? h('span', { class: 'ds-session-sub', title: s.project || null },
127
+ [s.project, s.time].filter(Boolean).join(' · ')) : null,
128
+ ].filter(Boolean)),
129
+ h('span', { key: 'meta', class: 'ds-session-meta' }, [
130
+ s.agent ? h('span', { class: 'ds-session-agent' }, s.agent) : null,
131
+ // Optional richer status ('error'|'stale'|'running'|'stopping') mirrors the
132
+ // SessionCard STATUS_DISC mapping used on the Live dashboard, so a session
133
+ // pinned to a "Running" rail group reads the same stuck-vs-busy signal it
134
+ // does there rather than only a boolean live dot. Falls back to the plain
135
+ // running dot when no status is supplied (existing callers unaffected).
136
+ s.status
137
+ ? h('span', { class: 'status-dot-disc ' + (STATUS_DISC[s.status] || 'status-dot-live'), 'aria-label': STATUS_WORD[s.status] || s.status, role: 'img' })
138
+ : s.running
139
+ ? h('span', { class: 'status-dot-disc status-dot-live', 'aria-label': 'running', role: 'img' })
140
+ : (s.unread ? h('span', { class: 'ds-session-unread', 'aria-label': 'new activity', role: 'img' }) : null),
141
+ ].filter(Boolean)),
142
+ (onRename || onDelete) ? h('span', { key: 'row-actions', class: 'ds-session-row-actions' }, [
143
+ onRename ? h('button', { key: 'ra-rn', type: 'button', class: 'ds-session-row-action', 'aria-label': 'rename',
144
+ onclick: (e) => { e.stopPropagation(); onStartRename ? onStartRename(s) : onRename(s, s.title); } }, Icon('pencil', { size: 12 })) : null,
145
+ onDelete ? h('button', { key: 'ra-del', type: 'button', class: 'ds-session-row-action ds-session-row-action-danger', 'aria-label': 'delete',
146
+ onclick: (e) => { e.stopPropagation(); onArmDelete ? onArmDelete(s) : onDelete(s); } }, Icon('trash', { size: 12 })) : null,
147
+ ].filter(Boolean)) : null,
148
+ ].filter(Boolean);
149
+ }
150
+ const row = h('div', {
151
+ // Stable key: prefer sid, else position - a missing/duplicate sid would make
152
+ // key undefined and crash webjsx applyDiff ("reading 'key'" of undefined).
153
+ key: 'cs-' + (s.sid != null ? s.sid : 'i' + i),
154
+ role: 'option',
155
+ tabindex: s.sid === selected ? '0' : '-1',
156
+ class: 'ds-session-row' + (s.sid === selected ? ' active' : '') + (s.rail ? ' rail-' + s.rail : '') +
157
+ (depth > 0 ? ' ds-session-row-nested' : ''),
158
+ style: depth > 0 ? ('padding-left: calc(var(--space-2) + ' + (depth * 16) + 'px)') : null,
159
+ 'aria-selected': s.sid === selected ? 'true' : 'false',
160
+ onclick: (renamingThis || confirmingThis) ? null : () => onSelect && onSelect(s),
161
+ onkeydown: (renamingThis || confirmingThis) ? null : (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect && onSelect(s); } },
162
+ }, ...content);
163
+ if (!hasKids || !kidsOpen) return [row];
164
+ // Depth-first flatten of open children keeps the caller-facing return shape
165
+ // (an array of rows) identical whether tree nesting is on or off.
166
+ const kidRows = childrenBySid.get(s.sid)
167
+ .slice().sort((a, b) => (b.time || '').localeCompare(a.time || ''))
168
+ .flatMap((k, ki) => rowFor(k, ki, depth + 1));
169
+ return [row, ...kidRows];
170
+ };
171
+
172
+ // The body is ALWAYS a single keyed wrapper element of the same tag, so webjsx
173
+ // diffs its children across state transitions (loading -> empty -> populated)
174
+ // instead of swapping the container type - the swap is what triggered the
175
+ // applyDiff "reading 'key'" crash on the first populated mount. Row children
176
+ // are uniformly keyed; non-row states render a single unkeyed status line.
177
+ let inner;
178
+ if (loading && !sessions.length) {
179
+ // Shape-matched skeleton rows during the cold ccsniff index walk (the rail
180
+ // showed a bare line before) - Claude-Desktop skeletons its sidebar on load.
181
+ inner = [
182
+ h('div', { key: 'st', class: 'ds-session-state', role: 'status', 'aria-live': 'polite' }, loadingText),
183
+ ...Array.from({ length: 5 }, (_, i) => h('div', { key: 'sk' + i, class: 'ds-session-row-skeleton', 'aria-hidden': 'true' },
184
+ h('div', { class: 'ds-skel ds-skel-title' }), h('div', { class: 'ds-skel ds-skel-meta' }))),
185
+ ];
186
+ } else if (error) {
187
+ inner = [h('div', { key: 'st', class: 'ds-session-state ds-session-state-error', role: 'status' }, String(error))];
188
+ } else if (!sessions.length) {
189
+ inner = [h('div', { key: 'st', class: 'ds-session-state', role: 'status' }, emptyText)];
190
+ } else if (groups && groups.length) {
191
+ const bySid = new Map(sessions.map((s) => [s.sid, s]));
192
+ inner = groups.map((g) => h('div', { key: 'g-' + g.label, class: 'ds-session-group', role: 'group', 'aria-label': g.label },
193
+ h('div', { key: 'gl', class: 'ds-session-group-label' }, g.label),
194
+ h('div', { key: 'gr', class: 'ds-session-group-rows', role: 'listbox', 'aria-label': g.label }, ...g.sids.map((sid) => bySid.get(sid)).filter(Boolean).flatMap((s, i) => rowFor(s, i)))));
195
+ } else if (tree) {
196
+ // Roots = rows with no parentSid, or whose parentSid isn't present in this
197
+ // list (an orphaned fork - the ancestor was deleted/filtered out) - each
198
+ // root's flatMap already walks its open descendants via rowFor's recursion.
199
+ const sidSet = new Set(sessions.map((s) => s.sid));
200
+ const roots = sessions.filter((s) => s.parentSid == null || !sidSet.has(s.parentSid));
201
+ inner = roots.flatMap((s, i) => rowFor(s, i, 0));
202
+ } else {
203
+ inner = sessions.flatMap((s, i) => rowFor(s, i));
204
+ }
205
+ // The load-more row sits INSIDE the scrollable list body (not the outer
206
+ // .ds-sessions shell) so it scrolls with the rows it extends, matching
207
+ // where a user's eye already is after scrolling to the bottom of the rail.
208
+ const loadMoreRow = (hasMore && onLoadMore && sessions.length)
209
+ ? h('button', { key: 'loadmore', type: 'button', class: 'ds-session-loadmore', onclick: onLoadMore }, loadMoreLabel)
210
+ : null;
211
+ const body = h('div', { key: 'body', class: 'ds-session-list', role: 'listbox', 'aria-label': caption || 'Conversations' },
212
+ ...inner, loadMoreRow);
213
+
214
+ return h('div', { class: 'ds-sessions' },
215
+ h('div', { key: 'head', class: 'ds-session-head' },
216
+ onNew ? h('button', { key: 'new', type: 'button', class: 'ds-session-new', onclick: onNew, 'aria-label': newLabel },
217
+ Icon('pencil'), h('span', { key: 'l' }, newLabel)) : null,
218
+ search ? SearchInput({
219
+ key: 'search', value: search.value || '',
220
+ label: search.placeholder || 'Search conversations',
221
+ placeholder: search.placeholder || 'Search conversations',
222
+ onInput: (v) => search.onInput && search.onInput(v),
223
+ resultCount,
224
+ }) : null),
225
+ // Per-tab caption telling the user what selecting a row does on this surface
226
+ // (chat = resume the conversation, history = browse its events) so visually
227
+ // identical rows are disambiguated.
228
+ caption ? h('div', { key: 'cap', class: 'ds-session-caption' }, caption) : null,
229
+ body);
230
+ }