@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,203 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The left dock's placement rules.
8
+ *
9
+ * Three properties, and each fails SILENTLY when broken:
10
+ *
11
+ * - **The sessions view is always on the left and cannot be placed anywhere
12
+ * else.** It is the dock's own view, so a stored row naming it is dropped
13
+ * rather than accepted — otherwise a blob could leave the left column showing
14
+ * a plugin with no way back to the session list.
15
+ * - **A panel is on ONE side at a time.** The same id keys the enablement set,
16
+ * the width map and the stored order, so a panel present in both lists would
17
+ * be two buttons for one panel and one of them would draw nothing.
18
+ * - **A row this build does not recognise is dropped.** The list is not only a
19
+ * preference here: it decides which buttons the dock draws, so a stale id
20
+ * would be a button that resolves to nothing.
21
+ */
22
+
23
+ import { describe, expect, test, beforeEach } from 'bun:test';
24
+ import { primeKabmoSettings, readKabmoSetting } from '@/shared/lib/settings/client';
25
+ import {
26
+ LEFT_DOCK_SETTING_KEY,
27
+ SESSIONS_VIEW_ID,
28
+ isLeftDockPanelId,
29
+ leftPanelIds,
30
+ panelSideOf,
31
+ readLeftDock,
32
+ ridesSides,
33
+ setPanelSide,
34
+ writeLeftDock,
35
+ } from '@/shared/lib/workspace/left-dock';
36
+ import {
37
+ LEFT_DOCK_WIDTHS_SETTING_KEY,
38
+ MIN_LEFT_DOCK_PLUGIN_WIDTH,
39
+ resolveLeftDockWidth,
40
+ saveLeftDockWidth,
41
+ } from '@/shared/lib/workspace/left-dock-width';
42
+ import { MAX_LEFT_PANEL_WIDTH, MIN_LEFT_PANEL_WIDTH } from '@/shared/lib/workspace/sidebar-width';
43
+
44
+ beforeEach(() => primeKabmoSettings({}));
45
+
46
+ describe('readLeftDock', () => {
47
+ test('nothing stored means OFF with no placed panels', () => {
48
+ expect(readLeftDock()).toEqual({ enabled: false, panels: [] });
49
+ });
50
+
51
+ test('a malformed row is filtered rather than keyed', () => {
52
+ primeKabmoSettings({
53
+ [LEFT_DOCK_SETTING_KEY]: { enabled: true, panels: ['plugin:a:rightPanel', 3, '', null, 'plugin:a:rightPanel', 'git', 'plugin:b:rightPanel'] },
54
+ });
55
+ // Built-in ids and duplicates are dropped: a bare `git` names a view whose
56
+ // floor and width slot were declared for the RIGHT column.
57
+ expect(readLeftDock()).toEqual({
58
+ enabled: true,
59
+ panels: ['plugin:a:rightPanel', 'plugin:b:rightPanel'],
60
+ });
61
+ });
62
+
63
+ test('the sessions view is never a placed panel', () => {
64
+ primeKabmoSettings({ [LEFT_DOCK_SETTING_KEY]: { enabled: true, panels: [SESSIONS_VIEW_ID] } });
65
+ expect(readLeftDock().panels).toEqual([]);
66
+ });
67
+
68
+ test('a non-object row reads as OFF', () => {
69
+ primeKabmoSettings({ [LEFT_DOCK_SETTING_KEY]: 'yes' });
70
+ expect(readLeftDock()).toEqual({ enabled: false, panels: [] });
71
+ });
72
+ });
73
+
74
+ describe('writeLeftDock / setPanelSide', () => {
75
+ test('the switch and the placement are independent halves', () => {
76
+ writeLeftDock({ enabled: true });
77
+ expect(readLeftDock()).toEqual({ enabled: true, panels: [] });
78
+ setPanelSide('plugin:a:rightPanel', 'left');
79
+ expect(readLeftDock()).toEqual({ enabled: true, panels: ['plugin:a:rightPanel'] });
80
+ });
81
+
82
+ test('placing twice is idempotent, and moving back removes it', () => {
83
+ setPanelSide('plugin:a:rightPanel', 'left');
84
+ setPanelSide('plugin:a:rightPanel', 'left');
85
+ expect(readLeftDock().panels).toEqual(['plugin:a:rightPanel']);
86
+ setPanelSide('plugin:a:rightPanel', 'right');
87
+ expect(readLeftDock().panels).toEqual([]);
88
+ });
89
+
90
+ test('a built-in id and the sessions view are refused', () => {
91
+ setPanelSide('git', 'left');
92
+ setPanelSide(SESSIONS_VIEW_ID, 'left');
93
+ expect(readLeftDock().panels).toEqual([]);
94
+ });
95
+ });
96
+
97
+ describe('placing a panel turns the rail on', () => {
98
+ test('a placement with the rail off enables it in the same state', () => {
99
+ // The rail exists to reach the panels on it: a successful placement with
100
+ // the rail still off is a move with no button and no view, which reads as
101
+ // nothing having happened.
102
+ expect(readLeftDock().enabled).toBe(false);
103
+ setPanelSide('plugin:a:rightPanel', 'left');
104
+ expect(readLeftDock()).toEqual({ enabled: true, panels: ['plugin:a:rightPanel'] });
105
+ });
106
+
107
+ test('moving a panel back to the right leaves the rail as the user set it', () => {
108
+ // The switch is the user's, so only a PLACEMENT turns it on; a removal must
109
+ // not turn it off behind their back.
110
+ writeLeftDock({ enabled: true });
111
+ setPanelSide('plugin:a:rightPanel', 'left');
112
+ setPanelSide('plugin:a:rightPanel', 'right');
113
+ expect(readLeftDock()).toEqual({ enabled: true, panels: [] });
114
+ });
115
+
116
+ test('a placement made BY HAND on a disabled dock stays off until the UI places it', () => {
117
+ // `writeLeftDock` is the raw store; the coupling lives at the placement
118
+ // action, so a blob written directly is not silently rewritten.
119
+ writeLeftDock({ enabled: false, panels: ['plugin:a:rightPanel'] });
120
+ expect(readLeftDock()).toEqual({ enabled: false, panels: ['plugin:a:rightPanel'] });
121
+ });
122
+ });
123
+
124
+ describe('panelSideOf / leftPanelIds', () => {
125
+ test('the sessions view is left; a placed plugin is left; everything else right', () => {
126
+ setPanelSide('plugin:a:rightPanel', 'left');
127
+ expect(panelSideOf(SESSIONS_VIEW_ID)).toBe('left');
128
+ expect(panelSideOf('plugin:a:rightPanel')).toBe('left');
129
+ expect(panelSideOf('plugin:b:rightPanel')).toBe('right');
130
+ expect(panelSideOf('git')).toBe('right');
131
+ });
132
+
133
+ test('the bar order is the sessions view then the placed panels', () => {
134
+ setPanelSide('plugin:b:rightPanel', 'left');
135
+ setPanelSide('plugin:a:rightPanel', 'left');
136
+ expect(leftPanelIds()).toEqual([SESSIONS_VIEW_ID, 'plugin:b:rightPanel', 'plugin:a:rightPanel']);
137
+ });
138
+ });
139
+
140
+ describe('slot rules', () => {
141
+ test('a view rides the rails; a takeover rides the editor column', () => {
142
+ // The rule is the SLOT's shape, not the plugin's identity: a `rightPanel` is
143
+ // an activity-bar view, a `panel` takes the editor column over whole.
144
+ expect(ridesSides('rightPanel')).toEqual(['left', 'right']);
145
+ expect(ridesSides('panel')).toEqual(['editor']);
146
+ });
147
+
148
+ test('a takeover cannot be placed on a rail', () => {
149
+ // A button on a rail that opens a column somewhere else is a control that
150
+ // lies about where it goes.
151
+ setPanelSide('plugin:x:panel', 'left');
152
+ expect(readLeftDock().panels).toEqual([]);
153
+ });
154
+
155
+ test('a view cannot be placed in the editor column', () => {
156
+ setPanelSide('plugin:x:rightPanel', 'editor');
157
+ // Refused, and the refusal leaves the panel where it was: the right column.
158
+ expect(panelSideOf('plugin:x:rightPanel')).toBe('right');
159
+ });
160
+
161
+ test('a takeover is in the editor column by default', () => {
162
+ expect(panelSideOf('plugin:x:panel')).toBe('editor');
163
+ });
164
+
165
+ test('two panels of one plugin are placed independently', () => {
166
+ // The whole reason placement is per PANEL: moving the right panel onto the
167
+ // left dock must not drag the plugin's editor takeover with it.
168
+ setPanelSide('plugin:session-info:rightPanel', 'left');
169
+ expect(panelSideOf('plugin:session-info:rightPanel')).toBe('left');
170
+ expect(panelSideOf('plugin:session-info:panel')).toBe('editor');
171
+ });
172
+ });
173
+
174
+ describe('isLeftDockPanelId', () => {
175
+ test('accepts the sessions view and a plugin key only', () => {
176
+ expect(isLeftDockPanelId(SESSIONS_VIEW_ID)).toBe(true);
177
+ expect(isLeftDockPanelId('plugin:a:rightPanel')).toBe(true);
178
+ expect(isLeftDockPanelId('files')).toBe(false);
179
+ expect(isLeftDockPanelId('plugin:')).toBe(false);
180
+ });
181
+ });
182
+
183
+ describe('left dock widths', () => {
184
+ test('a remembered width wins and is clamped to the view floor', () => {
185
+ saveLeftDockWidth('plugin:a:rightPanel', 400);
186
+ expect(resolveLeftDockWidth('plugin:a:rightPanel')).toBe(400);
187
+ saveLeftDockWidth('plugin:a:rightPanel', 10);
188
+ expect(resolveLeftDockWidth('plugin:a:rightPanel')).toBe(MIN_LEFT_DOCK_PLUGIN_WIDTH);
189
+ saveLeftDockWidth('plugin:a:rightPanel', 10_000);
190
+ expect(resolveLeftDockWidth('plugin:a:rightPanel')).toBe(MAX_LEFT_PANEL_WIDTH);
191
+ });
192
+
193
+ test('the session list keeps its own floor, and the setting it already had', () => {
194
+ primeKabmoSettings({ omp_sidebar_width: 333 });
195
+ expect(resolveLeftDockWidth(SESSIONS_VIEW_ID)).toBe(333);
196
+ saveLeftDockWidth(SESSIONS_VIEW_ID, 10);
197
+ expect(resolveLeftDockWidth(SESSIONS_VIEW_ID)).toBe(MIN_LEFT_PANEL_WIDTH);
198
+ // The dock's map is its own key: a plugin's width never rewrites the list's.
199
+ saveLeftDockWidth('plugin:a:rightPanel', 500);
200
+ expect(resolveLeftDockWidth(SESSIONS_VIEW_ID)).toBe(MIN_LEFT_PANEL_WIDTH);
201
+ expect((readKabmoSetting<Record<string, number>>(LEFT_DOCK_WIDTHS_SETTING_KEY) ?? {})['plugin:a:rightPanel']).toBe(500);
202
+ });
203
+ });
@@ -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
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Which panels each phone drawer offers.
8
+ *
9
+ * Both rules are silent failures when wrong, which is why they are pinned:
10
+ * a panel on BOTH strips is one panel with two chips (and one of them is a
11
+ * second place to be), while a panel on NEITHER is a placement that looks like
12
+ * an uninstall.
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+ import { phoneLeftStripVisible, phoneRightPanels } from '@/shared/lib/workspace/mobile-dock';
17
+ import type { PanelSide } from '@/shared/lib/workspace/left-dock';
18
+
19
+ const SESSIONS = 'sessions';
20
+
21
+ function panels(...rows: [string, PanelSide][]) {
22
+ return rows.map(([id, side]) => ({ id, side }));
23
+ }
24
+
25
+ describe('phoneRightPanels', () => {
26
+ test('drops the session list, which the phone draws as its own screen', () => {
27
+ const all = panels([SESSIONS, 'left'], ['files', 'right']);
28
+ expect(phoneRightPanels(all, SESSIONS, false).map((p) => p.id)).toEqual(['files']);
29
+ });
30
+
31
+ test('with the rail OFF a left-placed panel stays here — the left drawer cannot reach it', () => {
32
+ const all = panels([SESSIONS, 'left'], ['files', 'right'], ['plugin:demo:rightPanel', 'left']);
33
+ expect(phoneRightPanels(all, SESSIONS, false).map((p) => p.id)).toEqual(['files', 'plugin:demo:rightPanel']);
34
+ });
35
+
36
+ test('with the rail ON the left drawer owns it, so this one must not draw it twice', () => {
37
+ const all = panels([SESSIONS, 'left'], ['files', 'right'], ['plugin:demo:rightPanel', 'left']);
38
+ expect(phoneRightPanels(all, SESSIONS, true).map((p) => p.id)).toEqual(['files']);
39
+ });
40
+
41
+ test('the rail being on never hides a RIGHT-column view', () => {
42
+ const all = panels(['git', 'right'], ['todo', 'right']);
43
+ expect(phoneRightPanels(all, SESSIONS, true).map((p) => p.id)).toEqual(['git', 'todo']);
44
+ });
45
+ });
46
+
47
+ describe('phoneLeftStripVisible', () => {
48
+ test('no rail, no strip', () => {
49
+ expect(phoneLeftStripVisible(false, 2, true)).toBe(false);
50
+ });
51
+
52
+ test('a strip holding only Sessions is chrome that never changes', () => {
53
+ expect(phoneLeftStripVisible(true, 0, true)).toBe(false);
54
+ });
55
+
56
+ test('a placed panel is a second destination, so the strip appears', () => {
57
+ expect(phoneLeftStripVisible(true, 1, true)).toBe(true);
58
+ });
59
+
60
+ test('a stored view that no longer resolves still gets the strip — its body is a notice', () => {
61
+ // Without this the drawer would be stuck on a notice with no way back to
62
+ // the session list: the body has no chips of its own.
63
+ expect(phoneLeftStripVisible(true, 0, false)).toBe(true);
64
+ });
65
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Which panels each phone drawer offers.
8
+ *
9
+ * The phone has TWO drawers and one catalog, so the split is a rule rather
10
+ * than a per-component filter. It used to be one rule for the right drawer
11
+ * (`all`, minus the session list), because a panel placed on the left had
12
+ * nowhere else to be reached — the left drawer was a plain session list. Now
13
+ * the left drawer draws the dock too, so leaving `all` in the right one puts a
14
+ * single panel on two strips of one phone.
15
+ *
16
+ * Pure, and separate from both components, so the two drawers cannot drift and
17
+ * the rule can be tested without mounting either.
18
+ */
19
+
20
+ import type { PanelSide } from '@/shared/lib/workspace/left-dock';
21
+
22
+ /** The parts of a catalog entry these rules read. */
23
+ interface SidedPanel {
24
+ id: string;
25
+ side: PanelSide;
26
+ }
27
+
28
+ /**
29
+ * The right drawer's chips.
30
+ *
31
+ * `all` while the left rail is OFF: a placed panel is then unreachable from the
32
+ * left drawer (it has no strip), and hiding it from both would make the
33
+ * placement look like an uninstall. With the rail on the left drawer owns them.
34
+ */
35
+ export function phoneRightPanels<T extends SidedPanel>(
36
+ panels: readonly T[],
37
+ sessionsViewId: string,
38
+ dockEnabled: boolean,
39
+ ): T[] {
40
+ return panels.filter(
41
+ (panel) => panel.id !== sessionsViewId && !(dockEnabled && panel.side === 'left'),
42
+ );
43
+ }
44
+
45
+ /**
46
+ * Whether the left drawer draws its chip strip.
47
+ *
48
+ * A strip holding one chip is chrome that never changes, so it needs a second
49
+ * destination — either a placed panel, or a stored view that no longer
50
+ * resolves. The second half is the escape hatch: that body is a notice, and
51
+ * without the strip the session list would be unreachable.
52
+ */
53
+ export function phoneLeftStripVisible(dockEnabled: boolean, placedCount: number, showingSessions: boolean): boolean {
54
+ return dockEnabled && (placedCount > 0 || !showingSessions);
55
+ }
@@ -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', () => {