flexdesk 0.17.18 → 0.18.1

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 (126) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +1 -0
  3. package/css/flexdesk.css +7547 -5891
  4. package/css/reset.css +1 -1
  5. package/css/tokens.css +30 -1
  6. package/dist/charts.js +83 -13
  7. package/dist/charts.js.map +2 -2
  8. package/dist/chunk-3BLIVOFD.js +778 -0
  9. package/dist/chunk-3BLIVOFD.js.map +7 -0
  10. package/dist/{chunk-7OL6F2AM.js → chunk-4X4SY3CP.js} +7 -5
  11. package/dist/chunk-4X4SY3CP.js.map +7 -0
  12. package/dist/chunk-DNJ3N4T4.js +82 -0
  13. package/dist/chunk-DNJ3N4T4.js.map +7 -0
  14. package/dist/{chunk-PGZ4EHSE.js → chunk-HL3IIHU3.js} +501 -359
  15. package/dist/chunk-HL3IIHU3.js.map +7 -0
  16. package/dist/{chunk-REJ5HLIM.js → chunk-HYJYYEPK.js} +2 -2
  17. package/dist/{chunk-7U4IB65H.js → chunk-LLBJ2SJH.js} +5 -3
  18. package/dist/{chunk-7U4IB65H.js.map → chunk-LLBJ2SJH.js.map} +1 -1
  19. package/dist/{chunk-XJNADRGR.js → chunk-NFZJ6P42.js} +188 -96
  20. package/dist/chunk-NFZJ6P42.js.map +7 -0
  21. package/dist/chunk-PAI2ZUWV.js +260 -0
  22. package/dist/chunk-PAI2ZUWV.js.map +7 -0
  23. package/dist/chunk-Q7BALEPX.js +527 -0
  24. package/dist/chunk-Q7BALEPX.js.map +7 -0
  25. package/dist/{chunk-2Z2ENHR3.js → chunk-QRBOIUU3.js} +7 -8
  26. package/dist/chunk-QRBOIUU3.js.map +7 -0
  27. package/dist/chunk-QXAOTGCC.js +40 -0
  28. package/dist/chunk-QXAOTGCC.js.map +7 -0
  29. package/dist/chunk-UKP22RUG.js +219 -0
  30. package/dist/chunk-UKP22RUG.js.map +7 -0
  31. package/dist/chunk-VLOAI3HS.js +43 -0
  32. package/dist/chunk-VLOAI3HS.js.map +7 -0
  33. package/dist/{chunk-WUFLGETN.js → chunk-WVFGV5FT.js} +2 -39
  34. package/dist/{chunk-WUFLGETN.js.map → chunk-WVFGV5FT.js.map} +3 -3
  35. package/dist/editor.js +3 -2
  36. package/dist/editor.js.map +1 -1
  37. package/dist/flexdesk.css +7547 -5891
  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 +30 -1
  46. package/dist/widgets.js +4146 -1199
  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/charts.md +40 -0
  53. package/docs/components/chat-shell.md +204 -0
  54. package/docs/components/conversation.md +10 -0
  55. package/docs/components/filter-builder.md +292 -0
  56. package/docs/components/forms-and-dialogs.md +30 -0
  57. package/docs/components/git.md +355 -0
  58. package/docs/components/input-fields.md +191 -2
  59. package/docs/components/keymap.md +8 -0
  60. package/docs/components/menus.md +86 -2
  61. package/docs/components/panels-and-desktops.md +16 -5
  62. package/docs/components/shell.md +108 -1
  63. package/docs/components/workspace-picker.md +193 -0
  64. package/docs/concepts/building-and-packaging.md +2 -2
  65. package/docs/concepts/packages-and-entry-points.md +14 -13
  66. package/docs/concepts/state-guard.md +3 -3
  67. package/docs/quickstart.md +1 -1
  68. package/git.js +13 -0
  69. package/package.json +6 -1
  70. package/src/charts/plot_config_editor.js +92 -6
  71. package/src/charts/plot_grid.js +6 -1
  72. package/src/flow/kit/dom.js +28 -119
  73. package/src/flow/kit/step_picker.js +1 -1
  74. package/src/flow/kit/value_picker.js +1 -1
  75. package/src/flow/outline/menu.js +1 -1
  76. package/src/git/diff_viewer.js +212 -0
  77. package/src/git/diff_window.js +51 -0
  78. package/src/git/git_graph.js +401 -0
  79. package/src/git/git_ops.js +61 -0
  80. package/src/git/git_panel.js +755 -0
  81. package/src/git/index.js +9 -0
  82. package/src/git/line_diff.js +202 -0
  83. package/src/tiles/tile_base.js +5 -4
  84. package/src/tiling/desktops.js +10 -8
  85. package/src/tiling/keymap.js +6 -5
  86. package/src/tiling/kind_taxonomy.js +15 -2
  87. package/src/tiling/shell.js +124 -17
  88. package/src/tiling/tile_renderer.js +2 -3
  89. package/src/ui/components/action_dropdown.js +4 -78
  90. package/src/ui/components/anchored_layer.js +255 -0
  91. package/src/ui/components/chat_shell.js +926 -0
  92. package/src/ui/components/chat_state_machine.js +223 -0
  93. package/src/ui/components/choice_controls.js +45 -2
  94. package/src/ui/components/color_field.js +3 -2
  95. package/src/ui/components/color_list_field.js +3 -2
  96. package/src/ui/components/combobox.js +422 -0
  97. package/src/ui/components/context_menu.js +8 -2
  98. package/src/ui/components/conversation_view.js +17 -2
  99. package/src/ui/components/data_table.js +2 -2
  100. package/src/ui/components/filter_builder.js +791 -0
  101. package/src/ui/components/filter_model.js +682 -0
  102. package/src/ui/components/inline_renamer.js +10 -5
  103. package/src/ui/components/managed_window.js +24 -47
  104. package/src/ui/components/modal.js +104 -117
  105. package/src/ui/components/option_list.js +160 -0
  106. package/src/ui/components/popover.js +94 -0
  107. package/src/ui/components/recent_items.js +169 -0
  108. package/src/ui/components/select.js +322 -0
  109. package/src/ui/components/tag_input.js +77 -8
  110. package/src/ui/components/workspace_picker.js +1253 -0
  111. package/src/ui/utils/fire_event.js +17 -0
  112. package/src/ui/utils/focusable.js +25 -0
  113. package/src/ui/utils/modal_host.js +27 -0
  114. package/src/ui/utils/tooltip_service.js +81 -14
  115. package/widgets.js +12 -2
  116. package/css/base.css +0 -10547
  117. package/css/overrides.css +0 -2596
  118. package/dist/chunk-2Z2ENHR3.js.map +0 -7
  119. package/dist/chunk-7JI7HHSY.js +0 -107
  120. package/dist/chunk-7JI7HHSY.js.map +0 -7
  121. package/dist/chunk-7OL6F2AM.js.map +0 -7
  122. package/dist/chunk-MIW7D6WC.js +0 -526
  123. package/dist/chunk-MIW7D6WC.js.map +0 -7
  124. package/dist/chunk-PGZ4EHSE.js.map +0 -7
  125. package/dist/chunk-XJNADRGR.js.map +0 -7
  126. /package/dist/{chunk-REJ5HLIM.js.map → chunk-HYJYYEPK.js.map} +0 -0
