flexdesk 0.17.18 → 0.18.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 (118) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +1 -0
  3. package/css/flexdesk.css +7544 -5916
  4. package/css/reset.css +1 -1
  5. package/css/tokens.css +23 -1
  6. package/dist/charts.js +10 -6
  7. package/dist/charts.js.map +1 -1
  8. package/dist/chunk-5W4EEKSK.js +778 -0
  9. package/dist/chunk-5W4EEKSK.js.map +7 -0
  10. package/dist/chunk-DNJ3N4T4.js +82 -0
  11. package/dist/chunk-DNJ3N4T4.js.map +7 -0
  12. package/dist/{chunk-REJ5HLIM.js → chunk-HYJYYEPK.js} +2 -2
  13. package/dist/{chunk-7U4IB65H.js → chunk-LLBJ2SJH.js} +5 -3
  14. package/dist/{chunk-7U4IB65H.js.map → chunk-LLBJ2SJH.js.map} +1 -1
  15. package/dist/{chunk-7OL6F2AM.js → chunk-P74F6I6P.js} +6 -4
  16. package/dist/{chunk-7OL6F2AM.js.map → chunk-P74F6I6P.js.map} +1 -1
  17. package/dist/chunk-PAI2ZUWV.js +260 -0
  18. package/dist/chunk-PAI2ZUWV.js.map +7 -0
  19. package/dist/chunk-Q7BALEPX.js +527 -0
  20. package/dist/chunk-Q7BALEPX.js.map +7 -0
  21. package/dist/chunk-QXAOTGCC.js +40 -0
  22. package/dist/chunk-QXAOTGCC.js.map +7 -0
  23. package/dist/{chunk-XJNADRGR.js → chunk-RKVYIXDC.js} +664 -91
  24. package/dist/chunk-RKVYIXDC.js.map +7 -0
  25. package/dist/{chunk-2Z2ENHR3.js → chunk-U5GKJUOC.js} +3 -3
  26. package/dist/{chunk-2Z2ENHR3.js.map → chunk-U5GKJUOC.js.map} +1 -1
  27. package/dist/chunk-UKP22RUG.js +219 -0
  28. package/dist/chunk-UKP22RUG.js.map +7 -0
  29. package/dist/chunk-VLOAI3HS.js +43 -0
  30. package/dist/chunk-VLOAI3HS.js.map +7 -0
  31. package/dist/{chunk-WUFLGETN.js → chunk-WVFGV5FT.js} +2 -39
  32. package/dist/{chunk-WUFLGETN.js.map → chunk-WVFGV5FT.js.map} +3 -3
  33. package/dist/{chunk-PGZ4EHSE.js → chunk-ZL3VKINP.js} +41 -390
  34. package/dist/chunk-ZL3VKINP.js.map +7 -0
  35. package/dist/editor.js +3 -2
  36. package/dist/editor.js.map +1 -1
  37. package/dist/flexdesk.css +7544 -5916
  38. package/dist/flow.js +29 -97
  39. package/dist/flow.js.map +3 -3
  40. package/dist/git.js +1356 -0
  41. package/dist/git.js.map +7 -0
  42. package/dist/reset.css +1 -1
  43. package/dist/tiles.js +20 -15
  44. package/dist/tiles.js.map +2 -2
  45. package/dist/tokens.css +23 -1
  46. package/dist/widgets.js +3967 -1188
  47. package/dist/widgets.js.map +4 -4
  48. package/dist/wm.js +120 -32
  49. package/dist/wm.js.map +2 -2
  50. package/docs/README.md +9 -4
  51. package/docs/best-practices.md +2 -2
  52. package/docs/components/chat-shell.md +198 -0
  53. package/docs/components/conversation.md +10 -0
  54. package/docs/components/filter-builder.md +246 -0
  55. package/docs/components/forms-and-dialogs.md +22 -0
  56. package/docs/components/git.md +355 -0
  57. package/docs/components/input-fields.md +167 -2
  58. package/docs/components/keymap.md +8 -0
  59. package/docs/components/menus.md +82 -2
  60. package/docs/components/panels-and-desktops.md +16 -5
  61. package/docs/components/shell.md +104 -1
  62. package/docs/components/workspace-picker.md +149 -0
  63. package/docs/concepts/building-and-packaging.md +2 -2
  64. package/docs/concepts/packages-and-entry-points.md +14 -13
  65. package/docs/concepts/state-guard.md +3 -3
  66. package/docs/quickstart.md +1 -1
  67. package/git.js +13 -0
  68. package/package.json +6 -1
  69. package/src/flow/kit/dom.js +28 -119
  70. package/src/flow/kit/step_picker.js +1 -1
  71. package/src/flow/kit/value_picker.js +1 -1
  72. package/src/flow/outline/menu.js +1 -1
  73. package/src/git/diff_viewer.js +212 -0
  74. package/src/git/diff_window.js +51 -0
  75. package/src/git/git_graph.js +401 -0
  76. package/src/git/git_ops.js +61 -0
  77. package/src/git/git_panel.js +755 -0
  78. package/src/git/index.js +9 -0
  79. package/src/git/line_diff.js +202 -0
  80. package/src/tiles/tile_base.js +5 -4
  81. package/src/tiling/desktops.js +10 -8
  82. package/src/tiling/keymap.js +6 -5
  83. package/src/tiling/kind_taxonomy.js +15 -2
  84. package/src/tiling/shell.js +124 -17
  85. package/src/tiling/tile_renderer.js +2 -3
  86. package/src/ui/components/action_dropdown.js +4 -78
  87. package/src/ui/components/anchored_layer.js +255 -0
  88. package/src/ui/components/chat_shell.js +920 -0
  89. package/src/ui/components/chat_state_machine.js +223 -0
  90. package/src/ui/components/choice_controls.js +45 -2
  91. package/src/ui/components/color_field.js +3 -2
  92. package/src/ui/components/color_list_field.js +3 -2
  93. package/src/ui/components/combobox.js +422 -0
  94. package/src/ui/components/conversation_view.js +17 -2
  95. package/src/ui/components/data_table.js +2 -2
  96. package/src/ui/components/filter_builder.js +766 -0
  97. package/src/ui/components/filter_model.js +614 -0
  98. package/src/ui/components/managed_window.js +24 -47
  99. package/src/ui/components/modal.js +79 -109
  100. package/src/ui/components/option_list.js +160 -0
  101. package/src/ui/components/popover.js +94 -0
  102. package/src/ui/components/recent_items.js +169 -0
  103. package/src/ui/components/select.js +322 -0
  104. package/src/ui/components/tag_input.js +77 -8
  105. package/src/ui/components/workspace_picker.js +1155 -0
  106. package/src/ui/utils/fire_event.js +17 -0
  107. package/src/ui/utils/focusable.js +25 -0
  108. package/src/ui/utils/modal_host.js +27 -0
  109. package/widgets.js +12 -2
  110. package/css/base.css +0 -10547
  111. package/css/overrides.css +0 -2596
  112. package/dist/chunk-7JI7HHSY.js +0 -107
  113. package/dist/chunk-7JI7HHSY.js.map +0 -7
  114. package/dist/chunk-MIW7D6WC.js +0 -526
  115. package/dist/chunk-MIW7D6WC.js.map +0 -7
  116. package/dist/chunk-PGZ4EHSE.js.map +0 -7
  117. package/dist/chunk-XJNADRGR.js.map +0 -7
  118. /package/dist/{chunk-REJ5HLIM.js.map → chunk-HYJYYEPK.js.map} +0 -0
