pi-tau-web-server 1.0.8

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 (98) hide show
  1. package/README.md +303 -0
  2. package/bin/auth.js +96 -0
  3. package/bin/config.js +99 -0
  4. package/bin/model-utils.js +134 -0
  5. package/bin/server-main.js +1189 -0
  6. package/bin/sessions.js +492 -0
  7. package/bin/tau.js +8 -0
  8. package/bin/tree.js +248 -0
  9. package/bin/types.js +2 -0
  10. package/package.json +74 -0
  11. package/public/app-main.js +2025 -0
  12. package/public/app-types.js +1 -0
  13. package/public/app.js +1 -0
  14. package/public/command-palette.js +42 -0
  15. package/public/dialogs.js +199 -0
  16. package/public/file-browser.js +196 -0
  17. package/public/icons/apple-touch-icon.png +0 -0
  18. package/public/icons/favicon-16.png +0 -0
  19. package/public/icons/favicon-32.png +0 -0
  20. package/public/icons/tau-192.png +0 -0
  21. package/public/icons/tau-512.png +0 -0
  22. package/public/icons/tau-logo.png +0 -0
  23. package/public/icons/tau-logo.svg +13 -0
  24. package/public/icons/tau-maskable-512.png +0 -0
  25. package/public/icons/tau-new.png +0 -0
  26. package/public/index.html +264 -0
  27. package/public/launcher-panel.js +54 -0
  28. package/public/launcher.js +84 -0
  29. package/public/manifest.json +28 -0
  30. package/public/markdown.js +336 -0
  31. package/public/message-renderer.js +268 -0
  32. package/public/model-picker.js +478 -0
  33. package/public/session-sidebar.js +460 -0
  34. package/public/session-stats-card.js +123 -0
  35. package/public/state.js +81 -0
  36. package/public/style.css +3864 -0
  37. package/public/sw.js +66 -0
  38. package/public/themes.js +72 -0
  39. package/public/tool-card.js +317 -0
  40. package/public/tree-view.js +474 -0
  41. package/public/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  42. package/public/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  43. package/public/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  44. package/public/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  45. package/public/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  46. package/public/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  47. package/public/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  48. package/public/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  49. package/public/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  50. package/public/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  51. package/public/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  52. package/public/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  53. package/public/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  54. package/public/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  55. package/public/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  56. package/public/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  57. package/public/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  58. package/public/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  59. package/public/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  60. package/public/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  61. package/public/vendor/katex/katex.min.css +1 -0
  62. package/public/vendor/katex/katex.min.js +1 -0
  63. package/public/voice-input.js +74 -0
  64. package/public/websocket-client.js +156 -0
  65. package/scripts/copy-katex.mjs +32 -0
  66. package/src/pi-extension/tau-tree.ts +71 -0
  67. package/src/public/app-main.ts +2190 -0
  68. package/src/public/app-types.ts +96 -0
  69. package/src/public/app.ts +1 -0
  70. package/src/public/command-palette.ts +53 -0
  71. package/src/public/dialogs.ts +251 -0
  72. package/src/public/file-browser.ts +224 -0
  73. package/src/public/launcher-panel.ts +68 -0
  74. package/src/public/launcher.ts +101 -0
  75. package/src/public/legacy-dom.d.ts +29 -0
  76. package/src/public/markdown.ts +372 -0
  77. package/src/public/message-renderer.ts +311 -0
  78. package/src/public/model-picker.ts +500 -0
  79. package/src/public/session-sidebar.ts +522 -0
  80. package/src/public/session-stats-card.ts +176 -0
  81. package/src/public/state.ts +96 -0
  82. package/src/public/sw.ts +79 -0
  83. package/src/public/themes.ts +73 -0
  84. package/src/public/tool-card.ts +375 -0
  85. package/src/public/tree-view.ts +527 -0
  86. package/src/public/voice-input.ts +98 -0
  87. package/src/public/websocket-client.ts +165 -0
  88. package/src/server/auth.ts +88 -0
  89. package/src/server/config.ts +88 -0
  90. package/src/server/model-utils.ts +122 -0
  91. package/src/server/server-main.ts +1004 -0
  92. package/src/server/sessions.ts +481 -0
  93. package/src/server/tau.ts +9 -0
  94. package/src/server/tree.ts +288 -0
  95. package/src/server/types.ts +68 -0
  96. package/tsconfig.json +3 -0
  97. package/tsconfig.public.json +15 -0
  98. package/tsconfig.server.json +16 -0
