@kabmo-labs/kabmo 0.3.0 → 0.4.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 (136) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +412 -383
  3. package/dist/client/static/js/index-3qamd33d.css +1 -0
  4. package/dist/client/static/js/{index-8x5j8zrr.js → index-twsk4wfa.js} +791 -791
  5. package/package.json +1 -1
  6. package/src/client/components/common/batch-selection/BatchActionBar.tsx +8 -1
  7. package/src/client/components/common/session-actions-menu/index.tsx +23 -2
  8. package/src/client/components/layout/{RightActivityBar.test.ts → ActivityBar.test.ts} +5 -5
  9. package/src/client/components/layout/{RightActivityBar.tsx → ActivityBar.tsx} +87 -52
  10. package/src/client/components/layout/LeftActivityBar.test.ts +201 -0
  11. package/src/client/components/layout/LeftDock.tsx +119 -0
  12. package/src/client/components/layout/desktop-layout/LeftColumn.tsx +172 -0
  13. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +22 -5
  14. package/src/client/components/layout/desktop-layout/index.tsx +88 -61
  15. package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
  16. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
  17. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
  18. package/src/client/components/layout/session-sidebar/SessionItem.tsx +30 -5
  19. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  20. package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
  21. package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
  22. package/src/client/components/mobile/RightSidebar.tsx +8 -5
  23. package/src/client/components/mobile/SessionSidebar.tsx +6 -0
  24. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -3
  25. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
  26. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
  27. package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
  28. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +41 -13
  29. package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
  30. package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
  31. package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
  32. package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
  33. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
  34. package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
  35. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
  36. package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
  37. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
  38. package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
  39. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
  40. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
  41. package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
  42. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
  43. package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
  44. package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
  45. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
  46. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +14 -9
  47. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
  48. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
  49. package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +154 -0
  50. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
  51. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
  52. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
  53. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
  54. package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
  55. package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
  56. package/src/client/components/workspace/editor/index.tsx +1 -0
  57. package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
  58. package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
  59. package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
  60. package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
  61. package/src/client/components/workspace/panels/builtin.tsx +8 -0
  62. package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
  63. package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
  64. package/src/client/hooks/chat/composer/compact.ts +119 -0
  65. package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
  66. package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
  67. package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
  68. package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
  69. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
  70. package/src/client/hooks/chat/timeline/actions.ts +34 -35
  71. package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
  72. package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
  73. package/src/client/hooks/chat/timeline/index.ts +15 -19
  74. package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
  75. package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
  76. package/src/client/hooks/chat/waiting/cancel.ts +34 -0
  77. package/src/client/hooks/chat/waiting/choice.ts +53 -0
  78. package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
  79. package/src/client/hooks/chat/waiting/held.ts +42 -0
  80. package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
  81. package/src/client/hooks/chat/waiting/hold.ts +156 -0
  82. package/src/client/hooks/chat/waiting/store.ts +100 -0
  83. package/src/client/hooks/workspace/left-dock.ts +132 -0
  84. package/src/client/hooks/workspace/panel-registry.ts +13 -0
  85. package/src/client/tailwind.css +22 -0
  86. package/src/server/db.server.test.ts +52 -1
  87. package/src/server/db.server.ts +49 -1
  88. package/src/server/index.ts +9 -0
  89. package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
  90. package/src/server/lib/assets/compressed-cache.server.ts +88 -0
  91. package/src/server/lib/db/schema.test.ts +1 -0
  92. package/src/server/lib/db/schema.ts +3 -0
  93. package/src/server/lib/fs/fs-path.test.ts +1 -1
  94. package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
  95. package/src/server/lib/omp/session/delete.server.ts +8 -0
  96. package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
  97. package/src/server/lib/panels/git-update.server.test.ts +18 -1
  98. package/src/server/lib/panels/registry.server.ts +2 -2
  99. package/src/server/lib/queue/delivery.server.ts +31 -39
  100. package/src/server/lib/queue/payload.ts +73 -0
  101. package/src/server/lib/realtime/signals.server.ts +10 -1
  102. package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
  103. package/src/server/lib/realtime/topics/global.server.ts +5 -0
  104. package/src/server/lib/realtime/topics.server.ts +2 -0
  105. package/src/server/lib/util/with-timeout.ts +29 -0
  106. package/src/server/lib/waiting/row-clock.server.ts +37 -0
  107. package/src/server/lib/waiting/runtime.server.test.ts +278 -0
  108. package/src/server/lib/waiting/runtime.server.ts +313 -0
  109. package/src/server/lib/waiting/schema.server.ts +46 -0
  110. package/src/server/lib/waiting/settle.test.ts +84 -0
  111. package/src/server/lib/waiting/settle.ts +77 -0
  112. package/src/server/lib/waiting/store.server.test.ts +167 -0
  113. package/src/server/lib/waiting/store.server.ts +268 -0
  114. package/src/server/lib/waiting/sweep-trigger.ts +35 -0
  115. package/src/server/plugins/compress.ts +18 -6
  116. package/src/server/plugins/ssr.ts +16 -8
  117. package/src/server/plugins/static.test.ts +59 -0
  118. package/src/server/plugins/static.ts +56 -14
  119. package/src/server/routes/index.ts +2 -0
  120. package/src/server/routes/waiting/bindings.ts +14 -0
  121. package/src/server/routes/waiting/index.ts +123 -0
  122. package/src/server/routes/waiting/routes.test.ts +121 -0
  123. package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
  124. package/src/shared/lib/chat/timeline/gutter.ts +37 -0
  125. package/src/shared/lib/realtime/protocol.ts +10 -0
  126. package/src/shared/lib/workspace/left-dock-width.ts +78 -0
  127. package/src/shared/lib/workspace/left-dock.test.ts +203 -0
  128. package/src/shared/lib/workspace/left-dock.ts +197 -0
  129. package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
  130. package/src/shared/lib/workspace/panel-ids.ts +114 -26
  131. package/src/shared/lib/workspace/sidebar-width.ts +4 -0
  132. package/src/shared/types/index.ts +1 -0
  133. package/src/shared/types/waiting.ts +54 -0
  134. package/src/shared/types/workspace.ts +8 -0
  135. package/src/test-support/isolated-db.ts +3 -4
  136. package/dist/client/static/js/index-pdp5dwc8.css +0 -1