package/docs/README.md CHANGED
@@ -8,7 +8,7 @@ New here? Start with the [quickstart](quickstart.md). The [README](../README.md)
8
8
 
9
9
  ## Packages
10
10
 
11
- FlexDesk is nine entry points that share their code, so you load only what you use. [Packages and entry points](concepts/packages-and-entry-points.md) has the sizes and the reasoning.
11
+ FlexDesk is ten entry points that share their code, so you load only what you use. [Packages and entry points](concepts/packages-and-entry-points.md) has the sizes and the reasoning.
12
12
 
13
13
  | Entry | What is in it | Read |
14
14
  |---|---|---|
@@ -21,6 +21,7 @@ FlexDesk is nine entry points that share their code, so you load only what you u
21
21
  | `@flexdesk/editor` | Monaco loader, editor factory, tab bar, search, split panes | [Editor](components/editor.md) |
22
22
  | `@flexdesk/canvas` | The canvas kernel: a pannable, zoomable surface, the edge router, a graph layer | [Flow canvas editor](components/flow-canvas-editor.md) |
23
23
  | `@flexdesk/flow` | The flow-editor kit and the canvas, outline and lane editors | [Flow editors and the flow kit](components/flow-editors.md) |
24
+ | `@flexdesk/git` | A source-control panel, a commit graph and a diff viewer, over a written adapter contract | [Git panel, commit graph and diff viewer](components/git.md) |
24
25
 
25
26
  ## Components
26
27
 
@@ -48,8 +49,10 @@ FlexDesk is nine entry points that share their code, so you load only what you u
48
49
  - [Forms and dialogs](components/forms-and-dialogs.md): `openForm`, `openConfirm`, and every field type.
49
50
  - [Settings page](components/settings-view.md): a settings store's schema drawn as a page.
50
51
  - [Wizard](components/wizard.md): a multi-step flow, inline or in a window.
