@kabmo-labs/kabmo 0.3.1 → 0.5.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 (155) 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-28z316qk.js → index-cs3b557t.js} +792 -792
  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/TopNavbar.tsx +5 -3
  14. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +22 -5
  15. package/src/client/components/layout/desktop-layout/index.tsx +88 -61
  16. package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
  17. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
  18. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
  19. package/src/client/components/layout/session-sidebar/Header.tsx +2 -1
  20. package/src/client/components/layout/session-sidebar/SessionItem.tsx +42 -6
  21. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  22. package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
  23. package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
  24. package/src/client/components/mobile/LayoutWrapper.tsx +4 -0
  25. package/src/client/components/mobile/RightSidebar.tsx +30 -36
  26. package/src/client/components/mobile/SessionSidebar.tsx +124 -101
  27. package/src/client/components/mobile/mobile-main-view/Header.tsx +3 -2
  28. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -2
  29. package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +3 -2
  30. package/src/client/components/mobile/mobile-session-sidebar/Dialogs.tsx +106 -0
  31. package/src/client/components/mobile/mobile-session-sidebar/Dock.tsx +80 -0
  32. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +11 -5
  33. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
  34. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
  35. package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
  36. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +47 -14
  37. package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
  38. package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +89 -0
  39. package/src/client/components/mobile/panel-chips.test.tsx +155 -0
  40. package/src/client/components/mobile/panel-chips.tsx +74 -0
  41. package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
  42. package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
  43. package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
  44. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
  45. package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
  46. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
  47. package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
  48. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
  49. package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
  50. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
  51. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
  52. package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
  53. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
  54. package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
  55. package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
  56. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
  57. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +15 -10
  58. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
  59. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
  60. package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +162 -0
  61. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
  62. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
  63. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
  64. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
  65. package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
  66. package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
  67. package/src/client/components/workspace/editor/index.tsx +1 -0
  68. package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
  69. package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
  70. package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
  71. package/src/client/components/workspace/model-dropdown/index.tsx +3 -3
  72. package/src/client/components/workspace/model-dropdown/trigger.test.ts +123 -0
  73. package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
  74. package/src/client/components/workspace/panels/builtin.tsx +8 -0
  75. package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
  76. package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
  77. package/src/client/hooks/chat/composer/compact.ts +119 -0
  78. package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
  79. package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
  80. package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
  81. package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
  82. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
  83. package/src/client/hooks/chat/timeline/actions.ts +34 -35
  84. package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
  85. package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
  86. package/src/client/hooks/chat/timeline/index.ts +15 -19
  87. package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
  88. package/src/client/hooks/chat/timeline/queue-rewind.test.ts +6 -0
  89. package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
  90. package/src/client/hooks/chat/waiting/cancel.ts +34 -0
  91. package/src/client/hooks/chat/waiting/choice.ts +53 -0
  92. package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
  93. package/src/client/hooks/chat/waiting/held.ts +42 -0
  94. package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
  95. package/src/client/hooks/chat/waiting/hold.ts +156 -0
  96. package/src/client/hooks/chat/waiting/store.ts +100 -0
  97. package/src/client/hooks/ui/toasts.ts +12 -1
  98. package/src/client/hooks/workspace/left-dock.ts +132 -0
  99. package/src/client/hooks/workspace/panel-registry.ts +13 -0
  100. package/src/client/tailwind.css +22 -0
  101. package/src/server/db.server.test.ts +52 -1
  102. package/src/server/db.server.ts +49 -1
  103. package/src/server/index.ts +9 -0
  104. package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
  105. package/src/server/lib/assets/compressed-cache.server.ts +88 -0
  106. package/src/server/lib/db/schema.test.ts +1 -0
  107. package/src/server/lib/db/schema.ts +3 -0
  108. package/src/server/lib/fs/fs-path.test.ts +1 -1
  109. package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
  110. package/src/server/lib/omp/session/delete.server.ts +8 -0
  111. package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
  112. package/src/server/lib/panels/registry.server.ts +2 -2
  113. package/src/server/lib/queue/delivery.server.ts +31 -39
  114. package/src/server/lib/queue/payload.ts +73 -0
  115. package/src/server/lib/realtime/signals.server.ts +10 -1
  116. package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
  117. package/src/server/lib/realtime/topics/global.server.ts +5 -0
  118. package/src/server/lib/realtime/topics.server.ts +2 -0
  119. package/src/server/lib/util/with-timeout.ts +29 -0
  120. package/src/server/lib/waiting/row-clock.server.ts +37 -0
  121. package/src/server/lib/waiting/runtime.server.test.ts +278 -0
  122. package/src/server/lib/waiting/runtime.server.ts +313 -0
  123. package/src/server/lib/waiting/schema.server.ts +46 -0
  124. package/src/server/lib/waiting/settle.test.ts +84 -0
  125. package/src/server/lib/waiting/settle.ts +77 -0
  126. package/src/server/lib/waiting/store.server.test.ts +167 -0
  127. package/src/server/lib/waiting/store.server.ts +268 -0
  128. package/src/server/lib/waiting/sweep-trigger.ts +35 -0
  129. package/src/server/plugins/compress.ts +18 -6
  130. package/src/server/plugins/ssr.ts +16 -8
  131. package/src/server/plugins/static.test.ts +59 -0
  132. package/src/server/plugins/static.ts +56 -14
  133. package/src/server/routes/index.ts +2 -0
  134. package/src/server/routes/waiting/bindings.ts +14 -0
  135. package/src/server/routes/waiting/index.ts +123 -0
  136. package/src/server/routes/waiting/routes.test.ts +121 -0
  137. package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
  138. package/src/shared/lib/chat/timeline/gutter.ts +37 -0
  139. package/src/shared/lib/realtime/protocol.ts +10 -0
  140. package/src/shared/lib/ui/titlebar.test.ts +124 -0
  141. package/src/shared/lib/ui/titlebar.ts +54 -0
  142. package/src/shared/lib/workspace/left-dock-width.ts +78 -0
  143. package/src/shared/lib/workspace/left-dock.test.ts +203 -0
  144. package/src/shared/lib/workspace/left-dock.ts +197 -0
  145. package/src/shared/lib/workspace/mobile-dock.test.ts +65 -0
  146. package/src/shared/lib/workspace/mobile-dock.ts +55 -0
  147. package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
  148. package/src/shared/lib/workspace/panel-ids.ts +114 -26
  149. package/src/shared/lib/workspace/sidebar-width.ts +4 -0
  150. package/src/shared/types/index.ts +1 -0
  151. package/src/shared/types/waiting.ts +54 -0
  152. package/src/shared/types/workspace.ts +8 -0
  153. package/src/test-support/isolated-db.ts +3 -4
  154. package/src/test-support/silent-socket.ts +69 -0
  155. package/dist/client/static/js/index-pdp5dwc8.css +0 -1
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The phone's panel tab strip — ONE component, drawn by both drawers.
8
+ *
9
+ * A phone has no rail: the desktop's vertical activity bar is a horizontal
10
+ * strip of chips here. The right drawer had this from the start and the left
11
+ * drawer (the session list) did not, so a plugin panel placed on the left was
12
+ * reachable only through the RIGHT drawer's `all` catalog — the placement
13
+ * existed and nothing on the left ever showed it.
14
+ *
15
+ * Both drawers render this, so the two cannot drift in label, active state or
16
+ * the Source Control dot. The strip is scrollable rather than wrapping: a phone
17
+ * drawer is one row tall, and a wrapped strip would eat the list's height.
18
+ */
19
+
20
+ import type { ResolvedPanel } from '@/client/components/workspace/plugin-panel/catalog';
21
+ import { PluginMark } from '@/client/components/settings/categories/panel-plugins/PluginMark';
22
+ import { PANEL_MARK_SIZE } from '@/shared/lib/workspace/right-panels';
23
+
24
+ interface PanelChipsProps {
25
+ panels: readonly ResolvedPanel[];
26
+ activeId: string;
27
+ onSelect: (id: string) => void;
28
+ /**
29
+ * Draw the Source Control dot on the `git` chip. The left catalog never holds
30
+ * a built-in view, so only the right drawer passes this.
31
+ */
32
+ hasGitChanges?: boolean;
33
+ }
34
+
35
+ export function PanelChips({ panels, activeId, onSelect, hasGitChanges = false }: PanelChipsProps) {
36
+ return (
37
+ <div className="flex items-center space-x-1.5 overflow-x-auto no-scrollbar py-1">
38
+ {panels.map((panel) => {
39
+ const active = panel.id === activeId;
40
+ return (
41
+ <button
42
+ key={panel.id}
43
+ type="button"
44
+ onClick={() => onSelect(panel.id)}
45
+ aria-pressed={active}
46
+ // The name is the panel's TITLE, never its visible label. The label
47
+ // only renders while the chip is active, so without this the same
48
+ // button is called "Resources — stats" before a click and
49
+ // "Resources" after it — a control that renames itself when used is
50
+ // unusable to a screen reader and unfindable to a spec.
51
+ aria-label={panel.title}
52
+ className={`relative flex items-center transition-all cursor-pointer ${
53
+ active
54
+ ? 'space-x-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-ink text-canvas shadow-sm'
55
+ : 'p-2 rounded-lg text-ink/70 hover:bg-ink/5'
56
+ }`}
57
+ title={panel.title}
58
+ >
59
+ {panel.builtin ? panel.icon : <PluginMark name={panel.name} iconUrl={panel.iconUrl} size={PANEL_MARK_SIZE} />}
60
+ {active && (
61
+ <span className={panel.id === 'git' ? 'tracking-wide' : undefined}>{panel.label}</span>
62
+ )}
63
+ {panel.id === 'git' && hasGitChanges && (
64
+ <span
65
+ className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-info"
66
+ title="Uncommitted changes"
67
+ />
68
+ )}
69
+ </button>
70
+ );
71
+ })}
72
+ </div>
73
+ );
74
+ }
@@ -6,7 +6,7 @@ import { PluginMark } from '@/client/components/settings/categories/panel-plugin
6
6
  import { PluginReadmeModal } from '@/client/components/settings/categories/panel-plugins/ReadmeModal';