@@ -0,0 +1,268 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Panel placement: which destination each contributed panel is drawn on.
8
+ *
9
+ * The section was "Left dock", and its one question was "is this plugin's panel
10
+ * on the left or the right?" — which could not be answered for a plugin that
11
+ * contributes TWO panels. `marketplace/plugins/session-info` ships a `rightPanel`
12
+ * and a `panel`, and with one row per plugin, moving its right panel onto the
13
+ * left dock would have had no way to say what happened to the other one.
14
+ *
15
+ * So the row is per PANEL INSTANCE, and the row's destinations come from the
16
+ * SLOT rather than from the plugin:
17
+ *
18
+ * - a `rightPanel` is a view — it rides the left dock or the right column;
19
+ * - a `panel` is a takeover — it rides the editor column, and only that, because
20
+ * a takeover on a rail would be a button that opens a column it is not on.
21
+ *
22
+ * The dock's switch stays here as well, because a placement on the left is
23
+ * invisible without the rail: placing a panel turns it on (`setPanelSide`), and
24
+ * the pane says so rather than leaving the user to discover it.
25
+ */
26
+
27
+ import { ArrowLeft, ArrowRight, Columns2, Loader2, PanelLeft, PanelRight } from 'lucide-preact';
28
+ import type { PanelPluginStatus } from '@/shared/types';
29
+ import { pluginPanelKey } from '@/shared/lib/workspace/panel-ids';
30
+ import { SESSIONS_VIEW_ID, panelSideOf, ridesSides, type PanelSide } from '@/shared/lib/workspace/left-dock';
31
+ import type { PluginSlots } from '@/client/lib/plugins/slots';
32
+ import type { LeftDockHandle } from '@/client/hooks/workspace/left-dock';
33
+
34
+ interface LeftDockSettingsProps {
35
+ dock: LeftDockHandle;
36
+ plugins: PanelPluginStatus[];
37
+ slots: Map<string, PluginSlots>;
38
+ }
39
+
40
+ /** One contributed panel, with the row's own facts resolved. */
41
+ interface PlaceablePanel {
42
+ plugin: PanelPluginStatus;
43
+ key: string;
44
+ /** The slot it was registered for, which decides where it may go. */
45
+ slot: 'rightPanel' | 'panel';
46
+ /** The destination it is on right now. */
47
+ side: PanelSide;
48
+ title: string;
49
+ }
50
+
51
+ /** The status line's wording, which is a sentence rather than a button label. */
52
+ const SIDE_STATUS: Record<PanelSide, string> = {
53
+ left: 'Left dock',
54
+ right: 'Right column',
55
+ editor: 'Editor column',
56
+ };
57
+
58
+ /** What each destination is called, in a button and in a row's status line. */
59
+ const SIDE_LABEL: Record<PanelSide, string> = {
60
+ left: 'left dock',
61
+ right: 'right column',
62
+ editor: 'editor column',
63
+ };
64
+
65
+ const SIDE_GLYPH: Record<PanelSide, typeof PanelLeft> = {
66
+ left: PanelLeft,
67
+ right: PanelRight,
68
+ editor: Columns2,
69
+ };
70
+
71
+ /**
72
+ * The destinations one panel may be moved to.
73
+ *
74
+ * ONE button per destination rather than a toggle: there are up to three, so a
75
+ * toggle cannot express the choice, and a button carrying the CURRENT position
76
+ * would read as a state ("Left") on a control that is about to move it. Each
77
+ * button therefore names its destination, and the one the panel is already on is
78
+ * disabled — a control that can only go where it already is does nothing.
79
+ */
80
+ function DestinationGroup({
81
+ name,
82
+ destinations,
83
+ current,
84
+ onMove,
85
+ }: {
86
+ name: string;
87
+ destinations: readonly PanelSide[];
88
+ current: PanelSide;
89
+ onMove: (side: PanelSide) => void;
90
+ }) {
91
+ if (destinations.length < 2) return null;
92
+ return (
93
+ <span className="flex items-center gap-1 flex-shrink-0">
94
+ {destinations.map((side) => {
95
+ const Glyph = SIDE_GLYPH[side];
96
+ const here = side === current;
97
+ return (
98
+ <button
99
+ key={side}
100
+ type="button"
101
+ disabled={here}
102
+ onClick={() => onMove(side)}
103
+ title={here ? `${name} is on the ${SIDE_LABEL[side]}` : `Move ${name} to the ${SIDE_LABEL[side]}`}
104
+ aria-label={here ? `${name} is on the ${SIDE_LABEL[side]}` : `Move ${name} to the ${SIDE_LABEL[side]}`}
105
+ aria-pressed={here}
106
+ className={`flex items-center gap-1 px-1.5 py-0.5 text-[10px] rounded border flex-shrink-0 transition-colors ${
107
+ here
108
+ ? 'border-ink/30 bg-ink/10 text-ink/50 cursor-default'
109
+ : 'border-ink/25 text-ink/80 hover:bg-ink/5 cursor-pointer'
110
+ }`}
111
+ >
112
+ <Glyph size={11} />
113
+ {side === 'left' ? 'Left' : side === 'right' ? 'Right' : 'Editor'}
114
+ </button>
115
+ );
116
+ })}
117
+ </span>
118
+ );
119
+ }
120
+
121
+ export function LeftDockSettings({ dock, plugins, slots }: LeftDockSettingsProps) {
122
+ // ONE row per contributed panel, not per plugin: a plugin may register both a
123
+ // view and a takeover, and they are placed independently.
124
+ const placeable: PlaceablePanel[] = [];
125
+ for (const plugin of plugins) {
126
+ const entry = slots.get(plugin.pluginId);
127
+ if (!plugin.enabled || !entry) continue;
128
+ for (const slot of ['rightPanel', 'panel'] as const) {
129
+ if (!entry.components[slot]) continue;
130
+ const key = pluginPanelKey(plugin.pluginId, slot);
131
+ placeable.push({
132
+ plugin,
133
+ key,
134
+ slot,
135
+ side: panelSideOf(key, dock),
136
+ title: entry.titles[slot] ?? plugin.name,
137
+ });
138
+ }
139
+ }
140
+ const loading = plugins.filter((plugin) => plugin.enabled && !slots.get(plugin.pluginId)).length;
141
+
142
+ return (
143
+ <section>
144
+ <header className="flex items-center gap-2 mb-2">
145
+ <h3 className="text-sm font-medium text-ink">Panel placement</h3>
146
+ <span className="ml-auto text-[11px] text-ink/50">
147
+ {dock.enabled ? `${dock.panels.length} on the rail` : 'rail off'}
148
+ </span>
149
+ </header>
150
+
151
+ <p className="text-[11px] text-ink/50 mb-2">
152
+ The left column always holds the session list. Turn its rail on to add views beside it — a
153
+ panel placed there gets its own button and its own width, and leaves the right column. Each
154
+ contributed panel is placed on its own: a plugin that ships two panels gets two rows.
155
+ </p>
156
+
157
+ {!dock.enabled ? (
158
+ <p className="text-[11px] text-ink/45 mb-2">
159
+ The rail is off. Placing a panel on the left turns it on, and you can switch it back off
160
+ here at any time.
161
+ </p>
162
+ ) : null}
163
+
164
+ <div className="flex items-center gap-2.5 border border-ink/10 rounded-lg px-2.5 py-2 mb-2">
165
+ <span className="min-w-0 flex-1">
166
+ <span className="block text-xs text-ink">Show the left activity bar</span>
167
+ <span className="block text-[10px] text-ink/40">
168
+ Off by default. With it off the column shows only its active view.
169
+ </span>
170
+ </span>
171
+ <button
172
+ type="button"
173
+ role="switch"
174
+ aria-checked={dock.enabled}
175
+ aria-label={dock.enabled ? 'Hide the left activity bar' : 'Show the left activity bar'}
176
+ onClick={() => dock.setEnabled(!dock.enabled)}
177
+ className={`relative w-8 h-4 rounded-full transition-colors flex-shrink-0 ${
178
+ dock.enabled ? 'bg-ink/70' : 'bg-ink/20'
179
+ }`}
180
+ >
181
+ <span
182
+ className={`absolute top-0.5 w-3 h-3 rounded-full bg-canvas transition-all ${
183
+ dock.enabled ? 'left-[18px]' : 'left-0.5'
184
+ }`}
185
+ />
186
+ </button>
187
+ </div>
188
+
189
+ {placeable.length === 0 ? (
190
+ <p className="text-[11px] text-ink/45">
191
+ {loading > 0
192
+ ? `Loading ${loading} plugin ${loading === 1 ? 'bundle' : 'bundles'}…`
193
+ : 'No installed plugin contributes a panel yet. Install one from the store above and it appears here.'}
194
+ </p>
195
+ ) : (
196
+ <div className="grid gap-2 [grid-template-columns:repeat(auto-fill,minmax(240px,1fr))]">
197
+ <div className="flex flex-col gap-1.5 border border-ink/10 rounded-lg px-2.5 py-2 bg-ink/[0.02]">
198
+ <span className="min-w-0">
199
+ <span className="block text-xs text-ink">Sessions</span>
200
+ <span className="block text-[10px] text-ink/40">The dock's own view — always here.</span>
201
+ </span>
202
+ <span className="text-[10px] font-mono text-ink/40 self-start">{SESSIONS_VIEW_ID}</span>
203
+ </div>
204
+
205
+ {placeable.map(({ plugin, key, slot, side, title }) => {
206
+ const destinations = ridesSides(slot);
207
+ // TWO rows, not one. The identity block and the destination buttons
208
+ // shared a line, and two buttons with glyphs and labels (`Left`
209
+ // `Right`) took the width the title needed — so the title was
210
+ // truncated inside a card that had room for it, at the very column
211
+ // width this grid produces (240px). Stacked, the title gets the full
212
+ // card and the buttons get their own row.
213
+ return (
214
+ <div key={key} className="flex flex-col gap-1.5 border border-ink/10 rounded-lg px-2.5 py-2">
215
+ <span className="min-w-0">
216
+ <span className="block text-xs text-ink" title={`${title} — ${plugin.name}`}>
217
+ {title}
218
+ <span className="text-ink/40"> · {plugin.name}</span>
219
+ </span>
220
+ <span className="block text-[10px] text-ink/40">
221
+ {SIDE_STATUS[side]}
222
+ {slot === 'panel' ? ' · takeover' : ' · view'}
223
+ {side === 'left' && !dock.enabled ? ' · rail off' : ''}
224
+ </span>
225
+ </span>
226
+ {/* A takeover has one destination, so it gets a STATUS rather
227
+ than a control: a button that can only go where it already
228
+ is would be a control that does nothing. */}
229
+ {destinations.length > 1 ? (
230
+ <DestinationGroup
231
+ name={title}
232
+ destinations={destinations}
233
+ current={side}
234
+ onMove={(next) => dock.setSide(key, next)}
235
+ />
236
+ ) : (
237
+ <span
238
+ className="flex items-center gap-1 px-1.5 py-0.5 text-[10px] rounded border border-ink/10 text-ink/50 self-start"
239
+ title={`${title} draws the editor column — its slot has no other destination`}
240
+ >
241
+ <ArrowLeft size={11} className="opacity-40" />
242
+ Fixed
243
+ </span>
244
+ )}
245
+ </div>
246
+ );
247
+ })}
248
+ </div>
249
+ )}
250
+
251
+ {loading > 0 && placeable.length > 0 ? (
252
+ <p className="mt-2 flex items-center gap-1.5 text-[11px] text-ink/45">
253
+ <Loader2 size={11} className="animate-spin" />
254
+ {loading} plugin {loading === 1 ? 'bundle is' : 'bundles are'} still loading.
255
+ </p>
256
+ ) : null}
257
+
258
+ <p className="mt-2 flex items-start gap-1.5 text-[11px] text-ink/40">
259
+ <ArrowRight size={11} className="mt-0.5 flex-shrink-0 opacity-60" />
260
+ <span>
261
+ A takeover draws the editor column whole, so its switch is the editor panel's own rail
262
+ (Files · your panels). A view rides a rail and gets one button on the column it is placed
263
+ on.
264
+ </span>
265
+ </p>
266
+ </section>
267
+ );
268
+ }
@@ -10,6 +10,8 @@ import { InstallForm } from '@/client/components/settings/categories/panel-plugi
10
10
  import { AvailableList } from '@/client/components/settings/categories/panel-plugins/AvailableList';