51
- - [Input fields: slider and tags](components/input-fields.md): `SliderField` and `createTagInput`.
52
- - [Menus](components/menus.md): `createMenuBar`, `ActionDropdown` and `CategoryPicker`.
52
+ - [Input fields: slider, tags, select and combobox](components/input-fields.md): `SliderField`, `createTagInput`, `createSelect` and `createCombobox`.
53
+ - [Filter builder](components/filter-builder.md): recursive AND/OR/NOT filters: the model, a mountable builder, a dialog, and the condition tree a server compiles.
54
+ - [Menus](components/menus.md): `createMenuBar`, `ActionDropdown`, `CategoryPicker`, `openPopover` and `positionAnchored`.
55
+ - [Workspace picker](components/workspace-picker.md): the top-bar field, its switcher and the recent-items store.
53
56
 
54
57
  ### Layout, content and feedback
55
58
 
@@ -59,6 +62,7 @@ FlexDesk is nine entry points that share their code, so you load only what you u
59
62
  - [Slide-out panel](components/slide-out-panel.md): `SlideOutPanel`, an inline panel that slides in over its host.
60
63
  - [Markdown and document outline](components/markdown-and-outline.md): `renderMarkdown` and `createDocumentOutline`.
61
64
  - [Conversation view](components/conversation.md): a transcript above a composer.
65
+ - [Assistant chat shell](components/chat-shell.md): `mountChatShell`, tabs, a model picker and streaming replies over a thin transport.
62
66
  - [Notifications and progress](components/notifications.md): toasts, history, the alert log, progress bars, lists and phases.
63
67
 
64
68
  ### Charts, editor and dashboards
@@ -66,6 +70,7 @@ FlexDesk is nine entry points that share their code, so you load only what you u
66
70
  - [Charts](components/charts.md): `@flexdesk/charts`, on Plotly.
67
71
  - [Editor](components/editor.md): `@flexdesk/editor`, on Monaco.
68
72
  - [Dashboard tiles](components/tiles.md): `@flexdesk/tiles`, a draggable widget grid.
73
+ - [Git panel, commit graph and diff viewer](components/git.md): `@flexdesk/git`, over a written adapter contract that any backend can implement.
69
74
 
70
75
  ### Flow editors
71
76
 
@@ -82,7 +87,7 @@ These have no page of their own: `ManagedWindow`, modals, `TreeView`, context me
82
87
 
83
88
  ## Concepts
84
89
 
85
- - [Packages and entry points](concepts/packages-and-entry-points.md): the nine entries, what they weigh, and why they are separate.
90
+ - [Packages and entry points](concepts/packages-and-entry-points.md): the ten entries, what they weigh, and why they are separate.
86
91
  - [Host port](concepts/host-port.md): how FlexDesk reaches storage, dialogs and native windows without touching them.
87
92
  - [Domain neutrality](concepts/domain-neutrality.md): storage keys, theme names and MIME types are yours; no application globals.
88
93
  - [State guard](concepts/state-guard.md): who wrote that `data-*` attribute.
@@ -8,7 +8,7 @@ The rules the reference pages state, gathered in one place. Each item says what
8
8
 
