@kabmo-labs/kabmo 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +412 -383
  3. package/dist/client/static/js/index-3qamd33d.css +1 -0
  4. package/dist/client/static/js/{index-8x5j8zrr.js → index-twsk4wfa.js} +791 -791
  5. package/package.json +1 -1
  6. package/src/client/components/common/batch-selection/BatchActionBar.tsx +8 -1
  7. package/src/client/components/common/session-actions-menu/index.tsx +23 -2
  8. package/src/client/components/layout/{RightActivityBar.test.ts → ActivityBar.test.ts} +5 -5
  9. package/src/client/components/layout/{RightActivityBar.tsx → ActivityBar.tsx} +87 -52
  10. package/src/client/components/layout/LeftActivityBar.test.ts +201 -0
  11. package/src/client/components/layout/LeftDock.tsx +119 -0
  12. package/src/client/components/layout/desktop-layout/LeftColumn.tsx +172 -0
  13. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +22 -5
  14. package/src/client/components/layout/desktop-layout/index.tsx +88 -61
  15. package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
  16. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
  17. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
  18. package/src/client/components/layout/session-sidebar/SessionItem.tsx +30 -5
  19. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  20. package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
  21. package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
  22. package/src/client/components/mobile/RightSidebar.tsx +8 -5
  23. package/src/client/components/mobile/SessionSidebar.tsx +6 -0
  24. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -3
  25. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
  26. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
  27. package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
  28. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +41 -13
  29. package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
  30. package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
  31. package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
  32. package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
  33. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
  34. package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
  35. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
  36. package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
  37. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
  38. package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
  39. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
  40. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
  41. package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
  42. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
  43. package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
  44. package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
  45. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
  46. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +14 -9
  47. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
  48. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
  49. package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +154 -0
  50. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
  51. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
  52. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
  53. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
  54. package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
  55. package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
  56. package/src/client/components/workspace/editor/index.tsx +1 -0
  57. package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
  58. package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
  59. package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
  60. package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
  61. package/src/client/components/workspace/panels/builtin.tsx +8 -0
  62. package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
  63. package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
  64. package/src/client/hooks/chat/composer/compact.ts +119 -0
  65. package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
  66. package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
  67. package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
  68. package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
  69. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
  70. package/src/client/hooks/chat/timeline/actions.ts +34 -35
  71. package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
  72. package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
  73. package/src/client/hooks/chat/timeline/index.ts +15 -19
  74. package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
  75. package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
  76. package/src/client/hooks/chat/waiting/cancel.ts +34 -0
  77. package/src/client/hooks/chat/waiting/choice.ts +53 -0
  78. package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
  79. package/src/client/hooks/chat/waiting/held.ts +42 -0
  80. package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
  81. package/src/client/hooks/chat/waiting/hold.ts +156 -0
  82. package/src/client/hooks/chat/waiting/store.ts +100 -0
  83. package/src/client/hooks/workspace/left-dock.ts +132 -0
  84. package/src/client/hooks/workspace/panel-registry.ts +13 -0
  85. package/src/client/tailwind.css +22 -0
  86. package/src/server/db.server.test.ts +52 -1
  87. package/src/server/db.server.ts +49 -1
  88. package/src/server/index.ts +9 -0
  89. package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
  90. package/src/server/lib/assets/compressed-cache.server.ts +88 -0
  91. package/src/server/lib/db/schema.test.ts +1 -0
  92. package/src/server/lib/db/schema.ts +3 -0
  93. package/src/server/lib/fs/fs-path.test.ts +1 -1
  94. package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
  95. package/src/server/lib/omp/session/delete.server.ts +8 -0
  96. package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
  97. package/src/server/lib/panels/git-update.server.test.ts +18 -1
  98. package/src/server/lib/panels/registry.server.ts +2 -2
  99. package/src/server/lib/queue/delivery.server.ts +31 -39
  100. package/src/server/lib/queue/payload.ts +73 -0
  101. package/src/server/lib/realtime/signals.server.ts +10 -1
  102. package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
  103. package/src/server/lib/realtime/topics/global.server.ts +5 -0
  104. package/src/server/lib/realtime/topics.server.ts +2 -0
  105. package/src/server/lib/util/with-timeout.ts +29 -0
  106. package/src/server/lib/waiting/row-clock.server.ts +37 -0
  107. package/src/server/lib/waiting/runtime.server.test.ts +278 -0
  108. package/src/server/lib/waiting/runtime.server.ts +313 -0
  109. package/src/server/lib/waiting/schema.server.ts +46 -0
  110. package/src/server/lib/waiting/settle.test.ts +84 -0
  111. package/src/server/lib/waiting/settle.ts +77 -0
  112. package/src/server/lib/waiting/store.server.test.ts +167 -0
  113. package/src/server/lib/waiting/store.server.ts +268 -0
  114. package/src/server/lib/waiting/sweep-trigger.ts +35 -0
  115. package/src/server/plugins/compress.ts +18 -6
  116. package/src/server/plugins/ssr.ts +16 -8
  117. package/src/server/plugins/static.test.ts +59 -0
  118. package/src/server/plugins/static.ts +56 -14
  119. package/src/server/routes/index.ts +2 -0
  120. package/src/server/routes/waiting/bindings.ts +14 -0
  121. package/src/server/routes/waiting/index.ts +123 -0
  122. package/src/server/routes/waiting/routes.test.ts +121 -0
  123. package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
  124. package/src/shared/lib/chat/timeline/gutter.ts +37 -0
  125. package/src/shared/lib/realtime/protocol.ts +10 -0
  126. package/src/shared/lib/workspace/left-dock-width.ts +78 -0
  127. package/src/shared/lib/workspace/left-dock.test.ts +203 -0
  128. package/src/shared/lib/workspace/left-dock.ts +197 -0
  129. package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
  130. package/src/shared/lib/workspace/panel-ids.ts +114 -26
  131. package/src/shared/lib/workspace/sidebar-width.ts +4 -0
  132. package/src/shared/types/index.ts +1 -0
  133. package/src/shared/types/waiting.ts +54 -0
  134. package/src/shared/types/workspace.ts +8 -0
  135. package/src/test-support/isolated-db.ts +3 -4
  136. package/dist/client/static/js/index-pdp5dwc8.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kabmo-labs/kabmo",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Web console and diagnostic workbench for AI coding agents — Elysia + Preact on Bun.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Wijanarko Putra Rajeb <wijanarko.rajeb@gmail.com>",
