tinywebui 0.1.0

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 (96) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +586 -0
  3. package/bin/tinywebui.js +105 -0
  4. package/package.json +49 -0
  5. package/public/app.js +81 -0
  6. package/public/chat/attachments.js +304 -0
  7. package/public/chat/chat.js +282 -0
  8. package/public/chat/composer.js +289 -0
  9. package/public/chat/library.js +76 -0
  10. package/public/chat/outline.js +39 -0
  11. package/public/chat/queue.js +124 -0
  12. package/public/chat/sidebar.js +485 -0
  13. package/public/chat/stream.js +162 -0
  14. package/public/chat/tasks.js +36 -0
  15. package/public/chat/transcript.js +691 -0
  16. package/public/core/access.js +42 -0
  17. package/public/core/api.js +27 -0
  18. package/public/core/dom.js +13 -0
  19. package/public/core/id.js +7 -0
  20. package/public/core/md.js +131 -0
  21. package/public/core/state.js +31 -0
  22. package/public/favicon.svg +9 -0
  23. package/public/index.html +261 -0
  24. package/public/panels/admin.js +199 -0
  25. package/public/panels/automations.js +464 -0
  26. package/public/panels/panels.js +52 -0
  27. package/public/panels/settings.js +365 -0
  28. package/public/panels/usage.js +474 -0
  29. package/public/shared/attachment_note.js +23 -0
  30. package/public/shared/events.js +27 -0
  31. package/public/shared/features.js +21 -0
  32. package/public/shared/origins.js +8 -0
  33. package/public/shared/usage.js +59 -0
  34. package/public/styles.css +1455 -0
  35. package/public/themes/README.md +178 -0
  36. package/public/themes/cyber-grid/cyber-grid.css +59 -0
  37. package/public/themes/drivers/scene-kit.js +422 -0
  38. package/public/themes/fall-fairy/fall-fairy.css +221 -0
  39. package/public/themes/fall-fairy/fall-fairy.js +859 -0
  40. package/public/themes/manifest.json +6 -0
  41. package/public/themes/theme-loader.js +123 -0
  42. package/src/access/auth.js +228 -0
  43. package/src/access/auth_gate.js +67 -0
  44. package/src/access/policy.js +180 -0
  45. package/src/audit.js +7 -0
  46. package/src/automations/automation.js +136 -0
  47. package/src/automations/scheduler.js +147 -0
  48. package/src/chat/compact.js +242 -0
  49. package/src/chat/llm.js +448 -0
  50. package/src/chat/runs.js +180 -0
  51. package/src/chat/tool_executor.js +239 -0
  52. package/src/cli.js +478 -0
  53. package/src/config/approval.js +54 -0
  54. package/src/config/config.js +479 -0
  55. package/src/config/models.js +112 -0
  56. package/src/files/documents.js +91 -0
  57. package/src/files/images.js +62 -0
  58. package/src/harness/agent.js +115 -0
  59. package/src/harness/attribution.js +75 -0
  60. package/src/harness/checkpoint.js +96 -0
  61. package/src/harness/history.js +40 -0
  62. package/src/harness/prompt.js +93 -0
  63. package/src/harness/provider.js +430 -0
  64. package/src/http.js +95 -0
  65. package/src/log.js +24 -0
  66. package/src/mcp.js +405 -0
  67. package/src/retrieval/embedding.js +298 -0
  68. package/src/retrieval/retrieval.js +250 -0
  69. package/src/routes/admin.js +112 -0
  70. package/src/routes/auth.js +97 -0
  71. package/src/routes/automations.js +92 -0
  72. package/src/routes/chats.js +232 -0
  73. package/src/routes/documents.js +133 -0
  74. package/src/routes/library.js +51 -0
  75. package/src/routes/mcp.js +59 -0
  76. package/src/routes/settings.js +101 -0
  77. package/src/server.js +397 -0
  78. package/src/set-password.js +32 -0
  79. package/src/store/automations.js +119 -0
  80. package/src/store/chats.js +254 -0
  81. package/src/store/documents.js +256 -0
  82. package/src/store/embeddings.js +67 -0
  83. package/src/store/index.js +90 -0
  84. package/src/store/messages.js +93 -0
  85. package/src/store/schema.js +480 -0
  86. package/src/store/scope.js +43 -0
  87. package/src/store/search.js +56 -0
  88. package/src/store/turns.js +78 -0
  89. package/src/store/usage.js +327 -0
  90. package/src/store/users.js +102 -0
  91. package/src/store/wire.js +55 -0
  92. package/src/tools/ask_tool.js +93 -0
  93. package/src/tools/chat_search_tool.js +63 -0
  94. package/src/tools/context_tool.js +183 -0
  95. package/src/tools/document_tool.js +102 -0
  96. package/src/tools/task_tool.js +63 -0
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Messages sent while a turn is still running. Steering lands at the next
3
+ * safe point inside the run; a follow-up starts its own turn once the run is
4
+ * done. The server owns the queue -- this draws it, from whatever list the
5
+ * latest `queue` event or chat load carried.
6
+ *
7
+ * Follow-ups are different: they are held here, in the browser, and go
8
+ * nowhere until the user clicks their send button -- into the running turn
9
+ * if there still is one, as a new message otherwise.
10
+ */
11
+ import { $, el } from '../core/dom.js';
12
+ import { state } from '../core/state.js';
13
+ import { addError } from './transcript.js';
14
+ import { api } from '../core/api.js';
15
+ import { newId } from '../core/id.js';
16
+
17
+ const box = $('queue');
18
+
19
+
20
+ // Held follow-ups per chat id: [{id, content}]. Survive switching chats, not a reload.
21
+ const held = new Map();
22
+ let sendHeld;
23
+
24
+ /** `deps.sendHeld(text)` sends a held follow-up once its send button is clicked. */
25
+ export function initQueue(deps) { ({ sendHeld } = deps); }
26
+
27
+ export function holdFollowup(text) {
28
+ const id = state.chat.id;
29
+ held.set(id, [...(held.get(id) || []), { id: newId(), content: text }]);
30
+ renderQueue(state.queue);
31
+ }
32
+
33
+ function drawHeld() {
34
+ for (const item of held.get(state.chat?.id) || []) {
35
+ const chip = el('div', 'queued held');
36
+ const kind = el('span', 'queued-kind');
37
+ kind.textContent = 'held';
38
+ kind.title = 'Waits here until you send it';
39
+ const text = el('span', 'queued-text');
40
+ text.textContent = item.content;
41
+ const drop = el('button', 'queued-drop');
42
+ drop.type = 'button';
43
+ drop.setAttribute('aria-label', 'Remove held message');
44
+ drop.textContent = '×';
45
+ const forget = () => {
46
+ const id = state.chat.id;
47
+ held.set(id, (held.get(id) || []).filter((i) => i !== item));
48
+ renderQueue(state.queue);
49
+ };
50
+ drop.onclick = forget;
51
+ const send = el('button', 'queued-send');
52
+ send.type = 'button';
53
+ send.textContent = 'Send';
54
+ send.setAttribute('aria-label', 'Send held message');
55
+ send.onclick = () => { forget(); sendHeld(item.content); };
56
+ chip.append(kind, text, send, drop);
57
+ box.appendChild(chip);
58
+ }
59
+ }
60
+
61
+ export function renderQueue(items = []) {
62
+ state.queue = items;
63
+ box.innerHTML = '';
64
+ box.hidden = !items.length && !(held.get(state.chat?.id) || []).length;
65
+ drawHeld();
66
+ for (const item of items) {
67
+ const chip = el('div', `queued ${item.kind}`);
68
+ const kind = el('span', 'queued-kind');
69
+ kind.textContent = item.kind === 'steer' ? 'next step' : 'after this';
70
+ kind.title = item.kind === 'steer'
71
+ ? 'Delivered as soon as the current tool calls finish'
72
+ : 'Sent as a new message once this turn is done';
73
+ const text = el('span', 'queued-text');
74
+ text.textContent = item.content;
75
+ const drop = el('button', 'queued-drop');
76
+ drop.type = 'button';
77
+ drop.setAttribute('aria-label', 'Remove queued message');
78
+ drop.textContent = '×';
79
+ drop.onclick = async () => {
80
+ drop.disabled = true;
81
+ // 409: already delivered -- the transcript shows it, and the next
82
+ // queue event redraws this list either way.
83
+ try {
84
+ await api.del(`/api/chats/${state.chat.id}/queue/${encodeURIComponent(item.id)}`);
85
+ } catch (err) {
86
+ if (err.status === 409) return;
87
+ drop.disabled = false;
88
+ addError(`couldn't remove queued message: ${err.message}`);
89
+ }
90
+ };
91
+ chip.append(kind, text, drop);
92
+ box.appendChild(chip);
93
+ }
94
+ }
95
+
96
+ /** Queues `text` on the running turn. False when there is no run to take it. */
97
+ export async function enqueue(kind, text) {
98
+ let out;
99
+ try {
100
+ out = await api.post(`/api/chats/${state.chat.id}/queue`, { id: newId(), kind, message: text });
101
+ } catch (err) {
102
+ if (err.status === 409) return false;
103
+ addError(err.message);
104
+ return true;
105
+ }
106
+ renderQueue(out.items);
107
+ return true;
108
+ }
109
+
110
+ /**
111
+ * A run that was stopped (or failed, or a server that restarted) delivers
112
+ * nothing it had queued. Rather than send it anyway or drop it, it goes back
113
+ * into the composer, to be sent or edited by hand.
114
+ */
115
+ export async function reclaimQueue() {
116
+ const items = state.queue;
117
+ if (!items.length || !state.chat.id) return;
118
+ const input = $('input');
119
+ input.value = [...items.map((i) => i.content), input.value].filter(Boolean).join('\n\n');
120
+ input.dispatchEvent(new Event('input'));
121
+ renderQueue([]);
122
+ await Promise.all(items.map((i) =>
123
+ api.del(`/api/chats/${state.chat.id}/queue/${encodeURIComponent(i.id)}`).catch(() => {})));
124
+ }
@@ -0,0 +1,485 @@
1
+ /**
2
+ * The chat-history sidebar: the draggable width, the chronological list, the
3
+ * full-text search that swaps in over it, and the mobile drawer it lives in.
4
+ */
5
+ import { $, el } from '../core/dom.js';
6
+ import { state } from '../core/state.js';
7
+ import { api } from '../core/api.js';
8
+ import { markSnippet } from '../core/md.js';
9
+
10
+ let organizingChatId = null;
11
+ const expandedFolders = new Set();
12
+ // Marks the draft row's slot in a folder's chat list -- a distinct object
13
+ // rather than `null`, so it can't be confused with "no such folder" (a real
14
+ // Map lookup miss) or any other falsy value a chat row might someday carry.
15
+ const DRAFT = Symbol('draft');
16
+
17
+ /* ---------- sidebar width ---------- */
18
+
19
+ /**
20
+ * A draggable sidebar, remembered per browser.
21
+ *
22
+ * The width lives in a CSS variable rather than an inline style so the
23
+ * stylesheet keeps ownership of the default: clearing the variable is what
24
+ * resets it, and nothing here needs to know what 15rem is.
25
+ */
26
+ const SIDE_KEY = 'tinywebui.sideWidth';
27
+ const SIDE_MIN = 176;
28
+ const SIDE_MAX = 560;
29
+
30
+ function setSideWidth(px) {
31
+ const clamped = Math.round(Math.min(SIDE_MAX, Math.max(SIDE_MIN, px)));
32
+ document.documentElement.style.setProperty('--side-w', `${clamped}px`);
33
+ // Per-viewer convenience only, and blocked or full storage must not take the
34
+ // sidebar with it.
35
+ try { localStorage.setItem(SIDE_KEY, String(clamped)); } catch { /* not important enough to fail over */ }
36
+ return clamped;
37
+ }
38
+
39
+ function resetSideWidth() {
40
+ document.documentElement.style.removeProperty('--side-w');
41
+ try { localStorage.removeItem(SIDE_KEY); } catch { /* as above */ }
42
+ }
43
+
44
+ /* ---------- history: server-side, newest first ---------- */
45
+
46
+ function renderChatList() {
47
+ $('chats').innerHTML = '';
48
+ const isDraft = state.chat && !state.chat.id;
49
+ if (!state.chats.length && !isDraft && !state.folders.length) {
50
+ const e = el('div', 'empty');
51
+ e.textContent = 'no saved chats';
52
+ return $('chats').appendChild(e);
53
+ }
54
+ const groups = groupByFolder(isDraft);
55
+ const now = Date.now();
56
+ for (const [folder, chats] of groups) {
57
+ const contents = folder ? folderSection(folder, chats.length) : $('chats');
58
+ let lastBucket = null;
59
+ for (const c of chats) {
60
+ const bucket = c === DRAFT ? 'Today' : bucketOf(c.updated_at, now);
61
+ if (bucket !== lastBucket) {
62
+ lastBucket = bucket;
63
+ const heading = el('div', 'chat-date-heading');
64
+ heading.textContent = bucket;
65
+ contents.appendChild(heading);
66
+ }
67
+ contents.appendChild(c === DRAFT ? draftRow() : chatRow(c));
68
+ }
69
+ }
70
+ }
71
+
72
+ /** folder name (null = none) -> its chats, folders alphabetical and the unfiled last. */
73
+ function groupByFolder(isDraft) {
74
+ const groups = new Map();
75
+ // A folder just created and not yet moved into is still a real folder --
76
+ // it needs a row of its own, not just a spot in the organize dropdown.
77
+ for (const name of state.folders) groups.set(name, []);
78
+ for (const c of state.chats) {
79
+ const folder = (c.folder || '').trim() || null;
80
+ if (!groups.has(folder)) groups.set(folder, []);
81
+ groups.get(folder).push(c);
82
+ }
83
+ // The draft in progress isn't a chat on the server yet -- it gets a row
84
+ // only so the list shows where you are, never a second one on the next
85
+ // click. It disappears the moment the draft becomes a real chat or is
86
+ // abandoned for one that already exists. It always lands in the ungrouped
87
+ // "Today" bucket, never inside a folder it hasn't been organized into.
88
+ if (isDraft) {
89
+ if (!groups.has(null)) groups.set(null, []);
90
+ groups.get(null).unshift(DRAFT);
91
+ }
92
+ const names = [...groups.keys()].sort((a, b) => a === null ? 1 : b === null ? -1 : a.localeCompare(b));
93
+ return new Map(names.map((n) => [n, groups.get(n)]));
94
+ }
95
+
96
+ /** Appends a folder's collapsible section and returns the element its rows go in. */
97
+ function folderSection(folder, count) {
98
+ // Every folder starts collapsed -- with more than a couple of them,
99
+ // auto-opening turns the sidebar into a wall of chats.
100
+ const section = el('details', 'chat-folder');
101
+ section.open = expandedFolders.has(folder);
102
+ section.addEventListener('toggle', () => {
103
+ if (section.open) expandedFolders.add(folder);
104
+ else expandedFolders.delete(folder);
105
+ });
106
+ const heading = el('summary', 'chat-folder-heading');
107
+ const name = el('span', 'chat-folder-name'); name.textContent = folder;
108
+ const n = el('span', 'chat-folder-count'); n.textContent = String(count);
109
+ heading.append(name, n);
110
+ const contents = el('div', 'chat-folder-content');
111
+ section.append(heading, contents);
112
+ $('chats').appendChild(section);
113
+ return contents;
114
+ }
115
+
116
+ function draftRow() {
117
+ const row = el('div', 'chat-item active draft');
118
+ row.tabIndex = 0;
119
+ row.setAttribute('role', 'button');
120
+ row.setAttribute('aria-current', 'true');
121
+ const t = el('span', 't');
122
+ t.textContent = state.chat.title || 'New chat';
123
+ row.appendChild(t);
124
+ return row;
125
+ }
126
+
127
+ function chatRow(c) {
128
+ const current = state.chat && c.id === state.chat.id;
129
+ const row = el('div', 'chat-item' + (current ? ' active' : ''));
130
+ // A div with an onclick is unreachable without a mouse, so the row carries
131
+ // the button contract explicitly: focusable, named, and activated by key.
132
+ row.tabIndex = 0;
133
+ row.setAttribute('role', 'button');
134
+ if (current) row.setAttribute('aria-current', 'true');
135
+ const t = el('span', 't');
136
+ t.textContent = c.title;
137
+ row.appendChild(t);
138
+ // A turn is the server's, not this tab's, so a chat can be working while
139
+ // nothing is watching it -- including a chat opened in another window.
140
+ if (c.running) {
141
+ const dot = el('span', 'dot');
142
+ dot.title = 'working';
143
+ dot.setAttribute('aria-label', 'working');
144
+ row.appendChild(dot);
145
+ }
146
+ const organize = el('button', 'organize-chat');
147
+ organize.type = 'button';
148
+ organize.textContent = '⋯';
149
+ organize.title = 'Chat options';
150
+ organize.setAttribute('aria-label', `Options for "${c.title}"`);
151
+ organize.onclick = (e) => { e.stopPropagation(); openOrganize(c); };
152
+ organize.onkeydown = (e) => e.stopPropagation();
153
+ row.append(organize);
154
+ row.onclick = () => openChat(c.id);
155
+ row.onkeydown = (e) => {
156
+ if (e.key !== 'Enter' && e.key !== ' ') return;
157
+ e.preventDefault(); // space would scroll the list instead
158
+ openChat(c.id);
159
+ };
160
+ return row;
161
+ }
162
+
163
+ /** The ⋯ dialog: move to a folder, or delete. */
164
+ function openOrganize(c) {
165
+ organizingChatId = c.id;
166
+ fillOrganizeFolders(c.folder || '');
167
+ $('organizeError').hidden = true;
168
+ $('organizeDelete').title = `Delete "${c.title}"`;
169
+ $('organizeDelete').setAttribute('aria-label', `Delete "${c.title}"`);
170
+ $('organizeDialog').showModal();
171
+ $('organizeFolder').focus();
172
+ }
173
+
174
+ /** Recent chats get familiar rolling buckets; older chats use calendar months. */
175
+ function bucketOf(updatedAt, now) {
176
+ const startOfDay = (ms) => { const d = new Date(ms); d.setHours(0, 0, 0, 0); return d.getTime(); };
177
+ const days = Math.round((startOfDay(now) - startOfDay(updatedAt)) / 86400000);
178
+ if (days <= 0) return 'Today';
179
+ if (days === 1) return 'Yesterday';
180
+ if (days <= 7) return 'Previous 7 days';
181
+ if (days <= 30) return 'Previous 30 days';
182
+ const d = new Date(updatedAt);
183
+ const sameYear = d.getFullYear() === new Date(now).getFullYear();
184
+ return d.toLocaleDateString('en-US', sameYear ? { month: 'long' } : { month: 'long', year: 'numeric' });
185
+ }
186
+
187
+ let chatsRequest = 0;
188
+
189
+ export async function loadChats() {
190
+ // Many callers (polls, stream ends, edits) overlap; only the newest call
191
+ // may write, or an older list could land last and drop a just-made chat.
192
+ const request = ++chatsRequest;
193
+ const [chats, folders] = await Promise.allSettled([api.get('/api/chats'), api.get('/api/folders')]);
194
+ if (request !== chatsRequest) return;
195
+ // On failure keep whatever is already on screen rather than blanking it.
196
+ if (chats.status === 'fulfilled') state.chats = chats.value.chats || [];
197
+ if (folders.status === 'fulfilled') state.folders = folders.value.folders || [];
198
+ // A background poll (the running-dot refresh) must not clobber search
199
+ // results the user is looking at with the plain chronological list.
200
+ refreshSidebar();
201
+ }
202
+
203
+ /* ---------- search ---------- */
204
+
205
+ let searchQuery = '';
206
+ let searchResults = null;
207
+ let searchSeq = 0;
208
+
209
+ /** Redraws whichever view is current -- search results, or the plain list. */
210
+ function refreshSidebar() {
211
+ if (searchQuery && searchResults) renderSearchResults(searchResults, searchQuery);
212
+ else renderChatList();
213
+ }
214
+
215
+ async function runSearch(q) {
216
+ const seq = ++searchSeq;
217
+ if (!q.trim()) {
218
+ searchQuery = '';
219
+ searchResults = null;
220
+ return renderChatList();
221
+ }
222
+ let results = [];
223
+ try {
224
+ results = (await api.get(`/api/search?q=${encodeURIComponent(q)}`)).results || [];
225
+ } catch { /* leave whatever is on screen; the next keystroke will retry */ }
226
+ if (seq !== searchSeq) return; // a newer query landed first -- this one is stale
227
+ searchQuery = q;
228
+ searchResults = results;
229
+ renderSearchResults(results, q);
230
+ }
231
+
232
+ const SEARCH_ROLE_LABEL = { user: 'you', assistant: 'assistant', tool: 'tool' };
233
+
234
+ function renderSearchResults(results, query) {
235
+ $('chats').innerHTML = '';
236
+ if (!results.length) {
237
+ const e = el('div', 'empty');
238
+ e.textContent = `no matches for “${query}”`;
239
+ return $('chats').appendChild(e);
240
+ }
241
+ for (const r of results) {
242
+ const row = el('div', 'chat-item search-hit');
243
+ row.tabIndex = 0;
244
+ row.setAttribute('role', 'button');
245
+ const body = el('div', 't');
246
+ const title = el('div', 'hit-title');
247
+ title.textContent = r.chatTitle;
248
+ const role = el('span', 'hit-role');
249
+ role.textContent = SEARCH_ROLE_LABEL[r.role] || r.role;
250
+ title.appendChild(role);
251
+ const snip = el('div', 'hit-snippet');
252
+ snip.innerHTML = markSnippet(r.snippet);
253
+ body.append(title, snip);
254
+ row.appendChild(body);
255
+ const go = () => openChat(r.chatId);
256
+ row.onclick = go;
257
+ row.onkeydown = (e) => {
258
+ if (e.key !== 'Enter' && e.key !== ' ') return;
259
+ e.preventDefault();
260
+ go();
261
+ };
262
+ $('chats').appendChild(row);
263
+ }
264
+ }
265
+
266
+ let searchDebounce = null;
267
+ /** Chats can only move into a folder that already exists -- create-then-move,
268
+ * not type-a-new-name-here -- so the dropdown is rebuilt from state.folders
269
+ * (plus the chat's own folder, in case it was removed from that list since). */
270
+ function fillOrganizeFolders(current) {
271
+ const select = $('organizeFolder');
272
+ select.innerHTML = '';
273
+ const blank = el('option'); blank.value = ''; blank.textContent = '(no folder)';
274
+ select.appendChild(blank);
275
+ const names = current && !state.folders.includes(current)
276
+ ? [...state.folders, current].sort((a, b) => a.localeCompare(b))
277
+ : state.folders;
278
+ for (const name of names) {
279
+ const opt = el('option'); opt.value = name; opt.textContent = name;
280
+ select.appendChild(opt);
281
+ }
282
+ select.value = current;
283
+ }
284
+
285
+ export function clearSearch() {
286
+ clearTimeout(searchDebounce);
287
+ searchSeq++; // invalidate any in-flight query so a slow reply cannot land after this
288
+ searchQuery = '';
289
+ searchResults = null;
290
+ $('chatSearch').value = '';
291
+ $('chatSearchClear').hidden = true;
292
+ renderChatList();
293
+ }
294
+
295
+ /**
296
+ * The mobile drawer. Three ways in beyond the menu button: tapping the
297
+ * dimmed backdrop behind it, Escape, and picking a chat (via closeSideDrawer
298
+ * in openChat/newChat) -- a panel with only one way to close is the kind of
299
+ * thing that reads as broken on a phone even when it technically isn't.
300
+ */
301
+ /**
302
+ * Desktop hides the sidebar outright instead of opening it as a drawer; the
303
+ * same #menu button brings it back, so it means "show sidebar" at any width.
304
+ */
305
+ const COLLAPSE_KEY = 'tinywebui.side.collapsed';
306
+ const narrow = () => matchMedia('(max-width: 720px)').matches;
307
+
308
+ function setSideDrawer(open) {
309
+ const side = $('side');
310
+ const backdrop = $('side-backdrop');
311
+ const menu = $('menu');
312
+ const main = document.querySelector('main');
313
+ const mobile = narrow();
314
+
315
+ // The off-canvas sidebar remains in the DOM for its transition. Make it
316
+ // inert while closed so keyboard users never tab into invisible controls.
317
+ // When open, invert that boundary: Tab remains in the drawer rather than
318
+ // reaching the obscured transcript and composer behind it.
319
+ side.classList.toggle('open', mobile && open);
320
+ backdrop.classList.toggle('open', mobile && open);
321
+ side.inert = mobile && !open;
322
+ main.inert = mobile && open;
323
+ side.toggleAttribute('aria-hidden', mobile && !open);
324
+ menu.setAttribute('aria-expanded', String(mobile && open));
325
+
326
+ if (mobile && open) requestAnimationFrame(() => $('new').focus());
327
+ if (mobile && !open && side.contains(document.activeElement)) menu.focus();
328
+ }
329
+ export const closeSideDrawer = () => setSideDrawer(false);
330
+
331
+ function setCollapsed(on) {
332
+ document.body.classList.toggle('side-collapsed', on);
333
+ try { on ? localStorage.setItem(COLLAPSE_KEY, '1') : localStorage.removeItem(COLLAPSE_KEY); } catch { /* per-viewer nicety only */ }
334
+ }
335
+
336
+ /* ---------- wiring ---------- */
337
+
338
+ let openChat;
339
+ let newChat;
340
+
341
+ /** `deps.openChat(id)` and `deps.newChat()` are what picking a chat, or New, does. */
342
+ export function initSidebar(deps) {
343
+ ({ openChat, newChat } = deps);
344
+ initSideResize();
345
+
346
+ $('chatSearch').addEventListener('input', () => {
347
+ const q = $('chatSearch').value;
348
+ $('chatSearchClear').hidden = !q;
349
+ clearTimeout(searchDebounce);
350
+ searchDebounce = setTimeout(() => runSearch(q), 150);
351
+ });
352
+ $('chatSearch').addEventListener('keydown', (e) => {
353
+ if (e.key === 'Escape' && $('chatSearch').value) {
354
+ e.preventDefault();
355
+ clearSearch();
356
+ }
357
+ });
358
+ $('chatSearchClear').onclick = () => { clearSearch(); $('chatSearch').focus(); };
359
+
360
+ $('newFolder').addEventListener('click', () => {
361
+ $('newFolderName').value = '';
362
+ $('newFolderError').hidden = true;
363
+ $('newFolderDialog').showModal();
364
+ $('newFolderName').focus();
365
+ });
366
+ $('newFolderCancel').addEventListener('click', () => $('newFolderDialog').close());
367
+ $('newFolderForm').addEventListener('submit', async (e) => {
368
+ e.preventDefault();
369
+ const button = $('newFolderForm').querySelector('[type="submit"]');
370
+ button.disabled = true;
371
+ $('newFolderError').hidden = true;
372
+ try {
373
+ const name = $('newFolderName').value;
374
+ const body = await api.post('/api/folders', { name });
375
+ $('newFolderDialog').close();
376
+ // A freshly created folder starts open -- otherwise the workflow this
377
+ // exists for (create it, then move chats in) hides its own result.
378
+ expandedFolders.add(body.folder);
379
+ await loadChats();
380
+ } catch (err) {
381
+ $('newFolderError').textContent = err.message || 'Could not create folder.';
382
+ $('newFolderError').hidden = false;
383
+ } finally {
384
+ button.disabled = false;
385
+ }
386
+ });
387
+
388
+ $('organizeCancel').addEventListener('click', () => $('organizeDialog').close());
389
+ $('organizeDelete').addEventListener('click', async () => {
390
+ if (!organizingChatId) return;
391
+ const id = organizingChatId;
392
+ try {
393
+ await api.del(`/api/chats/${id}`);
394
+ } catch (err) {
395
+ $('organizeError').textContent = `Could not delete chat: ${err.message}`;
396
+ $('organizeError').hidden = false;
397
+ return;
398
+ }
399
+ $('organizeDialog').close();
400
+ organizingChatId = null;
401
+ await loadChats();
402
+ if (state.chat && state.chat.id === id) newChat();
403
+ });
404
+ $('organizeForm').addEventListener('submit', async (e) => {
405
+ e.preventDefault();
406
+ if (!organizingChatId) return;
407
+ const button = $('organizeForm').querySelector('[type="submit"]');
408
+ button.disabled = true;
409
+ $('organizeError').hidden = true;
410
+ try {
411
+ await api.post(`/api/chats/${organizingChatId}/organize`, { folder: $('organizeFolder').value });
412
+ $('organizeDialog').close();
413
+ organizingChatId = null;
414
+ await loadChats();
415
+ } catch (err) {
416
+ $('organizeError').textContent = err.message || 'Could not save chat organization.';
417
+ $('organizeError').hidden = false;
418
+ } finally {
419
+ button.disabled = false;
420
+ }
421
+ });
422
+
423
+ try { if (localStorage.getItem(COLLAPSE_KEY)) document.body.classList.add('side-collapsed'); } catch { /* as above */ }
424
+ $('sideCollapse').onclick = () => (narrow() ? closeSideDrawer() : setCollapsed(true));
425
+ $('menu').onclick = () => (narrow() ? setSideDrawer(!$('side').classList.contains('open')) : setCollapsed(false));
426
+ $('side-backdrop').onclick = closeSideDrawer;
427
+ document.addEventListener('keydown', (e) => {
428
+ if (e.key === 'Escape' && $('side').classList.contains('open')) closeSideDrawer();
429
+ });
430
+
431
+ $('new').onclick = newChat;
432
+ // The logo and name go home, which here means a fresh chat.
433
+ $('brandHome').onclick = newChat;
434
+
435
+ // A resize can move a currently open drawer onto the desktop layout (or vice
436
+ // versa). Reset the inert boundary in that transition rather than leaving the
437
+ // transcript inaccessible until the next sidebar interaction.
438
+ matchMedia('(max-width: 720px)').addEventListener('change', () => setSideDrawer(false));
439
+ setSideDrawer(false);
440
+ }
441
+
442
+ function initSideResize() {
443
+ const side = $('side');
444
+ const grip = $('side-resize');
445
+ if (!side || !grip) return;
446
+
447
+ try {
448
+ const saved = Number(localStorage.getItem(SIDE_KEY));
449
+ if (Number.isFinite(saved) && saved > 0) setSideWidth(saved);
450
+ } catch { /* first run, or storage is unavailable; the stylesheet decides */ }
451
+
452
+ grip.addEventListener('pointerdown', (e) => {
453
+ // Pointer capture keeps the drag alive over the transcript, an iframe or
454
+ // off the window edge, which a plain mousemove listener would lose.
455
+ grip.setPointerCapture(e.pointerId);
456
+ document.body.classList.add('resizing');
457
+ const startX = e.clientX;
458
+ const startW = side.getBoundingClientRect().width;
459
+
460
+ const move = (ev) => setSideWidth(startW + (ev.clientX - startX));
461
+ const done = () => {
462
+ grip.removeEventListener('pointermove', move);
463
+ grip.removeEventListener('pointerup', done);
464
+ grip.removeEventListener('pointercancel', done);
465
+ document.body.classList.remove('resizing');
466
+ };
467
+ grip.addEventListener('pointermove', move);
468
+ grip.addEventListener('pointerup', done);
469
+ grip.addEventListener('pointercancel', done);
470
+ e.preventDefault();
471
+ });
472
+
473
+ grip.addEventListener('dblclick', resetSideWidth);
474
+
475
+ // A drag is not reachable without a pointer, so the separator is operable
476
+ // from the keyboard too, which is also what its role promises.
477
+ grip.addEventListener('keydown', (e) => {
478
+ const step = e.shiftKey ? 48 : 16;
479
+ if (e.key === 'ArrowLeft') setSideWidth(side.getBoundingClientRect().width - step);
480
+ else if (e.key === 'ArrowRight') setSideWidth(side.getBoundingClientRect().width + step);
481
+ else if (e.key === 'Home' || e.key === 'Escape') resetSideWidth();
482
+ else return;
483
+ e.preventDefault();
484
+ });
485
+ }