@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,119 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The left dock's view stack: the view on screen, plus the ones kept mounted.
8
+ *
9
+ * The left column used to be the session sidebar and nothing else. It is a DOCK
10
+ * now, exactly like the right column: a stack of views with one on screen.
11
+ *
12
+ * Two rules are load-bearing, and each is a bug if dropped:
13
+ *
14
+ * - **A view is never responsible for the column's furniture.** The Settings ·
15
+ * About strip, the update popup and the About modal belong to the COLUMN
16
+ * (`LeftColumn`), not to this stack: a plugin panel on the same dock must not
17
+ * take away the only visible way into Settings. That is also what lets the
18
+ * strip span the rail and the body together, which is where it belongs.
19
+ * - **The session list stays MOUNTED while another view is on screen**, hidden
20
+ * with CSS, the way the right stack keeps its views: its folder expansion,
21
+ * scroll position and batch selection must survive a look at another view. A
22
+ * plugin panel is mounted only while it is the active one — a hidden plugin is
23
+ * a component nobody is looking at.
24
+ */
25
+
26
+ import { Suspense } from 'preact/compat';
27
+ import type { ReactNode } from 'preact/compat';
28
+ import { PluginPanelBody } from '@/client/components/workspace/plugin-panel/resolve';
29
+ import { usePanelCatalog } from '@/client/components/workspace/plugin-panel/catalog';
30
+ import type { PanelBodyProps } from '@/client/components/workspace/panels/builtin';
31
+ import { PanelHostProvider } from '@kabmo-labs/kabmo-ui';
32
+ import type { AgentStreamStatus } from '@/shared/lib/chat/omp/status';
33
+
34
+ interface LeftDockViewProps {
35
+ className?: string;
36
+ /** The view on screen. A stored id naming a vanished panel falls back. */
37
+ activeView: string;
38
+ onClose?: () => void;
39
+ appSettings?: Record<string, any>;
40
+ streamStatus?: AgentStreamStatus;
41
+ refreshKey?: number;
42
+ onRefresh?: () => void;
43
+ onOpenFile?: (file: unknown) => void;
44
+ }
45
+
46
+ /**
47
+ * The views are `lazy()` (the session list is its own chunk), so the stack needs
48
+ * a boundary of its own: Preact THROWS the pending promise out of the tree when
49
+ * a lazy child suspends with no `<Suspense>` above it, which is a blank page
50
+ * rather than a loading state — measured with the whole app failing to mount.
51
+ */
52
+ function DockSuspense({ children }: { children: ReactNode }) {
53
+ return (
54
+ <Suspense
55
+ fallback={
56
+ <div className="h-full flex items-center justify-center text-ink/40">
57
+ <span className="text-xs font-mono">Loading…</span>
58
+ </div>
59
+ }
60
+ >
61
+ {children}
62
+ </Suspense>
63
+ );
64
+ }
65
+
66
+ export function LeftDockView({
67
+ className = '',
68
+ activeView,
69
+ onClose,
70
+ appSettings = {},
71
+ streamStatus,
72
+ refreshKey = 0,
73
+ onRefresh,
74
+ onOpenFile,
75
+ }: LeftDockViewProps) {
76
+ const catalog = usePanelCatalog('left');
77
+ const active = catalog.find((panel) => panel.id === activeView);
78
+
79
+ const body: PanelBodyProps = {
80
+ className: 'w-full h-full',
81
+ enabled: true,
82
+ active: true,
83
+ refreshKey,
84
+ ...(onRefresh ? { onRefresh } : {}),
85
+ ...(onOpenFile ? { onOpenFile } : {}),
86
+ ...(onClose ? { onClose } : {}),
87
+ appSettings,
88
+ ...(streamStatus ? { streamStatus } : {}),
89
+ };
90
+
91
+ return (
92
+ <DockSuspense>
93
+ {/* `aside`, not a `div`: this IS the sidebar landmark — the session list
94
+ lives here — and the browser specs address it as one (`aside` scopes a
95
+ query to the sidebar rather than to the whole page). The dock body was
96
+ a `div` for one revision and two specs went red on it. */}
97
+ <aside className={`relative h-full ${className}`}>
98
+ {catalog.map((panel) => {
99
+ const isActive = panel.id === activeView;
100
+ if (!panel.builtin && !isActive) return null;
101
+ const viewBody: PanelBodyProps = { ...body, active: isActive, enabled: isActive };
102
+ return (
103
+ <div key={panel.id} className={isActive ? 'w-full h-full' : 'hidden'}>
104
+ <PanelHostProvider value={viewBody}>{panel.render(viewBody)}</PanelHostProvider>
105
+ </div>
106
+ );
107
+ })}
108
+ {/* The selection names a panel that is switched off, uninstalled, or
109
+ whose bundle has not loaded — a notice, never a blank column. The
110
+ session list is always drawable, so it is never this case. */}
111
+ {!active ? (
112
+ <div className="w-full h-full">
113
+ <PluginPanelBody panelKey={activeView} />
114
+ </div>
115
+ ) : null}
116
+ </aside>
117
+ </DockSuspense>
118
+ );
119
+ }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The desktop's left column: the optional rail, the dock, its furniture, and
8
+ * its separator.
9
+ *
10
+ * Split out of `DesktopLayout` because the column has four parts that answer
11
+ * four different questions — whether the rail is drawn (a global preference),
12
+ * which view is on screen (per session), how wide the column is (per view), and
13
+ * what belongs to the COLUMN rather than to a view — and the layout component is
14
+ * where the group's own width arithmetic lives.
15
+ *
16
+ * Four rules are load-bearing, and each was a defect first:
17
+ *
18
+ * - **The rail is INSIDE the column's panel**, not a sibling of it. It used to
19
+ * be a sibling, which made it a panel of its own: the group's width arithmetic
20
+ * had to budget for a third fixed panel, and — the bug that was reported — the
21
+ * footer could only be as wide as the dock BODY, so turning the rail on left a
22
+ * strip under the rail with nothing in it.
23
+ * - **The rail sits BETWEEN the title row and the footer**, never above them.
24
+ * The column reads top-to-bottom as title · (rail | view) · strip, so the rail
25
+ * cannot run up into the app's titlebar and cannot outlive the strip.
26
+ * - **The footer spans the whole column.** Settings · About act on Kabmo, not on
27
+ * the view above them, so a plugin panel on the dock must not take them away
28
+ * and the strip must not shrink to one view's width.
29
+ * - **The rail exists only while the user turned it on** (`left-dock.ts`). Off —
30
+ * the default — means no rail and the column is the session list as it always
31
+ * was.
32
+ * - **The column's body is always mounted** (the panel collapses to zero width),
33
+ * so the session list's expansion, scroll and batch selection survive a
34
+ * collapse and the width transition has something to animate.
35
+ */
36
+
37
+ import { useState } from 'preact/hooks';
38
+ import type { RefObject } from 'preact';
39
+ import { Panel, type PanelImperativeHandle } from '@/client/components/layout/desktop-layout/resizer';
40
+ import { ActivityBar } from '@/client/components/layout/ActivityBar';
41
+ import { LeftDockView } from '@/client/components/layout/LeftDock';
42
+ import { ResizeHandle } from '@/client/components/layout/desktop-layout/ResizeHandle';
43
+ import { AboutModal } from '@/client/components/layout/session-sidebar/about-modal';
44
+ import { UpdatePopup } from '@/client/components/layout/update-popup';
45
+ import { SettingsModal } from '@/client/components/settings/LazyModal';
46
+ import { SessionSidebarFooter } from '@/client/components/layout/session-sidebar/Footer';
47
+ import { SessionSidebarHeader } from '@/client/components/layout/session-sidebar/Header';
48
+ import { ToastStack } from '@/client/components/common/ToastStack';
49
+ import { useAndroidDownload } from '@/client/hooks/ui/updates/android-download';
50
+ import { useKabmoEvent } from '@/client/hooks/ui/window-event';
51
+ import { useToasts } from '@/client/hooks/ui/toasts';
52
+ import { useUpdates } from '@/client/hooks/ui/updates';
53
+ import { UPDATE_REQUEST_EVENT } from '@/shared/lib/updates/popup-state';
54
+ import { MAX_LEFT_PANEL_WIDTH } from '@/shared/lib/workspace/sidebar-width';
55
+ import type { AgentStreamStatus } from '@/shared/lib/chat/omp/status';
56
+
57
+ interface LeftColumnProps {
58
+ panelRef: RefObject<PanelImperativeHandle | null>;
59
+ open: boolean;
60
+ /** Draw the rail. A global preference, off by default. */
61
+ railEnabled: boolean;
62
+ /** The view the dock actually shows, after resolving the stored selection. */
63
+ view: string;
64
+ onSelectView: (view: string) => void;
65
+ onCollapse: () => void;
66
+ width: number;
67
+ minWidth: number;
68
+ hasActiveContext: boolean;
69
+ activeProjectPath?: string | null;
70
+ appSettings?: Record<string, any>;
71
+ streamStatus?: AgentStreamStatus;
72
+ refreshKey: number;
73
+ onRefresh: () => void;
74
+ onOpenFile: (file: unknown) => void;
75
+ }
76
+
77
+ export function LeftColumn({
78
+ panelRef,
79
+ open,
80
+ railEnabled,
81
+ view,
82
+ onSelectView,
83
+ onCollapse,
84
+ width,
85
+ minWidth,
86
+ hasActiveContext,
87
+ activeProjectPath,
88
+ appSettings = {},
89
+ streamStatus,
90
+ refreshKey,
91
+ onRefresh,
92
+ onOpenFile,
93
+ }: LeftColumnProps) {
94
+ const [settingsOpen, setSettingsOpen] = useState(false);
95
+ const [infoOpen, setInfoOpen] = useState(false);
96
+ const updates = useUpdates();
97
+ const { toasts, pushToast, dismissToast } = useToasts();
98
+ const androidDownload = useAndroidDownload(
99
+ window.__OMP_BOOTSTRAP__?.shellVersion,
100
+ window.__OMP_BOOTSTRAP__?.isAndroid,
101
+ );
102
+
103
+ // The update popup's "Update now" hands the run to the About modal, which owns
104
+ // the live output. The column owns both, so a view switch cannot lose them.
105
+ useKabmoEvent(UPDATE_REQUEST_EVENT, () => {
106
+ setInfoOpen(true);
107
+ void updates.apply('kabmo').then((result) => {
108
+ if (!result) return;
109
+ pushToast(result.message, result.success && !result.manual ? 'success' : 'error');
110
+ });
111
+ });
112
+
113
+ return (
114
+ <>
115
+ <Panel
116
+ panelRef={panelRef}
117
+ id="left-panel"
118
+ defaultSize={width}
119
+ minSize={minWidth}
120
+ maxSize={MAX_LEFT_PANEL_WIDTH}
121
+ collapsed={!open}
122
+ >
123
+ {/* The column is three ROWS, and the order is what makes it read as one
124
+ column: a title row across the whole width, then the rail beside the
125
+ view, then the strip across the whole width again. The rail must not
126
+ start above the title — it would run under the app's own titlebar —
127
+ and the strip must not stop at the view's edge. */}
128
+ <div className="flex flex-col h-full w-full bg-paper">
129
+ <SessionSidebarHeader streamStatus={streamStatus} onCollapse={onCollapse} />
130
+ <div className="flex flex-1 min-h-0 w-full">
131
+ {railEnabled ? (
132
+ <ActivityBar
133
+ side="left"
134
+ activePanel={view}
135
+ onChangePanel={onSelectView}
136
+ isPanelOpen={open}
137
+ hasActiveContext={hasActiveContext}
138
+ activeProjectPath={activeProjectPath}
139
+ />
140
+ ) : null}
141
+ <LeftDockView
142
+ className="flex-1 min-w-0 h-full"
143
+ activeView={view}
144
+ onClose={onCollapse}
145
+ appSettings={appSettings}
146
+ refreshKey={refreshKey}
147
+ onRefresh={onRefresh}
148
+ onOpenFile={onOpenFile}
149
+ />
150
+ </div>
151
+ <SessionSidebarFooter
152
+ onSettings={() => setSettingsOpen(true)}
153
+ onInfo={() => setInfoOpen(true)}
154
+ androidDownload={androidDownload}
155
+ updateAvailable={updates.hasUpdate}
156
+ onUpdateClick={() => {
157
+ setInfoOpen(true);
158
+ void updates.check();
159
+ }}
160
+ />
161
+ </div>
162
+ </Panel>
163
+
164
+ {open ? <ResizeHandle /> : null}
165
+
166
+ <SettingsModal isOpen={settingsOpen} onClose={() => setSettingsOpen(false)} appSettings={appSettings} />
167
+ <AboutModal isOpen={infoOpen} onClose={() => setInfoOpen(false)} updates={updates} onToast={pushToast} />
168
+ <UpdatePopup updates={updates} />
169
+ <ToastStack toasts={toasts} onDismiss={dismissToast} />
170
+ </>
171
+ );
172
+ }
@@ -7,6 +7,7 @@ import { useInlineRename } from '@/client/hooks/ui/inline-rename';
7
7
  import { useSessionRenaming } from '@/client/hooks/workspace/renaming-sessions';