9
9
  **2. Start with the smallest host.** Pass `createHost` only the capabilities you have. A capability you leave out is absent and the library degrades instead of throwing: no native window buttons without `window`, a Blob download for CSV without `dialogs`. An in-memory `Map` is a perfectly good `state`, and `NULL_HOST` is a valid host with nothing behind it. See [the host port](concepts/host-port.md#the-host-port).
10
10
 
11
- **3. Load every entry from one copy.** The entries share code through chunks, so importing several does not give you several event buses or several copies of a module-level singleton. Keep it that way: map all entries to the same install or the same import map, rather than mixing a vendored `dist/` with a second bundled copy. See [packages and entry points](concepts/packages-and-entry-points.md#nine-entry-points).
11
+ **3. Load every entry from one copy.** The entries share code through chunks, so importing several does not give you several event buses or several copies of a module-level singleton. Keep it that way: map all entries to the same install or the same import map, rather than mixing a vendored `dist/` with a second bundled copy. See [packages and entry points](concepts/packages-and-entry-points.md#ten-entry-points).
12
12
 
13
13
  **4. Name your storage before anything reads it.** Every key FlexDesk writes is an option, with a generic default. Set them at boot, before the first read. Tile layouts are the sharp edge: the store is a singleton, and one built before you supply your key uses the default, so every layout your users saved becomes silently invisible, with no error. If users already have data under older keys, pass those keys explicitly and nothing is lost. See [storage keys and names](concepts/domain-neutrality.md#storage-keys-and-names-are-yours-0170) and [layouts are keyed by whatever you key them by](components/tiles.md#layouts-are-keyed-by-whatever-you-key-them-by).
14
14
 
@@ -36,7 +36,7 @@ The rules the reference pages state, gathered in one place. Each item says what
36
36
 
37
37
  **14. Restyle through the tokens.** Every rule reads its values from `tokens.css`, so swapping that one file restyles the shell. Library classes are `twm-` prefixed, and that prefix is stable; the editor components and the help modal keep the class names they were extracted with. Supply the icon font, or accept that icons show their text names. See [theming and CSS](concepts/styling.md#styling).
38
38
 
39
- **15. Run the state guard strictly in your own code.** `StateGuardService` defaults to `mode: 'warn'`. FlexDesk's own widgets run clean under `'strict'`, so choose strict for your code and add your application's keys on top of `DEFAULT_STATE_GUARD_CONFIG`: a key you write that no FlexDesk widget does is yours to list. The defaults also still allow some keys that no widget writes, which a strict guard lets through unless you filter them out ([what 0.18.0 removed and what stayed](concepts/state-guard.md#thirteen-application-keys-leave-the-defaults-0180)). See [state guard](concepts/state-guard.md#the-state-guard-who-wrote-that-attribute-0170) and [the default allow-list](concepts/state-guard.md#the-defaults-cover-flexdesks-own-keys-0170).
39
+ **15. Run the state guard strictly in your own code.** `StateGuardService` defaults to `mode: 'warn'`. FlexDesk's own widgets run clean under `'strict'`, so choose strict for your code and add your application's keys on top of `DEFAULT_STATE_GUARD_CONFIG`: a key you write that no FlexDesk widget does is yours to list. The defaults also still allow some keys that no widget writes, which a strict guard lets through unless you filter them out ([what 0.17.14 removed and what stayed](concepts/state-guard.md#thirteen-application-keys-leave-the-defaults-01714)). See [state guard](concepts/state-guard.md#the-state-guard-who-wrote-that-attribute-0170) and [the default allow-list](concepts/state-guard.md#the-defaults-cover-flexdesks-own-keys-0170).
40
40
 
41
41
  **16. Dispose what you create.** `shell.dispose()` removes the `?` shortcut and the desktop bar along with the rest of the shell. A `NotificationCenter`'s `dispose()` hands the toast helpers back. Menu bars, tables and lists have `dispose()` too.
42
42
 
@@ -414,6 +414,10 @@ config it was given and then calls `onChange(config)`.
414
414
  is disposed. A `dispose()` that throws is reported to `logger.warn` and does
415
415
  not stop the rest of the editor being disposed.
416
416
 
417
+ - [`hide`](#hiding-parts-of-the-editor-hide-0181) takes parts of the editor away,
418
+ and [`constantsProvider`](#a-reference-line-that-names-a-constant-constantsprovider-resolveconstant-0181)
419
+ lets a reference line name a constant.
420
+
417
421
  It returns `{ dispose, refresh, selectSubplot, activeSubplot }`: `dispose()`
418
422
  disposes every field it created (every `AutocompleteField` among them) and
419
423
  removes the editor; `refresh()` redraws it after the config was changed from
@@ -431,3 +435,39 @@ const win = await openPlotPopoutWindow({ id, title, plotConfig, getResults, serv
431
435
 
432
436
  Its classes are under `.twm-plot-config` in `flexdesk.css`.
433
437
  `tests/plot_config_editor.test.mjs` pins all of it.
438
+
439
+ ## Hiding parts of the editor: `hide` (0.18.1)
440
+
441
+ An embedder whose plots are always 2D time series has no use for some of the editor:
442
+
443
+ ```js
444
+ renderPlotConfigEditor(el, plotConfig, { hide: ['3d', 'stackGroup', 'xAxis'] });
445
+ ```
446
+
447
+ | Name | What goes |
448
+ |---|---|
449
+ | `'3d'` | The 3D chart types and the z axis section. A subplot saved as 3D keeps its chart type, marked as one the list does not offer, until someone picks a 2D one. |
450
+ | `'stackGroup'` | Each series' stack group box. A saved stack group stays in the config and is drawn. |
451
+ | `'xAxis'` | The x axis section. The subplot then plots against time: `xAxis.useTime` is set to true when it opens, and `xAxis.variable` is left as it was. |
452
+
453
+ Any other name throws. Without `hide` the editor draws what it always did.
454
+
455
+ ## A reference line that names a constant: `constantsProvider`, `resolveConstant` (0.18.1)
456
+
457
+ A reference line's value is a number, so it cannot follow a parameter whose value changes between runs. With `constantsProvider` it may also name one:
458
+
459
+ ```js
460
+ renderPlotConfigEditor(el, plotConfig, {
461
+ constantsProvider: () => [{ name: 'bank.rate', value: 0.25, label: 'Bank rate', group: 'bank' }],
462
+ });
463
+ renderPlotGrid(host, plotConfig, results, {
464
+ resolveConstant: (name) => constants.get(name), // a number, or undefined
465
+ });
466
+ ```
467
+
468
+ The value box then offers the names, with their values and under their groups, as `attachInputOptions` does. It is asked each time the list is drawn. What is stored is a number when what was typed is one, even when it is part of a name on the list, and the name as a string otherwise; beside a name it knows, the box shows `= 0.25`. An empty box stores `null`, as the number box does. A click shows every name.
469
+
470
+ `renderPlotGrid` asks `resolveConstant(name)` each time it draws a subplot, so a redraw follows a new value, and `grid.update({ results })` is enough. A name it does not know draws no line and no label, and so does any name without `resolveConstant`. The config keeps the name.
471
+
472
+ Without `constantsProvider` the box is the number box it always was.
473
+
@@ -0,0 +1,204 @@
1
+ # Assistant chat shell
2
+
3
+ > **Package:** `@flexdesk/widgets` · [All docs](../README.md)
4
+
5
+ A whole assistant chat over the [conversation view](conversation.md): conversation tabs, an optional mode toggle, a model picker, a reply that streams, Markdown bodies, time and copy on every message, and a slot for your own controls. It knows no provider and nothing of what a reply is about.
6
+
7
+ ## An assistant chat: `mountChatShell` (0.18.0)
8
+
9
+ ```js
10
+ import { mountChatShell } from '@flexdesk/widgets';
11
+
12
+ const chat = mountChatShell(el, {
13
+ transport: {
14
+ send(request, emit) { backend.ask(request, (e) => emit(toChatEvent(e))); },
15
+ cancel(request) { backend.stop(); },
16
+ },
17
+ modes: [{ id: 'ask', label: 'Ask' }, { id: 'act', label: 'Act' }],
18
+ models: { items: [{ id: 'large', label: 'Large' }, { id: 'small', label: 'Small' }], activeId: 'large' },
19
+ onModelSelect: (item) => chat.setModels({ activeId: item.id }),
20
+ renderControls: (el, message) => drawMyControls(el, message.meta),
21
+ conversations: JSON.parse(localStorage.getItem('chat') ?? 'null'),
22
+ onChange: (snapshot) => localStorage.setItem('chat', JSON.stringify(snapshot)),
23
+ placeholder: 'Ask anything',
24
+ welcome: { icon: 'smart_toy', text: 'What can I help you with?' },
25
+ });
26
+ ```
27
+
28
+ The chat fills its host. It needs `flexdesk.css`, and the Material Symbols font for its icons.
29
+
30
+ | Option | Is |
31
+ |---|---|
32
+ | `transport` | **Required.** `{ send(request, emit), cancel?(request) }`; `cancel` may answer a promise, see [below](#a-backend-with-no-run-id) |
33
+ | `renderControls(el, message, chat)` | Fills the slot on an assistant message |
34
+ | `markdown` | Options for [`renderMarkdown`](markdown-and-outline.md) (`highlight`, `math`, `inlineRules`, ...) |
35
+ | `onChange(snapshot)` | Something worth keeping changed: a message arrived or finished, a conversation or its meta changed. Not called per text delta |
36
+ | `onConversationChange(conversation, reason)` | Another conversation is in front; `reason` is `new`, `switch`, `close` or `reset`. `conversation` is `{ id, title, conversationId, messages }` |
37
+ | `modes`, `mode`, `onModeChange(id)` | `[{ id, label, title? }]`. The labels are yours. No modes, no toggle |
38
+ | `models`, `onModelSelect(item)`, `onModelAction(action, item)` | See [The model picker](#the-model-picker) |
39
+ | `conversations` | A `snapshot()` to restore |
40
+ | `titleFrom(text)` | A new conversation's title, from its first message. Default: the first 30 characters |
41
+ | `formatTime(at, message)` | A message's time; `''` shows none. Default: the locale's hours and minutes |
42
+ | `placeholder`, `welcome: { icon?, text }` | The composer's placeholder; what an empty transcript shows |
43
+ | `labels` | Over `CHAT_SHELL_LABELS`, every text the chat shows |
44
+ | `eventBus`, `logger` | `eventBus` gets `CHAT_BUS_EVENTS` |
45
+
46
+ ### The transport
47
+
48
+ `send(request, emit)` starts a turn. A request is `{ text, tabId, conversationId, mode, model }`: `tabId` is the conversation's tab, `conversationId` is your backend's id (`null` until you give one), `model` is the picker's `activeId`.
49
+
50
+ `emit` takes normalised events, one at a time (`CHAT_TRANSPORT_EVENTS`):
51
+
52
+ | Event | Does |
53
+ |---|---|
54
+ | `{ type: 'text-delta', text }` | Adds to the reply, which re-renders |
55
+ | `{ type: 'tool-use', id?, name, label?, status? }` | Lists a tool call under the reply, or updates the one with that `id`. `status` is `running` (the default), `done` or `error`; `label` is shown instead of `name` |
56
+ | `{ type: 'message-end', text?, conversationId?, meta? }` | Ends the turn. `text` fills the reply only if nothing was streamed; `conversationId` is kept; `meta` is merged into the message's |
57
+ | `{ type: 'error', message }` | Ends the turn with an error message in the transcript |
58
+
59
+ Every `emit` belongs to its turn, and what it is handed after that turn ended or was cancelled is dropped. A `send` that throws, or answers a promise that rejects, is an `error` event. To tell the chat a backend id before the turn ends, call `setConversationId(request.tabId, id)`.
60
+
61
+ So a transport that gives each run its own callback, and calls that run's `emit`, needs no filtering of its own.
62
+
63
+ #### A backend with no run id
64
+
65
+ A backend that pushes to a global has one place to send a frame, and the frame does not say which run it is from. The adapter holds the latest `emit`, so the chat cannot tell an old run's chunk from the new reply's. Runs must therefore not overlap, and `cancel` is how: it answers a promise that settles when the cancelled run is over and the backend takes a new request. The next turn shows at once, with its typing dots, and its `send` is called when the promise settles. A promise that rejects is logged and counts as settled. One that never settles, or a run-over that is lost, holds the next turn only until the user stops it: [Stop releases a turn that is waiting](#stop-releases-a-turn-that-is-waiting-0181).
66
+
67
+ ```js
68
+ let emit = null;
69
+ let runEnded = null;
70
+ const ended = () => { runEnded?.(); runEnded = null; };
71
+ window.myBridge = {
72
+ onChunk: ({ chunk }) => emit?.({ type: 'text-delta', text: chunk }),
73
+ onDone: ({ text, id, changes }) => {
74
+ emit?.({ type: 'message-end', text, conversationId: id, meta: { changes } });
75
+ ended();
76
+ },
77
+ onError: ({ error }) => {
78
+ emit?.({ type: 'error', message: error });
79
+ ended();
80
+ },
81
+ };
82
+ const transport = {
83
+ async send(request, e) {
84
+ emit = e;
85
+ try {
86
+ const result = await api.ask(request);
87
+ if (!result.ok) throw new Error(result.error);
88
+ } catch (error) {
89
+ ended();
90
+ throw error;
91
+ }
92
+ },
93
+ cancel: () => new Promise((resolve) => {
94
+ runEnded = resolve;
95
+ api.stop();
96
+ }),
97
+ };
98
+ ```
99
+
100
+ While the run winds down, `emit` is still the cancelled turn's, so its late chunks and its report that it was cancelled are dropped, with no check of yours. The promise must not settle before the backend would accept a request: if the backend sends its last frame and only then frees itself, have it send the end of the run after that, and settle on that instead. A `cancel` that answers no promise says there is nothing to wait for, and `send` is called at once.
101
+
102
+ #### Stop releases a turn that is waiting (0.18.1)
103
+
104
+ A send made after a Stop waits for `cancel`'s promise. If that promise never settles, because the backend stalled or its run-over was lost, the turn shows its typing dots with nothing behind them. Stop on that turn releases the wait: it was never sent, so `transport.cancel` is not called for it, and the next send goes out at once.
105
+
106
+ The person has then said twice that they will not wait, and the chat does what they ask. So a backend whose old run may still be alive can see a request while it runs. One that takes one run at a time should refuse the overlapping request: the refusal shows as an error with Retry. One that accepts it will mix the old run's frames into the new reply, because they arrive through the one `emit` the adapter holds.
107
+
108
+ ### A turn
109
+
110
+ The user's message goes in, then a streamed assistant message that shows the typing dots while it is empty. While the turn runs, Send is Cancel and Esc cancels, as in the conversation view, and the New and Clear buttons are disabled. A turn ends in one of three ways:
111
+
112
+ - **`message-end`.** Running tools become `done`. The message gets its controls, its time and its copy button.
113
+ - **Stop.** Cancel, Esc or `chat.cancel()`. What arrived stays, running tools become `stopped`, and `transport.cancel(request)` is called once, unless the turn's `send` was still [waiting](#a-backend-with-no-run-id) and so never called. The chat can send again at once.
114
+ - **`error`.** As Stop without `transport.cancel`, and an error message follows. While it is the last message it carries *Retry*, which sends the user message before it again.
115
+
116
+ In all three, a reply left with no text, no tool call and no `meta` is taken out. `chat.isBusy()` answers at once.
117
+
118
+ ### Conversations
119
+
120
+ The chat owns the tabs: one per conversation, with a close mark while there is more than one, double-click or F2 to rename, a New button and a Clear button (which asks first when there is something to clear). A new conversation takes its title from its first message unless you renamed it. While a turn runs, the conversation in front cannot be left, closed or cleared; a switch is refused and the strip stays where it was.
121
+
122
+ `chat.snapshot()` is plain data to keep, and `conversations` takes it back:
123
+
124
+ ```js
125
+ { activeId, tabs: [{ id, title, conversationId, messages: [{ id, role, text, at?, tools?, meta? }] }] }
126
+ ```
127
+
128
+ A reply still empty is not in a snapshot, and a tool that was `running` when a snapshot was taken comes back `stopped`.
129
+
130
+ | Method | Does |
131
+ |---|---|
132
+ | `send(text)`, `cancel()` → bool | As the user would; false when refused |
133
+ | `isBusy()`, `messages()`, `snapshot()` | A turn is running; the messages in front; everything to keep |
134
+ | `activeConversation()` → id, `newConversation()` → id, `switchTo(id)`, `close(id)`, `rename(id, title)`, `reset()` | The tabs. `activeConversation` answers the id of the one in front, not the conversation; `snapshot()` holds its messages. `reset` empties the one in front. Refused ones answer false (`newConversation`: null) |
135
+ | `setConversationId(tabId, id)` | Your backend's id for a conversation |
136
+ | `setMessages(tabId, messages)` | Replaces a conversation's messages, when your backend knows better. The one a turn is writing to refuses |
137
+ | `updateMeta(id, patch)`, `refreshControls(id)`, `controlsSlot(id)` | A message's controls, below |
138
+ | `mode()`, `setMode(id)`, `setModels(patch)` | The bar |
139
+ | `getText()`, `setText(text)`, `focus()`, `destroy()` | The composer, and teardown. `destroy` cancels a running turn |
140
+ | `root`, `stateMachine` | The element, and the machine below |
141
+
142
+ ### Your controls on a message
143
+
144
+ Each assistant message has a slot between its tool calls and its footer. `renderControls(el, message, chat)` fills an emptied `el` when the reply has finished, and again when the message comes back on a tab switch or a restore, after `updateMeta(id, patch)`, and on `refreshControls(id)`. It is not called while a reply streams. Whatever it needs to survive goes in `message.meta`, which is kept in snapshots and handed back untouched:
145
+
146
+ ```js
147
+ renderControls(el, message, chat) {
148
+ if (!message.meta?.changes) return;
149
+ const button = document.createElement('button');
150
+ button.textContent = message.meta.undone ? 'Redo' : 'Undo';
151
+ button.onclick = () => chat.updateMeta(message.id, { undone: !message.meta.undone });
152
+ el.append(button);
153
+ }
154
+ ```
155
+
156
+ A `renderControls` that throws is logged and the chat carries on.
157
+
158
+ ### The model picker
159
+
160
+ An [`ActionDropdown`](menus.md) on a button in the bar. It is controlled: choosing an entry calls your callback, and the picker changes when you call `setModels`. `setModels` takes any of these; what you leave out stays.
161
+
162
+ | Key | Is |
163
+ |---|---|
164
+ | `items` | Entries: `{ id, label, description?, icon? }` (a model), `{ action, label, description?, icon? }` (a command), `{ separator: true }`. Labels and descriptions are text, never markup |
165
+ | `activeId` | The model marked, and `request.model` |
166
+ | `label` | The button's text. Default: the active item's label, then `labels.selectModel` |
167
+ | `status` | `{ state?: 'ready' \| 'pending', label? }`: a dot on the button, its tooltip the label. None without `status` |
168
+
169
+ A model calls `onModelSelect(item)`; a command calls `onModelAction(action, item)`. You get your own item back, with whatever else you put on it. With no items and no label there is no button; with no modes either there is no bar.
170
+
171
+ ### The state machine
172
+
173
+ `ChatStateMachine` is the conversation lifecycle with no view in it, exported for a chat of your own. `chat.stateMachine` is the shell's.
174
+
175
+ | State | Goes to |
176
+ |---|---|
177
+ | `idle` | `waiting` (`sendMessage`) |
178
+ | `waiting` | `streaming` (`streamStart`), `idle` (`streamComplete`, `cancel`, `reset`), `error` (`error`) |
179
+ | `streaming` | `idle` (`streamComplete`, `cancel`, `reset`), `error` (`error`) |
180
+ | `error` | `waiting` (`sendMessage`), `idle` (`reset`) |
181
+
182
+ `newConversation()` and `loadConversation(id)` go back to `idle` from anywhere. `canSend()`, `isProcessing()`, `mode`, `setMode(mode)`, `conversationId` and `lastError` read and set the rest; a mode is any string of yours.
183
+
184
+ Calls are queued in order, because text can arrive in the same tick as the request that asked for it, and each answers a promise of `{ transitioned, reason? }`. A transition the state does not have is refused, not thrown. So `getState()` lags a call by some microtasks, and is `null` until the machine has entered `idle`; `settled()` resolves when everything called so far has taken effect.
185
+
186
+ `new ChatStateMachine({ eventBus, logger, mode, onChange })`: `onChange({ state, conversationId, mode })` hears every state entered. `eventBus` gets `CHAT_BUS_EVENTS`:
187
+
188
+ | Constant | Event | Payload |
189
+ |---|---|---|
190
+ | `STATE_CHANGED` | `chat:state:changed` | `{ state, conversationId, mode }` |
191
+ | `MESSAGE_SENT` | `chat:message:sent` | `{ conversationId, text, mode }` |
192
+ | `STREAM_COMPLETE` | `chat:stream:completed` | `{ conversationId, text, meta }` from the shell |
193
+ | `MODE_CHANGED` | `chat:mode:changed` | `{ mode }` |
194
+ | `CONVERSATION_NEW` | `chat:conversation:new` | `{}` |
195
+ | `CONVERSATION_LOADED` | `chat:conversation:loaded` | `{ conversationId }` |
196
+ | `ERROR` | `chat:error:raised` | `{ conversationId, message, detail }` |
197
+
198
+ ### Classes
199
+
200
+ The block is `twm-chat-shell`, around the conversation view's `twm-chat`: `__tab-bar`, `__tabs`, `__tab-btn` (`--clear`), `__rename`, `__conversation`, `__welcome`, `__welcome-icon`, `__welcome-text`, `__typing`, `__tools`, `__tool` (`--running`, `--done`, `--error`, `--stopped`), `__tool-icon`, `__tool-label`, `__controls`, `__footer`, `__time`, `__copy` (`--done`), `__retry`, `__bar`, `__bar-spacer`, `__modes`, `__mode` (`--on`), `__model-btn`, `__model-label`, `__model-chevron`, `__model-menu`, `__status` (`--ready`, `--pending`).
201
+
202
+ A message's role is the view's modifier, `twm-chat__msg--<role>`; the shell uses `user`, `assistant` and `error`. Every rule reads FlexDesk's tokens. Nothing is stored in a `data-` attribute beyond the tab strip's `data-tab-id` and the dropdown's own keys.
203
+
204
+ `tests/chat_shell.test.mjs` and `tests/chat_state_machine.test.mjs` cover all of this in jsdom.
@@ -26,6 +26,7 @@ view.setBusy(false);
26
26
  | Method | Does |
27
27
  |---|---|
28
28
  | `append({ id?, role, text, meta? })` → id | Adds a message; `role` becomes `twm-chat__msg--<role>` |
29
+ | `remove(id)` → bool | Takes one message out ([below](#taking-one-message-out-remove-0180)) |
29
30
  | `replaceAll(messages)`, `clear()` | Restores a transcript (and scrolls to its end), or empties it |
30
31
  | `beginStream(message?)` → id, `appendChunk(text)`, `endStream()` | A message that grows; each chunk re-renders that message's body and nothing else |
31
32
  | `slot(id)` | The element below a message's body, yours to fill (tool output, buttons); a re-render leaves it alone |
@@ -70,3 +71,12 @@ view.setBusy(false);
70
71
 
71
72
  `tests/conversation_view.test.mjs` drives all of it in jsdom with a fake
72
73
  scroll geometry.
74
+
75
+ ## Taking one message out: `remove` (0.18.0)
76
+
77
+ `view.remove(id)` takes one message out and answers `true`, or `false` for an
78
+ unknown id. Every other message keeps its row and its slot, so it costs no
79
+ re-render and moves no reader, where `replaceAll` of the rest redrew all of them
80
+ and scrolled to the end. An open stream on that message is over: `streamingId()`
81
+ is `null` and later chunks go nowhere. It is how the
82
+ [assistant chat shell](chat-shell.md) takes out a reply that ended empty.