7
7
  import { GitFreshness } from '@/client/components/settings/categories/panel-plugins/GitFreshness';
8
8
  import type { PluginSlots, SlotName } from '@/client/lib/plugins/slots';
9
- import { pluginPanelKey } from '@/shared/lib/workspace/panel-ids';
9
+ import { pluginEnablementKey } from '@/shared/lib/workspace/panel-ids';
10
10
 
11
11
  /** The four slots a plugin may fill, in the order a card lists them. */
12
12
  const SLOT_ORDER: readonly SlotName[] = ['rightPanel', 'panel', 'headerPanel', 'settingsSection'];
@@ -74,7 +74,7 @@ export function InstalledList({
74
74
  // The ENABLEMENT key is the panel key, not the plugin id: it is the
75
75
  // id the client filters its panel catalog by, so a switch that wrote
76
76
  // the bare id would report success while the panel stayed hidden.
77
- const panelKey = pluginPanelKey(plugin.pluginId);
77
+ const panelKey = pluginEnablementKey(plugin.pluginId);
78
78
  const toggling = busy === `enable:${panelKey}` || busy === `disable:${panelKey}`;
79
79
  const contributed = entry ? SLOT_ORDER.filter((slot) => entry.components[slot]) : [];
80
80
 
@@ -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
+ });