@kabmo-labs/kabmo 0.3.1 → 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 (135) 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-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/registry.server.ts +2 -2
  98. package/src/server/lib/queue/delivery.server.ts +31 -39
  99. package/src/server/lib/queue/payload.ts +73 -0
  100. package/src/server/lib/realtime/signals.server.ts +10 -1
  101. package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
  102. package/src/server/lib/realtime/topics/global.server.ts +5 -0
  103. package/src/server/lib/realtime/topics.server.ts +2 -0
  104. package/src/server/lib/util/with-timeout.ts +29 -0
  105. package/src/server/lib/waiting/row-clock.server.ts +37 -0
  106. package/src/server/lib/waiting/runtime.server.test.ts +278 -0
  107. package/src/server/lib/waiting/runtime.server.ts +313 -0
  108. package/src/server/lib/waiting/schema.server.ts +46 -0
  109. package/src/server/lib/waiting/settle.test.ts +84 -0
  110. package/src/server/lib/waiting/settle.ts +77 -0
  111. package/src/server/lib/waiting/store.server.test.ts +167 -0
  112. package/src/server/lib/waiting/store.server.ts +268 -0
  113. package/src/server/lib/waiting/sweep-trigger.ts +35 -0
  114. package/src/server/plugins/compress.ts +18 -6
  115. package/src/server/plugins/ssr.ts +16 -8
  116. package/src/server/plugins/static.test.ts +59 -0
  117. package/src/server/plugins/static.ts +56 -14
  118. package/src/server/routes/index.ts +2 -0
  119. package/src/server/routes/waiting/bindings.ts +14 -0
  120. package/src/server/routes/waiting/index.ts +123 -0
  121. package/src/server/routes/waiting/routes.test.ts +121 -0
  122. package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
  123. package/src/shared/lib/chat/timeline/gutter.ts +37 -0
  124. package/src/shared/lib/realtime/protocol.ts +10 -0
  125. package/src/shared/lib/workspace/left-dock-width.ts +78 -0
  126. package/src/shared/lib/workspace/left-dock.test.ts +203 -0
  127. package/src/shared/lib/workspace/left-dock.ts +197 -0
  128. package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
  129. package/src/shared/lib/workspace/panel-ids.ts +114 -26
  130. package/src/shared/lib/workspace/sidebar-width.ts +4 -0
  131. package/src/shared/types/index.ts +1 -0
  132. package/src/shared/types/waiting.ts +54 -0
  133. package/src/shared/types/workspace.ts +8 -0
  134. package/src/test-support/isolated-db.ts +3 -4
  135. package/dist/client/static/js/index-pdp5dwc8.css +0 -1