8
8
  import type { AgentStreamStatus } from '@/shared/lib/chat/omp/status';
9
9
  import { withChord } from '@/client/lib/ui/with-chord';
10
+ import { titlebarPaddingLeft, titlebarPaddingRight } from '@/shared/lib/ui/titlebar';
10
11
 
11
12
  interface TopNavbarProps {
12
13
  sessionTitle: string | null;
@@ -59,10 +60,11 @@ export function TopNavbar({
59
60
  );
60
61
  return (
61
62
  <header
62
- className="h-10 flex-shrink-0 border-b border-ink/10 bg-paper flex items-center justify-between pr-4 z-20 titlebar-drag-region select-none"
63
+ className="h-10 flex-shrink-0 border-b border-ink/10 bg-paper flex items-center justify-between z-20 titlebar-drag-region select-none"
63
64
  style={{
64
- paddingLeft: !showLeftPanel ? 'max(1rem, env(titlebar-area-x, 0px))' : undefined,
65
- paddingRight: 'max(1rem, calc(100vw - env(titlebar-area-width, 100vw)))',
65
+ // The sidebar's own header carries the left inset while it is open.
66
+ paddingLeft: !showLeftPanel ? titlebarPaddingLeft('1rem') : undefined,
67
+ paddingRight: titlebarPaddingRight('1rem'),
66
68
  }}
67
69
  >
68
70
  <div className="flex items-center space-x-2 px-4 min-w-0">
@@ -3,9 +3,10 @@ import { Suspense, lazy } from 'preact/compat';
3
3
  import type { ReactNode, RefObject } from 'preact/compat';
4
4
  import { Group, Panel, type PanelImperativeHandle } from '@/client/components/layout/desktop-layout/resizer';
5
5
  import { ChatTimeline } from '@/client/components/workspace/chat-timeline/index';
6
- import { usePanelCatalog, PluginPanelBody, type PanelBodyProps } from '@/client/components/workspace/plugin-panel/resolve';
6
+ import { usePanelCatalog, PluginPanelBody, usePanelSlotPlugins, type PanelBodyProps } from '@/client/components/workspace/plugin-panel/resolve';
7
+ import { EditorPluginRail } from '@/client/components/workspace/editor/PluginRail';
7
8
  import { PanelHostProvider } from '@kabmo-labs/kabmo-ui';
8
- import { RightActivityBar } from '@/client/components/layout/RightActivityBar';
9
+ import { ActivityBar } from '@/client/components/layout/ActivityBar';
9
10
  import { useEditorDrop, type EditorDropResult } from '@/client/hooks/editor/use-editor-drop';
10
11
  import { EditorDropOverlay } from '@/client/components/workspace/editor/EditorDropOverlay';
11
12
  import { ResizeHandle } from '@/client/components/layout/desktop-layout/ResizeHandle';
@@ -46,6 +47,9 @@ interface WorkspacePanelsProps {
46
47
  showRightPanel: boolean;
47
48
  /** A built-in view id or a plugin panel key (`plugin:<id>/<panel>`). */
48
49
  activeRightPanel: string;
50
+ /** The `plugin:<id>` whose panel owns the editor column, or null. */
51
+ activeEditorPanelKey: string | null;
52
+ onSelectEditorPanel: (panelKey: string | null) => void;
49
53
  rightPanelRef: RefObject<PanelImperativeHandle | null>;
50
54
  /** Remembered width per panel, keyed the same way the layout reports them. */
51
55
  panelWidths: PanelWidths;
@@ -77,6 +81,8 @@ export function WorkspacePanels(props: WorkspacePanelsProps) {
77
81
  editorPanelRef,
78
82
  showRightPanel,
79
83
  activeRightPanel,
84
+ activeEditorPanelKey,
85
+ onSelectEditorPanel,
80
86
  rightPanelRef,
81
87
  panelWidths,
82
88
  editorWidthMode,
@@ -127,6 +133,10 @@ export function WorkspacePanels(props: WorkspacePanelsProps) {
127
133
  const catalog = usePanelCatalog();
128
134
  const { sessionId } = useSessionStateContext();
129
135
  const activePanel = catalog.find((panel) => panel.id === activeRightPanel);
136
+ // The EDITOR column's plugin slot (`panel`): a takeover, so it has no tab in
137
+ // the strip and this rail is its only switch. Empty until a plugin registers
138
+ // one, which is why a Kabmo without one looks exactly as it did.
139
+ const editorPlugins = usePanelSlotPlugins();
130
140
 
131
141
  // Separators present in this group, which also consume width.
132
142
  const handleCount = (showEditor ? 1 : 0) + (showRightPanel ? 1 : 0);
@@ -256,12 +266,18 @@ export function WorkspacePanels(props: WorkspacePanelsProps) {
256
266
 
257
267
  {showEditor && <ResizeHandle />}
258
268
  <Panel panelRef={editorPanelRef} id="editor-panel" defaultSize={editorWidth} minSize={MIN_EDITOR_PANEL_WIDTH} maxSize={editorMaxSize} collapsed={!showEditor}>
259
- <div className="relative w-full h-full" {...drop.dropProps}>
269
+ <div className="flex w-full h-full" {...drop.dropProps}>
260
270
  <EditorDropOverlay visible={drop.isDragging} />
271
+ <EditorPluginRail
272
+ activeKey={activeEditorPanelKey}
273
+ onSelect={onSelectEditorPanel}
274
+ plugins={editorPlugins}
275
+ />
276
+ <div className="relative flex-1 min-w-0 h-full">
261
277
  <PanelSuspense>
262
278
  <Editor
263
279
  className="w-full h-full"
264
- activeEditorPanelKey={null}
280
+ activeEditorPanelKey={activeEditorPanelKey}
265
281
  openedFiles={openedFiles}
266
282
  activeFileId={activeFileId}
267
283
  onSelectFile={onSetActiveFileId}
@@ -271,6 +287,7 @@ export function WorkspacePanels(props: WorkspacePanelsProps) {
271
287
  onFileSaved={onRefreshWorkspace}
272
288
  />
273
289
  </PanelSuspense>
290
+ </div>
274
291
  </div>
275
292
  </Panel>
276
293
 
@@ -320,7 +337,7 @@ export function WorkspacePanels(props: WorkspacePanelsProps) {
320
337
  </Panel>
321
338
  </Group>
322
339
 
323
- <RightActivityBar activePanel={activeRightPanel} onChangePanel={onChangeRightPanel} isPanelOpen={showRightPanel} hasActiveContext={hasActiveContext} activeProjectPath={activeProjectPath} />
340
+ <ActivityBar side="right" activePanel={activeRightPanel} onChangePanel={onChangeRightPanel} isPanelOpen={showRightPanel} hasActiveContext={hasActiveContext} activeProjectPath={activeProjectPath} />
324
341
  </div>
325
342
  );
326
343
  }
@@ -1,7 +1,7 @@
1
1
  import { useCallback, useMemo, useRef, useState } from 'preact/hooks';
2
2
  import { useSearchParams } from '@/client/lib/router/search-params';
3
3
  import { Group, Panel, type PanelImperativeHandle } from '@/client/components/layout/desktop-layout/resizer';
4
- import { SessionSidebar } from '@/client/components/layout/session-sidebar/index';
4
+ import { LeftColumn } from '@/client/components/layout/desktop-layout/LeftColumn';
5
5
  import { SettingsModal } from '@/client/components/settings/LazyModal';
6
6
  import type { SettingsCategoryId } from '@/shared/types';
7
7
  import { activeProjectForSession } from '@/shared/lib/workspace/active-project';
@@ -9,13 +9,16 @@ import { useFileTabs } from '@/client/hooks/workspace/file-tabs';
9
9
  import { useSessionState } from '@/client/hooks/workspace/session-state';
10
10
  import { TopNavbar } from '@/client/components/layout/desktop-layout/TopNavbar';
11
11
  import { WorkspacePanels } from '@/client/components/layout/desktop-layout/WorkspacePanels';
12
+ import { usePanelSlotPlugins } from '@/client/components/workspace/plugin-panel/resolve';
12
13
  import { useAgentStreamStatus } from '@/client/hooks/chat/omp/status';
13
14
  import { usePanelWidths } from '@/client/hooks/workspace/panel-widths';
14
- import { useSidebarWidth } from '@/client/hooks/workspace/sidebar-width';
15
15
  import { useSidebarData } from '@/client/hooks/chat/omp/session-list';
16
- import { MAX_LEFT_PANEL_WIDTH, MIN_LEFT_PANEL_WIDTH } from '@/shared/lib/workspace/sidebar-width';
16
+ import { MIN_LEFT_PANEL_WIDTH } from '@/shared/lib/workspace/sidebar-width';
17
+ import { MIN_LEFT_DOCK_PLUGIN_WIDTH } from '@/shared/lib/workspace/left-dock-width';
18
+ import { SESSIONS_VIEW_ID } from '@/shared/lib/workspace/left-dock';
19
+ import { useLeftDock, useLeftDockWidth } from '@/client/hooks/workspace/left-dock';
20
+ import { useLeftDockView } from '@/client/components/workspace/plugin-panel/catalog';
17
21
  import type { EditorWidthMode } from '@/shared/lib/workspace/panel-widths';
18
- import { ResizeHandle } from '@/client/components/layout/desktop-layout/ResizeHandle';
19
22
  import { useKabmoEvent, useWindowEvent } from '@/client/hooks/ui/window-event';
20
23
  import { PluginBootstrap } from '@/client/lib/plugins/bootstrap';
21
24
  import { WORKSPACE_KEY_BINDINGS } from '@/shared/lib/workspace/keymap';
@@ -38,10 +41,22 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
38
41
  const [showRightPanel, setShowRightPanel] = useSessionState<boolean>('layout.showRightPanel', appSettings.showRightPanel ?? true);
39
42
  const [activeRightPanel, setActiveRightPanel] = useSessionState<string>('layout.activeRightPanel', (appSettings.activeRightPanel as string) ?? 'files');
40
43
  const [showLeftPanel, setShowLeftPanel] = useSessionState<boolean>('layout.showLeftPanel', appSettings.showLeftPanel ?? true);
44
+ const [activeLeftView, setActiveLeftView] = useSessionState<string>('layout.activeLeftPanel', SESSIONS_VIEW_ID);
45
+ // Which plugin panel, if any, owns the editor column. Per session, like every
46
+ // other layout choice: two chats may be working on different things.
47
+ const [activeEditorPanelKey, setActiveEditorPanelKey] = useSessionState<string | null>('layout.activeEditorPanel', null);
41
48
  const { widths: panelWidths, commitWidths } = usePanelWidths();
42
- // The sidebar's width is the one panel width that is NOT per session: it
43
- // lists the same sessions in every chat, so it lives in `app_settings`.
44
- const [sidebarWidth, commitSidebarWidth] = useSidebarWidth();
49
+ // The dock's width is per VIEW and global, like the right panel's order: it
50
+ // lists the same views in every chat.
51
+ const dock = useLeftDock();
52
+ // What the dock actually shows: a stored id naming a panel that has moved to
53
+ // the right column falls back to the session list, while one naming nothing at
54
+ // all is left for the dock to explain.
55
+ const leftView = useLeftDockView(activeLeftView, dock.enabled);
56
+ const [sidebarWidth, commitSidebarWidth] = useLeftDockWidth(leftView);
57
+ // A plugin view placed on the dock declares its own floor; the session list
58
+ // keeps the column's own. Resolved here because the resizer clamps to it.
59
+ const leftDockMinWidth = leftView === SESSIONS_VIEW_ID ? MIN_LEFT_PANEL_WIDTH : MIN_LEFT_DOCK_PLUGIN_WIDTH;
45
60
 
46
61
  const editorPanelRef = useRef<PanelImperativeHandle | null>(null);
47
62
  const rightPanelRef = useRef<PanelImperativeHandle | null>(null);
@@ -72,7 +87,17 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
72
87
  convertDiffToEditor,
73
88
  } = useFileTabs(activeProjectPath, sessionId || '', handleOpenTab);
74
89
 
75
- const showEditor = openedFiles.length > 0 && (userToggledEditor ?? true);
90
+ // Three ways the column has content: a file, a chosen takeover, and a
91
+ // takeover that merely exists. Only the first two OPEN it — installing a
92
+ // plugin must not push a column onto the screen — but the third is what the
93
+ // toggle has to open on to, since the rail lives inside the column.
94
+ const editorTakeovers = usePanelSlotPlugins();
95
+ // The user's own switch wins over every default, in both directions.
96
+ const editorHasContent =
97
+ openedFiles.length > 0 || activeEditorPanelKey !== null || editorTakeovers.length > 0;
98
+ const showEditor =
99
+ editorHasContent &&
100
+ (userToggledEditor ?? (openedFiles.length > 0 || activeEditorPanelKey !== null));
76
101
 
77
102
  // A diff wants room a source file does not, so the editor panel keeps a
78
103
  // separate remembered width per tab kind.
@@ -81,13 +106,9 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
81
106
  [openedFiles, activeFileId],
82
107
  );
83
108
 
84
- // Width restoration is declarative: every panel's `defaultSize` is derived
85
- // from the remembered width for its current slot, and `Panel` adopts a
86
- // changed `defaultSize` itself. That covers the two cases that used to need
87
- // an imperative push — a right-panel view switch and an editor source↔diff
88
- // switch, both of which keep the panel mounted while only its width slot
89
- // changes — and it cannot go stale, because there is no cached layout to
90
- // disagree with the props.
109
+ // Width restoration is declarative: each panel's `defaultSize` comes from the
110
+ // remembered width for its current slot and `Panel` adopts a change, so a
111
+ // view switch needs no imperative push and no cached layout can go stale.
91
112
 
92
113
  const [settingsOpen, setSettingsOpen] = useState(false);
93
114
  const [settingsCategory, setSettingsCategory] = useState<SettingsCategoryId>('appearance');
@@ -114,13 +135,9 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
114
135
  const { toasts, pushToast, dismissToast } = useToasts();
115
136
 
116
137
  /**
117
- * A drop on the workspace either opens a file or is refused, and the refusal
118
- * has a reason worth reading ("outside every workspace", "not text") — a
119
- * silent no-op would read as a broken drop target.
120
- *
121
- * The TARGET itself is not here: it belongs to the chat and editor panels
122
- * (`WorkspacePanels`), because the sidebar and the right panel are panels in
123
- * the same group and a layout-wide target would accept a drop over them too.
138
+ * A refused drop names its reason ("outside every workspace", "not text") —
139
+ * a silent no-op reads as a broken target. The TARGET is `WorkspacePanels`':
140
+ * a layout-wide one would accept a drop over the sidebar and right panel too.
124
141
  */
125
142
  const handleDropResult = useCallback(
126
143
  (result: EditorDropResult) => pushToast(result.message, result.ok ? 'success' : 'error'),
@@ -142,21 +159,21 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
142
159
  : undefined;
143
160
 
144
161
  /**
145
- * The workspace's own shortcuts, resolved from the shared keymap so the
146
- * tooltips on the panel toggles name the chord that is actually bound.
147
- *
148
- * `⌘S` is deliberately absent here: the editor panel already owns it (it is
149
- * the only surface with a buffer to save), and binding it twice would save
150
- * the file twice.
162
+ * The workspace shortcuts, from the shared keymap so the toggles' tooltips
163
+ * name the bound chord. `⌘S` is absent: the editor owns it, and binding it
164
+ * twice would save the file twice.
151
165
  */
152
166
  useWindowEvent('keydown', (e) => {
153
167
  const command = resolveBinding(WORKSPACE_KEY_BINDINGS, e);
154
168
  if (!command) return;
155
169
  if (command === 'saveFile') return;
156
170
  if (command === 'toggleEditorPanel' && !showEditor) {
157
- // The editor panel is only meaningful with a file open, and `showEditor`
158
- // already folds in the user's own toggle.
159
- if (openedFiles.length === 0) return;
171
+ // The editor panel needs something to show — a file or a takeover — and
172
+ // `showEditor` already folds in the user's own toggle.
173
+ // The toggle is also the only way to REACH a takeover while the column is
174
+ // closed, since the rail lives inside the column: with no file and no
175
+ // takeover there would be nothing to open on to, and with one there is.
176
+ if (openedFiles.length === 0 && !activeEditorPanelKey && editorTakeovers.length === 0) return;
160
177
  }
161
178
  e.preventDefault();
162
179
  if (command === 'toggleSidebar') setShowLeftPanel((previous) => !previous);
@@ -181,34 +198,17 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
181
198
  setSettingsOpen(true);
182
199
  });
183
200
 
201
+ // Clicking the ACTIVE view on an open panel collapses that panel; any other
202
+ // click selects and opens. One handler for both columns' rails.
184
203
  const handleChangeRightPanel = (panel: string) => {
185
- let nextShow = showRightPanel;
186
- let nextActive = activeRightPanel;
187
-
188
- if (activeRightPanel === panel && showRightPanel) {
189
- nextShow = false;
190
- } else {
191
- nextActive = panel;
192
- nextShow = true;
193
- }
194
-
195
- setShowRightPanel(nextShow);
196
- setActiveRightPanel(nextActive);
197
- };
198
-
199
- const handleToggleLeftPanel = (show: boolean) => {
200
- setShowLeftPanel(show);
204
+ const collapsing = activeRightPanel === panel && showRightPanel;
205
+ setShowRightPanel(!collapsing);
206
+ if (!collapsing) setActiveRightPanel(panel);
201
207
  };
202
208
 
203
- const handleToggleRightPanel = () => {
204
- setShowRightPanel(!showRightPanel);
205
- };
206
-
207
- const handleToggleEditor = () => {
208
- setUserToggledEditor(!showEditor);
209
- // The editor panel keeps its own remembered width per tab kind, so a
210
- // dragged width survives the toggle without being re-applied here.
211
- };
209
+ const handleToggleLeftPanel = (show: boolean) => setShowLeftPanel(show);
210
+ const handleToggleRightPanel = () => setShowRightPanel(!showRightPanel);
211
+ const handleToggleEditor = () => setUserToggledEditor(!showEditor);
212
212
 
213
213
  return (
214
214
  <div
@@ -216,7 +216,8 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
216
216
  className="relative flex flex-col h-screen w-screen overflow-hidden bg-canvas text-ink font-sans selection:bg-ink selection:text-canvas"
217
217
  >
218
218
 
219
- {/* Main Workspace: full-height left sidebar (resizable) + right stack */}
219
+ {/* Main Workspace: the resizable LEFT dock (rail + view, see
220
+ `LeftColumn`) plus the right stack. */}
220
221
  <div className="flex flex-1 overflow-hidden">
221
222
  <Group
222
223
  orientation="horizontal"
@@ -226,10 +227,29 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
226
227
  if (left != null && left > 0) commitSidebarWidth(Math.round(left));
227
228
  }}
228
229
  >
229
- <Panel panelRef={leftPanelRef} id="left-panel" defaultSize={sidebarWidth} minSize={MIN_LEFT_PANEL_WIDTH} maxSize={MAX_LEFT_PANEL_WIDTH} collapsed={!showLeftPanel}>
230
- <SessionSidebar className="w-full h-full" onClose={() => handleToggleLeftPanel(false)} appSettings={appSettings} streamStatus={streamStatus} />
231
- </Panel>
232
- {showLeftPanel && <ResizeHandle />}
230
+ <LeftColumn
231
+ panelRef={leftPanelRef}
232
+ open={showLeftPanel}
233
+ railEnabled={dock.enabled}
234
+ view={leftView}
235
+ onSelectView={(panel) => {
236
+ // A rail click means "show this view" — never "toggle" it. A
237
+ // toggle would hide the rail the user is aiming at, and the dock
238
+ // has its own collapse control in the header.
239
+ setActiveLeftView(panel);
240
+ if (!showLeftPanel) handleToggleLeftPanel(true);
241
+ }}
242
+ onCollapse={() => handleToggleLeftPanel(false)}
243
+ width={sidebarWidth}
244
+ minWidth={leftDockMinWidth}
245
+ hasActiveContext={hasActiveContext}
246
+ activeProjectPath={activeProjectPath}
247
+ appSettings={appSettings}
248
+ streamStatus={streamStatus}
249
+ refreshKey={refreshKey}
250
+ onRefresh={handleRefreshWorkspace}
251
+ onOpenFile={(file) => handleOpenFile(file as Parameters<typeof handleOpenFile>[0])}
252
+ />
233
253
 
234
254
  {/* Right stack: top navbar + resizable workspace (the group's filler) */}
235
255
  <Panel id="main-right-stack" filler minSize={0}>
@@ -255,6 +275,13 @@ export function DesktopLayout({ sessionId, onSwitchToMobile, appSettings = {} }:
255
275
  editorPanelRef={editorPanelRef}
256
276
  showRightPanel={showRightPanel}
257
277
  activeRightPanel={activeRightPanel}
278
+ activeEditorPanelKey={activeEditorPanelKey}
279
+ onSelectEditorPanel={(panelKey) => {
280
+ setActiveEditorPanelKey(panelKey);
281
+ // Choosing a takeover must OPEN the column, or the click would
282
+ // land on a panel that is still collapsed.
283
+ if (panelKey) setUserToggledEditor(true);
284
+ }}
258
285
  rightPanelRef={rightPanelRef}
259
286
  panelWidths={panelWidths}
260
287
  editorWidthMode={editorWidthMode}