@@ -0,0 +1,920 @@
1
+ /**
2
+ * ══ THE ASSISTANT CHAT SHELL ══════════════════════════════════════════════
3
+ *
4
+ * A whole assistant chat over the conversation view: conversation tabs, an
5
+ * optional mode toggle, a model picker, a reply that streams (typing dots, tool
6
+ * calls, stop), Markdown bodies, time and copy on every message, and a slot on
7
+ * each assistant message for controls of the caller's. It knows no provider and
8
+ * nothing of what a reply is about. The caller passes a transport and reads
9
+ * back snapshots to keep.
10
+ *
11
+ * const chat = mountChatShell(el, {
12
+ * transport: {
13
+ * send(request, emit) { backend.ask(request, (e) => emit(toChatEvent(e))); },
14
+ * cancel(request) { backend.stop(); },
15
+ * },
16
+ * modes: [{ id: 'ask', label: 'Ask' }, { id: 'act', label: 'Act' }],
17
+ * models: { items: [{ id: 'a', label: 'Large' }], activeId: 'a' },
18
+ * onModelSelect: (item) => chat.setModels({ activeId: item.id }),
19
+ * renderControls: (el, message) => { ... },
20
+ * conversations: saved,
21
+ * onChange: (snapshot) => save(snapshot),
22
+ * });
23
+ *
24
+ * THE TRANSPORT is `{ send(request, emit), cancel?(request) }`. A request is
25
+ * `{ text, tabId, conversationId, mode, model }`. `emit` takes one event at a
26
+ * time (`CHAT_TRANSPORT_EVENTS`):
27
+ *
28
+ * { type: 'text-delta', text }
29
+ * { type: 'tool-use', id?, name, label?, status? } running | done | error
30
+ * { type: 'message-end', text?, conversationId?, meta? }
31
+ * { type: 'error', message }
32
+ *
33
+ * `message-end` and `error` finish the turn. Every `emit` belongs to its turn:
34
+ * what it is handed once that turn has finished or been cancelled is dropped.
35
+ * A `send` that throws, or answers a promise that rejects, is an `error` event.
36
+ * A `tool-use` with the id of one already shown updates it.
37
+ *
38
+ * A backend whose frames carry no run id can only hand them to the latest
39
+ * `emit`, so runs must not overlap: `cancel` answers a promise that settles when
40
+ * the cancelled run is really over, and the next turn's `send` waits for it. That
41
+ * turn shows at once, and until then the old run's frames still reach the dead
42
+ * `emit`. Stopping a turn whose `send` is still waiting calls no `transport.cancel`.
43
+ *
44
+ * A TURN: the user's message goes in, then a streamed reply that shows the
45
+ * typing dots while it is empty. Tool calls are listed under the body. The turn
46
+ * ends in one of three ways, and a reply left with no text, no tool call and no
47
+ * `meta` is taken out in each:
48
+ * - `message-end`: `text` fills the reply only if nothing was streamed; `meta`
49
+ * is merged into the message's.
50
+ * - Stop (Cancel, Esc, or `cancel()`): what arrived stays, running tools become
51
+ * `stopped`, `transport.cancel` is called once `send` has been.
52
+ * - `error`: as Stop, without `transport.cancel`, and an error message follows.
53
+ * While it is the last message it carries Retry, which sends the user message
54
+ * before it again.
55
+ *
56
+ * CONVERSATIONS: the shell owns the tabs. While a turn runs the tab in front
57
+ * cannot be left, closed or cleared; other tabs can still be closed, and the
58
+ * last one never can. `snapshot()` is `{ activeId, tabs: [{ id, title,
59
+ * conversationId, messages }] }`, plain data to keep, and `conversations` takes
60
+ * one back. A message is `{ id, role, text, at?, tools?, meta? }`; `meta` is
61
+ * yours and is handed back untouched. A reply still empty is not in a snapshot.
62
+ *
63
+ * CONTROLS: `renderControls(el, message, chat)` fills an emptied element below an
64
+ * assistant message's body once the reply has finished, and again when the
65
+ * message comes back from a tab switch or `refreshControls(id)`/`updateMeta(id,
66
+ * patch)`. Everything it needs to survive goes in `message.meta`.
67
+ *
68
+ * THE MODEL PICKER is controlled: choosing an item calls `onModelSelect(item)`
69
+ * (an item with an `action` calls `onModelAction(action, item)`), and the picker
70
+ * changes when you call `setModels`, which takes any of `{ items, activeId, label,
71
+ * status }`. With no items and no label there is no button; with no modes either
72
+ * there is no bar.
73
+ *
74
+ * Nothing is stored in the DOM. `destroy()` cancels a running turn and removes
75
+ * everything, the picker's menu on `document.body` included.
76
+ */
77
+
78
+ import { ActionDropdown } from './action_dropdown.js';
79
+ import { CHAT_STATES, ChatStateMachine } from './chat_state_machine.js';
80
+ import { showConfirmDialog } from './confirm_dialog.js';
81
+ import { mountConversationView } from './conversation_view.js';
82
+ import { createTabs } from './tabs.js';
83
+ import { renderMarkdown } from '../utils/markdown.js';
84
+
85
+ export const CHAT_TRANSPORT_EVENTS = Object.freeze({
86
+ TEXT_DELTA: 'text-delta',
87
+ TOOL_USE: 'tool-use',
88
+ MESSAGE_END: 'message-end',
89
+ ERROR: 'error',
90
+ });
91
+
92
+ /** Every text the shell shows; pass `labels` to replace any of them. */
93
+ export const CHAT_SHELL_LABELS = Object.freeze({
94
+ send: 'Send',
95
+ cancel: 'Cancel',
96
+ jump: 'Jump to latest',
97
+ input: 'Message',
98
+ conversations: 'Conversations',
99
+ newConversation: 'New chat',
100
+ add: 'New conversation',
101
+ clear: 'Clear conversation',
102
+ clearTitle: 'Clear conversation',
103
+ clearMessage: 'Clear this conversation? This cannot be undone.',
104
+ clearOk: 'Clear',
105
+ clearCancel: 'Cancel',
106
+ rename: 'Conversation name',
107
+ modes: 'Mode',
108
+ selectModel: 'Select model...',
109
+ typing: 'The assistant is replying',
110
+ copy: 'Copy message',
111
+ retry: 'Retry',
112
+ retryTitle: 'Send the message again',
113
+ unknownError: 'Unknown error',
114
+ });
115
+
116
+ const TITLE_CHARS = 30;
117
+ const COPIED_MS = 1500;
118
+ const TOOL_ICONS = Object.freeze({ running: 'build', done: 'check', error: 'error', stopped: 'block' });
119
+
120
+ const defaultTitle = (text) => (text.length > TITLE_CHARS ? `${text.slice(0, TITLE_CHARS)}...` : text);
121
+
122
+ function defaultTime(at) {
123
+ const date = new Date(at);
124
+ if (Number.isNaN(date.getTime())) return '';
125
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
126
+ }
127
+
128
+ /**
129
+ * @param {HTMLElement} host
130
+ * @param {object} opts
131
+ * @param {object} opts.transport `{ send(request, emit), cancel?(request) }`
132
+ * @param {Function} [opts.renderControls] `(el, message, chat)` — an assistant message's controls
133
+ * @param {object} [opts.markdown] options for `renderMarkdown`
134
+ * @param {Function} [opts.onChange] `(snapshot)` — something worth keeping changed
135
+ * @param {Function} [opts.onConversationChange] `(conversation, reason)` — another conversation is in front; reason is `new`, `switch`, `close` or `reset`
136
+ * @param {Array} [opts.modes] `[{ id, label, title? }]`; none, no toggle
137
+ * @param {string} [opts.mode] the mode to start in; the first without it
138
+ * @param {Function} [opts.onModeChange] `(id)` — the user chose a mode
139
+ * @param {object} [opts.models] `{ items, activeId, label, status }`, as `setModels`
140
+ * @param {Function} [opts.onModelSelect] `(item)`
141
+ * @param {Function} [opts.onModelAction] `(action, item)`
142
+ * @param {object} [opts.conversations] a `snapshot()` to restore
143
+ * @param {Function} [opts.titleFrom] `(text)` — a new conversation's title, from its first message
144
+ * @param {Function} [opts.formatTime] `(at, message)` — a message's time; `''` shows none
145
+ * @param {string} [opts.placeholder] the composer's placeholder
146
+ * @param {object} [opts.welcome] `{ icon?, text }`, shown while the transcript is empty
147
+ * @param {object} [opts.labels] over `CHAT_SHELL_LABELS`
148
+ * @param {object} [opts.eventBus] gets `CHAT_BUS_EVENTS`
149
+ * @param {object} [opts.logger]
150
+ * @returns {object} the chat (see the methods below)
151
+ */
152
+ export function mountChatShell(host, {
153
+ transport, renderControls = null, markdown = {}, onChange = null, onConversationChange = null,
154
+ modes = [], mode = null, onModeChange = null, models = null, onModelSelect = null, onModelAction = null,
155
+ conversations = null, titleFrom = defaultTitle, formatTime = defaultTime, placeholder = '', welcome = null,
156
+ labels = {}, eventBus = null, logger = null,
157
+ } = {}) {
158
+ if (typeof transport?.send !== 'function') {
159
+ throw new TypeError('mountChatShell: a transport with send(request, emit) is required');
160
+ }
161
+ const text = { ...CHAT_SHELL_LABELS, ...labels };
162
+ const doc = host.ownerDocument;
163
+ const el = (tag, cls, content) => {
164
+ const n = doc.createElement(tag);
165
+ if (cls) n.className = cls;
166
+ if (content !== undefined) n.textContent = content;
167
+ return n;
168
+ };
169
+ const icon = (name, cls = '') => {
170
+ const i = el('span', `material-symbols-outlined ${cls}`.trim(), name);
171
+ i.setAttribute('aria-hidden', 'true');
172
+ return i;
173
+ };
174
+ const button = (cls, title) => {
175
+ const b = el('button', cls);
176
+ b.type = 'button';
177
+ b.title = title;
178
+ b.setAttribute('aria-label', title);
179
+ return b;
180
+ };
181
+ /** A callback of the caller's: one that throws is logged, and the chat carries on. */
182
+ const safely = (fn, ...args) => {
183
+ try { return fn?.(...args); } catch (error) {
184
+ logger?.error?.('chat-shell', 'A callback threw', { error });
185
+ return undefined;
186
+ }
187
+ };
188
+
189
+ let destroyed = false;
190
+ /** The exchange in flight: `{ token, replyId, request, started, sent }`, or null. */
191
+ let turn = null;
192
+ /** Settles when the run `transport.cancel` stopped is over; null when none is ending. */
193
+ let runEnding = null;
194
+ /** id → { at, tools, meta, toolsEl, controlsEl, footerEl }, for the messages on show. */
195
+ const extras = new Map();
196
+ const copyTimers = new Set();
197
+
198
+ const modeIds = modes.map((m) => m.id);
199
+ const machine = new ChatStateMachine({
200
+ eventBus,
201
+ logger,
202
+ mode: modeIds.includes(mode) ? mode : (modeIds[0] ?? null),
203
+ onChange: ({ state }) => { if (!destroyed) showState(state); },
204
+ });
205
+
206
+ // ── the frame ────────────────────────────────────────────────────────
207
+ const root = el('div', 'twm-chat-shell');
208
+ const tabBar = el('div', 'twm-chat-shell__tab-bar');
209
+ const tabsHost = el('div', 'twm-chat-shell__tabs');
210
+ const addBtn = button('twm-chat-shell__tab-btn', text.add);
211
+ addBtn.append(icon('add'));
212
+ const clearBtn = button('twm-chat-shell__tab-btn twm-chat-shell__tab-btn--clear', text.clear);
213
+ clearBtn.append(icon('delete_sweep'));
214
+ tabBar.append(tabsHost, addBtn, clearBtn);
215
+ const conversationHost = el('div', 'twm-chat-shell__conversation');
216
+ const bar = el('div', 'twm-chat-shell__bar');
217
+ root.append(tabBar, conversationHost, bar);
218
+ host.append(root);
219
+
220
+ const view = mountConversationView(conversationHost, {
221
+ render: drawBody,
222
+ onSubmit: (value) => send(value),
223
+ onCancel: () => cancel(),
224
+ onRenderError: (error, message) => logger?.error?.('chat-shell', 'Could not draw a message', { error, message }),
225
+ placeholder,
226
+ labels: { send: text.send, cancel: text.cancel, jump: text.jump, input: text.input },
227
+ });
228
+
229
+ const welcomeEl = welcome ? el('div', 'twm-chat-shell__welcome') : null;
230
+ if (welcomeEl) {
231
+ if (welcome.icon) welcomeEl.append(icon(welcome.icon, 'twm-chat-shell__welcome-icon'));
232
+ welcomeEl.append(el('p', 'twm-chat-shell__welcome-text', welcome.text ?? ''));
233
+ view.root.querySelector('.twm-chat__viewport').prepend(welcomeEl);
234
+ }
235
+ const syncWelcome = () => { if (welcomeEl) welcomeEl.hidden = view.messages().length > 0; };
236
+
237
+ function showState(state) {
238
+ const busy = state === CHAT_STATES.WAITING || state === CHAT_STATES.STREAMING;
239
+ view.setBusy(busy);
240
+ addBtn.disabled = busy;
241
+ clearBtn.disabled = busy;
242
+ }
243
+
244
+ // ── bodies ───────────────────────────────────────────────────────────
245
+ /** An assistant's body is Markdown, with the typing dots while it is empty and streaming; any other is text. */
246
+ function drawBody(message, body, { streaming }) {
247
+ if (message.role !== 'assistant') {
248
+ body.textContent = message.text;
249
+ return;
250
+ }
251
+ if (streaming && !message.text) {
252
+ const dots = el('span', 'twm-chat-shell__typing');
253
+ dots.setAttribute('role', 'img');
254
+ dots.setAttribute('aria-label', text.typing);
255
+ dots.append(el('span'), el('span'), el('span'));
256
+ body.append(dots);
257
+ return;
258
+ }
259
+ body.innerHTML = renderMarkdown(message.text, markdown);
260
+ }
261
+
262
+ // ── what sits below a body ───────────────────────────────────────────
263
+ const viewMessage = (id) => view.messages().find((m) => m.id === id) ?? null;
264
+
265
+ /** A message as it is kept and as callers see it. */
266
+ function kept({ id, role, text: body }) {
267
+ const { at, tools, meta } = extras.get(id) ?? {};
268
+ const message = { id, role, text: body };
269
+ if (at) message.at = at;
270
+ if (tools?.length) message.tools = tools.map((t) => ({ ...t }));
271
+ if (meta !== undefined) message.meta = meta;
272
+ return message;
273
+ }
274
+
275
+ /** The messages worth showing a caller: a reply with nothing in it yet is not one. */
276
+ function keptMessages() {
277
+ const streaming = view.streamingId();
278
+ return view.messages()
279
+ .filter((m) => m.id !== streaming || m.text || extras.get(m.id)?.tools.length)
280
+ .map(kept);
281
+ }
282
+
283
+ /** Puts a message's three blocks in its slot and remembers what it carries. */
284
+ function register(id, { at, tools = [], meta }) {
285
+ const entry = {
286
+ at,
287
+ tools,
288
+ meta,
289
+ toolsEl: el('div', 'twm-chat-shell__tools'),
290
+ controlsEl: el('div', 'twm-chat-shell__controls'),
291
+ footerEl: el('div', 'twm-chat-shell__footer'),
292
+ };
293
+ extras.set(id, entry);
294
+ view.slot(id).append(entry.toolsEl, entry.controlsEl, entry.footerEl);
295
+ return entry;
296
+ }
297
+
298
+ function drawTools(id) {
299
+ const entry = extras.get(id);
300
+ entry.toolsEl.replaceChildren(...entry.tools.map((tool) => {
301
+ const row = el('div', `twm-chat-shell__tool twm-chat-shell__tool--${tool.status}`);
302
+ row.append(
303
+ icon(TOOL_ICONS[tool.status], 'twm-chat-shell__tool-icon'),
304
+ el('span', 'twm-chat-shell__tool-label', tool.label ?? tool.name),
305
+ );
306
+ return row;
307
+ }));
308
+ }
309
+
310
+ function drawControls(id) {
311
+ const message = viewMessage(id);
312
+ const entry = extras.get(id);
313
+ if (!renderControls || !message || !entry) return;
314
+ if (message.role !== 'assistant' || id === view.streamingId()) return;
315
+ entry.controlsEl.replaceChildren();
316
+ safely(renderControls, entry.controlsEl, kept(message), chat);
317
+ }
318
+
319
+ function copyButton(id) {
320
+ const copy = button('twm-chat-shell__copy', text.copy);
321
+ const glyph = icon('content_copy');
322
+ copy.append(glyph);
323
+ copy.addEventListener('click', () => {
324
+ Promise.resolve()
325
+ .then(() => doc.defaultView.navigator.clipboard.writeText(viewMessage(id)?.text ?? ''))
326
+ .then(() => {
327
+ copy.classList.add('twm-chat-shell__copy--done');
328
+ glyph.textContent = 'check';
329
+ const timer = setTimeout(() => {
330
+ copyTimers.delete(timer);
331
+ copy.classList.remove('twm-chat-shell__copy--done');
332
+ glyph.textContent = 'content_copy';
333
+ }, COPIED_MS);
334
+ copyTimers.add(timer);
335
+ })
336
+ .catch((error) => logger?.warn?.('chat-shell', 'Could not copy a message', { error }));
337
+ });
338
+ return copy;
339
+ }
340
+
341
+ /** The user message a failed request sent, while that failure is the last message. */
342
+ function retryable(id, list) {
343
+ const last = list.at(-1);
344
+ if (last?.id !== id || last.role !== 'error') return null;
345
+ return list.slice(0, -1).reverse().find((m) => m.role === 'user') ?? null;
346
+ }
347
+
348
+ function drawFooter(id, list = view.messages()) {
349
+ const entry = extras.get(id);
350
+ entry.footerEl.replaceChildren();
351
+ if (id === view.streamingId()) return;
352
+ const user = retryable(id, list);
353
+ if (user) {
354
+ const retry = el('button', 'twm-chat-shell__retry');
355
+ retry.type = 'button';
356
+ retry.title = text.retryTitle;
357
+ retry.append(icon('refresh'), doc.createTextNode(text.retry));
358
+ retry.addEventListener('click', () => send(user.text));
359
+ entry.footerEl.append(retry);
360
+ }
361
+ const message = list.find((m) => m.id === id);
362
+ const time = entry.at ? safely(formatTime, entry.at, kept(message)) : '';
363
+ if (time) entry.footerEl.append(el('span', 'twm-chat-shell__time', time));
364
+ entry.footerEl.append(copyButton(id));
365
+ }
366
+
367
+ /** The slots grew too, and the view only follows changes of its own. */
368
+ const follow = () => { if (view.isPinned()) view.scrollToBottom(); };
369
+
370
+ /** A message just took the last place: the failure before it loses its Retry. */
371
+ function redrawFailures() {
372
+ const list = view.messages();
373
+ for (const { id, role } of list) if (role === 'error') drawFooter(id, list);
374
+ }
375
+
376
+ function appendMessage(role, body) {
377
+ const id = view.append({ role, text: body });
378
+ register(id, { at: new Date().toISOString() });
379
+ redrawFailures();
380
+ drawFooter(id);
381
+ follow();
382
+ return id;
383
+ }
384
+
385
+ // ── conversations ────────────────────────────────────────────────────
386
+ const newTab = (id) => ({
387
+ id, title: text.newConversation, conversationId: null, messages: [], scrollTop: null,
388
+ });
389
+ const tabs = [];
390
+ let activeId = null;
391
+ let nextTab = 1;
392
+ const tabOf = (id) => tabs.find((t) => t.id === String(id)) ?? null;
393
+ const activeTab = () => tabOf(activeId);
394
+ function freshTab() {
395
+ let id;
396
+ do id = `chat-${nextTab++}`; while (tabOf(id));
397
+ const tab = newTab(id);
398
+ tabs.push(tab);
399
+ return tab;
400
+ }
401
+
402
+ /** Kept data back into a message list: a tool cannot still be running after a restart. */
403
+ function restored(messages) {
404
+ return (messages ?? [])
405
+ .filter((m) => m && typeof m.role === 'string')
406
+ .map((m) => {
407
+ const tools = m.tools?.map((t) => (t.status === 'running' ? { ...t, status: 'stopped' } : { ...t }));
408
+ return tools ? { ...m, tools } : { ...m };
409
+ });
410
+ }
411
+
412
+ const strip = createTabs(tabsHost, {
413
+ items: [],
414
+ compact: true,
415
+ label: text.conversations,
416
+ onSelect: (id) => {
417
+ if (id == null || id === activeId) return;
418
+ switchTo(id);
419
+ // A switch refused leaves the conversation in front where it was.
420
+ if (strip.active() !== activeId) strip.update(tabItems(), activeId);
421
+ },
422
+ onClose: (id) => close(id),
423
+ });
424
+ function tabItems() {
425
+ const closable = tabs.length > 1;
426
+ return tabs.map(({ id, title }) => ({ id, label: title, title, closable }));
427
+ }
428
+ const drawTabs = () => strip.update(tabItems(), activeId);
429
+
430
+ /** Rename over the tab (a tab is a button, whose content cannot be edited). Enter or leaving commits; Escape does not. */
431
+ function beginRename(id, tabEl) {
432
+ if (tabBar.querySelector('.twm-chat-shell__rename')) return;
433
+ const original = tabOf(id)?.title ?? '';
434
+ const barRect = tabBar.getBoundingClientRect();
435
+ const tabRect = tabEl.getBoundingClientRect();
436
+ const input = el('input', 'twm-chat-shell__rename');
437
+ input.type = 'text';
438
+ input.value = original;
439
+ input.spellcheck = false;
440
+ input.setAttribute('aria-label', text.rename);
441
+ input.style.left = `${Math.max(0, tabRect.left - barRect.left)}px`;
442
+ input.style.width = `${Math.max(tabRect.width, 120)}px`;
443
+ tabBar.append(input);
444
+ input.focus();
445
+ input.select();
446
+ let done = false;
447
+ const finish = (commit) => {
448
+ if (done) return;
449
+ done = true;
450
+ input.remove();
451
+ const name = input.value.trim();
452
+ if (commit && name && name !== original) rename(id, name);
453
+ };
454
+ input.addEventListener('keydown', (e) => {
455
+ if (e.key !== 'Enter' && e.key !== 'Escape') return;
456
+ e.preventDefault();
457
+ e.stopPropagation();
458
+ finish(e.key === 'Enter');
459
+ });
460
+ input.addEventListener('blur', () => finish(true));
461
+ }
462
+ const renameTarget = (e) => {
463
+ const tab = e.target.closest?.('.twm-tabs__tab');
464
+ if (tab) beginRename(tab.dataset.tabId, tab);
465
+ };
466
+ strip.root.addEventListener('dblclick', renameTarget);
467
+ strip.root.addEventListener('keydown', (e) => { if (e.key === 'F2') renameTarget(e); });
468
+
469
+ /** The live transcript back into its tab. */
470
+ function stash() {
471
+ const tab = activeTab();
472
+ tab.messages = keptMessages();
473
+ tab.scrollTop = view.getScrollTop();
474
+ }
475
+
476
+ function showTab(tab) {
477
+ const messages = restored(tab.messages);
478
+ extras.clear();
479
+ view.replaceAll(messages.map(({ id, role, text: body }) => ({ id, role, text: body })));
480
+ const list = view.messages();
481
+ list.forEach(({ id }, i) => {
482
+ const { at, tools, meta } = messages[i];
483
+ register(id, { at, tools: tools ?? [], meta });
484
+ drawTools(id);
485
+ drawControls(id);
486
+ drawFooter(id, list);
487
+ });
488
+ if (tab.scrollTop !== null) view.setScrollTop(tab.scrollTop);
489
+ else view.scrollToBottom();
490
+ syncWelcome();
491
+ }
492
+
493
+ function snapshot() {
494
+ stash();
495
+ return {
496
+ activeId,
497
+ tabs: tabs.map(({ id, title, conversationId, messages }) => ({
498
+ id, title, conversationId, messages: messages.map((m) => ({ ...m })),
499
+ })),
500
+ };
501
+ }
502
+ const changed = () => { if (onChange) safely(onChange, snapshot()); };
503
+
504
+ function bringToFront(tab, reason) {
505
+ activeId = tab.id;
506
+ if (tab.conversationId) machine.loadConversation(tab.conversationId);
507
+ else machine.newConversation();
508
+ showTab(tab);
509
+ drawTabs();
510
+ const { id, title, conversationId } = tab;
511
+ safely(onConversationChange, { id, title, conversationId, messages: restored(tab.messages) }, reason);
512
+ changed();
513
+ }
514
+
515
+ function newConversation() {
516
+ if (turn) return null;
517
+ stash();
518
+ const tab = freshTab();
519
+ bringToFront(tab, 'new');
520
+ return tab.id;
521
+ }
522
+
523
+ function switchTo(id) {
524
+ const tab = tabOf(id);
525
+ if (turn || !tab || tab.id === activeId) return false;
526
+ stash();
527
+ bringToFront(tab, 'switch');
528
+ return true;
529
+ }
530
+
531
+ function close(id) {
532
+ const tab = tabOf(id);
533
+ if (!tab || tabs.length <= 1 || (turn && tab.id === activeId)) return false;
534
+ const wasActive = tab.id === activeId;
535
+ tabs.splice(tabs.indexOf(tab), 1);
536
+ if (wasActive) {
537
+ bringToFront(tabs[0], 'close');
538
+ } else {
539
+ drawTabs();
540
+ changed();
541
+ }
542
+ return true;
543
+ }
544
+
545
+ function rename(id, title) {
546
+ const tab = tabOf(id);
547
+ const name = String(title ?? '').trim();
548
+ if (!tab || !name) return false;
549
+ tab.title = name;
550
+ drawTabs();
551
+ changed();
552
+ return true;
553
+ }
554
+
555
+ /** Empties the conversation in front and forgets its title and id. */
556
+ function reset() {
557
+ if (turn) return false;
558
+ const tab = activeTab();
559
+ Object.assign(tab, { title: text.newConversation, conversationId: null, messages: [], scrollTop: null });
560
+ bringToFront(tab, 'reset');
561
+ return true;
562
+ }
563
+
564
+ addBtn.addEventListener('click', () => newConversation());
565
+ clearBtn.addEventListener('click', async () => {
566
+ if (view.messages().length > 0) {
567
+ const confirmed = await showConfirmDialog({
568
+ title: text.clearTitle,
569
+ message: text.clearMessage,
570
+ icon: 'delete_sweep',
571
+ okLabel: text.clearOk,
572
+ cancelLabel: text.clearCancel,
573
+ okVariant: 'danger',
574
+ });
575
+ if (!confirmed || destroyed) return;
576
+ }
577
+ reset();
578
+ });
579
+
580
+ // ── the turn ─────────────────────────────────────────────────────────
581
+ function send(value) {
582
+ const message = String(value ?? '').trim();
583
+ if (!message || turn) return false;
584
+ const tab = activeTab();
585
+ appendMessage('user', message);
586
+ if (tab.title === text.newConversation) {
587
+ const title = safely(titleFrom, message);
588
+ if (title) {
589
+ tab.title = String(title);
590
+ drawTabs();
591
+ }
592
+ }
593
+ const replyId = view.beginStream({ role: 'assistant' });
594
+ register(replyId, { at: new Date().toISOString() });
595
+ redrawFailures();
596
+ syncWelcome();
597
+ const token = {};
598
+ const request = {
599
+ text: message,
600
+ tabId: tab.id,
601
+ conversationId: tab.conversationId,
602
+ mode: machine.mode,
603
+ model: currentModels.activeId ?? null,
604
+ };
605
+ const current = { token, replyId, request, started: false, sent: false };
606
+ turn = current;
607
+ machine.sendMessage(message, tab.conversationId);
608
+ changed();
609
+
610
+ const emit = (event) => { if (turn?.token === token) handle(event); };
611
+ const fail = (error) => emit({ type: CHAT_TRANSPORT_EVENTS.ERROR, message: error?.message ?? String(error) });
612
+ const start = () => {
613
+ current.sent = true;
614
+ try {
615
+ Promise.resolve(transport.send(request, emit)).catch(fail);
616
+ } catch (error) {
617
+ fail(error);
618
+ }
619
+ };
620
+ if (runEnding) runEnding.then(() => { if (turn === current) start(); });
621
+ else start();
622
+ return true;
623
+ }
624
+
625
+ function handle(event) {
626
+ switch (event?.type) {
627
+ case CHAT_TRANSPORT_EVENTS.TEXT_DELTA:
628
+ if (!event.text) return;
629
+ markStarted();
630
+ view.appendChunk(event.text);
631
+ return;
632
+ case CHAT_TRANSPORT_EVENTS.TOOL_USE:
633
+ markStarted();
634
+ useTool(event);
635
+ return;
636
+ case CHAT_TRANSPORT_EVENTS.MESSAGE_END:
637
+ endMessage(event);
638
+ return;
639
+ case CHAT_TRANSPORT_EVENTS.ERROR:
640
+ failTurn(event);
641
+ return;
642
+ default:
643
+ logger?.warn?.('chat-shell', 'Unknown transport event', { event });
644
+ }
645
+ }
646
+
647
+ function markStarted() {
648
+ if (turn.started) return;
649
+ turn.started = true;
650
+ machine.streamStart();
651
+ }
652
+
653
+ function useTool({ id, name, label, status = 'running' }) {
654
+ const entry = extras.get(turn.replyId);
655
+ const known = status in TOOL_ICONS ? status : 'running';
656
+ const tool = id != null ? entry.tools.find((t) => t.id === id) : null;
657
+ if (tool) {
658
+ Object.assign(tool, { status: known, name: name ?? tool.name, label: label ?? tool.label });
659
+ } else {
660
+ entry.tools.push({ id: id ?? `tool-${entry.tools.length + 1}`, name, label, status: known });
661
+ }
662
+ drawTools(turn.replyId);
663
+ follow();
664
+ }
665
+
666
+ /** Ends the reply. Tools still running take `toolStatus`; a reply with nothing to show is taken out. */
667
+ function closeReply(toolStatus) {
668
+ const id = turn.replyId;
669
+ turn = null;
670
+ const entry = extras.get(id);
671
+ for (const tool of entry.tools) if (tool.status === 'running') tool.status = toolStatus;
672
+ if (!viewMessage(id).text && !entry.tools.length && entry.meta === undefined) {
673
+ view.remove(id);
674
+ extras.delete(id);
675
+ return;
676
+ }
677
+ view.endStream();
678
+ drawTools(id);
679
+ drawControls(id);
680
+ drawFooter(id);
681
+ follow();
682
+ }
683
+
684
+ function endMessage({ text: final, conversationId, meta }) {
685
+ const id = turn.replyId;
686
+ const entry = extras.get(id);
687
+ if (final && !viewMessage(id).text) view.appendChunk(final);
688
+ if (meta !== undefined) entry.meta = { ...(entry.meta ?? {}), ...meta };
689
+ if (conversationId) setConversationId(activeId, conversationId);
690
+ const reply = viewMessage(id).text;
691
+ closeReply('done');
692
+ machine.streamComplete({ text: reply, meta: entry.meta });
693
+ syncWelcome();
694
+ changed();
695
+ }
696
+
697
+ function failTurn({ message }) {
698
+ const body = message || text.unknownError;
699
+ closeReply('stopped');
700
+ machine.error(body);
701
+ appendMessage('error', body);
702
+ syncWelcome();
703
+ changed();
704
+ }
705
+
706
+ /** Stops the reply: the chat can send again at once, and that send waits for `runEnding`. */
707
+ function cancel() {
708
+ if (!turn) return false;
709
+ const { request, sent } = turn;
710
+ closeReply('stopped');
711
+ machine.cancel();
712
+ if (sent) callCancel(request);
713
+ syncWelcome();
714
+ changed();
715
+ return true;
716
+ }
717
+
718
+ function callCancel(request) {
719
+ const failed = (error) => logger?.warn?.('chat-shell', 'Cancel failed', { error });
720
+ try {
721
+ const ended = transport.cancel?.(request);
722
+ if (typeof ended?.then !== 'function') return;
723
+ const ending = Promise.resolve(ended).catch(failed).then(() => {
724
+ if (runEnding === ending) runEnding = null;
725
+ });
726
+ runEnding = ending;
727
+ } catch (error) {
728
+ failed(error);
729
+ }
730
+ }
731
+
732
+ function setConversationId(tabId, conversationId) {
733
+ const tab = tabOf(tabId);
734
+ if (!tab) return false;
735
+ tab.conversationId = conversationId ?? null;
736
+ if (tab.id === activeId) machine.conversationId = tab.conversationId;
737
+ return true;
738
+ }
739
+
740
+ /** Replaces a conversation's messages (a backend knew better). The one a turn is writing to refuses. */
741
+ function setMessages(tabId, messages) {
742
+ const tab = tabOf(tabId);
743
+ if (!tab || (turn && tab.id === activeId)) return false;
744
+ tab.messages = restored(messages);
745
+ tab.scrollTop = null;
746
+ if (tab.id === activeId) showTab(tab);
747
+ changed();
748
+ return true;
749
+ }
750
+
751
+ // ── the bar: modes ───────────────────────────────────────────────────
752
+ const modeButtons = new Map();
753
+ if (modes.length) {
754
+ const group = el('div', 'twm-chat-shell__modes');
755
+ group.setAttribute('role', 'group');
756
+ group.setAttribute('aria-label', text.modes);
757
+ for (const { id, label, title } of modes) {
758
+ const b = el('button', 'twm-chat-shell__mode', label);
759
+ b.type = 'button';
760
+ if (title) b.title = title;
761
+ b.addEventListener('click', () => {
762
+ if (id === machine.mode) return;
763
+ setMode(id);
764
+ safely(onModeChange, id);
765
+ });
766
+ modeButtons.set(id, b);
767
+ group.append(b);
768
+ }
769
+ bar.append(group);
770
+ }
771
+ function drawMode() {
772
+ for (const [id, b] of modeButtons) {
773
+ const on = id === machine.mode;
774
+ b.classList.toggle('twm-chat-shell__mode--on', on);
775
+ b.setAttribute('aria-pressed', String(on));
776
+ }
777
+ }
778
+ function setMode(id) {
779
+ if (!modeButtons.has(id)) return false;
780
+ machine.setMode(id);
781
+ drawMode();
782
+ return true;
783
+ }
784
+ drawMode();
785
+ bar.append(el('div', 'twm-chat-shell__bar-spacer'));
786
+
787
+ // ── the bar: the model picker ────────────────────────────────────────
788
+ let currentModels = { items: [], activeId: null, label: null, status: null };
789
+ const modelBtn = el('button', 'twm-chat-shell__model-btn');
790
+ modelBtn.type = 'button';
791
+ modelBtn.setAttribute('aria-haspopup', 'menu');
792
+ const modelDot = el('span', 'twm-chat-shell__status');
793
+ const modelLabel = el('span', 'twm-chat-shell__model-label');
794
+ modelBtn.append(modelDot, modelLabel, icon('expand_more', 'twm-chat-shell__model-chevron'));
795
+ bar.append(modelBtn);
796
+ /** ActionDropdown puts an option's label and description in as markup; these are text. */
797
+ const asMarkup = (value) => {
798
+ const span = el('span');
799
+ span.textContent = String(value ?? '');
800
+ return span.innerHTML;
801
+ };
802
+ const modelMenu = new ActionDropdown({
803
+ trigger: modelBtn,
804
+ options: [],
805
+ className: 'twm-chat-shell__model-menu',
806
+ onSelect: ({ item }) => {
807
+ if (item.action !== undefined) safely(onModelAction, item.action, item);
808
+ else safely(onModelSelect, item);
809
+ },
810
+ });
811
+
812
+ /**
813
+ * Any of `{ items, activeId, label, status }`; what is left out stays. An item
814
+ * is `{ id, label, description?, icon? }`, `{ action, label, description?, icon? }`
815
+ * or `{ separator: true }`. `label` is the button's text (the active item's
816
+ * without it); `status` is `{ state?: 'ready' | 'pending', label? }`, a dot on it.
817
+ */
818
+ function setModels(patch = {}) {
819
+ currentModels = { ...currentModels, ...patch };
820
+ const { items, activeId: active, label, status } = currentModels;
821
+ modelMenu.setOptions(items.map((item) => {
822
+ if (item.separator) return { separator: true };
823
+ const isAction = item.action !== undefined;
824
+ const radio = item.id === active ? 'radio_button_checked' : 'radio_button_unchecked';
825
+ return {
826
+ type: isAction ? 'action' : 'model',
827
+ item,
828
+ label: asMarkup(item.label),
829
+ icon: item.icon ?? (isAction ? undefined : radio),
830
+ description: item.description ? asMarkup(item.description) : undefined,
831
+ };
832
+ }));
833
+ modelLabel.textContent = label ?? items.find((i) => !i.separator && i.id === active)?.label ?? text.selectModel;
834
+ modelDot.className = status?.state
835
+ ? `twm-chat-shell__status twm-chat-shell__status--${status.state}` : 'twm-chat-shell__status';
836
+ modelDot.title = status?.label ?? '';
837
+ modelDot.hidden = !status;
838
+ modelBtn.hidden = !items.length && !label;
839
+ bar.hidden = modelBtn.hidden && !modes.length;
840
+ }
841
+ setModels(models ?? {});
842
+
843
+ // ── the chat ─────────────────────────────────────────────────────────
844
+ const chat = {
845
+ root,
846
+ /** Sends as the user would; false when a turn is running or the text is blank. */
847
+ send,
848
+ /** Stops the running reply; false when there is none. */
849
+ cancel,
850
+ /** A turn is running. Answers at once, where `stateMachine` follows in microtasks. */
851
+ isBusy: () => turn !== null,
852
+ stateMachine: machine,
853
+
854
+ /** The messages of the conversation in front. */
855
+ messages: keptMessages,
856
+ /** Merges into a message's `meta`, redraws its controls, and reports a change. */
857
+ updateMeta(id, patch) {
858
+ const entry = extras.get(id);
859
+ if (!entry) return false;
860
+ entry.meta = { ...(entry.meta ?? {}), ...patch };
861
+ drawControls(id);
862
+ changed();
863
+ return true;
864
+ },
865
+ refreshControls: (id) => drawControls(id),
866
+ /** The element holding a message's controls, or null. */
867
+ controlsSlot: (id) => extras.get(id)?.controlsEl ?? null,
868
+
869
+ snapshot,
870
+ activeConversation: () => activeId,
871
+ newConversation,
872
+ switchTo,
873
+ close,
874
+ rename,
875
+ reset,
876
+ setConversationId,
877
+ setMessages,
878
+
879
+ mode: () => machine.mode,
880
+ setMode,
881
+ setModels,
882
+
883
+ getText: () => view.getText(),
884
+ setText: (value) => view.setText(value),
885
+ focus: () => view.focus(),
886
+
887
+ destroy() {
888
+ if (destroyed) return;
889
+ destroyed = true;
890
+ if (turn) {
891
+ const { request } = turn;
892
+ turn = null;
893
+ callCancel(request);
894
+ }
895
+ for (const timer of copyTimers) clearTimeout(timer);
896
+ copyTimers.clear();
897
+ extras.clear();
898
+ modelMenu.destroy();
899
+ strip.destroy();
900
+ view.destroy();
901
+ root.remove();
902
+ },
903
+ };
904
+
905
+ // ── what it starts with ──────────────────────────────────────────────
906
+ for (const saved of conversations?.tabs ?? []) {
907
+ tabs.push({
908
+ ...newTab(String(saved.id)),
909
+ title: saved.title || text.newConversation,
910
+ conversationId: saved.conversationId ?? null,
911
+ messages: restored(saved.messages),
912
+ });
913
+ }
914
+ const first = tabOf(conversations?.activeId) ?? tabs[0] ?? freshTab();
915
+ activeId = first.id;
916
+ if (first.conversationId) machine.loadConversation(first.conversationId);
917
+ showTab(first);
918
+ drawTabs();
919
+ return chat;
920
+ }