@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.
- 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-twsk4wfa.js} +791 -791
- 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/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/SessionItem.tsx +30 -5
- 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/RightSidebar.tsx +8 -5
- package/src/client/components/mobile/SessionSidebar.tsx +6 -0
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -3
- 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 +41 -13
- package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
- 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 +14 -9
- 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 +154 -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/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/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/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/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/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/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
|
|
|
@@ -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
|
/**
|
|
@@ -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
|
+
}
|