@@ -0,0 +1,527 @@
1
+ import type { MessageContentBlock } from './app-types.js';
2
+
3
+ // ═══════════════════════════════════════
4
+ // Session Tree View
5
+ // ═══════════════════════════════════════
6
+ // Centered modal (same overlay/frosted-sheet pattern as the model picker)
7
+ // that renders pi's session entry tree (get_tree RPC) and lets the user jump
8
+ // the active leaf to any earlier entry via the backend-local navigate_tree
9
+ // RPC command. The conversation itself re-renders when the server broadcasts
10
+ // a fresh snapshot after the leaf moves — this module never re-renders the
11
+ // message list itself.
12
+
13
+ type TreeEntryMessage = {
14
+ role?: string;
15
+ content?: string | MessageContentBlock[];
16
+ toolName?: string;
17
+ [key: string]: unknown;
18
+ };
19
+
20
+ type TreeEntry = {
21
+ type?: string;
22
+ id?: string;
23
+ parentId?: string | null;
24
+ timestamp?: string;
25
+ message?: TreeEntryMessage;
26
+ customType?: string;
27
+ content?: string;
28
+ summary?: string;
29
+ provider?: string;
30
+ modelId?: string;
31
+ thinkingLevel?: string;
32
+ name?: string;
33
+ label?: string;
34
+ [key: string]: unknown;
35
+ };
36
+
37
+ type TreeNode = {
38
+ entry: TreeEntry;
39
+ children: TreeNode[];
40
+ label?: string;
41
+ labelTimestamp?: string;
42
+ };
43
+
44
+ type FlatRow = { node: TreeNode; depth: number };
45
+
46
+ type TreeViewOptions = {
47
+ getActiveLiveSessionId(): string | null;
48
+ isStreaming(): boolean;
49
+ setComposerText(text: string): void;
50
+ flashStatusError(message: string, ms?: number): void;
51
+ };
52
+
53
+ const HINT_DEFAULT = '↑↓ move · Enter jump · Esc close';
54
+ const HINT_STREAMING = 'Session is streaming — navigation is disabled until the turn finishes.';
55
+
56
+ // customType of the internal marker entries the server appends to persist a
57
+ // leaf move (keep in sync with NAVIGATION_MARKER_TYPE in src/server/tree.ts).
58
+ // They are tau plumbing, not conversation content, so the tree never shows
59
+ // them — not even in "All entries" mode.
60
+ const NAVIGATION_MARKER_TYPE = 'tau:navigate-tree';
61
+
62
+ // Indent guides are capped so deeply-branched trees stay readable on narrow
63
+ // screens (depth counts branch points, not entries — see flatten()).
64
+ const MAX_GUIDE_DEPTH = 6;
65
+
66
+ // Small inline-SVG role glyphs, matching the app's existing icon style
67
+ // (16px, stroke currentColor).
68
+ const SVG_ATTRS = 'width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"';
69
+ const ICONS: Record<string, string> = {
70
+ user: `<svg ${SVG_ATTRS}><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`,
71
+ assistant: `<svg ${SVG_ATTRS}><path d="M12 3l2 5.5L19.5 10 14 12l-2 5.5L10 12l-5.5-2L10 8.5z"/></svg>`,
72
+ tool: `<svg ${SVG_ATTRS}><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>`,
73
+ compaction: `<svg ${SVG_ATTRS}><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><line x1="10" y1="12" x2="14" y2="12"/></svg>`,
74
+ meta: `<svg ${SVG_ATTRS}><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>`,
75
+ };
76
+
77
+ function firstLine(text: string) {
78
+ return (String(text || '').split('\n').find((line) => line.trim()) || '').trim();
79
+ }
80
+
81
+ function messageText(msg: TreeEntryMessage | undefined) {
82
+ if (!msg) return '';
83
+ if (typeof msg.content === 'string') return msg.content;
84
+ if (Array.isArray(msg.content)) {
85
+ return msg.content.filter((b) => b.type === 'text').map((b) => b.text || '').join('\n');
86
+ }
87
+ return '';
88
+ }
89
+
90
+ // Classify an entry for glyph + filtering, and produce the one-line snippet.
91
+ function describeEntry(entry: TreeEntry): { kind: string; text: string } {
92
+ if (entry.type === 'message') {
93
+ const role = entry.message?.role || '';
94
+ if (role === 'user') return { kind: 'user', text: firstLine(messageText(entry.message)) || '(empty message)' };
95
+ if (role === 'assistant') {
96
+ const text = firstLine(messageText(entry.message));
97
+ if (text) return { kind: 'assistant', text };
98
+ const toolNames = Array.isArray(entry.message?.content)
99
+ ? entry.message.content.filter((b) => b.type === 'toolCall').map((b) => b.name || 'tool').join(', ')
100
+ : '';
101
+ return { kind: 'assistant', text: toolNames ? `→ ${toolNames}` : '(empty message)' };
102
+ }
103
+ if (role === 'toolResult') return { kind: 'tool', text: entry.message?.toolName || 'tool result' };
104
+ return { kind: 'meta', text: firstLine(messageText(entry.message)) || role || 'message' };
105
+ }
106
+ if (entry.type === 'custom_message') {
107
+ return { kind: 'user', text: firstLine(String(entry.content || '')) || entry.customType || 'custom message' };
108
+ }
109
+ if (entry.type === 'custom') return { kind: 'meta', text: entry.customType || 'custom' };
110
+ if (entry.type === 'compaction') {
111
+ const summary = firstLine(String(entry.summary || ''));
112
+ return { kind: 'compaction', text: summary ? `Compaction — ${summary}` : 'Compaction' };
113
+ }
114
+ if (entry.type === 'branch_summary') {
115
+ const summary = firstLine(String(entry.summary || ''));
116
+ return { kind: 'compaction', text: summary ? `Branch summary — ${summary}` : 'Branch summary' };
117
+ }
118
+ // Model id can contain slashes; display is always the provider/id join.
119
+ if (entry.type === 'model_change') return { kind: 'meta', text: `model → ${[entry.provider, entry.modelId].filter(Boolean).join('/')}` };
120
+ if (entry.type === 'thinking_level_change') return { kind: 'meta', text: `thinking → ${entry.thinkingLevel || ''}` };
121
+ if (entry.type === 'session_info') return { kind: 'meta', text: `name → ${entry.name || ''}` };
122
+ if (entry.type === 'label') return { kind: 'meta', text: `label → ${entry.label || ''}` };
123
+ return { kind: 'meta', text: entry.type || 'entry' };
124
+ }
125
+
126
+ export function setupTreeView(options: TreeViewOptions) {
127
+ const { getActiveLiveSessionId, isStreaming, setComposerText, flashStatusError } = options;
128
+
129
+ // Static index.html shell elements — assert non-null at the query site,
130
+ // matching the other setup modules.
131
+ const treeBtn = document.getElementById('tree-btn')!;
132
+ const overlay = document.getElementById('tree-view-overlay')!;
133
+ const modal = document.getElementById('tree-view')!;
134
+ const listEl = document.getElementById('tree-view-list')!;
135
+ const messageEl = document.getElementById('tree-view-message')!;
136
+ const filterBtn = document.getElementById('tree-view-filter')!;
137
+ const closeBtn = document.getElementById('tree-view-close')!;
138
+
139
+ let treeData: TreeNode[] = [];
140
+ let leafId: string | null = null;
141
+ let showAll = false; // default: messages only (pi's default /tree filter)
142
+ let rows: FlatRow[] = [];
143
+ let rowEls: HTMLButtonElement[] = [];
144
+ let activeIndex = -1;
145
+ let openSessionId: string | null = null;
146
+ let navigating = false;
147
+
148
+ function isOpen() {
149
+ return !modal.classList.contains('hidden');
150
+ }
151
+
152
+ function setMessage(text: string, isError = false) {
153
+ messageEl.textContent = text;
154
+ messageEl.classList.toggle('error', isError);
155
+ }
156
+
157
+ function updateFilterButton() {
158
+ filterBtn.textContent = showAll ? 'All entries' : 'Messages only';
159
+ filterBtn.setAttribute('aria-pressed', showAll ? 'true' : 'false');
160
+ filterBtn.title = showAll ? 'Showing every entry — click to hide tool results and housekeeping entries' : 'Hiding tool results and housekeeping entries — click to show every entry';
161
+ }
162
+
163
+ // Flatten the tree depth-first. Depth counts BRANCH POINTS, not entries: a
164
+ // linear run of parent→child entries stays at one indent level and only a
165
+ // node with several children pushes its children one level deeper —
166
+ // otherwise every ordinary exchange would gain a level and long sessions
167
+ // would indent themselves off-screen. Filtered-out entries never render:
168
+ // their children are promoted into their place (at the depth the hidden
169
+ // entry would have had), so the branch structure stays readable in
170
+ // messages-only mode. "Messages only" mirrors pi's no-tools filter: tool
171
+ // results and housekeeping entries (model/thinking/name changes, custom
172
+ // meta entries) are hidden, messages and compactions stay. tau's internal
173
+ // navigation markers are plumbing and are hidden in every mode.
174
+ function flatten(nodes: TreeNode[], depth: number, out: FlatRow[]) {
175
+ for (const node of nodes) {
176
+ const entry = node.entry;
177
+ const isInternalMarker = entry.type === 'custom' && entry.customType === NAVIGATION_MARKER_TYPE;
178
+ const kind = describeEntry(entry).kind;
179
+ const hidden = isInternalMarker || (!showAll && (kind === 'tool' || kind === 'meta'));
180
+ if (!hidden) out.push({ node, depth });
181
+ const childDepth = depth + ((node.children || []).length > 1 ? 1 : 0);
182
+ flatten(node.children, childDepth, out);
183
+ }
184
+ return out;
185
+ }
186
+
187
+ // ids on the active path (root → current leaf), derived by walking
188
+ // parentId links from the leaf.
189
+ function computeActivePath(): Set<string> {
190
+ const byId = new Map<string, TreeEntry>();
191
+ const walk = (nodes: TreeNode[]) => {
192
+ for (const node of nodes) {
193
+ if (node.entry?.id) byId.set(node.entry.id, node.entry);
194
+ walk(node.children || []);
195
+ }
196
+ };
197
+ walk(treeData);
198
+ const path = new Set<string>();
199
+ let cursor = leafId;
200
+ while (cursor && byId.has(cursor) && !path.has(cursor)) {
201
+ path.add(cursor);
202
+ cursor = byId.get(cursor)?.parentId || null;
203
+ }
204
+ return path;
205
+ }
206
+
207
+ function setActiveIndex(index: number, scroll = false) {
208
+ if (!rows.length) { activeIndex = -1; listEl.removeAttribute('aria-activedescendant'); return; }
209
+ activeIndex = Math.max(0, Math.min(index, rows.length - 1));
210
+ rowEls.forEach((el, i) => {
211
+ const active = i === activeIndex;
212
+ el.classList.toggle('active', active);
213
+ el.setAttribute('aria-selected', active ? 'true' : 'false');
214
+ });
215
+ // Keep the roving highlight visible to screen readers: DOM focus stays on
216
+ // the listbox, aria-activedescendant follows the highlighted option.
217
+ const activeEl = rowEls[activeIndex];
218
+ if (activeEl?.id) listEl.setAttribute('aria-activedescendant', activeEl.id);
219
+ if (scroll) activeEl?.scrollIntoView({ block: 'nearest' });
220
+ }
221
+
222
+ function render(scrollLeafIntoView = false) {
223
+ rows = flatten(treeData, 0, []);
224
+ rowEls = [];
225
+ listEl.innerHTML = '';
226
+ listEl.removeAttribute('aria-activedescendant');
227
+ const path = computeActivePath();
228
+ const streaming = isStreaming();
229
+ setMessage(streaming ? HINT_STREAMING : HINT_DEFAULT, false);
230
+ listEl.classList.toggle('tree-view-inert', streaming);
231
+
232
+ if (!rows.length) {
233
+ const empty = document.createElement('div');
234
+ empty.className = 'tree-view-empty';
235
+ empty.textContent = 'No entries in this session yet.';
236
+ listEl.appendChild(empty);
237
+ activeIndex = -1;
238
+ return;
239
+ }
240
+
241
+ // The "current" marker goes on the DEEPEST VISIBLE row of the active
242
+ // path, not on the raw leaf entry: the leaf is often hidden (tau's
243
+ // navigation marker, a filtered tool result) or a housekeeping entry
244
+ // appended below the spot the user actually jumped to. DFS order lists
245
+ // ancestors before descendants, so the last on-path row is the deepest.
246
+ let currentIndex = -1;
247
+ rows.forEach((row, index) => {
248
+ if (row.node.entry.id && path.has(row.node.entry.id)) currentIndex = index;
249
+ });
250
+
251
+ rows.forEach((row, index) => {
252
+ const entry = row.node.entry;
253
+ const { kind, text } = describeEntry(entry);
254
+ const onPath = !!entry.id && path.has(entry.id);
255
+ const isCurrent = index === currentIndex;
256
+
257
+ const btn = document.createElement('button');
258
+ btn.type = 'button';
259
+ btn.id = `tree-row-${index}`;
260
+ btn.className = `tree-row${onPath ? ' on-path' : ''}${isCurrent ? ' leaf' : ''}`;
261
+ btn.setAttribute('role', 'option');
262
+ btn.setAttribute('aria-selected', 'false');
263
+ if (streaming) btn.disabled = true;
264
+ btn.title = text;
265
+
266
+ const guides = document.createElement('span');
267
+ guides.className = 'tree-guides';
268
+ for (let i = 0; i < Math.min(row.depth, MAX_GUIDE_DEPTH); i++) {
269
+ const guide = document.createElement('span');
270
+ guide.className = 'tree-guide';
271
+ guides.appendChild(guide);
272
+ }
273
+ btn.appendChild(guides);
274
+
275
+ const icon = document.createElement('span');
276
+ icon.className = `tree-row-icon tree-row-icon-${kind}`;
277
+ icon.innerHTML = ICONS[kind] || ICONS.meta;
278
+ btn.appendChild(icon);
279
+
280
+ const snippet = document.createElement('span');
281
+ snippet.className = 'tree-row-text';
282
+ snippet.textContent = text;
283
+ btn.appendChild(snippet);
284
+
285
+ const label = row.node.label;
286
+ if (label) {
287
+ const badge = document.createElement('span');
288
+ badge.className = 'tree-row-label';
289
+ badge.textContent = label;
290
+ btn.appendChild(badge);
291
+ }
292
+
293
+ if (isCurrent) {
294
+ const current = document.createElement('span');
295
+ current.className = 'tree-row-current';
296
+ current.textContent = 'current';
297
+ btn.appendChild(current);
298
+ }
299
+
300
+ btn.addEventListener('mouseenter', () => setActiveIndex(index));
301
+ btn.addEventListener('click', () => { void selectRow(index); });
302
+ listEl.appendChild(btn);
303
+ rowEls.push(btn);
304
+ });
305
+
306
+ setActiveIndex(currentIndex >= 0 ? currentIndex : rows.length - 1);
307
+ if (scrollLeafIntoView) {
308
+ requestAnimationFrame(() => rowEls[activeIndex]?.scrollIntoView({ block: 'center' }));
309
+ }
310
+ }
311
+
312
+ async function selectRow(index: number) {
313
+ const row = rows[index];
314
+ const entryId = row?.node.entry?.id;
315
+ if (!entryId || navigating) return;
316
+ if (isStreaming()) {
317
+ setMessage(HINT_STREAMING, false);
318
+ return;
319
+ }
320
+ const sessionId = openSessionId || getActiveLiveSessionId();
321
+ if (!sessionId) {
322
+ setMessage('Select a live Tau tab first.', true);
323
+ return;
324
+ }
325
+ navigating = true;
326
+ setMessage('Jumping…', false);
327
+ try {
328
+ const resp = await fetch('/api/rpc', {
329
+ method: 'POST',
330
+ headers: { 'Content-Type': 'application/json' },
331
+ body: JSON.stringify({ type: 'navigate_tree', sessionId, entryId }),
332
+ });
333
+ const data = await resp.json();
334
+ if (data?.success) {
335
+ const editorText = data.data?.editorText;
336
+ close();
337
+ // The conversation re-renders when the server broadcasts a fresh
338
+ // snapshot; we only need to hand the message text to the composer.
339
+ if (typeof editorText === 'string') setComposerText(editorText);
340
+ } else {
341
+ const error = data?.error || 'Failed to navigate the session tree';
342
+ setMessage(error, true);
343
+ flashStatusError(error);
344
+ }
345
+ } catch {
346
+ const error = 'Failed to navigate the session tree';
347
+ setMessage(error, true);
348
+ flashStatusError(error);
349
+ } finally {
350
+ navigating = false;
351
+ }
352
+ }
353
+
354
+ // Page step for PageUp/PageDown, mirroring pi's TUI tree paging.
355
+ const PAGE_STEP = 10;
356
+
357
+ function onKeyDown(e: KeyboardEvent) {
358
+ if (!isOpen()) return;
359
+ if (e.key === 'Escape') {
360
+ e.preventDefault();
361
+ e.stopPropagation();
362
+ close();
363
+ return;
364
+ }
365
+ if (!rows.length) return;
366
+ // Arrow keys wrap around like the model picker's list.
367
+ if (e.key === 'ArrowDown') {
368
+ e.preventDefault();
369
+ setActiveIndex((activeIndex + 1) % rows.length, true);
370
+ return;
371
+ }
372
+ if (e.key === 'ArrowUp') {
373
+ e.preventDefault();
374
+ setActiveIndex((activeIndex - 1 + rows.length) % rows.length, true);
375
+ return;
376
+ }
377
+ if (e.key === 'Home') {
378
+ e.preventDefault();
379
+ setActiveIndex(0, true);
380
+ return;
381
+ }
382
+ if (e.key === 'End') {
383
+ e.preventDefault();
384
+ setActiveIndex(rows.length - 1, true);
385
+ return;
386
+ }
387
+ if (e.key === 'PageDown') {
388
+ e.preventDefault();
389
+ setActiveIndex(activeIndex + PAGE_STEP, true);
390
+ return;
391
+ }
392
+ if (e.key === 'PageUp') {
393
+ e.preventDefault();
394
+ setActiveIndex(activeIndex - PAGE_STEP, true);
395
+ return;
396
+ }
397
+ if (e.key === 'Enter') {
398
+ e.preventDefault();
399
+ if (activeIndex >= 0) void selectRow(activeIndex);
400
+ }
401
+ }
402
+
403
+ // Monotonic sequence so a slow get_tree response never clobbers a newer one
404
+ // (open → turn-end reload → snapshot reload can overlap).
405
+ let loadSeq = 0;
406
+
407
+ async function loadTree(centerCurrent: boolean) {
408
+ const sessionId = openSessionId;
409
+ if (!sessionId) return;
410
+ const seq = ++loadSeq;
411
+ try {
412
+ const resp = await fetch('/api/rpc', {
413
+ method: 'POST',
414
+ headers: { 'Content-Type': 'application/json' },
415
+ body: JSON.stringify({ type: 'get_tree', sessionId }),
416
+ });
417
+ const data = await resp.json();
418
+ if (!isOpen() || openSessionId !== sessionId || seq !== loadSeq) return;
419
+ if (!data?.success || !data.data) {
420
+ setMessage(data?.error || 'Failed to load the session tree', true);
421
+ return;
422
+ }
423
+ treeData = (data.data.tree || []) as TreeNode[];
424
+ leafId = (data.data.leafId as string | null) ?? null;
425
+ render(centerCurrent);
426
+ } catch {
427
+ if (isOpen() && openSessionId === sessionId && seq === loadSeq) {
428
+ setMessage('Failed to load the session tree', true);
429
+ }
430
+ }
431
+ }
432
+
433
+ async function open() {
434
+ const sessionId = getActiveLiveSessionId();
435
+ if (!sessionId) {
436
+ flashStatusError('Select a live Tau tab first.');
437
+ return;
438
+ }
439
+ openSessionId = sessionId;
440
+ treeData = [];
441
+ leafId = null;
442
+ rows = [];
443
+ rowEls = [];
444
+ activeIndex = -1;
445
+ listEl.innerHTML = '';
446
+ listEl.classList.remove('tree-view-inert');
447
+ updateFilterButton();
448
+ setMessage('Loading session tree…', false);
449
+ modal.classList.remove('hidden');
450
+ overlay.classList.remove('hidden');
451
+ document.addEventListener('keydown', onKeyDown, true);
452
+ // Move focus into the dialog (the model picker focuses its input the same
453
+ // way) so Tab does not keep walking the page behind the overlay and the
454
+ // listbox's aria-activedescendant highlight is announced.
455
+ listEl.focus();
456
+ await loadTree(true);
457
+ }
458
+
459
+ function close() {
460
+ const hadFocus = modal.contains(document.activeElement);
461
+ modal.classList.add('hidden');
462
+ overlay.classList.add('hidden');
463
+ document.removeEventListener('keydown', onKeyDown, true);
464
+ listEl.innerHTML = '';
465
+ listEl.removeAttribute('aria-activedescendant');
466
+ treeData = [];
467
+ rows = [];
468
+ rowEls = [];
469
+ activeIndex = -1;
470
+ openSessionId = null;
471
+ // Hand focus back to the button that opened the dialog.
472
+ if (hadFocus && treeBtn instanceof HTMLElement && !treeBtn.classList.contains('hidden')) treeBtn.focus();
473
+ }
474
+
475
+ // Keep an open modal in sync with the session's live state instead of
476
+ // freezing whatever was true at open time. Streaming starting disables the
477
+ // rows in place; streaming ending means the turn appended entries, so the
478
+ // tree is refetched (which also re-enables the rows and re-centers on the
479
+ // new current position).
480
+ function notifyStreamingChanged(sessionId: string | null | undefined, streaming: boolean) {
481
+ if (!isOpen() || !openSessionId || (sessionId && sessionId !== openSessionId)) return;
482
+ if (streaming) {
483
+ listEl.classList.add('tree-view-inert');
484
+ rowEls.forEach((el) => { el.disabled = true; });
485
+ if (!navigating) setMessage(HINT_STREAMING, false);
486
+ } else {
487
+ void loadTree(true);
488
+ }
489
+ }
490
+
491
+ // Another client moved this session's leaf (the server broadcast a fresh
492
+ // snapshot) — refetch so the displayed tree is not stale.
493
+ function notifyTreeChanged(sessionId: string | null | undefined) {
494
+ if (!isOpen() || !openSessionId || (sessionId && sessionId !== openSessionId)) return;
495
+ if (!navigating) void loadTree(true);
496
+ }
497
+
498
+ function closeIfOpen() {
499
+ if (!isOpen()) return false;
500
+ close();
501
+ return true;
502
+ }
503
+
504
+ // Header entry button visibility is gated on having an active live
505
+ // session, mirroring the other session-dependent header controls.
506
+ function setVisible(visible: boolean) {
507
+ treeBtn.classList.toggle('hidden', !visible);
508
+ }
509
+
510
+ filterBtn.addEventListener('click', () => {
511
+ showAll = !showAll;
512
+ updateFilterButton();
513
+ // Re-render with centering, like open(): rebuilding the list resets the
514
+ // scroll position, so without it toggling the filter would throw the view
515
+ // back to the session root and lose the current position.
516
+ if (treeData.length || leafId) render(true);
517
+ });
518
+ treeBtn.addEventListener('click', () => { void open(); });
519
+ overlay.addEventListener('click', close);
520
+ closeBtn.addEventListener('click', close);
521
+ // Focusable so open() can move focus into the dialog; keyboard interaction
522
+ // is roving-highlight (aria-activedescendant), not per-row tab stops.
523
+ (listEl as HTMLElement).tabIndex = -1;
524
+ updateFilterButton();
525
+
526
+ return { open, close, closeIfOpen, setVisible, notifyStreamingChanged, notifyTreeChanged };
527
+ }
@@ -0,0 +1,98 @@
1
+ type SpeechRecognitionInstance = EventTarget & {
2
+ continuous: boolean;
3
+ interimResults: boolean;
4
+ lang: string;
5
+ start(): void;
6
+ stop(): void;
7
+ };
8
+
9
+ type SpeechRecognitionResultAlt = { transcript: string };
10
+ type SpeechRecognitionResult = {
11
+ isFinal: boolean;
12
+ length: number;
13
+ 0: SpeechRecognitionResultAlt;
14
+ };
15
+ type SpeechRecognitionResultList = {
16
+ length: number;
17
+ [index: number]: SpeechRecognitionResult;
18
+ };
19
+ type SpeechRecognitionResultEvent = Event & {
20
+ resultIndex: number;
21
+ results: SpeechRecognitionResultList;
22
+ };
23
+ type SpeechRecognitionErrorEvent = Event & { error: string };
24
+
25
+ export function setupVoiceInput(micBtn: HTMLElement, messageInput: HTMLElement) {
26
+ let recognition: SpeechRecognitionInstance | null = null;
27
+ let isRecording = false;
28
+
29
+ if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) {
30
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
31
+ if (!SpeechRecognition) {
32
+ micBtn.style.display = 'none';
33
+ return;
34
+ }
35
+ recognition = new SpeechRecognition();
36
+ recognition.continuous = true;
37
+ recognition.interimResults = true;
38
+ recognition.lang = 'en-AU';
39
+
40
+ let finalTranscript = '';
41
+ let interimTranscript = '';
42
+
43
+ recognition.addEventListener('result', (e: Event) => {
44
+ const ev = e as SpeechRecognitionResultEvent;
45
+ interimTranscript = '';
46
+ for (let i = ev.resultIndex; i < ev.results.length; i++) {
47
+ if (ev.results[i].isFinal) {
48
+ finalTranscript += ev.results[i][0].transcript;
49
+ } else {
50
+ interimTranscript += ev.results[i][0].transcript;
51
+ }
52
+ }
53
+ messageInput.value = finalTranscript + interimTranscript;
54
+ messageInput.dispatchEvent(new Event('input'));
55
+ });
56
+
57
+ recognition.addEventListener('end', () => {
58
+ if (isRecording) stopRecording();
59
+ });
60
+
61
+ recognition.addEventListener('error', (e: Event) => {
62
+ const ev = e as SpeechRecognitionErrorEvent;
63
+ console.error('[Voice] Error:', ev.error);
64
+ stopRecording();
65
+ });
66
+
67
+ micBtn.addEventListener('click', () => {
68
+ if (isRecording) {
69
+ stopRecording();
70
+ } else {
71
+ startRecording();
72
+ }
73
+ });
74
+
75
+ function startRecording() {
76
+ if (!recognition) return;
77
+ finalTranscript = messageInput.value;
78
+ interimTranscript = '';
79
+ isRecording = true;
80
+ micBtn.classList.add('recording');
81
+ micBtn.title = 'Stop recording';
82
+ recognition.start();
83
+ messageInput.focus();
84
+ }
85
+
86
+ function stopRecording() {
87
+ isRecording = false;
88
+ micBtn.classList.remove('recording');
89
+ micBtn.title = 'Voice input';
90
+ try { recognition?.stop(); } catch {}
91
+ messageInput.value = finalTranscript;
92
+ messageInput.dispatchEvent(new Event('input'));
93
+ messageInput.focus();
94
+ }
95
+ } else {
96
+ micBtn.style.display = 'none';
97
+ }
98
+ }