@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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +412 -383
- package/dist/client/static/js/index-3qamd33d.css +1 -0
- package/dist/client/static/js/{index-28z316qk.js → index-cs3b557t.js} +792 -792
- package/package.json +1 -1
- package/src/client/components/common/batch-selection/BatchActionBar.tsx +8 -1
- package/src/client/components/common/session-actions-menu/index.tsx +23 -2
- package/src/client/components/layout/{RightActivityBar.test.ts → ActivityBar.test.ts} +5 -5
- package/src/client/components/layout/{RightActivityBar.tsx → ActivityBar.tsx} +87 -52
- package/src/client/components/layout/LeftActivityBar.test.ts +201 -0
- package/src/client/components/layout/LeftDock.tsx +119 -0
- package/src/client/components/layout/desktop-layout/LeftColumn.tsx +172 -0
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -3
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +22 -5
- package/src/client/components/layout/desktop-layout/index.tsx +88 -61
- package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
- package/src/client/components/layout/session-sidebar/Header.tsx +2 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +42 -6
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
- package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +4 -0
- package/src/client/components/mobile/RightSidebar.tsx +30 -36
- package/src/client/components/mobile/SessionSidebar.tsx +124 -101
- package/src/client/components/mobile/mobile-main-view/Header.tsx +3 -2
- package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -2
- package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +3 -2
- package/src/client/components/mobile/mobile-session-sidebar/Dialogs.tsx +106 -0
- package/src/client/components/mobile/mobile-session-sidebar/Dock.tsx +80 -0
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +11 -5
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
- package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +47 -14
- package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
- package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +89 -0
- package/src/client/components/mobile/panel-chips.test.tsx +155 -0
- package/src/client/components/mobile/panel-chips.tsx +74 -0
- package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
- package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
- package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
- package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
- package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
- package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
- package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
- package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
- package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +15 -10
- package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
- package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
- package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +162 -0
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
- package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
- package/src/client/components/workspace/editor/index.tsx +1 -0
- package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
- package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
- package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
- package/src/client/components/workspace/model-dropdown/index.tsx +3 -3
- package/src/client/components/workspace/model-dropdown/trigger.test.ts +123 -0
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
- package/src/client/components/workspace/panels/builtin.tsx +8 -0
- package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
- package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
- package/src/client/hooks/chat/composer/compact.ts +119 -0
- package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
- package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
- package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
- package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
- package/src/client/hooks/chat/timeline/actions.ts +34 -35
- package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
- package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
- package/src/client/hooks/chat/timeline/index.ts +15 -19
- package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
- package/src/client/hooks/chat/timeline/queue-rewind.test.ts +6 -0
- package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
- package/src/client/hooks/chat/waiting/cancel.ts +34 -0
- package/src/client/hooks/chat/waiting/choice.ts +53 -0
- package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
- package/src/client/hooks/chat/waiting/held.ts +42 -0
- package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
- package/src/client/hooks/chat/waiting/hold.ts +156 -0
- package/src/client/hooks/chat/waiting/store.ts +100 -0
- package/src/client/hooks/ui/toasts.ts +12 -1
- package/src/client/hooks/workspace/left-dock.ts +132 -0
- package/src/client/hooks/workspace/panel-registry.ts +13 -0
- package/src/client/tailwind.css +22 -0
- package/src/server/db.server.test.ts +52 -1
- package/src/server/db.server.ts +49 -1
- package/src/server/index.ts +9 -0
- package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
- package/src/server/lib/assets/compressed-cache.server.ts +88 -0
- package/src/server/lib/db/schema.test.ts +1 -0
- package/src/server/lib/db/schema.ts +3 -0
- package/src/server/lib/fs/fs-path.test.ts +1 -1
- package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
- package/src/server/lib/omp/session/delete.server.ts +8 -0
- package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
- package/src/server/lib/panels/registry.server.ts +2 -2
- package/src/server/lib/queue/delivery.server.ts +31 -39
- package/src/server/lib/queue/payload.ts +73 -0
- package/src/server/lib/realtime/signals.server.ts +10 -1
- package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
- package/src/server/lib/realtime/topics/global.server.ts +5 -0
- package/src/server/lib/realtime/topics.server.ts +2 -0
- package/src/server/lib/util/with-timeout.ts +29 -0
- package/src/server/lib/waiting/row-clock.server.ts +37 -0
- package/src/server/lib/waiting/runtime.server.test.ts +278 -0
- package/src/server/lib/waiting/runtime.server.ts +313 -0
- package/src/server/lib/waiting/schema.server.ts +46 -0
- package/src/server/lib/waiting/settle.test.ts +84 -0
- package/src/server/lib/waiting/settle.ts +77 -0
- package/src/server/lib/waiting/store.server.test.ts +167 -0
- package/src/server/lib/waiting/store.server.ts +268 -0
- package/src/server/lib/waiting/sweep-trigger.ts +35 -0
- package/src/server/plugins/compress.ts +18 -6
- package/src/server/plugins/ssr.ts +16 -8
- package/src/server/plugins/static.test.ts +59 -0
- package/src/server/plugins/static.ts +56 -14
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/waiting/bindings.ts +14 -0
- package/src/server/routes/waiting/index.ts +123 -0
- package/src/server/routes/waiting/routes.test.ts +121 -0
- package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
- package/src/shared/lib/chat/timeline/gutter.ts +37 -0
- package/src/shared/lib/realtime/protocol.ts +10 -0
- package/src/shared/lib/ui/titlebar.test.ts +124 -0
- package/src/shared/lib/ui/titlebar.ts +54 -0
- package/src/shared/lib/workspace/left-dock-width.ts +78 -0
- package/src/shared/lib/workspace/left-dock.test.ts +203 -0
- package/src/shared/lib/workspace/left-dock.ts +197 -0
- package/src/shared/lib/workspace/mobile-dock.test.ts +65 -0
- package/src/shared/lib/workspace/mobile-dock.ts +55 -0
- package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
- package/src/shared/lib/workspace/panel-ids.ts +114 -26
- package/src/shared/lib/workspace/sidebar-width.ts +4 -0
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/waiting.ts +54 -0
- package/src/shared/types/workspace.ts +8 -0
- package/src/test-support/isolated-db.ts +3 -4
- package/src/test-support/silent-socket.ts +69 -0
- 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 {
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
|
243
|
-
<span className="text-ink/50 font-normal
|
|
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
|
|
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
|
/**
|