@@ -0,0 +1,197 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The LEFT dock: which view fills it, and which panels may live there.
8
+ *
9
+ * The left column used to be the session sidebar and nothing else. It is now a
10
+ * DOCK with a view on it, exactly as the right column is — the sessions list is
11
+ * the built-in view, and a plugin panel can be placed beside it by the user.
12
+ *
13
+ * Three decisions are encoded here rather than at a call site:
14
+ *
15
+ * - **The dock is OPTIONAL and OFF by default.** Its activity bar is a setting,
16
+ * and while it is off the dock shows only its active view with no rail and no
17
+ * second width to reserve. That is what makes the feature free for the many
18
+ * users who never place a panel on the left: nothing renders, nothing is
19
+ * measured, and no extra panel competes with the chat for the group's budget.
20
+ * - **Placement is the USER's, never the plugin's.** A plugin keeps registering
21
+ * `app.rightPanel(...)`; where it is drawn is a per-install preference. That
22
+ * is what keeps ONE id (`plugin:<id>`) meaning one panel — the id the server
23
+ * gates the bundle with, the enablement set stores, the width map keys and the
24
+ * stored order ranks. A second slot name would have split all four.
25
+ * - **The sessions view cannot be moved.** It is the reason the left column
26
+ * exists, and moving it would leave a chat with no way to reach another one.
27
+ *
28
+ * Stored GLOBAL (`app_settings.omp_left_panels`), like the right panel's order:
29
+ * the dock lists the same panels in every session, so a per-session placement
30
+ * would move the user's own arrangement when they picked another chat.
31
+ */
32
+
33
+ import { readKabmoSetting, writeSetting } from '@/shared/lib/settings/client';
34
+ import { pluginKeyOf, pluginSlotOf, type PluginPanelSlot } from '@/shared/lib/workspace/panel-ids';
35
+
36
+ /** `app_settings` key holding the dock's own state. */
37
+ export const LEFT_DOCK_SETTING_KEY = 'omp_left_panels';
38
+
39
+ /** The built-in view that owns the left column unless the user places another. */
40
+ export const SESSIONS_VIEW_ID = 'sessions';
41
+
42
+ /**
43
+ * Where a panel is drawn.
44
+ *
45
+ * `editor` is the third destination and it is the one that makes a two-panel
46
+ * plugin coherent: `session-info` registers a `rightPanel` AND a `panel`, and
47
+ * with per-instance placement they are independent — either may sit on the left,
48
+ * the other may stay right or take the editor column. Placement is per PANEL, so
49
+ * moving one never drags its sibling along.
50
+ *
51
+ * The left column holds ONE panel, and a plugin's second panel goes to the
52
+ * editor column — that is what its `panel` slot is FOR. A second left pane was
53
+ * built and removed: it made the left column two columns' worth of width for a
54
+ * plugin that already had a home for its second panel, and a pane with no
55
+ * switcher is a surface whose occupant nothing on screen names.
56
+ */
57
+ export type PanelSide = 'left' | 'right' | 'editor';
58
+
59
+ /** The destinations a PLUGIN panel may be placed on. */
60
+ export const PLUGIN_SIDES: readonly PanelSide[] = ['left', 'right', 'editor'];
61
+
62
+ /**
63
+ * Where a panel may go, given its SLOT.
64
+ *
65
+ * Not every slot can use every destination, and the rule is the slot's shape:
66
+ *
67
+ * - `rightPanel` is a VIEW — an activity-bar button and a body in a column — so
68
+ * it may sit on the left dock or on the right, never in the editor column
69
+ * (which a takeover owns whole).
70
+ * - `panel` is a TAKEOVER — it draws the editor column with no tab strip and no
71
+ * chrome — so it may sit there and nowhere else. A takeover on a rail would be
72
+ * a button that opens a column that is not the one it is on.
73
+ */
74
+ export function ridesSides(slot: PluginPanelSlot | null): readonly PanelSide[] {
75
+ if (slot === 'panel') return ['editor'];
76
+ if (slot === 'rightPanel') return ['left', 'right'];
77
+ return [];
78
+ }
79
+
80
+ /** The dock's stored state, validated. */
81
+ export interface LeftDockState {
82
+ /** Whether the dock's activity bar is drawn at all. OFF unless set. */
83
+ enabled: boolean;
84
+ /** Panel ids the user placed on the left dock, in bar order. */
85
+ panels: string[];
86
+ }
87
+
88
+ const EMPTY: LeftDockState = { enabled: false, panels: [] };
89
+
90
+ /**
91
+ * The ids that may live on the left.
92
+ *
93
+ * The sessions view is the dock's own; anything else must be a plugin panel —
94
+ * a bare built-in id (`git`, `files`) names a view whose width slot, floor and
95
+ * workspace gate were all declared for the RIGHT column, and moving it is a
96
+ * second feature rather than a placement.
97
+ */
98
+ export function isLeftDockPanelId(value: unknown): value is string {
99
+ if (value === SESSIONS_VIEW_ID) return true;
100
+ return pluginKeyOf(value) !== null;
101
+ }
102
+
103
+ /**
104
+ * The stored state, validated.
105
+ *
106
+ * A row this build does not recognise is DROPPED rather than kept: unlike the
107
+ * right panel's order, this list is also the enablement of the dock's buttons,
108
+ * so a stale id would draw a button that resolves to nothing.
109
+ */
110
+ export function readLeftDock(): LeftDockState {
111
+ const stored = readKabmoSetting<unknown>(LEFT_DOCK_SETTING_KEY);
112
+ if (typeof stored !== 'object' || stored === null) return EMPTY;
113
+ const raw = stored as { enabled?: unknown; panels?: unknown };
114
+ const panels: string[] = [];
115
+ const seen = new Set<string>();
116
+ if (Array.isArray(raw.panels)) {
117
+ for (const value of raw.panels) {
118
+ if (!isLeftDockPanelId(value) || value === SESSIONS_VIEW_ID || seen.has(value)) continue;
119
+ seen.add(value);
120
+ panels.push(value);
121
+ }
122
+ }
123
+ return { enabled: raw.enabled === true, panels };
124
+ }
125
+
126
+ /** Persist one change to the dock, keeping the other half. */
127
+ export function writeLeftDock(patch: Partial<LeftDockState>): LeftDockState {
128
+ const current = readLeftDock();
129
+ const next: LeftDockState = {
130
+ enabled: patch.enabled ?? current.enabled,
131
+ panels: patch.panels
132
+ ? [
133
+ ...new Set(
134
+ patch.panels.filter(
135
+ (id) => isLeftDockPanelId(id) && ridesSides(pluginSlotOf(id)).includes('left'),
136
+ ),
137
+ ),
138
+ ]
139
+ : current.panels,
140
+ };
141
+ writeSetting(LEFT_DOCK_SETTING_KEY, next);
142
+ return next;
143
+ }
144
+
145
+ /**
146
+ * Place a plugin panel on the left, or take it back to the right.
147
+ *
148
+ * **Placing a panel turns the rail ON.** The rail exists to reach the panels on
149
+ * it, so a successful placement with the rail still off is a move with no
150
+ * button, no view and nothing on screen to undo it — the settings row would be
151
+ * the only control, which is the state a user reads as broken. The switch is
152
+ * written in the SAME state as the placement, so no reader can observe one
153
+ * without the other.
154
+ *
155
+ * Moving a panel back does NOT turn the rail off: that switch is the user's, and
156
+ * the dock may still be worth showing without this panel on it.
157
+ */
158
+ export function setPanelSide(panelId: string, side: PanelSide): LeftDockState {
159
+ if (!isLeftDockPanelId(panelId) || panelId === SESSIONS_VIEW_ID) return readLeftDock();
160
+ // A destination the slot cannot use is refused, not stored: a `panel`
161
+ // takeover on a rail, or a view inside the editor column, is a placement
162
+ // that could never be drawn.
163
+ if (!ridesSides(pluginSlotOf(panelId)).includes(side)) return readLeftDock();
164
+ const current = readLeftDock();
165
+ const placed = current.panels.includes(panelId);
166
+ if (side === 'left') {
167
+ if (placed && current.enabled) return current;
168
+ const panels = placed ? current.panels : [...current.panels, panelId];
169
+ return writeLeftDock({ enabled: true, panels });
170
+ }
171
+ if (!placed) return current;
172
+ return writeLeftDock({ panels: current.panels.filter((id) => id !== panelId) });
173
+ }
174
+
175
+ /**
176
+ * Which side a panel is drawn on.
177
+ *
178
+ * The sessions view is left by construction; a plugin panel is left only while
179
+ * the user has placed it there; a built-in view is always right.
180
+ */
181
+ export function panelSideOf(panelId: string, dock: LeftDockState = readLeftDock()): PanelSide {
182
+ if (panelId === SESSIONS_VIEW_ID) return 'left';
183
+ const key = pluginKeyOf(panelId) ?? panelId;
184
+ if (dock.panels.includes(key)) return 'left';
185
+ // A takeover's home is the editor column: that is the only place its slot can
186
+ // be drawn, so it is where it is when the user has not moved it.
187
+ if (pluginSlotOf(panelId) === 'panel') return 'editor';
188
+ return 'right';
189
+ }
190
+
191
+ /**
192
+ * The left column's views in bar order: the dock's own view, then every placed
193
+ * plugin in the order they were placed.
194
+ */
195
+ export function leftPanelIds(dock: LeftDockState = readLeftDock()): string[] {
196
+ return [SESSIONS_VIEW_ID, ...dock.panels];
197
+ }
@@ -13,24 +13,48 @@
13
13
  */