@@ -35,6 +35,12 @@ export interface BatchActionBarProps {
35
35
  onUnselectAll: () => void;
36
36
  onDelete: () => void;
37
37
  onCancel: () => void;
38
+ /**
39
+ * The bar sits in the phone drawer, whose list and toolbar are both `p-3`.
40
+ * The desktop sidebar's list is `p-2`, so its bar keeps the tighter inset —
41
+ * one shared value would leave the phone's bar 2px inside the rows it counts.
42
+ */
43
+ drawer?: boolean;
38
44
  }
39
45
 
40
46
  export function BatchActionBar({
@@ -47,6 +53,7 @@ export function BatchActionBar({
47
53
  onUnselectAll,
48
54
  onDelete,
49
55
  onCancel,
56
+ drawer = false,
50
57
  }: BatchActionBarProps) {
51
58
  const noun = mode === 'workspace' ? 'workspace' : 'session';
52
59
  const plural = count === 1 ? noun : `${noun}s`;
@@ -55,7 +62,7 @@ export function BatchActionBar({
55
62
  return (
56
63
  <div
57
64
  data-testid="batch-action-bar"
58
- className="border-t border-ink/15 bg-paper px-2.5 py-2 flex flex-col gap-1.5 flex-shrink-0"
65
+ className={`border-t border-ink/15 bg-paper ${drawer ? 'px-3' : 'px-2.5'} py-2 flex flex-col gap-1.5 flex-shrink-0`}
59
66
  >
60
67
  <div className="flex items-center justify-between gap-2 text-[11px]">
61
68
  <span className="min-w-0 truncate text-ink/70">
@@ -28,7 +28,7 @@
28
28
  import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
29
29
  import type { TargetedMouseEvent } from 'preact';
30
30
  import { createPortal } from 'preact/compat';
31
- import { Archive, ArchiveRestore, Pencil, Trash2, WandSparkles } from 'lucide-preact';
31
+ import { Archive, ArchiveRestore, Hourglass, Pencil, Trash2, WandSparkles } from 'lucide-preact';
32
32
 
33
33
  /** Viewport coordinates the menu hangs off, plus which edge it aligns to. */
34
34
  export interface MenuAnchor {
@@ -90,6 +90,13 @@ export interface SessionActionsMenuProps {
90
90
  onRenameWithAi?: () => void;
91
91
  onArchive?: () => void;
92
92
  onDelete?: () => void;
93
+ /**
94
+ * Drop this row's held prompt (a `new-…` chat whose prompt is waiting on
95
+ * another session). Omitted for every other row: a wait is the only state a
96
+ * pending chat can cancel from here, and offering the item elsewhere would
97
+ * promise an action with nothing behind it.
98
+ */
99
+ onCancelWait?: () => void;
93
100
  onClose: () => void;
94
101
  }
95
102
 
@@ -100,6 +107,7 @@ export function SessionActionsMenu({
100
107
  onRenameWithAi,
101
108
  onArchive,
102
109
  onDelete,
110
+ onCancelWait,
103
111
  onClose,
104
112
  }: SessionActionsMenuProps) {
105
113
  const menuRef = useRef<HTMLDivElement>(null);
@@ -188,6 +196,19 @@ export function SessionActionsMenu({
188
196
  <span>Rename with AI</span>
189
197
  </button>
190
198
  )}
199
+ {/* A held prompt is the row's whole content, so dropping it belongs in
200
+ the same menu as the row's other verbs rather than on the row face. */}
201
+ {onCancelWait && (
202
+ <button
203
+ type="button"
204
+ role="menuitem"
205
+ onClick={run(onCancelWait)}
206
+ className="w-full text-left px-3 py-1.5 hover:bg-ink/5 flex items-center gap-2 transition-colors cursor-pointer"
207
+ >
208
+ <Hourglass size={12} className="text-ink/60" />
209
+ <span>Cancel waiting prompt</span>
210
+ </button>
211
+ )}
191
212
  {onArchive && (
192
213
  <button
193
214
  type="button"
@@ -201,7 +222,7 @@ export function SessionActionsMenu({
201
222
  <span>{isArchived ? 'Unarchive' : 'Archive'}</span>
202
223
  </button>
203
224
  )}
204
- {(onRename || onRenameWithAi || onArchive) && onDelete && <div className="my-1 border-t border-ink/10" />}
225
+ {(onRename || onRenameWithAi || onArchive || onCancelWait) && onDelete && <div className="my-1 border-t border-ink/10" />}
205
226
  {onDelete && (
206
227
  <button
207
228
  type="button"
@@ -20,7 +20,7 @@ import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:tes
20
20
  import { Window } from 'happy-dom';
21
21
  import { h, render } from 'preact';
22
22
  import { act } from 'preact/test-utils';
23
- import { RightActivityBar } from '@/client/components/layout/RightActivityBar';
23
+ import { ActivityBar } from '@/client/components/layout/ActivityBar';
24
24
  import { SessionStateProvider } from '@/client/components/common/session-state-provider/index';
25
25
  import { useRepoScope } from '@/client/hooks/workspace/repo-scope';
26
26
  import { primeKabmoSettings, readKabmoSetting, writeSetting } from '@/shared/lib/settings/client';
@@ -110,13 +110,13 @@ async function mount() {
110
110
  render(
111
111
  h(SessionStateProvider, {
112
112
  sessionId: SESSION,
113
- children: h('div', null, h(Picker, { repo: '.' }), h(RightActivityBar, {
113
+ children: h('div', null, h(Picker, { repo: '.' }), h(ActivityBar, {
114
+ side: 'right',
114
115
  activePanel: 'git',
115
116
  onChangePanel: () => {},
116
117
  isPanelOpen: true,
117
118
  hasActiveContext: true,
118
119
  activeProjectPath: ROOT,
119
- refreshKey: 0,
120
120
  })),
121
121
  }),
122
122
  container,
@@ -129,7 +129,7 @@ async function mount() {
129
129
 
130
130
  const dot = () => Boolean(container.querySelector('[title="Ada perubahan git"]'));
131
131
 
132
- describe('RightActivityBar git dot', () => {
132
+ describe('ActivityBar git dot', () => {
133
133
  test('reports the selected repo, and follows the picker to the root', async () => {
134
134
  await mount();
135
135
 
@@ -200,7 +200,7 @@ async function dragOnto(from: string, to: string) {
200
200
  for (let i = 0; i < 8; i += 1) await act(async () => {});
201
201
  }
202
202
 
203
- describe('RightActivityBar panel order', () => {
203
+ describe('ActivityBar panel order', () => {
204
204
  test('a drag reorders the bar and persists the new order', async () => {
205
205
  primeKabmoSettings({});
206
206
  await mount();
@@ -1,20 +1,50 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * An activity bar: the rail of panel buttons, for one column.
8
+ *
9
+ * ONE component for both sides. The two rails list different catalogs and sit
10
+ * on opposite edges, but everything that made the right rail work — a drag that
11
+ * commits the order once, a right-click menu that keeps a switched-off view
12
+ * reachable, the Source Control dot — is the same problem on the left. Writing
13
+ * it twice is how the two headers drifted, and a user switching layouts had to
14
+ * re-find every control.
15
+ *
16
+ * What is per-side is data, not branches:
17
+ *
18
+ * - the CATALOG (`usePanelCatalog(side)`), so the left rail draws the session
19
+ * list plus whatever the user placed there;
20
+ * - the ORDER, which the right column stores in `omp_right_panel_order`; the
21
+ * left column is ordered by its placement list, so it is drawn as the catalog
22
+ * returns it and a drag is not offered there;
23
+ * - the DOT, which belongs to Source Control and therefore to the right rail.
24
+ */
25
+
1
26
  import { useRef, useState } from 'preact/hooks';
2
27
  import type { TargetedDragEvent, TargetedMouseEvent } from 'preact';
3
-
4
28
  import { useGitStatus } from '@/client/hooks/workspace/git-status';
5
29
  import { useResolvedRepo } from '@/client/hooks/workspace/repo-scope';
6
- import { usePanelCatalog, type ResolvedPanel } from '@/client/components/workspace/plugin-panel/resolve';
30
+ import {
31
+ usePanelCatalog,
32
+ type PanelCatalogSide,
33
+ type ResolvedPanel,
34
+ } from '@/client/components/workspace/plugin-panel/catalog';
7
35
  import { usePanelPluginActions, usePanelRegistry } from '@/client/hooks/workspace/panel-registry';
8
36
  import { usePanelOrder } from '@/client/hooks/workspace/panel-order';
9
37
  import { usePanelReorder } from '@/client/hooks/workspace/panel-reorder';
10
- import { pluginPanelKey } from '@/shared/lib/workspace/panel-ids';
38
+ import { pluginEnablementKey } from '@/shared/lib/workspace/panel-ids';
11
39
  import { PANEL_MARK_SIZE } from '@/shared/lib/workspace/right-panels';
40
+ import { SESSIONS_VIEW_ID, isLeftDockPanelId } from '@/shared/lib/workspace/left-dock';
12
41
  import { BUILTIN_PANELS } from '@/client/components/workspace/panels/builtin';
13
42
  import { PluginMark } from '@/client/components/settings/categories/panel-plugins/PluginMark';
14
43
  import { PanelVisibilityMenu, type PanelVisibilityItem } from '@/client/components/layout/PanelVisibilityMenu';
15
44
 
16
- interface RightActivityBarProps {
17
- /** A built-in view id or a plugin panel key (`plugin:<pluginId>`). */
45
+ interface ActivityBarProps {
46
+ side: PanelCatalogSide;
47
+ /** A built-in view id, `sessions`, or a plugin panel key (`plugin:<pluginId>`). */
18
48
  activePanel: string;
19
49
  onChangePanel: (panel: string) => void;
20
50
  isPanelOpen: boolean;
@@ -23,39 +53,25 @@ interface RightActivityBarProps {
23
53
  }
24
54
 
25
55
  /**
26
- * The right developer panel's activity bar.
27
- *
28
- * ONE list of buttons: the built-in views and the installed plugins both come
29
- * from `usePanelCatalog`, so the bar cannot draw one kind from a table and the
30
- * other from a fetch — and the catalog is what the phone's tab strip and the
31
- * desktop panel stack read too, so the order here is the only order any of them
32
- * can draw.
56
+ * One column's rail.
33
57
  *
34
- * The buttons are DRAGGABLE, and a drop commits the whole order to
35
- * `app_settings.omp_right_panel_order` (`usePanelOrder` + `usePanelReorder`).
36
- * The reorder is previewed while the gesture runs and written ONCE at the end.
37
- *
38
- * Right-clicking the bar opens the enablement menu — VS Code's own affordance,
39
- * with its own semantics here: a view that is switched off leaves the bar but
40
- * stays in that menu with its checkbox off, which is the only place it can be
41
- * switched back on. EVERY view is switchable, built-in or plugin, because
42
- * enablement is one set of ids and a built-in view can be put away exactly like
43
- * an installed one. A switched-off view KEEPS its place in the stored order, so
44
- * switching it back on returns it where the user put it.
45
- *
46
- * A plugin contributes AT MOST ONE right panel, so one plugin can never produce
47
- * two buttons here.
58
+ * The left rail is the DOCK's, so its ordering is the placement list and a drag
59
+ * would have nothing to commit — `draggable` is false there and the reorder hook
60
+ * is never given a commit. Everything else is shared.
48
61
  */
49
- export function RightActivityBar({ activePanel, onChangePanel, isPanelOpen, hasActiveContext, activeProjectPath }: RightActivityBarProps) {
62
+ export function ActivityBar({ side, activePanel, onChangePanel, isPanelOpen, hasActiveContext, activeProjectPath }: ActivityBarProps) {
50
63
  // The dot is the Source Control view's, so it follows the repo that view is
51
64
  // on. Reading it through the shared pick (not through a private copy) is what
52
65
  // makes this update the moment the panel's picker moves; polling the workspace
53
- // root instead showed the wrong repository's changes.
54
- const activeRepo = useResolvedRepo(activeProjectPath ?? undefined, hasActiveContext);
55
- const { changes } = useGitStatus(activeProjectPath ?? undefined, activeRepo, hasActiveContext);
66
+ // root instead showed the wrong repository's changes. The left rail has no
67
+ // Source Control, so it does not poll at all.
68
+ const wantsGitDot = side !== 'left';
69
+ const activeRepo = useResolvedRepo(activeProjectPath ?? undefined, wantsGitDot && hasActiveContext);
70
+ const { changes } = useGitStatus(activeProjectPath ?? undefined, activeRepo, wantsGitDot && hasActiveContext);
56
71
  const hasGitChanges = changes.length > 0;
57
72
 
58
- const catalog = usePanelCatalog();
73
+ const catalog = usePanelCatalog(side);
74
+ const reorderable = side !== 'left';
59
75
  const { commitReorder } = usePanelOrder();
60
76
  const { disabledPanels } = usePanelRegistry();
61
77
  const { setEnabled } = usePanelPluginActions();
@@ -71,7 +87,8 @@ export function RightActivityBar({ activePanel, onChangePanel, isPanelOpen, hasA
71
87
  // simply not drawn — the id stays in the stored order, so switching the panel
72
88
  // back on returns it where it was.
73
89
  const byId = new Map(catalog.map((panel) => [panel.id, panel]));
74
- const drawn = reorder.order
90
+ const order = reorderable ? reorder.order : catalogIds;
91
+ const drawn = order
75
92
  .map((id) => byId.get(id))
76
93
  .filter((panel): panel is ResolvedPanel => panel !== undefined);
77
94
 
@@ -84,13 +101,13 @@ export function RightActivityBar({ activePanel, onChangePanel, isPanelOpen, hasA
84
101
  const dividerBefore =
85
102
  firstPlugin > 0 && drawn.slice(firstPlugin).every((panel) => !panel.builtin) ? firstPlugin : -1;
86
103
 
87
- // The menu lists EVERY panel, off or on — a switched-off view must stay
88
- // reachable, and this list is the only place it can be switched back on. It
89
- // is built from the FULL id list rather than from the catalog, because a
90
- // switched-off view is not in the catalog at all. The entry is keyed by the
91
- // SAME id the disabled set stores, so the checkbox and the write cannot
104
+ // The menu lists EVERY panel this rail could draw, off or on — a switched-off
105
+ // view must stay reachable, and this list is the only place it can be switched
106
+ // back on. It is built from the FULL id list rather than from the catalog,
107
+ // because a switched-off view is not in the catalog at all. The entry is keyed
108
+ // by the SAME id the disabled set stores, so the checkbox and the write cannot
92
109
  // address different panels.
93
- const allItems = useAllPanelItems();
110
+ const allItems = useAllPanelItems(side);
94
111
 
95
112
  const openMenu = (event: TargetedMouseEvent<HTMLElement>) => {
96
113
  event.preventDefault();
@@ -108,7 +125,8 @@ export function RightActivityBar({ activePanel, onChangePanel, isPanelOpen, hasA
108
125
  <nav
109
126
  ref={navRef}
110
127
  onContextMenu={openMenu}
111
- className="w-11 flex-shrink-0 border-l border-ink/10 bg-paper flex flex-col items-center py-3 space-y-2 z-10"
128
+ aria-label={side === 'left' ? 'Left panel views' : 'Right panel views'}
129
+ className={`w-11 flex-shrink-0 ${side === 'left' ? 'border-r' : 'border-l'} border-ink/10 bg-paper flex flex-col items-center py-3 space-y-2 z-10`}
112
130
  >
113
131
  <div className="flex flex-col items-center space-y-1 w-full">
114
132
  {drawn.map((panel, index) => (
@@ -116,8 +134,9 @@ export function RightActivityBar({ activePanel, onChangePanel, isPanelOpen, hasA
116
134
  {index === dividerBefore ? <div className="w-6 mx-auto border-t border-ink/10 my-1" /> : null}
117
135
  <PanelButton
118
136
  panel={panel}
137
+ draggable={reorderable}
119
138
  isActive={isPanelOpen && activePanel === panel.id}
120
- isDragging={reorder.draggingId === panel.id}
139
+ isDragging={reorderable && reorder.draggingId === panel.id}
121
140
  onClick={() => onChangePanel(panel.id)}
122
141
  onDragStart={(event) => reorder.onDragStart(event, panel.id)}
123
142
  onDragOver={(event) => reorder.onDragOver(event, panel.id)}
@@ -154,17 +173,29 @@ export function RightActivityBar({ activePanel, onChangePanel, isPanelOpen, hasA
154
173
  * static list, so they are always listed; a plugin is listed while it is
155
174
  * INSTALLED, read from `plugins` rather than from `panels`, because `panels`
156
175
  * holds only the enabled ones and a disabled plugin would lose its row.
176
+ *
177
+ * The list is per SIDE: the left rail offering eleven built-in views it cannot
178
+ * draw would be a menu of controls that do nothing.
157
179
  */
158
- function useAllPanelItems(): PanelVisibilityItem[] {
180
+ function useAllPanelItems(side: PanelCatalogSide): PanelVisibilityItem[] {
159
181
  const { plugins } = usePanelRegistry();
160
- return [
161
- ...BUILTIN_PANELS.map((panel) => ({ id: panel.id, title: panel.title, removable: true })),
162
- ...plugins.map((plugin) => ({
163
- id: pluginPanelKey(plugin.pluginId),
164
- title: plugin.name,
165
- removable: true,
166
- })),
167
- ];
182
+ const items: PanelVisibilityItem[] = [];
183
+ if (side !== 'right') {
184
+ // The session list is the dock's own view and cannot be switched off: it is
185
+ // the only way back to a conversation.
186
+ items.push({ id: SESSIONS_VIEW_ID, title: 'Sessions', removable: false });
187
+ }
188
+ if (side !== 'left') {
189
+ for (const panel of BUILTIN_PANELS) items.push({ id: panel.id, title: panel.title, removable: true });
190
+ }
191
+ for (const plugin of plugins) {
192
+ const id = pluginEnablementKey(plugin.pluginId);
193
+ // A plugin is offered on both rails: its placement decides which one draws
194
+ // it, and the switch is the same switch on either.
195
+ if (side === 'left' && !isLeftDockPanelId(id)) continue;
196
+ items.push({ id, title: plugin.name, removable: true });
197
+ }
198
+ return items;
168
199
  }
169
200
 
170
201
  /**
@@ -175,6 +206,7 @@ function useAllPanelItems(): PanelVisibilityItem[] {
175
206
  */
176
207
  function PanelButton({
177
208
  panel,
209
+ draggable,
178
210
  isActive,
179
211
  isDragging,
180
212
  onClick,
@@ -185,6 +217,7 @@ function PanelButton({
185
217
  showGitDot,
186
218
  }: {
187
219
  panel: ResolvedPanel;
220
+ draggable: boolean;
188
221
  isActive: boolean;
189
222
  isDragging: boolean;
190
223
  onClick: () => void;
@@ -196,11 +229,13 @@ function PanelButton({
196
229
  }) {
197
230
  return (
198
231
  <button
199
- className={`relative w-full h-10 flex items-center justify-center transition-colors border-l-2 ${
232
+ className={`relative w-full h-10 flex items-center justify-center transition-colors ${
233
+ panel.side === 'left' ? 'border-r-2' : 'border-l-2'
234
+ } ${
200
235
  isActive ? 'text-ink border-ink bg-ink/5' : 'text-ink/40 border-transparent hover:text-ink hover:bg-ink/5'
201
236
  } ${isDragging ? 'opacity-40 cursor-grabbing' : 'cursor-pointer'}`}
202
237
  onClick={onClick}
203
- draggable
238
+ draggable={draggable}
204
239
  onDragStart={onDragStart}
205
240
  onDragOver={onDragOver}
206
241
  onDrop={onDrop}
@@ -208,7 +243,7 @@ function PanelButton({
208
243
  title={panel.title}
209
244
  aria-label={panel.title}
210
245
  >
211
- {panel.builtin ? panel.icon : <PluginMark name={panel.name} iconUrl={panel.iconUrl} size={PANEL_MARK_SIZE} />}
246
+ {panel.builtin && panel.icon ? panel.icon : panel.builtin ? <PluginMark name={panel.label} size={PANEL_MARK_SIZE} /> : <PluginMark name={panel.name} iconUrl={panel.iconUrl} size={PANEL_MARK_SIZE} />}
212
247
  {showGitDot ? (
213
248
  <span
214
249
  className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full bg-info"
@@ -0,0 +1,201 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The LEFT rail: what it draws, and what it refuses to draw.
8
+ *
9
+ * Three properties, and each is a bug the right rail's shape would produce if
10
+ * the left one were a copy of it:
11
+ *
12
+ * - **The session list is always on it and cannot be switched off.** It is the
13
+ * only way back to a conversation, so the visibility menu lists it as pinned
14
+ * rather than offering a switch that would strand the user.
15
+ * - **The rail draws only what the LEFT column can show.** A menu of eleven
16
+ * built-in views it cannot render would be a list of controls that do nothing.
17
+ * - **A plugin is on exactly one rail.** Its placement decides which, so the
18
+ * same panel cannot be two buttons — one of which would draw nothing.
19
+ *
20
+ * Rendered with `h()` (no JSX) against happy-dom, through the real session-state
21
+ * provider: the rail reads placement from the settings snapshot, which is module
22
+ * state a test has to prime.
23
+ */
24
+
25
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
26
+ import { Window } from 'happy-dom';
27
+ import { h, render } from 'preact';
28
+ import { act } from 'preact/test-utils';
29
+ import { ActivityBar } from '@/client/components/layout/ActivityBar';
30
+ import { SessionStateProvider } from '@/client/components/common/session-state-provider/index';
31
+ import { definePluginApp } from '@kabmo-labs/kabmo-sdk/app';
32
+ import { primeKabmoSettings } from '@/shared/lib/settings/client';
33
+ import { registerPluginBundle, resetPluginSlots } from '@/client/lib/plugins/slots';
34
+ import { resetPanelRegistry } from '@/client/hooks/workspace/panel-registry';
35
+ import { LEFT_DOCK_SETTING_KEY } from '@/shared/lib/workspace/left-dock';
36
+ import { resetRealtimeClient } from '@/shared/lib/realtime/client';
37
+ import { TOPIC_PANELS } from '@/shared/lib/realtime/protocol';
38
+ import { startRealtimeTestServer, type RealtimeTestServer } from '@/test-support/realtime-server';
39
+ import {
40
+ installDomGlobals,
41
+ pristineWebSocket,
42
+ restoreDomGlobals,
43
+ } from '@/test-support/pristine-globals';
44
+ import type { TopicResolver } from '@/server/lib/realtime/hub.server';
45
+
46
+ const SESSION = 'new-left-rail';
47
+ const nativeFetch = Bun.fetch;
48
+ let container: HTMLElement | null = null;
49
+ let server: RealtimeTestServer;
50
+
51
+ /**
52
+ * ONE harness per file, started before any case — the rule the helper documents:
53
+ * the hub, its topics and the listener are process state, so a server started
54
+ * per test leaves the previous one's socket to report its close into the NEXT
55
+ * file's globals (`Unhandled error. (ErrorEvent …)`, measured).
56
+ */
57
+ beforeAll(async () => {
58
+ // The rail reads the plugin registry, which is a realtime topic — so the test
59
+ // needs a real channel rather than a stub, the same reason the right rail's
60
+ // suite starts one.
61
+ const registry: TopicResolver = async () => ({
62
+ // `demo` is INSTALLED; whether it draws a button depends on its bundle
63
+ // having registered a component, which is what the placement case sets up.
64
+ panels: [{ pluginId: 'demo', name: 'demo', version: '1.0.0', appUrl: '/api/panels/bundle/demo' }],
65
+ marketplaces: [],
66
+ errors: [],
67
+ plugins: [],
68
+ catalog: [],
69
+ disabledPanels: [],
70
+ gitStatus: {},
71
+ autoUpdate: false,
72
+ syncBlocked: {},
73
+ });
74
+ server = await startRealtimeTestServer(new Map<string, TopicResolver>([[TOPIC_PANELS, registry]]));
75
+ const target = globalThis as unknown as Record<string, unknown>;
76
+ target.fetch = async () => new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });
77
+ });
78
+
79
+ beforeEach(() => {
80
+ const win = new Window({ url: `http://127.0.0.1:${server.port}` });
81
+ installDomGlobals(win);
82
+ const target = globalThis as unknown as Record<string, unknown>;
83
+ target.WebSocket = pristineWebSocket;
84
+ primeKabmoSettings({});
85
+ resetRealtimeClient();
86
+ });
87
+
88
+ afterAll(() => {
89
+ const target = globalThis as unknown as Record<string, unknown>;
90
+ target.fetch = nativeFetch;
91
+ restoreDomGlobals();
92
+ server.stop();
93
+ });
94
+
95
+ afterEach(() => {
96
+ if (container) {
97
+ render(null, container);
98
+ container.remove();
99
+ container = null;
100
+ }
101
+ primeKabmoSettings({});
102
+ resetRealtimeClient();
103
+ // The registry store is a `globalThis` slot, so a payload adopted here would
104
+ // be every later suite's starting state — the right rail would draw this
105
+ // file's plugin.
106
+ resetPanelRegistry();
107
+ resetPluginSlots();
108
+ });
109
+
110
+ async function mount() {
111
+ // The registry is a realtime read: the bar draws plugins only once the
112
+ // payload has been adopted, so the mount waits for the client's own value
113
+ // rather than for a duration.
114
+ await server.waitForTopic(TOPIC_PANELS, (value) => value !== null);
115
+ container = document.createElement('div');
116
+ document.body.appendChild(container);
117
+ await act(async () => {
118
+ render(
119
+ h(SessionStateProvider, {
120
+ sessionId: SESSION,
121
+ children: h(ActivityBar, {
122
+ side: 'left',
123
+ activePanel: 'sessions',
124
+ onChangePanel: () => {},
125
+ isPanelOpen: true,
126
+ hasActiveContext: true,
127
+ }),
128
+ }),
129
+ container!,
130
+ );
131
+ });
132
+ await flush();
133
+ }
134
+
135
+ /**
136
+ * Let the channel's frames land and the tree re-render.
137
+ *
138
+ * The registry arrives over a real socket, so a microtask flush is not enough:
139
+ * the frame is a macrotask. This yields to the event loop a bounded number of
140
+ * times rather than sleeping a guessed duration.
141
+ */
142
+ async function flush(times = 12) {
143
+ for (let i = 0; i < times; i += 1) {
144
+ await act(async () => {
145
+ await new Promise((resolve) => setTimeout(resolve, 5));
146
+ });
147
+ }
148
+ }
149
+
150
+ const labels = () =>
151
+ [...(container?.querySelectorAll('nav button[aria-label]') ?? [])].map((el) => el.getAttribute('aria-label') ?? '');
152
+
153
+ describe('left activity bar', () => {
154
+ test('draws the session list, and nothing from the right column', async () => {
155
+ await mount();
156
+ expect(labels()).toEqual(['Sessions']);
157
+ });
158
+
159
+ test('the sessions row is offered as pinned, not switchable', async () => {
160
+ await mount();
161
+ const nav = container!.querySelector('nav') as HTMLElement;
162
+ await act(async () => {
163
+ nav.dispatchEvent(new window.Event('contextmenu', { bubbles: true, cancelable: true }));
164
+ });
165
+ const menu = document.querySelector('[role="menu"][aria-label="Panel visibility"]');
166
+ expect(menu).not.toBeNull();
167
+ const row = [...menu!.querySelectorAll('[role="menuitemcheckbox"]')].find(
168
+ (el) => el.textContent?.includes('Sessions'),
169
+ ) as HTMLButtonElement;
170
+ expect(row).toBeDefined();
171
+ // Disabled and still checked: the row exists so the menu is complete, and
172
+ // it says the view cannot be put away rather than pretending a click works.
173
+ expect(row.disabled).toBe(true);
174
+ expect(row.getAttribute('aria-checked')).toBe('true');
175
+ // The built-ins are RIGHT-column views and are not offered here at all.
176
+ expect(menu!.textContent).not.toContain('Source Control');
177
+ menu!.parentElement?.remove();
178
+ });
179
+
180
+ test('a placed plugin panel is drawn here and gone from the right rail', async () => {
181
+ // One bundle, registered the way a loaded plugin registers it, and a
182
+ // registry that names it — then the placement decides the rail.
183
+ registerPluginBundle('demo', {
184
+ default: definePluginApp((app) => app.rightPanel({ id: 'main', title: 'Demo panel', component: () => null })),
185
+ });
186
+ // The INSTANCE key (plugin + slot). A bare `plugin:demo` still resolves —
187
+ // to the right-panel instance — but the dock holds instance keys, so that
188
+ // is what a placement writes.
189
+ primeKabmoSettings({ [LEFT_DOCK_SETTING_KEY]: { enabled: true, panels: ['plugin:demo:rightPanel'] } });
190
+ await mount();
191
+ expect(labels()).toEqual(['Sessions', 'Demo panel — demo']);
192
+
193
+ // The same plugin on the RIGHT: the left rail loses the button, because a
194
+ // panel is on one rail at a time.
195
+ primeKabmoSettings({ [LEFT_DOCK_SETTING_KEY]: { enabled: true, panels: [] } });
196
+ render(null, container!);
197
+ container = null;
198
+ await mount();
199
+ expect(labels()).toEqual(['Sessions']);
200
+ });
201
+ });