@kabmo-labs/kabmo 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +412 -383
  3. package/dist/client/static/js/index-3qamd33d.css +1 -0
  4. package/dist/client/static/js/{index-28z316qk.js → index-twsk4wfa.js} +791 -791
  5. package/package.json +1 -1
  6. package/src/client/components/common/batch-selection/BatchActionBar.tsx +8 -1
  7. package/src/client/components/common/session-actions-menu/index.tsx +23 -2
  8. package/src/client/components/layout/{RightActivityBar.test.ts → ActivityBar.test.ts} +5 -5
  9. package/src/client/components/layout/{RightActivityBar.tsx → ActivityBar.tsx} +87 -52
  10. package/src/client/components/layout/LeftActivityBar.test.ts +201 -0
  11. package/src/client/components/layout/LeftDock.tsx +119 -0
  12. package/src/client/components/layout/desktop-layout/LeftColumn.tsx +172 -0
  13. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +22 -5
  14. package/src/client/components/layout/desktop-layout/index.tsx +88 -61
  15. package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
  16. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
  17. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
  18. package/src/client/components/layout/session-sidebar/SessionItem.tsx +30 -5
  19. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  20. package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
  21. package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
  22. package/src/client/components/mobile/RightSidebar.tsx +8 -5
  23. package/src/client/components/mobile/SessionSidebar.tsx +6 -0
  24. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -3
  25. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
  26. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
  27. package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
  28. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +41 -13
  29. package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
  30. package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
  31. package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
  32. package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
  33. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
  34. package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
  35. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
  36. package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
  37. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
  38. package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
  39. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
  40. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
  41. package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
  42. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
  43. package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
  44. package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
  45. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
  46. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +14 -9
  47. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
  48. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
  49. package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +154 -0
  50. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
  51. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
  52. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
  53. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
  54. package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
  55. package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
  56. package/src/client/components/workspace/editor/index.tsx +1 -0
  57. package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
  58. package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
  59. package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
  60. package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
  61. package/src/client/components/workspace/panels/builtin.tsx +8 -0
  62. package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
  63. package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
  64. package/src/client/hooks/chat/composer/compact.ts +119 -0
  65. package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
  66. package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
  67. package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
  68. package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
  69. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
  70. package/src/client/hooks/chat/timeline/actions.ts +34 -35
  71. package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
  72. package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
  73. package/src/client/hooks/chat/timeline/index.ts +15 -19
  74. package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
  75. package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
  76. package/src/client/hooks/chat/waiting/cancel.ts +34 -0
  77. package/src/client/hooks/chat/waiting/choice.ts +53 -0
  78. package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
  79. package/src/client/hooks/chat/waiting/held.ts +42 -0
  80. package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
  81. package/src/client/hooks/chat/waiting/hold.ts +156 -0
  82. package/src/client/hooks/chat/waiting/store.ts +100 -0
  83. package/src/client/hooks/workspace/left-dock.ts +132 -0
  84. package/src/client/hooks/workspace/panel-registry.ts +13 -0
  85. package/src/client/tailwind.css +22 -0
  86. package/src/server/db.server.test.ts +52 -1
  87. package/src/server/db.server.ts +49 -1
  88. package/src/server/index.ts +9 -0
  89. package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
  90. package/src/server/lib/assets/compressed-cache.server.ts +88 -0
  91. package/src/server/lib/db/schema.test.ts +1 -0
  92. package/src/server/lib/db/schema.ts +3 -0
  93. package/src/server/lib/fs/fs-path.test.ts +1 -1
  94. package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
  95. package/src/server/lib/omp/session/delete.server.ts +8 -0
  96. package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
  97. package/src/server/lib/panels/registry.server.ts +2 -2
  98. package/src/server/lib/queue/delivery.server.ts +31 -39
  99. package/src/server/lib/queue/payload.ts +73 -0
  100. package/src/server/lib/realtime/signals.server.ts +10 -1
  101. package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
  102. package/src/server/lib/realtime/topics/global.server.ts +5 -0
  103. package/src/server/lib/realtime/topics.server.ts +2 -0
  104. package/src/server/lib/util/with-timeout.ts +29 -0
  105. package/src/server/lib/waiting/row-clock.server.ts +37 -0
  106. package/src/server/lib/waiting/runtime.server.test.ts +278 -0
  107. package/src/server/lib/waiting/runtime.server.ts +313 -0
  108. package/src/server/lib/waiting/schema.server.ts +46 -0
  109. package/src/server/lib/waiting/settle.test.ts +84 -0
  110. package/src/server/lib/waiting/settle.ts +77 -0
  111. package/src/server/lib/waiting/store.server.test.ts +167 -0
  112. package/src/server/lib/waiting/store.server.ts +268 -0
  113. package/src/server/lib/waiting/sweep-trigger.ts +35 -0
  114. package/src/server/plugins/compress.ts +18 -6
  115. package/src/server/plugins/ssr.ts +16 -8
  116. package/src/server/plugins/static.test.ts +59 -0
  117. package/src/server/plugins/static.ts +56 -14
  118. package/src/server/routes/index.ts +2 -0
  119. package/src/server/routes/waiting/bindings.ts +14 -0
  120. package/src/server/routes/waiting/index.ts +123 -0
  121. package/src/server/routes/waiting/routes.test.ts +121 -0
  122. package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
  123. package/src/shared/lib/chat/timeline/gutter.ts +37 -0
  124. package/src/shared/lib/realtime/protocol.ts +10 -0
  125. package/src/shared/lib/workspace/left-dock-width.ts +78 -0
  126. package/src/shared/lib/workspace/left-dock.test.ts +203 -0
  127. package/src/shared/lib/workspace/left-dock.ts +197 -0
  128. package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
  129. package/src/shared/lib/workspace/panel-ids.ts +114 -26
  130. package/src/shared/lib/workspace/sidebar-width.ts +4 -0
  131. package/src/shared/types/index.ts +1 -0
  132. package/src/shared/types/waiting.ts +54 -0
  133. package/src/shared/types/workspace.ts +8 -0
  134. package/src/test-support/isolated-db.ts +3 -4
  135. package/dist/client/static/js/index-pdp5dwc8.css +0 -1