14
14
 
15
15
  import { test, expect, describe } from 'bun:test';
16
- import { isPanelId, pluginIdOf, pluginKeyOf, pluginPanelKey } from '@/shared/lib/workspace/panel-ids';
16
+ import {
17
+ isPanelId,
18
+ migrateLegacyPanelId,
19
+ pluginEnablementKey,
20
+ pluginIdOf,
21
+ pluginKeyOf,
22
+ pluginPanelKey,
23
+ pluginSlotOf,
24
+ } from '@/shared/lib/workspace/panel-ids';
17
25
 
18
26
  describe('pluginPanelKey', () => {
19
- test('builds the id from the plugin id', () => {
20
- expect(pluginPanelKey('hello')).toBe('plugin:hello');
27
+ test('names ONE panel: the plugin id AND the slot', () => {
28
+ expect(pluginPanelKey('hello', 'rightPanel')).toBe('plugin:hello:rightPanel');
29
+ expect(pluginPanelKey('hello', 'panel')).toBe('plugin:hello:panel');
30
+ // Two panels of one plugin are two keys, which is what lets a placement and
31
+ // a width follow one of them without dragging the other along.
32
+ expect(pluginPanelKey('hello', 'panel')).not.toBe(pluginPanelKey('hello', 'rightPanel'));
33
+ });
34
+ });
35
+
36
+ describe('pluginEnablementKey', () => {
37
+ test('names the PLUGIN, whatever it contributes', () => {
38
+ // Enablement is per plugin: the switch says "Disable <plugin>" and the
39
+ // server gates that plugin's bundle on this key, so one switch is one id.
40
+ expect(pluginEnablementKey('hello')).toBe('plugin:hello');
21
41
  });
22
42
  });
23
43
 
24
44
  describe('pluginKeyOf', () => {
25
- test('accepts a well-formed plugin key', () => {
26
- expect(pluginKeyOf('plugin:hello')).toBe('plugin:hello');
45
+ test('accepts both forms, normalizing the retired one to rightPanel', () => {
46
+ expect(pluginKeyOf('plugin:hello:rightPanel')).toBe('plugin:hello:rightPanel');
47
+ expect(pluginKeyOf('plugin:hello:panel')).toBe('plugin:hello:panel');
48
+ // The form that shipped, before the slot segment: the only reachable slot
49
+ // then was the right panel, so that is where such a row still points.
50
+ expect(pluginKeyOf('plugin:hello')).toBe('plugin:hello:rightPanel');
27
51
  });
28
52
 
29
- test('rejects a bare prefix and an extra segment', () => {
53
+ test('rejects a bare prefix, a slash, a third segment and an unknown slot', () => {
30
54
  expect(pluginKeyOf('plugin:')).toBeNull();
31
- // The id used to carry a panel segment; a blob written then must not be
32
- // adopted as a plugin id that no registry can report.
33
55
  expect(pluginKeyOf('plugin:hello/greeter')).toBeNull();
56
+ expect(pluginKeyOf('plugin:a:b:c')).toBeNull();
57
+ expect(pluginKeyOf('plugin:hello:headerPanel')).toBeNull();
34
58
  });
35
59
 
36
60
  test('rejects a built-in view id and a non-string', () => {
@@ -40,9 +64,33 @@ describe('pluginKeyOf', () => {
40
64
  });
41
65
  });
42
66
 
67
+ describe('migrateLegacyPanelId', () => {
68
+ test('reports the retired form and what it becomes', () => {
69
+ expect(migrateLegacyPanelId('plugin:hello')).toEqual({
70
+ from: 'plugin:hello',
71
+ to: 'plugin:hello:rightPanel',
72
+ });
73
+ });
74
+
75
+ test('says nothing for a value that is already current or not a panel id', () => {
76
+ expect(migrateLegacyPanelId('plugin:hello:panel')).toBeNull();
77
+ expect(migrateLegacyPanelId('files')).toBeNull();
78
+ expect(migrateLegacyPanelId(null)).toBeNull();
79
+ });
80
+ });
81
+
82
+ describe('pluginSlotOf', () => {
83
+ test('names the slot, so a placement can be per panel', () => {
84
+ expect(pluginSlotOf('plugin:hello:panel')).toBe('panel');
85
+ expect(pluginSlotOf('plugin:hello')).toBe('rightPanel');
86
+ expect(pluginSlotOf('files')).toBeNull();
87
+ });
88
+ });
89
+
43
90
  describe('pluginIdOf', () => {
44
- test('unwraps the plugin id', () => {
91
+ test('unwraps the plugin id, from either form', () => {
45
92
  expect(pluginIdOf('plugin:hello')).toBe('hello');
93
+ expect(pluginIdOf('plugin:hello:panel')).toBe('hello');
46
94
  expect(pluginIdOf('files')).toBeNull();
47
95
  });
48
96
  });
@@ -54,8 +102,9 @@ describe('isPanelId', () => {
54
102
  expect(isPanelId('plan')).toBe(true);
55
103
  });
56
104
 
57
- test('accepts a plugin key', () => {
105
+ test('accepts a plugin panel key, in both forms', () => {
58
106
  expect(isPanelId('plugin:hello')).toBe(true);
107
+ expect(isPanelId('plugin:hello:panel')).toBe(true);
59
108
  });
60
109
 
61
110
  test('rejects an unknown view and a malformed plugin key', () => {
@@ -1,15 +1,26 @@
1
1
  /**
2
- * Panel ids: the built-in right-panel views plus the panels plugin code
3
- * contributes.
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Panel ids: the built-in views plus the panels plugin code contributes.
4
8
  *
5
- * A plugin panel's id is `plugin:<pluginId>` — one string the layout stores,
6
- * keys widths by, and resolves a component from. Keeping it a plain string
7
- * rather than a second union member is what lets the activity bar, the mobile
8
- * tab bar and the width map handle a plugin without each learning about it.
9
+ * A plugin panel's id is `plugin:<pluginId>:<slot>` — one string the layout
10
+ * stores, keys widths by, and resolves a component from. One plugin contributes
11
+ * at most one panel PER SLOT (`rightPanel`, `panel`), and a plugin may register
12
+ * both: `marketplace/plugins/session-info` ships a `rightPanel` and a `panel`.
13
+ * The slot segment is what keeps those two apart.
9
14
  *
10
- * There is no panel-id segment because a plugin contributes AT MOST ONE panel
11
- * per slot: the layout gives it one activity-bar button and one editor tab, so
12
- * a second id could never be reached.
15
+ * It was not always. The id used to carry a panel segment, then dropped it on the
16
+ * claim that a plugin contributes at most one panel — true per slot, false per
17
+ * plugin. With the editor column's slot reachable again, one id for both would
18
+ * make them one entry in the enablement set, one width and one placement, so
19
+ * moving a plugin's right panel would move its editor panel with it.
20
+ *
21
+ * The BARE form is the one that shipped and is still accepted, resolving to the
22
+ * `rightPanel` slot — see {@link migrateLegacyPanelId}. Writers emit the
23
+ * segmented form; readers accept both.
13
24
  */
14
25
 
15
26
  import { RIGHT_PANEL_TYPES, type RightPanelType } from '@/shared/lib/workspace/right-panels';
@@ -17,43 +28,120 @@ import { RIGHT_PANEL_TYPES, type RightPanelType } from '@/shared/lib/workspace/r
17
28
  /** Prefix every plugin-contributed panel id carries. */
18
29
  export const PLUGIN_PANEL_PREFIX = 'plugin:';
19
30
 
20
- /** `plugin:<pluginId>` for a plugin's contributed panel. */
21
- export function pluginPanelKey(pluginId: string): string {
31
+ /**
32
+ * The slots a plugin can contribute a panel to.
33
+ *
34
+ * NOT `headerPanel`/`settingsSection`: those are not panels — one is a navbar
35
+ * entry, the other a settings block — and neither keys a width or a placement.
36
+ */
37
+ export const PLUGIN_PANEL_SLOTS = ['rightPanel', 'panel'] as const;
38
+
39
+ export type PluginPanelSlot = (typeof PLUGIN_PANEL_SLOTS)[number];
40
+
41
+ /** The slot a bare `plugin:<id>` names: the form that shipped. */
42
+ /** The slot a bare `plugin:<id>` names, where an INSTANCE key is expected. */
43
+ export const LEGACY_PLUGIN_SLOT: PluginPanelSlot = 'rightPanel';
44
+
45
+ /**
46
+ * `plugin:<pluginId>:<slot>` — ONE PANEL of a plugin.
47
+ *
48
+ * The key every panel-shaped store uses: the width map, the dock placement, the
49
+ * stored order and the active selection. Two panels of one plugin are two panels
50
+ * here, which is the whole point.
51
+ */
52
+ export function pluginPanelKey(pluginId: string, slot: PluginPanelSlot): string {
53
+ return `${PLUGIN_PANEL_PREFIX}${pluginId}:${slot}`;
54
+ }
55
+
56
+ /**
57
+ * `plugin:<pluginId>` — the PLUGIN, not a panel.
58
+ *
59
+ * Separate from {@link pluginPanelKey} on purpose. Enablement is per PLUGIN: the
60
+ * Settings switch says "Disable <plugin>" and the server gates that plugin's
61
+ * BUNDLE on this key, so one switch has to be one id no matter how many panels
62
+ * the plugin contributes. Making enablement per panel would leave a plugin whose
63
+ * bundle is still served while half its panels are "off".
64
+ *
65
+ * This is also the form that shipped, so every stored disabled row still names a
66
+ * plugin and needs no migration.
67
+ */
68
+ export function pluginEnablementKey(pluginId: string): string {
22
69
  return `${PLUGIN_PANEL_PREFIX}${pluginId}`;
23
70
  }
24
71
 
72
+ function isSlot(value: string): value is PluginPanelSlot {
73
+ return (PLUGIN_PANEL_SLOTS as readonly string[]).includes(value);
74
+ }
75
+
25
76
  /**
26
- * The plugin id a panel id names, or null when `value` is not one.
77
+ * The parts of a plugin panel id, or null.
78
+ *
79
+ * Two shapes are accepted, and the second is DELIBERATELY the one that shipped:
27
80
  *
28
- * The segment must be non-empty: `plugin:` is the shape a truncated write or a
29
- * half-typed value produces, and accepting it would key a width map and a
30
- * component lookup on an id no registry can ever report.
81
+ * - `plugin:<id>:<slot>` — the current form;
82
+ * - `plugin:<id>` — a stored blob, an enablement row or an order entry written
83
+ * before the slot segment existed. It resolves to `rightPanel`, because that
84
+ * was the only reachable slot when it was written.
85
+ *
86
+ * The plugin id is validated as ONE segment: an empty id, a slash or a third
87
+ * colon is a truncated write or a hand-edited blob, and accepting it would key a
88
+ * width map and a component lookup on an id no registry can report.
31
89
  */
32
- export function pluginKeyOf(value: unknown): string | null {
90
+ export function pluginPanelParts(value: unknown): { pluginId: string; slot: PluginPanelSlot } | null {
33
91
  if (typeof value !== 'string' || !value.startsWith(PLUGIN_PANEL_PREFIX)) return null;
34
92
  const rest = value.slice(PLUGIN_PANEL_PREFIX.length);
35
- if (!rest || rest.includes('/')) return null;
36
- return value;
93
+ const pieces = rest.split(':');
94
+ if (pieces.length > 2) return null;
95
+ const [pluginId, slot] = pieces;
96
+ if (!pluginId || pluginId.includes('/')) return null;
97
+ if (slot === undefined) return { pluginId, slot: LEGACY_PLUGIN_SLOT };
98
+ if (!isSlot(slot)) return null;
99
+ return { pluginId, slot };
100
+ }
101
+
102
+ /**
103
+ * The PANEL INSTANCE key a stored value names, or null.
104
+ *
105
+ * A bare `plugin:<id>` normalizes to the `rightPanel` instance, because that was
106
+ * the only reachable slot when such a row was written — the width map, the dock
107
+ * placement and the stored order all read through here, so a row from an older
108
+ * build keeps pointing at the panel it pointed at before.
109
+ */
110
+ export function pluginKeyOf(value: unknown): string | null {
111
+ const parts = pluginPanelParts(value);
112
+ return parts ? pluginPanelKey(parts.pluginId, parts.slot) : null;
113
+ }
114
+
115
+ /** Whether a stored value is the retired bare form, and what it resolves to. */
116
+ export function migrateLegacyPanelId(value: unknown): { from: string; to: string } | null {
117
+ const parts = pluginPanelParts(value);
118
+ if (!parts || typeof value !== 'string') return null;
119
+ const to = pluginPanelKey(parts.pluginId, parts.slot);
120
+ return value === to ? null : { from: value, to };
37
121
  }
38
122
 
39
123
  /** The plugin id inside a panel id, or null. */
40
124
  export function pluginIdOf(value: unknown): string | null {
41
- const key = pluginKeyOf(value);
42
- return key ? key.slice(PLUGIN_PANEL_PREFIX.length) : null;
125
+ return pluginPanelParts(value)?.pluginId ?? null;
126
+ }
127
+
128
+ /** The slot a panel id names, or null when `value` is not a plugin panel id. */
129
+ export function pluginSlotOf(value: unknown): PluginPanelSlot | null {
130
+ return pluginPanelParts(value)?.slot ?? null;
43
131
  }
44
132
 
45
133
  /**
46
- * Whether a stored `layout.activeRightPanel` may be adopted on load.
134
+ * Whether a stored `layout.activeRightPanel` / width key may be adopted on load.
47
135
  *
48
136
  * A plugin panel is accepted on the shape of its id alone: the registry is a
49
137
  * network read and the slots exist only after a bundle has been imported, so
50
- * requiring the panel to be present would drop a perfectly valid selection
51
- * every time the server is slower than the first paint. A panel whose plugin
52
- * has since been uninstalled renders its own "not installed" notice rather than
53
- * falling back silently to Files.
138
+ * requiring the panel to be present would drop a perfectly valid selection every
139
+ * time the server is slower than the first paint. A panel whose plugin has since
140
+ * been uninstalled renders its own "not installed" notice rather than falling
141
+ * back silently to Files.
54
142
  */
55
143
  export function isPanelId(value: unknown): value is string {
56
- return (RIGHT_PANEL_TYPES as readonly string[]).includes(value as string) || pluginKeyOf(value) !== null;
144
+ return (RIGHT_PANEL_TYPES as readonly string[]).includes(value as string) || pluginPanelParts(value) !== null;
57
145
  }
58
146
 
59
147
  export type { RightPanelType };
@@ -43,6 +43,10 @@ export const DEFAULT_LEFT_PANEL_WIDTH = 280;
43
43
  * `MIN_CHAT_PANEL_WIDTH` in `panel-widths.ts`: the sidebar cannot open past 500
44
44
  * and still leave the editor (320) and a browser view (320) room on a 1440px
45
45
  * display.
46
+ *
47
+ * It was raised to 720 for a second left pane and put back when that pane was
48
+ * removed: the dock holds ONE panel beside the list, and a wider ceiling would
49
+ * only invite a column nothing else in the layout budgets for.
46
50
  */
47
51
  export const MIN_LEFT_PANEL_WIDTH = 264;
48
52
  export const MAX_LEFT_PANEL_WIDTH = 500;
@@ -4,6 +4,7 @@ export * from '@/shared/types/fs';
4
4
  export * from '@/shared/types/git';
5
5
  export * from '@/shared/types/chat';
6
6
  export * from '@/shared/types/schedule';
7
+ export * from '@/shared/types/waiting';
7
8
  export * from '@/shared/types/btw';
8
9
  export * from '@/shared/types/models';
9
10
  export * from '@/shared/types/terminal';
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * A prompt held until another session finishes.
8
+ *
9
+ * The row is Kabmo's own (SQLite `waiting_prompts`): omp has no notion of a
10
+ * prompt that has not been sent yet, so the wait has to live somewhere that
11
+ * outlives the tab. Its `dependsOn` session is a SESSION ID, never a snapshot of
12
+ * its state — the state is re-read at each sweep, because a session that looks
13
+ * finished can pick up a queued follow-up a moment later.
14
+ */
15
+
16
+ import type { Attachment, QueuedMessageModel } from '@/shared/types/chat';
17
+
18
+ export type WaitingPromptStatus = 'waiting' | 'dispatched' | 'cancelled' | 'error';
19
+
20
+ /** Plan/goal flags the spawn must carry (a new session has no JSONL to read). */
21
+ export interface WaitingPromptModes {
22
+ plan: boolean;
23
+ goal: boolean;
24
+ }
25
+
26
+ export interface WaitingPrompt {
27
+ id: string;
28
+ /**
29
+ * The client's pending id (`new-…`) this prompt belongs to. The dispatch
30
+ * answers with omp's real session id, and the client moves the sidebar row
31
+ * across by this pairing — the same adoption the local send path performs.
32
+ */
33
+ pendingId: string | null;
34
+ /** The session whose completion releases this prompt. */
35
+ dependsOn: string;
36
+ /** Title of `dependsOn` at creation time, for the waiting row's own label. */
37
+ dependsOnTitle: string | null;
38
+ /** Composer text as the user typed it. */
39
+ prompt: string;
40
+ attachments: Attachment[];
41
+ /** Model/thinking/access snapshot, replayed at dispatch. */
42
+ model: QueuedMessageModel | null;
43
+ modes: WaitingPromptModes | null;
44
+ /** Absolute directory the new session spawns in, resolved at creation. */
45
+ cwd: string;
46
+ folderId: number | null;
47
+ status: WaitingPromptStatus;
48
+ /** omp's real session id, once the prompt was dispatched. */
49
+ sessionId: string | null;
50
+ /** Why the dispatch failed (status `error`). */
51
+ error: string | null;
52
+ createdAt: number;
53
+ updatedAt: number;
54
+ }
@@ -30,6 +30,14 @@ export interface SessionItemData {
30
30
  * provider/model in every tab and Kabmo instances. Absent for a session
31
31
  * with no run row; callers fall back to the transcript's model entry. */
32
32
  runModel?: { provider: string; modelId: string };
33
+ /**
34
+ * Set on a PENDING row whose prompt is held until another session finishes.
35
+ *
36
+ * The row has no transcript and no omp id yet — it exists because the user
37
+ * sent a prompt that is deferred, and the cue is the only thing on screen
38
+ * saying so. Absent means the row is not waiting (the normal case).
39
+ */
40
+ waitingFor?: { waitId: string; sessionId: string; title: string | null };
33
41
  }
34
42
 
35
43
  /**
@@ -23,14 +23,14 @@ import { mkdtempSync } from 'node:fs';
23
23
  import { tmpdir } from 'node:os';
24
24
  import { join } from 'node:path';
25
25
 
26
+ import { closeDb } from '@/server/db.server';
26
27
  import { removeDir } from '@/test-support/remove-dir';
27
28
 
28
29
  let dir: string | null = null;
29
30
 
30
31
  /** Point the process at a fresh database. Call from `beforeEach`. */
31
32
  export function isolateDb(): string {
32
- globalThis.__kabmoDb?.resolved?.raw.close();
33
- globalThis.__kabmoDb = undefined;
33
+ closeDb();
34
34
 
35
35
  dir = mkdtempSync(join(tmpdir(), 'omc-db-'));
36
36
  const path = join(dir, 'db.sqlite');
@@ -40,8 +40,7 @@ export function isolateDb(): string {
40
40
 
41
41
  /** Close the handle and drop the temp directory. Call from `afterEach`. */
42
42
  export function releaseDb(): void {
43
- globalThis.__kabmoDb?.resolved?.raw.close();
44
- globalThis.__kabmoDb = undefined;
43
+ closeDb();
45
44
  delete Bun.env.KABMO_DB_PATH;
46
45
  // `removeDir`, not a bare `rmSync`: a closed SQLite handle is released by the
47
46
  // OS asynchronously on Windows, so the delete races it and fails with EBUSY —