11
11
  import { InstalledList } from '@/client/components/settings/categories/panel-plugins/InstalledList';
12
12
  import { BuiltinList } from '@/client/components/settings/categories/panel-plugins/BuiltinList';
13
+ import { LeftDockSettings } from '@/client/components/settings/categories/panel-plugins/LeftDockSettings';
14
+ import { useLeftDock } from '@/client/hooks/workspace/left-dock';
13
15
  import { PluginToolbar } from '@/client/components/settings/categories/panel-plugins/Toolbar';
14
16
  import { SyncBlockedNotice } from '@/client/components/settings/categories/panel-plugins/SyncBlockedNotice';
15
17
 
@@ -85,6 +87,7 @@ export function PanelPluginsSection() {
85
87
  usePanelRegistry();
86
88
  const { slots, failures } = usePanelSlots();
87
89
  const actions = usePanelPluginActions();
90
+ const dock = useLeftDock();
88
91
 
89
92
  const refresh = () => void refreshPanelRegistry();
90
93
 
@@ -137,6 +140,11 @@ export function PanelPluginsSection() {
137
140
  they are listed whether or not the registry read settled. */}
138
141
  <BuiltinList disabled={disabledPanels} busy={actions.busy} onSetEnabled={actions.setEnabled} />
139
142
 
143
+ {/* After the built-ins, because it places the panels those rows switch on:
144
+ a placement for a panel that is switched off would be a row for
145
+ nothing. */}
146
+ <LeftDockSettings dock={dock} plugins={plugins} slots={slots} />
147
+
140
148
  {ready && available.length === 0 && plugins.length === 0 && errors.length === 0 ? (
141
149
  <div className="text-xs text-ink/60 space-y-1.5">
142
150
  <p>No panel plugins installed.</p>
@@ -10,6 +10,12 @@ import { RunFooter } from '@/client/components/workspace/chat-timeline/RunFooter
10
10
  import { GeneratingIndicator } from '@/client/components/workspace/chat-timeline/GeneratingIndicator';
11
11
  import { resolveRunFooters, streamingRowIndex } from '@/shared/lib/chat/timeline/run-footer';
12
12
  import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
13
+ import {
14
+ WaitingDependencyPicker,
15
+ waitingCandidates,
16
+ type WaitingCandidate,
17
+ } from '@/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker';
18
+ import { Hourglass } from 'lucide-preact';
13
19
 
14
20
  interface EmptyWorkspacePromptProps {
15
21
  className?: string;
@@ -55,6 +61,17 @@ interface EmptyWorkspacePromptProps {
55
61
  onUndo?: (id: string, content?: string) => void;
56
62
  onRetry?: (id: string) => void;
57
63
  onNewChat?: (content: string, attachments?: Attachment[]) => void;
64
+ /** Server-tracked `stream` ids: the only sessions the wait may name. */
65
+ streamStatusMap?: Record<string, string | undefined>;
66
+ /** A name for a streaming session the sidebar has not scanned yet. */
67
+ titleForSession?: (id: string) => string | undefined;
68
+ /** The "wait for session" pick, persisted per chat. */
69
+ waitingFor?: WaitingCandidate | null;
70
+ onWaitingForChange?: (candidate: WaitingCandidate | null) => void;
71
+ /** A held prompt for THIS chat: the composer is disabled until it is released
72
+ * or cancelled, because there is no session to send a second prompt to. */
73
+ heldWait?: { title: string | null } | null;
74
+ onCancelWait?: () => void;
58
75
  }
59
76
 
60
77
  export function EmptyWorkspacePrompt({
@@ -89,6 +106,12 @@ export function EmptyWorkspacePrompt({
89
106
  onUndo,
90
107
  onRetry,
91
108
  onNewChat,
109
+ streamStatusMap = {},
110
+ titleForSession,
111
+ waitingFor = null,
112
+ onWaitingForChange,
113
+ heldWait = null,
114
+ onCancelWait,
92
115
  }: EmptyWorkspacePromptProps) {
93
116
  const [showWorkspace, setShowWorkspace] = useState(false);
94
117
  const workspaceRef = useRef<HTMLDivElement>(null);
@@ -99,6 +122,13 @@ export function EmptyWorkspacePrompt({
99
122
  return folders?.find(f => f.id === selectedFolderId);
100
123
  }, [folders, selectedFolderId]);
101
124
 
125
+ // Sessions the wait may name: the ones with a run in flight, from the same
126
+ // server-tracked map the sidebar's spinner is drawn from.
127
+ const candidates = useMemo(
128
+ () => waitingCandidates(folders, streamStatusMap, null, titleForSession),
129
+ [folders, streamStatusMap, titleForSession],
130
+ );
131
+
102
132
  // Run footers are boundary rows: emitted after the run's last row, so a
103
133
  // trailing notice never separates the footer from the next user message.
104
134
  const { streamingIdx, footers } = useMemo(
@@ -128,6 +158,7 @@ export function EmptyWorkspacePrompt({
128
158
  background). Hidden once a chat has been sent — the session now has
129
159
  a target workspace, so the composer is the only focus. */}
130
160
  {messages.length === 0 && (
161
+ <div className="flex items-center gap-1 flex-wrap">
131
162
  <div className="relative shrink-0" ref={workspaceRef}>
132
163
  <button
133
164
  type="button"
@@ -172,6 +203,21 @@ export function EmptyWorkspacePrompt({
172
203
  </div>
173
204
  </div>
174
205
  )}
206
+ </div>
207
+ {/* The wait picker sits BESIDE the workspace picker, because both
208
+ answer the same question about the chat that has not started:
209
+ where it runs, and what it waits for. It only appears while there
210
+ is something to wait for (or a pick already made). */}
211
+ {onWaitingForChange && (waitingFor || candidates.length > 0) && (
212
+ <WaitingDependencyPicker
213
+ folders={folders}
214
+ streamStatusMap={streamStatusMap}
215
+ sessionId={null}
216
+ titleFor={titleForSession}
217
+ value={waitingFor}
218
+ onChange={onWaitingForChange}
219
+ />
220
+ )}
175
221
  </div>
176
222
  )}
177
223
 
@@ -197,6 +243,7 @@ export function EmptyWorkspacePrompt({
197
243
  isStreaming={isLoading}
198
244
  onUndo={onUndo}
199
245
  onNewChat={onNewChat}
246
+ isMobile={variant === 'mobile'}
200
247
  className={isAiFragment ? 'mt-1' : 'mt-8'}
201
248
  />
202
249
  {footer && (
@@ -232,6 +279,27 @@ export function EmptyWorkspacePrompt({
232
279
  providerNames={providerNames}
233
280
  />
234
281
  )}
282
+ {/* A held prompt owns this chat until it is released: there is no
283
+ session to send a second prompt to, and a composer that looked live
284
+ would accept one that could not be delivered. */}
285
+ {heldWait && (
286
+ <div className="flex items-center gap-2 rounded-lg border border-ink/15 bg-ink/[0.03] px-3 py-2 text-xs text-ink/70">
287
+ <Hourglass size={12} className="shrink-0 animate-pulse text-ink/60" />
288
+ <span className="min-w-0 flex-1 truncate">
289
+ Waiting for {heldWait.title ?? 'another session'} to finish before this prompt is sent.
290
+ </span>
291
+ {onCancelWait && (
292
+ <button
293
+ type="button"
294
+ data-testid="cancel-held-prompt"
295
+ onClick={onCancelWait}
296
+ className="shrink-0 rounded border border-ink/20 px-2 py-0.5 text-[11px] text-ink/70 hover:bg-ink/5 hover:text-ink cursor-pointer"
297
+ >
298
+ Cancel
299
+ </button>
300
+ )}
301
+ </div>
302
+ )}
235
303
  <ChatInput
236
304
  value={inputValue}
237
305
  onChange={setInputValue}
@@ -240,7 +308,7 @@ export function EmptyWorkspacePrompt({
240
308
  onAttachmentsChange={setInputAttachments}
241
309
  onSend={onSend}
242
310
  isGenerating={isGenerating}
243
- disabled={!selectedFolderId}
311
+ disabled={!selectedFolderId || Boolean(heldWait)}
244
312
  className="w-full shrink-0"
245
313
  appSettings={appSettings}
246
314
  onThinkingLevelChange={onThinkingLevelChange}
@@ -0,0 +1,118 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Where a framed transcript block's edge lands, per layout.
8
+ *
9
+ * The phone's gutter is the timeline container's own `px-3` — the same 12px
10
+ * the composer dock uses — so a block that adds `mx-3` on top of it sits 12px
11
+ * inside the composer's card. That is what this pins: the same tree, mounted
12
+ * twice, and the only difference is `isMobile`.
13
+ *
14
+ * Asserted through the rendered DOM rather than the helper, because the bug
15
+ * was a call site that never passed the flag: `transcriptGutter` can be
16
+ * perfect and a `ChatMessageItem` that drops `isMobile` still renders `mx-3`.
17
+ */
18
+
19
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
20
+ import { Window } from 'happy-dom';
21
+ import { h, render } from 'preact';
22
+ import { act } from 'preact/test-utils';
23
+ import { ChatMessageItem } from '@/client/components/workspace/chat-timeline/MessageItem';
24
+ import { SessionStateProvider } from '@/client/components/common/session-state-provider';
25
+ import type { ChatMessageData } from '@/shared/types';
26
+
27
+ const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'HTMLInputElement', 'Event', 'MouseEvent', 'KeyboardEvent'] as const;
28
+ const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
29
+
30
+ beforeAll(() => {
31
+ const win = new Window({ url: 'http://localhost' });
32
+ const target = globalThis as unknown as Record<string, unknown>;
33
+ for (const key of DOM_GLOBALS) {
34
+ if (!(key in nativeGlobals)) nativeGlobals[key] = target[key];
35
+ target[key] = (win as unknown as Record<string, unknown>)[key];
36
+ }
37
+ });
38
+
39
+ afterAll(() => {
40
+ const target = globalThis as unknown as Record<string, unknown>;
41
+ for (const key of DOM_GLOBALS) {
42
+ if (nativeGlobals[key] === undefined) delete target[key];
43
+ else target[key] = nativeGlobals[key];
44
+ }
45
+ });
46
+
47
+ let container: HTMLElement | null = null;
48
+
49
+ beforeEach(() => {
50
+ container?.remove();
51
+ container = null;
52
+ });
53
+
54
+ /** A user row, and an AI row carrying one tool call — the two framed blocks. */
55
+ const userRow: ChatMessageData = { id: 'u1', role: 'user', content: 'hello' };
56
+ const toolRow: ChatMessageData = {
57
+ id: 'a1',
58
+ role: 'ai',
59
+ content: 'done',
60
+ toolCalls: [{
61
+ id: 't1',
62
+ type: 'bash',
63
+ title: 'Bash',
64
+ command: 'echo hi',
65
+ output: 'hi',
66
+ status: 'success',
67
+ }],
68
+ };
69
+
70
+ async function mount(msg: ChatMessageData, isMobile: boolean): Promise<HTMLElement> {
71
+ const host = document.createElement('div');
72
+ document.body.appendChild(host);
73
+ await act(async () => {
74
+ render(
75
+ h(SessionStateProvider, {
76
+ sessionId: 'gutter-test',
77
+ children: h(ChatMessageItem, { msg, isMobile }),
78
+ }),
79
+ host,
80
+ );
81
+ });
82
+ container = host;
83
+ return host;
84
+ }
85
+
86
+ /** The user bubble: the framed block a user row draws. */
87
+ function bubble(host: HTMLElement): HTMLElement {
88
+ return host.querySelector('div.bg-paper.rounded-xl') as HTMLElement;
89
+ }
90
+
91
+ describe('transcript block gutter', () => {
92
+ test('a phone draws the user bubble at the container gutter, with no extra inset', async () => {
93
+ const host = await mount(userRow, true);
94
+ const box = bubble(host);
95
+ expect(box.className).not.toContain('mx-3');
96
+ // And its padding is the one that lands text on the 24px column.
97
+ expect(box.className).toContain('p-3');
98
+ expect(box.className).not.toContain('p-3.5');
99
+ });
100
+
101
+ test('desktop keeps the inset frame and the roomier padding', async () => {
102
+ const host = await mount(userRow, false);
103
+ const box = bubble(host);
104
+ expect(box.className).toContain('mx-3');
105
+ expect(box.className).toContain('p-3.5');
106
+ });
107
+
108
+ test('the tool section follows the same rule — and is reachable by its own marker', async () => {
109
+ const phone = await mount(toolRow, true);
110
+ const phoneSection = phone.querySelector('[data-tool-section]') as HTMLElement;
111
+ expect(phoneSection).not.toBeNull();
112
+ expect(phoneSection.className).not.toContain('mx-3');
113
+
114
+ const desktop = await mount(toolRow, false);
115
+ const desktopSection = desktop.querySelector('[data-tool-section]') as HTMLElement;
116
+ expect(desktopSection.className).toContain('mx-3');
117
+ });
118
+ });
@@ -14,6 +14,7 @@ import { PluginMessageActions } from '@/client/components/workspace/chat-timelin
14
14
  import { capitalizeFirstLetter } from '@/shared/lib/chat/capitalize';
15
15
  import { isNoticeRow, messageAnswerText } from '@/shared/lib/chat/notice-row';
16
16
  import { formatMessageStamp } from '@/shared/lib/format/time';
17
+ import { transcriptBlockPadding, transcriptGutter } from '@/shared/lib/chat/timeline/gutter';
17
18
 
18
19
  interface ChatMessageItemProps {
19
20
  msg: ChatMessageData | any;
@@ -37,6 +38,11 @@ interface ChatMessageItemProps {
37
38
  * instead of rendering buttons that do nothing.
38
39
  */
39
40
  userActions?: boolean;
41
+ /**
42
+ * Phone layout. A framed block's margin is then the timeline gutter itself
43
+ * rather than an inset — see `transcriptGutter`.
44
+ */
45
+ isMobile?: boolean;
40
46
  }
41
47
 
42
48
  export const ChatMessageItem = memo(function ChatMessageItem({
@@ -47,6 +53,7 @@ export const ChatMessageItem = memo(function ChatMessageItem({
47
53
  className = '',
48
54
  isPrevAssistant = false,
49
55
  userActions = true,
56
+ isMobile = false,
50
57
  }: ChatMessageItemProps) {
51
58
  const isUser = msg.role === 'user';
52
59
 
@@ -88,7 +95,7 @@ export const ChatMessageItem = memo(function ChatMessageItem({
88
95
  return (
89
96
  <div id={msg.id} className={`flex flex-col items-end space-y-1.5 w-full max-w-full ${className}`}>
90
97
  {/* User bubble - standardized to text-[13px] with markdown support */}
91
- <div className="bg-paper p-3.5 sm:p-4 rounded-xl border border-ink/15 text-[13px] text-ink shadow-xs max-w-[92%] sm:max-w-[85%] break-words overflow-hidden flex flex-col space-y-2 font-sans select-text mx-3" style={{ lineHeight: 'var(--markdown-body-line-height)' }}>
98
+ <div className={`bg-paper ${transcriptBlockPadding(isMobile)} sm:p-4 rounded-xl border border-ink/15 text-[13px] text-ink shadow-xs max-w-[92%] sm:max-w-[85%] break-words overflow-hidden flex flex-col space-y-2 font-sans select-text ${transcriptGutter(isMobile)}`} style={{ lineHeight: 'var(--markdown-body-line-height)' }}>
92
99
  {isAgentAttributed && (
93
100
  <div className="flex items-center space-x-1.5 pb-1.5 mb-1 border-b border-ink/10 text-[11px] font-mono text-ink/70">
94
101
  <Bot size={12} className="text-ink shrink-0" />
@@ -186,7 +193,7 @@ export const ChatMessageItem = memo(function ChatMessageItem({
186
193
  <div className="w-full space-y-2.5 font-sans leading-relaxed">
187
194
 
188
195
  {/* System Notice Alert (a notice carrying the answer renders as content below) */}
189
- {isNotice && <SystemNotice notice={msg.notice} source={msg.noticeSource} />}
196
+ {isNotice && <SystemNotice notice={msg.notice} source={msg.noticeSource} isMobile={isMobile} />}
190
197
 
191
198
  {/* Thinking / Reasoning Accordion */}
192
199
  {thinkingData && (
@@ -194,12 +201,13 @@ export const ChatMessageItem = memo(function ChatMessageItem({
194
201
  thinking={thinkingData}
195
202
  defaultExpanded={false}
196
203
  showSeparator={isPrevAssistant}
204
+ isMobile={isMobile}
197
205
  />
198
206
  )}
199
207
 
200
208
  {/* Provider / API Error Alert */}
201
209
  {msg.error && (
202
- <div className="flex items-start space-x-2.5 bg-error/10 border border-error/30 rounded-lg mx-3 px-3.5 py-2.5 text-[12px] text-ink select-text">
210
+ <div className={`flex items-start space-x-2.5 bg-error/10 border border-error/30 rounded-lg ${transcriptGutter(isMobile)} px-3.5 py-2.5 text-[12px] text-ink select-text`}>
203
211
  <AlertCircle size={15} className="text-error flex-shrink-0 mt-0.5" />
204
212
  <div className="min-w-0 flex-1 space-y-0.5">
205
213
  <div className="flex items-center space-x-2 flex-wrap">
@@ -252,6 +260,7 @@ export const ChatMessageItem = memo(function ChatMessageItem({
252
260
  <ToolCallingSection
253
261
  tools={allToolCalls}
254
262
  defaultExpanded={false}
263
+ isMobile={isMobile}
255
264
  />
256
265
  )}
257
266
 
@@ -270,6 +279,7 @@ export const ChatMessageItem = memo(function ChatMessageItem({
270
279
  <ToolCallingSection
271
280
  tools={secondaryToolCalls}
272
281
  defaultExpanded={true}
282
+ isMobile={isMobile}
273
283
  />
274
284
  )}
275
285
 
@@ -90,6 +90,7 @@ export const MessageList = memo(function MessageList({
90
90
  onNewChat={onNewChat}
91
91
  isPrevAssistant={isPrevAssistant}
92
92
  userActions={userActions}
93
+ isMobile={isMobile}
93
94
  className={isNoticeRow(msg) ? 'mt-3 mb-1' : isAiFragment ? 'mt-1' : 'mt-3'}
94
95
  />
95
96
  {footer && (