@@ -5,7 +5,7 @@ import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
5
5
  import type { Attachment } from '@/shared/types';
6
6
  import type { UserTurnRef } from '@/shared/types/chat';
7
7
  import type { ExtensionDialogResponse } from '@/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/Lazy';
8
- import { EmptyWorkspacePrompt } from '@/client/components/workspace/chat-timeline/EmptyWorkspacePrompt';
8
+ import { PendingChatView } from '@/client/components/workspace/chat-timeline/PendingChatView';
9
9
  import { SessionSkeleton } from '@/client/components/workspace/chat-timeline/SessionSkeleton';
10
10
  import { SubagentView } from '@/client/components/workspace/chat-timeline/SubagentView';
11
11
  import { TimelineBody } from '@/client/components/workspace/chat-timeline/TimelineBody';
@@ -15,12 +15,14 @@ import { useSessionTitle } from '@/client/hooks/chat/timeline/session-title';
15
15
  import { useSubagentView } from '@/client/hooks/chat/timeline/subagent-view';
16
16
  import { useUndoConfirmation } from '@/client/hooks/chat/timeline/undo-confirmation';
17
17
  import { useModelNames } from '@/client/hooks/models/use-model-names';
18
+ import { useRunIdentity } from '@/client/hooks/chat/timeline/run-identity';
18
19
  import { useProviderNames } from '@/client/hooks/models/use-provider-names';
19
20
  import { useToasts } from '@/client/hooks/ui/toasts';
20
21
  import { ChatOverlays } from '@/client/components/workspace/chat-timeline/ChatOverlays';
21
22
  import { ToastStack } from '@/client/components/common/ToastStack';
22
23
  import { composerRootFor } from '@/shared/lib/workspace/active-project';
23
24
  import { useSidebarData } from '@/client/hooks/chat/omp/session-list';
25
+ import { useHeldWait } from '@/client/hooks/chat/waiting/held';
24
26
 
