@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
@@ -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
 
@@ -239,10 +239,10 @@ export function ModelDropdown({
239
239
  />
240
240
  )}
241
241
  </span>
242
- <span className="flex items-center space-x-1 min-w-0 max-w-[280px]">
243
- <span className="text-ink/50 font-normal truncate">{selectedModel.provider}</span>
242
+ <span className="flex items-center space-x-1 min-w-0">
243
+ <span className="shrink-0 text-ink/50 font-normal">{selectedModel.provider}</span>
244
244
  <span className="text-ink/30 flex-shrink-0">•</span>
245
- <span className="truncate">{selectedModel.name}</span>
245
+ <span className="min-w-0 flex-1 truncate">{selectedModel.name}</span>
246
246
  </span>
247
247
  </>
248
248
  ) : (
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The composer trigger's provider/model label.
8
+ *
9
+ * The trigger showed `kenari • Deepseek-V4-1-Flash` as `kena… • Deepse…` on a
10
+ * 412px phone with 64px of unused room beside it. Two hardcoded caps were the
11
+ * cause, and neither could see the space it was sitting in:
12
+ *
13
+ * - the WRAPPER carried `max-w-[58%]` on the phone, a share of the toolbar
14
+ * that ignores the four controls to its right;
15
+ * - the LABEL carried `max-w-[280px]`, a fixed budget that has nothing to do
16
+ * with the panel width (the chat column is 320px wide on a 1440px screen).
17
+ *
18
+ * Measured before/after at a 412px viewport: the label went 117px → 210px
19
+ * available and the model name 80px → 110px, with the toolbar reporting no
20
+ * overflow and the action strip still ending 12px inside its own edge.
21
+ *
22
+ * The rule this file pins is the one that has to hold at EVERY width: the
23
+ * model name is the only thing that gives up width. The provider is an
24
+ * identity ("kenari" and "kenari-jatis" are different accounts), so shrinking
25
+ * it first made two accounts render as the same `kena…`.
26
+ */
27
+
28
+ import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test';
29
+ import { Window } from 'happy-dom';
30
+ import { h, render } from 'preact';
31
+ import { act } from 'preact/test-utils';
32
+ import { ModelDropdown } from '@/client/components/workspace/model-dropdown/index';
33
+ import type { AIModelOption } from '@/shared/types';
34
+ import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
35
+ import { restoreRealtime, silenceRealtime } from '@/test-support/silent-socket';
36
+
37
+ const MODEL: AIModelOption = {
38
+ id: 'deepseek-v4-1-flash',
39
+ name: 'Deepseek-V4-1-Flash',
40
+ provider: 'kenari',
41
+ };
42
+
43
+ let container: HTMLElement;
44
+
45
+ beforeEach(() => {
46
+ installDomGlobals(new Window());
47
+ // The picker reads the `models` topic on mount, and this window has no origin
48
+ // (`about:blank`), so a real dial would target `ws:///api/realtime/ws` and
49
+ // fail asynchronously — after this suite has restored the globals, which
50
+ // reports as an unhandled error in the NEXT file. The channel is not what this
51
+ // file measures.
52
+ silenceRealtime();
53
+ container = document.createElement('div');
54
+ document.body.appendChild(container);
55
+ });
56
+
57
+ afterEach(() => {
58
+ render(null, container);
59
+ container.remove();
60
+ restoreRealtime();
61
+ restoreDomGlobals();
62
+ });
63
+
64
+ afterAll(() => restoreDomGlobals());
65
+
66
+ function mount(className: string, model: AIModelOption = MODEL): HTMLElement {
67
+ act(() => {
68
+ render(h(ModelDropdown, { selectedModel: model, className }), container);
69
+ });
70
+ return container;
71
+ }
72
+
73
+ /** The trigger button is the only one carrying the provider • model title. */
74
+ function trigger(el: HTMLElement): HTMLButtonElement {
75
+ const button = el.querySelector('button[title]') as HTMLButtonElement | null;
76
+ if (!button) throw new Error('model trigger button not found');
77
+ return button;
78
+ }
79
+
80
+ function labelParts(el: HTMLElement): { provider: HTMLElement; separator: HTMLElement; model: HTMLElement } {
81
+ const provider = el.querySelector('button[title] > span > span') as HTMLElement | null;
82
+ const separator = provider?.nextElementSibling as HTMLElement | null;
83
+ const model = separator?.nextElementSibling as HTMLElement | null;
84
+ if (!provider || !separator || !model) throw new Error('label parts not found');
85
+ return { provider, separator, model };
86
+ }
87
+
88
+ describe('composer model trigger label', () => {
89
+ test('grows to its content instead of a fixed px budget', () => {
90
+ // `max-w-[280px]` is what truncated a 216px model name inside a 968px
91
+ // toolbar; the cap has to come from the flex parent, not from a constant.
92
+ const { provider, model } = labelParts(mount('min-w-0'));
93
+ const label = provider.parentElement!;
94
+
95
+ expect(label.className).not.toContain('max-w-');
96
+ expect(provider.className).not.toContain('truncate');
97
+ expect(model.className).toContain('truncate');
98
+ });
99
+
100
+ test('keeps the provider at its natural width and lets the model shrink', () => {
101
+ // Two accounts (`kenari` / `kenari-jatis`) render identically once the
102
+ // provider truncates, which is why it must not be a shrink target.
103
+ const { provider, separator, model } = labelParts(mount('min-w-0'));
104
+
105
+ expect(provider.className).toContain('shrink-0');
106
+ expect(separator.className).toContain('shrink-0');
107
+ expect(model.className).toContain('flex-1');
108
+ expect(model.className).toContain('min-w-0');
109
+ });
110
+
111
+ test('the trigger itself may shrink on every layout', () => {
112
+ // The phone-only `max-w-[58%]` made the trigger a property of the layout
113
+ // rather than of the room beside it: a 479px viewport left 64px unused.
114
+ const button = trigger(mount('min-w-0'));
115
+ expect(button.className).toContain('min-w-0');
116
+ expect(button.className).not.toContain('max-w-[58%]');
117
+ });
118
+
119
+ test('names both halves in the title, so an ellipsis never hides the identity', () => {
120
+ const button = trigger(mount('min-w-0'));
121
+ expect(button.title).toBe('kenari • Deepseek-V4-1-Flash');
122
+ });
123
+ });
@@ -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
  /**