25
27
  interface ChatTimelineProps {
26
28
  className?: string;
@@ -36,7 +38,7 @@ interface ChatTimelineProps {
36
38
  }
37
39
 
38
40
  export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle, variant = 'desktop' }: ChatTimelineProps) {
39
- const { folders, streamStatusMap } = useSidebarData();
41
+ const { folders, streamStatusMap, titleHint } = useSidebarData();
40
42
  const isMobile = variant === 'mobile';
41
43
  const { toasts, pushToast, dismissToast } = useToasts();
42
44
  const {
@@ -82,6 +84,7 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
82
84
  planReview,
83
85
  composerModelRef,
84
86
  deferredComposerPickRef,
87
+ waitingDependency,
85
88
  extensionDialogs,
86
89
  resolveExtensionDialog,
87
90
  respondToExtensionUi,
@@ -151,24 +154,14 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
151
154
 
152
155
  const modelNames = useModelNames();
153
156
  const providerNames = useProviderNames();
154
- // The run's model is persisted beside the stream status and rides the sidebar
155
- // payload, so the indicator names the real provider/model even for a run this
156
- // page never started — the transcript is the fallback, not the source.
157
- const runModel = useMemo(
158
- () => folders.flatMap((f) => f.sessions ?? []).find((s) => String(s.id) === String(sessionId))?.runModel ?? null,
159
- [folders, sessionId],
160
- );
161
- const fallbackModel = typeof sessionData?.model === 'object' ? sessionData.model : null;
162
- const sessionProvider = runModel?.provider ?? fallbackModel?.provider;
163
- const sessionModelId = runModel?.modelId ?? fallbackModel?.modelId;
164
- const sessionModelName = sessionModelId
165
- ? (modelNames[sessionModelId] ?? sessionModelId)
166
- : (typeof sessionData?.model === 'string' ? sessionData.model : undefined);
157
+ const { sessionProvider, sessionModelName } = useRunIdentity({ folders, sessionId, sessionData, modelNames });
167
158
 
168
159
  // A pending "new-…" session has no messages yet, so the workspace picker
169
160
  // must stay available until the first chat is sent (which spawns the real
170
161
  // omp session). Never lock the user into a folder before sending.
171
162
  const isPendingSession = Boolean(sessionId?.startsWith('new-'));
163
+
164
+ const { heldWait, cancelHeldWait } = useHeldWait(sessionId, isPendingSession, pushToast);
172
165
  const composerRoot = composerRootFor(folders, sessionId, selectedFolderId);
173
166
 
174
167
  // The chat-level "a run is in flight" flag is computed in `useChatTimeline`
@@ -184,7 +177,7 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
184
177
 
185
178
  if (!sessionId || isPendingSession) {
186
179
  return (
187
- <EmptyWorkspacePrompt
180
+ <PendingChatView
188
181
  className={className}
189
182
  folders={folders}
190
183
  selectedFolderId={selectedFolderId}
@@ -216,6 +209,12 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
216
209
  onUndo={handleRequestUndo}
217
210
  onRetry={handleRetry}
218
211
  onNewChat={handleNewChat}
212
+ streamStatusMap={streamStatusMap}
213
+ titleForSession={titleHint}
214
+ waitingFor={waitingDependency.value}
215
+ onWaitingForChange={waitingDependency.set}
216
+ heldWait={heldWait ? { title: heldWait.dependsOnTitle } : null}
217
+ onCancelWait={cancelHeldWait}
219
218
  />
220
219
  );
221
220
  }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The editor column's switcher: files, or one of the plugins that took the
8
+ * column over.
9
+ *
10
+ * The `panel` slot is a TAKEOVER — the plugin draws the whole column, with no tab
11
+ * strip, no file tree and no chrome — so a file tab cannot represent it and the
12
+ * tab strip cannot switch to it. This narrow strip is that switch, and it is why
13
+ * the slot is reachable at all: until it existed the slot was registered and
14
+ * never rendered (`activeEditorPanelKey` was a hard-coded `null`), so a plugin
15
+ * like `session-info` shipped a Scratchpad nobody could open.
16
+ *
17
+ * Two rules:
18
+ *
19
+ * - **Nothing is drawn when no plugin contributes a `panel`.** A rail with one
20
+ * button would be a control that only ever says "you are already here", so a
21
+ * Kabmo with no such plugin looks exactly as it did before the slot.
22
+ * - **Files is the first entry and always present**, because the takeover has to
23
+ * be reversible: without it a plugin that owns the column would leave no way
24
+ * back to the editor.
25
+ */
26
+
27
+ import { Files } from 'lucide-preact';
28
+ import { PluginMark } from '@/client/components/settings/categories/panel-plugins/PluginMark';
29
+ import type { ResolvedPluginPanel } from '@/client/components/workspace/plugin-panel/resolve';
30
+
31
+ interface EditorPluginRailProps {
32
+ /** The `plugin:<id>` whose panel owns the column, or null for the files view. */
33
+ activeKey: string | null;
34
+ onSelect: (panelKey: string | null) => void;
35
+ plugins: readonly ResolvedPluginPanel[];
36
+ }
37
+
38
+ export function EditorPluginRail({ activeKey, onSelect, plugins }: EditorPluginRailProps) {
39
+ if (plugins.length === 0) return null;
40
+
41
+ const entryClass = (active: boolean) =>
42
+ `relative w-full h-9 flex items-center justify-center transition-colors border-l-2 ${
43
+ active ? 'text-ink border-ink bg-ink/5' : 'text-ink/40 border-transparent hover:text-ink hover:bg-ink/5'
44
+ } cursor-pointer`;
45
+
46
+ return (
47
+ <nav
48
+ aria-label="Editor panel views"
49
+ className="w-9 flex-shrink-0 border-r border-ink/10 bg-paper flex flex-col items-center py-2 space-y-1 z-10"
50
+ >
51
+ <button
52
+ type="button"
53
+ className={entryClass(activeKey === null)}
54
+ onClick={() => onSelect(null)}
55
+ title="Files"
56
+ aria-label="Files"
57
+ aria-pressed={activeKey === null}
58
+ >
59
+ <Files size={16} />
60
+ </button>
61
+ {plugins.map((plugin) => {
62
+ const active = activeKey === plugin.panelKey;
63
+ return (
64
+ <button
65
+ key={plugin.panelKey}
66
+ type="button"
67
+ className={entryClass(active)}
68
+ onClick={() => onSelect(plugin.panelKey)}
69
+ title={plugin.title}
70
+ aria-label={plugin.title}
71
+ aria-pressed={active}
72
+ >
73
+ <PluginMark name={plugin.name} iconUrl={plugin.iconUrl} size={16} />
74
+ </button>
75
+ );
76
+ })}
77
+ </nav>
78
+ );
79
+ }
@@ -271,6 +271,7 @@ export function Editor({
271
271
  );
272
272
  }
273
273
 
274
+
274
275
  if (openedFiles.length === 0) {
275
276
  return (
276
277
  <div className={`flex flex-col h-full bg-canvas items-center justify-center text-ink/40 ${className}`}>
@@ -0,0 +1,126 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The editor column's plugin slot (`app.panel`).
8
+ *
9
+ * The slot was registered and NEVER rendered: `WorkspacePanels` passed
10
+ * `activeEditorPanelKey={null}` hard-coded and no call site ever wrote it, so a
11
+ * plugin that shipped two panels — `marketplace/plugins/session-info` registers a
12
+ * `rightPanel` and a `panel` — had exactly one of them reachable. These cases pin
13
+ * the two halves that make it reachable: a rail that can switch to it, and a
14
+ * column that stays open for it with no file behind it.
15
+ *
16
+ * The rail is a plain component, so it is asserted directly. The open/closed
17
+ * decision belongs to the layout and is asserted through `showEditor`'s own rule
18
+ * (`openedFiles.length > 0 || an editor plugin is chosen`), which this file
19
+ * reproduces rather than mounts the whole desktop for.
20
+ */
21
+
22
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
23
+ import { Window } from 'happy-dom';
24
+ import { h, render } from 'preact';
25
+ import { act } from 'preact/test-utils';
26
+ import { EditorPluginRail } from '@/client/components/workspace/editor/PluginRail';
27
+ import type { ResolvedPluginPanel } from '@/client/components/workspace/plugin-panel/resolve';
28
+ import { installDomGlobals } from '@/test-support/pristine-globals';
29
+
30
+ let container: HTMLElement | null = null;
31
+
32
+ // Installed per CASE: another suite's teardown can strip `window` mid-file when
33
+ // the runner interleaves files, and this hook's first render would then throw.
34
+ beforeEach(() => {
35
+ installDomGlobals(new Window({ url: 'http://127.0.0.1:1' }));
36
+ });
37
+
38
+ function panel(pluginId: string, title: string): ResolvedPluginPanel {
39
+ return {
40
+ panelKey: `plugin:${pluginId}`,
41
+ pluginId,
42
+ name: pluginId,
43
+ title,
44
+ Component: () => null,
45
+ };
46
+ }
47
+
48
+ async function mountRail(props: {
49
+ activeKey: string | null;
50
+ plugins: ResolvedPluginPanel[];
51
+ onSelect?: (key: string | null) => void;
52
+ }) {
53
+ const host = document.createElement('div');
54
+ document.body.appendChild(host);
55
+ container = host;
56
+ await act(async () => {
57
+ render(
58
+ h(EditorPluginRail, {
59
+ activeKey: props.activeKey,
60
+ onSelect: props.onSelect ?? (() => {}),
61
+ plugins: props.plugins,
62
+ }),
63
+ host,
64
+ );
65
+ });
66
+ }
67
+
68
+ const buttons = () =>
69
+ [...(container?.querySelectorAll('nav[aria-label="Editor panel views"] button') ?? [])].map((el) => ({
70
+ label: el.getAttribute('aria-label'),
71
+ pressed: el.getAttribute('aria-pressed'),
72
+ }));
73
+
74
+ afterEach(() => {
75
+ if (container) {
76
+ render(null, container);
77
+ container.remove();
78
+ container = null;
79
+ }
80
+ });
81
+
82
+ describe('editor plugin rail', () => {
83
+ test('draws nothing when no plugin took the column over', async () => {
84
+ await mountRail({ activeKey: null, plugins: [] });
85
+ // A rail with only "Files" would be a control that never changes anything,
86
+ // so a Kabmo without such a plugin looks exactly as it did.
87
+ expect(container!.querySelector('nav')).toBeNull();
88
+ });
89
+
90
+ test('offers Files and every takeover, with the active one pressed', async () => {
91
+ await mountRail({ activeKey: 'plugin:session-info', plugins: [panel('session-info', 'Scratchpad')] });
92
+ expect(buttons()).toEqual([
93
+ { label: 'Files', pressed: 'false' },
94
+ { label: 'Scratchpad', pressed: 'true' },
95
+ ]);
96
+ });
97
+
98
+ test('Files is always an entry, so a takeover is reversible', async () => {
99
+ const selected: (string | null)[] = [];
100
+ await mountRail({
101
+ activeKey: 'plugin:demo',
102
+ plugins: [panel('demo', 'Demo')],
103
+ onSelect: (key) => selected.push(key),
104
+ });
105
+ const files = [...container!.querySelectorAll('nav button')].find(
106
+ (el) => el.getAttribute('aria-label') === 'Files',
107
+ ) as HTMLButtonElement;
108
+ await act(async () => files.click());
109
+ // `null`, not a sentinel: the layout's own state spells "no plugin" that way.
110
+ expect(selected).toEqual([null]);
111
+ });
112
+
113
+ test('choosing a takeover reports its panel key', async () => {
114
+ const selected: (string | null)[] = [];
115
+ await mountRail({
116
+ activeKey: null,
117
+ plugins: [panel('demo', 'Demo')],
118
+ onSelect: (key) => selected.push(key),
119
+ });
120
+ const takeover = [...container!.querySelectorAll('nav button')].find(
121
+ (el) => el.getAttribute('aria-label') === 'Demo',
122
+ ) as HTMLButtonElement;
123
+ await act(async () => takeover.click());
124
+ expect(selected).toEqual(['plugin:demo']);
125
+ });
126
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The picker's derived grouping, with the row shapes that have actually arrived.
8
+ *
9
+ * The case that matters is a model entry with NO provider. `/api/models` is a
10
+ * projection of omp's registry, and a partial entry (a custom provider whose
11
+ * field never landed) reached `buildPickerGroups` as `undefined` and threw
12
+ * `model.provider.toUpperCase` — which took the whole composer down, because
13
+ * the model pill renders inside `ChatInput`. Measured: eight `compact composer`
14
+ * cases failed with exactly that stack, in a suite that never mentions models.
15
+ *
16
+ * The guard is `String(… ?? 'unknown')`, so the row stays visible under a
17
+ * stable section instead of vanishing from a list the user may have selected.
18
+ */
19
+
20
+ import { describe, expect, test } from 'bun:test';
21
+ import { buildPickerGroups } from '@/client/components/workspace/model-dropdown/groups';
22
+ import type { AIModelOption } from '@/shared/types';
23
+
24
+ function model(overrides: Partial<AIModelOption> & { id: string }): AIModelOption {
25
+ return { name: overrides.id, provider: 'p', ...overrides } as AIModelOption;
26
+ }
27
+
28
+ describe('buildPickerGroups', () => {
29
+ test('a row with no provider does not throw, and keeps its section', () => {
30
+ const rows = [model({ id: 'a' }), { id: 'b', name: 'B' } as AIModelOption];
31
+ const groups = buildPickerGroups(rows, '', {}, [], []);
32
+ expect(Object.keys(groups.byProvider).sort()).toEqual(['P', 'UNKNOWN']);
33
+ expect(groups.byProvider.UNKNOWN?.map((m) => m.id)).toEqual(['b']);
34
+ // It is still a row the user can see and pick.
35
+ expect(groups.visibleFlatList.map((m) => m.id).sort()).toEqual(['a', 'b']);
36
+ });
37
+
38
+ test('an empty provider string lands in the same section as a missing one', () => {
39
+ const groups = buildPickerGroups([model({ id: 'a', provider: '' })], '', {}, [], []);
40
+ expect(Object.keys(groups.byProvider)).toEqual(['UNKNOWN']);
41
+ });
42
+
43
+ test('a search still narrows a guarded row rather than crashing on it', () => {
44
+ const rows = [model({ id: 'a', name: 'Alpha' }), { id: 'b', name: 'Beta' } as AIModelOption];
45
+ const groups = buildPickerGroups(rows, 'beta', {}, [], []);
46
+ expect(groups.filtered.map((m) => m.id)).toEqual(['b']);
47
+ });
48
+
49
+ test('rows group by upper-cased provider and keep catalog order', () => {
50
+ const groups = buildPickerGroups(
51
+ [model({ id: 'a', provider: 'anthropic' }), model({ id: 'b', provider: 'Anthropic' })],
52
+ '',
53
+ {},
54
+ [],
55
+ [],
56
+ );
57
+ expect(Object.keys(groups.byProvider)).toEqual(['ANTHROPIC']);
58
+ expect(groups.byProvider.ANTHROPIC?.map((m) => m.id)).toEqual(['a', 'b']);
59
+ });
60
+ });
@@ -75,7 +75,13 @@ export function buildPickerGroups(
75
75
 
76
76
  const byProvider: Record<string, AIModelOption[]> = {};
77
77
  for (const model of filtered) {
78
- const provider = model.provider.toUpperCase();
78
+ // `String(…)` rather than a bare call: this is the composer's picker, and a
79
+ // row whose provider never landed must not take the chat input down. Rows
80
+ // that reach here from `/api/models` are normalized in `toOption`; this is
81
+ // the guard for every other caller (a plugin panel, a stored snapshot).
82
+ // An EMPTY string is the same problem as a missing one — it would render a
83
+ // heading with no name — so both land in `unknown`.
84
+ const provider = String(model.provider || 'unknown').toUpperCase();
79
85
  (byProvider[provider] ??= []).push(model);
80
86
  }
81
87
 
@@ -12,8 +12,14 @@ function toOption(m: ModelEntry): AIModelOption {
12
12
  const cost = m.cost;
13
13
  return {
14
14
  id: m.id,
15
- name: m.name,
16
- provider: m.provider,
15
+ name: m.name || m.id,
16
+ // A row without a provider still has to render. `/api/models` is read from
17
+ // omp's registry, and a partial entry (a custom provider whose `provider`
18
+ // field never landed) used to crash the whole picker — `buildPickerGroups`
19
+ // upper-cased `undefined` and the composer's model pill took the chat input
20
+ // down with it. `unknown` keeps the row visible and gives it a stable
21
+ // section instead of dropping a model the user may have selected.
22
+ provider: m.provider || 'unknown',
17
23
  contextWindow: m.contextWindow,
18
24
  thinkingLevel: ladder[0] ?? 'off',
19
25
  thinkingLevels: ladder,
@@ -22,6 +22,7 @@ import {
22
22
  RIGHT_PANEL_TYPES,
23
23
  type RightPanelType,
24
24
  } from '@/shared/lib/workspace/right-panels';
25
+ import type { AgentStreamStatus } from '@/shared/lib/chat/omp/status';
25
26
 
26
27
  /**
27
28
  * Props a panel BODY may receive.
@@ -51,6 +52,13 @@ export interface PanelBodyProps {
51
52
  onClose?: () => void;
52
53
  /** The phone's drawer hides a view's own header; the desktop stack does not. */
53
54
  showHeader?: boolean;
55
+ /**
56
+ * The boot settings snapshot, for the views that read it (the session list
57
+ * passes it to its own Settings modal and to the prewarm's access mode).
58
+ */
59
+ appSettings?: Record<string, any>;
60
+ /** The live agent stream status, for the session list's wordmark badge. */
61
+ streamStatus?: AgentStreamStatus;
54
62
  }
55
63
 
56
64
  /**
@@ -0,0 +1,237 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The panel catalog: one list, two columns.
8
+ *
9
+ * The built-in views and the installed plugins travel the SAME path — one
10
+ * merged list, one enablement set, one entry shape — so the activity bars, the
11
+ * desktop panel stacks and the phone's tab strip cannot disagree about what
12
+ * exists or what it is called.
13
+ *
14
+ * Two axes are decided here and nowhere else:
15
+ *
16
+ * - **SIDE.** A built-in view is `right` by construction; a plugin panel is
17
+ * `left` only while the user placed it there (`left-dock.ts`). A caller asks
18
+ * for the column it draws (`usePanelCatalog('left')`), or for everything
19
+ * (`usePanelCatalog('all')`, which is what the phone's drawer reads — a panel
20
+ * placed on the left must stay reachable on a phone, which has one drawer).
21
+ * - **ORDER.** The right column keeps the user's dragged order
22
+ * (`omp_right_panel_order`); the left column is ordered by the placement list
23
+ * itself, because placing a panel there IS the arrangement.
24
+ *
25
+ * The left column also carries a view of its own — `sessions`, the session list
26
+ * that used to BE the column. It is a catalog entry like any other so the bar
27
+ * draws it with the same button, the same active state and the same visibility
28
+ * menu; it is never removable, because a chat with no way back to the session
29
+ * list is a dead end.
30
+ */
31
+
32
+ import { lazy } from 'preact/compat';
33
+ import type { ReactNode } from 'preact/compat';
34
+ import { MessagesSquare } from 'lucide-preact';
35
+ import { useMemo } from 'preact/hooks';
36
+ import { usePanelRegistry, usePanelSlots } from '@/client/hooks/workspace/panel-registry';
37
+ import { usePanelOrder } from '@/client/hooks/workspace/panel-order';
38
+ import { useLeftDock } from '@/client/hooks/workspace/left-dock';
39
+ import { PluginIdProvider } from '@kabmo-labs/kabmo-ui';
40
+ import { rightPanelOf } from '@/client/lib/plugins/slots';
41
+ import { pluginEnablementKey, pluginPanelKey } from '@/shared/lib/workspace/panel-ids';
42
+ import { SESSIONS_VIEW_ID, panelSideOf, type PanelSide } from '@/shared/lib/workspace/left-dock';
43
+ import { resolvePluginPanelWidths } from '@/shared/lib/workspace/panel-widths';
44
+ import { DEFAULT_LEFT_PANEL_WIDTH, MIN_LEFT_PANEL_WIDTH } from '@/shared/lib/workspace/sidebar-width';
45
+ import {
46
+ enabledBuiltinPanels,
47
+ type BuiltinPanelView,
48
+ type PanelBodyProps,
49
+ } from '@/client/components/workspace/panels/builtin';
50
+
51
+ /** `left`, `right`, or both — `all` is the phone's drawer and the settings pane. */
52
+ export type PanelCatalogSide = PanelSide | 'all';
53
+
54
+ /** One panel in the merged catalog — a built-in view, the dock's view, or a plugin. */
55
+ export interface ResolvedPanel {
56
+ /** The panel id: a bare built-in view id, `sessions`, or `plugin:<pluginId>`. */
57
+ id: string;
58
+ /** The column it is drawn in. */
59
+ side: PanelSide;
60
+ /** The activity bar's tooltip. */
61
+ title: string;
62
+ /** The phone's chip label. */
63
+ label: string;
64
+ /** A built-in view's own icon; a plugin draws its mark instead. */
65
+ icon?: ReactNode;
66
+ /** A plugin's icon URL, when its manifest declares one. */
67
+ iconUrl?: string;
68
+ /** The plugin's display name; empty for a built-in view. */
69
+ name: string;
70
+ builtin: boolean;
71
+ requiresWorkspace: boolean;
72
+ liveWhileHidden: boolean;
73
+ minWidth: number;
74
+ /** The px width the view opens at before the group's area is measured. */
75
+ defaultWidth: number;
76
+ defaultFraction: number;
77
+ /** Render the body. One call, so every layout draws a view the same way. */
78
+ render: (props: PanelBodyProps) => ReactNode;
79
+ }
80
+
81
+ const SessionsView = lazy(() =>
82
+ import('@/client/components/layout/session-sidebar/SessionsView').then((m) => ({ default: m.SessionsView })),
83
+ );
84
+
85
+ /** The dock's own view. Never removable, never placed anywhere else. */
86
+ const SESSIONS_PANEL: ResolvedPanel = {
87
+ id: SESSIONS_VIEW_ID,
88
+ side: 'left',
89
+ title: 'Sessions',
90
+ label: 'Sessions',
91
+ icon: <MessagesSquare size={16} />,
92
+ name: '',
93
+ builtin: true,
94
+ // The list is the workspace's own navigation: it reads folders and sessions,
95
+ // not the working tree of the active folder.
96
+ requiresWorkspace: false,
97
+ liveWhileHidden: false,
98
+ minWidth: MIN_LEFT_PANEL_WIDTH,
99
+ defaultWidth: DEFAULT_LEFT_PANEL_WIDTH,
100
+ // The left column is px-sized (a session list does not get more useful on a
101
+ // wider monitor), so the fraction is only what a phone's drawer would use.
102
+ defaultFraction: 0.25,
103
+ // The column's title row and footer are NOT this view's: `LeftColumn` draws
104
+ // them, so the rail and the list sit under ONE title and above ONE strip.
105
+ render: (props) => <SessionsView className={props.className} appSettings={props.appSettings} />,
106
+ };
107
+
108
+ /** A built-in view's catalog entry, with its renderer closed over its component. */
109
+ function builtinEntry(view: BuiltinPanelView): ResolvedPanel {
110
+ const View = view.Component;
111
+ return {
112
+ id: view.id,
113
+ // A built-in view is a RIGHT-column view by construction: its floor, width
114
+ // slot and workspace gate were all declared for that column.
115
+ side: 'right',
116
+ title: view.title,
117
+ label: view.label,
118
+ icon: view.icon,
119
+ name: '',
120
+ builtin: true,
121
+ requiresWorkspace: view.requiresWorkspace,
122
+ liveWhileHidden: view.liveWhileHidden === true,
123
+ minWidth: view.minWidth,
124
+ defaultWidth: view.defaultWidth,
125
+ defaultFraction: view.defaultFraction,
126
+ render: (props) => <View {...props} />,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * The panel catalog the activity bars and the panel stacks render.
132
+ *
133
+ * `side` selects the column. `all` is deliberately a first-class value rather
134
+ * than "no filter": the phone's drawer and the settings pane both need every
135
+ * panel in one list, and a caller that forgot to pass a side would otherwise get
136
+ * a silently halved catalog.
137
+ */
138
+ export function usePanelCatalog(side: PanelCatalogSide = 'right'): ResolvedPanel[] {
139
+ const { panels, disabledPanels } = usePanelRegistry();
140
+ const { slots } = usePanelSlots();
141
+ const { apply } = usePanelOrder();
142
+ const dock = useLeftDock();
143
+
144
+ return useMemo(() => {
145
+ const out: ResolvedPanel[] = [];
146
+
147
+ if (side !== 'left') {
148
+ for (const view of enabledBuiltinPanels(disabledPanels)) out.push(builtinEntry(view));
149
+ }
150
+
151
+ const placed = new Map(dock.panels.map((id, index) => [id, index]));
152
+ const left: ResolvedPanel[] = [];
153
+
154
+ for (const panel of panels) {
155
+ const entry = slots.get(panel.pluginId);
156
+ const Component = entry ? rightPanelOf(entry) : undefined;
157
+ if (!Component) continue;
158
+ const key = pluginPanelKey(panel.pluginId, 'rightPanel');
159
+ if (disabledPanels.includes(pluginEnablementKey(panel.pluginId))) continue;
160
+ const size = entry?.sizing.rightPanel;
161
+ const widths = resolvePluginPanelWidths({ ...size });
162
+ const resolved: ResolvedPanel = {
163
+ id: key,
164
+ // Where the USER put it, keyed per PANEL INSTANCE. A plugin that also
165
+ // contributes a takeover has a second, independent key, so placing this
166
+ // one on the left never moves that one.
167
+ side: panelSideOf(key, dock),
168
+ title: `${entry?.titles.rightPanel ?? panel.name} — ${panel.name}`,
169
+ label: entry?.titles.rightPanel ?? panel.name,
170
+ name: panel.name,
171
+ builtin: false,
172
+ // A plugin reads whatever the host published and draws what it can, so
173
+ // gating it on a workspace folder would hide a panel that works.
174
+ requiresWorkspace: false,
175
+ liveWhileHidden: false,
176
+ minWidth: widths.min,
177
+ defaultWidth: widths.px,
178
+ defaultFraction: widths.fraction,
179
+ ...(panel.iconUrl ? { iconUrl: panel.iconUrl } : {}),
180
+ // Wrapped so the kit's participant hooks resolve THIS plugin's id while
181
+ // its panel renders — the scope is what `useRpc`/`useRealtime` read.
182
+ render: (props) => (
183
+ <PluginIdProvider pluginId={panel.pluginId}>
184
+ <Component sessionId={props.sessionId ?? null} workspacePath={null} />
185
+ </PluginIdProvider>
186
+ ),
187
+ };
188
+ // `editor` is not a column this catalog draws: the takeover has its own
189
+ // list (`usePanelSlotPlugins`) and its own rail in the editor column.
190
+ if (resolved.side === 'editor') continue;
191
+ if (resolved.side === 'left') left.push(resolved);
192
+ else if (side !== 'left') out.push(resolved);
193
+ }
194
+
195
+ if (side !== 'right') {
196
+ // The dock's own view first, then the placed plugins in PLACEMENT order:
197
+ // on the left, putting a panel there IS the arrangement, so the right
198
+ // column's dragged order would be a second answer to the same question.
199
+ left.sort((a, b) => (placed.get(a.id) ?? 0) - (placed.get(b.id) ?? 0));
200
+ out.unshift(SESSIONS_PANEL, ...left);
201
+ }
202
+
203
+ return side === 'left' ? out : apply(out);
204
+ }, [panels, slots, disabledPanels, apply, dock, side]);
205
+ }
206
+
207
+ /** Whether a panel id names the dock's own session list. */
208
+ export function isSessionsPanel(id: string): boolean {
209
+ return id === SESSIONS_VIEW_ID;
210
+ }
211
+
212
+ /**
213
+ * Which view the dock actually shows, given the stored selection.
214
+ *
215
+ * Three answers, and they are different states a blank column would conflate:
216
+ *
217
+ * - the stored id is a LEFT panel → draw it;
218
+ * - the stored id names a panel that still exists but lives on the RIGHT (the
219
+ * user moved it back, or it was never placed here) → the dock's own view,
220
+ * because that is not an error;
221
+ * - the stored id names nothing at all (uninstalled, switched off) → the id is
222
+ * returned as-is so the dock can say WHY, which is what the panel position's
223
+ * notice exists for.
224
+ */
225
+ export function useLeftDockView(stored: string, railEnabled: boolean): string {
226
+ const left = usePanelCatalog('left');
227
+ const all = usePanelCatalog('all');
228
+ return useMemo(() => {
229
+ // With no rail there is no control that switches views, so the dock shows
230
+ // its own view: leaving a plugin panel up would strand the user there with
231
+ // no way back to the session list.
232
+ if (!railEnabled) return SESSIONS_VIEW_ID;
233
+ if (left.some((panel) => panel.id === stored)) return stored;
234
+ if (all.some((panel) => panel.id === stored)) return SESSIONS_VIEW_ID;
235
+ return stored;
236
+ }, [left, all, stored, railEnabled]);
237
+ }