@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
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `/api/waiting` — prompts held until another session finishes.
|
|
8
|
+
*
|
|
9
|
+
* The list is also served over the `waiting` realtime topic, which is what the
|
|
10
|
+
* sidebar and the composer read; these routes exist for the create/cancel
|
|
11
|
+
* actions and as the read path for a client whose socket is down.
|
|
12
|
+
*
|
|
13
|
+
* Validation is the last gate before the row. In particular the dependency must
|
|
14
|
+
* look UNFINISHED at creation: a dropdown that went stale (the session finished
|
|
15
|
+
* while the user was typing) would otherwise create a prompt that is released on
|
|
16
|
+
* the very next sweep — a prompt the user believed they had deferred. The client
|
|
17
|
+
* re-checks and sends normally instead; this is the server's own refusal for a
|
|
18
|
+
* client that did not.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { isAbsolute } from 'path';
|
|
22
|
+
import { json } from '@/server/lib/remix-compat';
|
|
23
|
+
import type { ActionFunctionArgs, LoaderFunctionArgs } from '@/server/lib/remix-compat';
|
|
24
|
+
import { methodNotAllowed } from '@/server/lib/route-adapter';
|
|
25
|
+
import { emitRealtimeSignal } from '@/server/lib/realtime/signals.server';
|
|
26
|
+
import { isDependencySettled } from '@/server/lib/waiting/runtime.server';
|
|
27
|
+
import {
|
|
28
|
+
cancelWaitingPrompt,
|
|
29
|
+
createWaitingPrompt,
|
|
30
|
+
getWaitingPrompt,
|
|
31
|
+
listWaitingPrompts,
|
|
32
|
+
} from '@/server/lib/waiting/store.server';
|
|
33
|
+
import { attachmentHasPayload } from '@/shared/lib/chat/attachments';
|
|
34
|
+
import { parseModeSelection } from '@/server/lib/omp/mode/request';
|
|
35
|
+
import { isSinglePathSegment } from '@/server/lib/fs/path-segment';
|
|
36
|
+
import { isMockMode } from '@/server/mock.server';
|
|
37
|
+
|
|
38
|
+
/** GET — every prompt a client may still need (waiting + recent outcomes). */
|
|
39
|
+
export async function loader(_args: LoaderFunctionArgs) {
|
|
40
|
+
return json({ prompts: await listWaitingPrompts() });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
44
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** POST — hold a prompt until `dependsOn` finishes. */
|
|
48
|
+
async function createPrompt(request: Request) {
|
|
49
|
+
// A demo install spawns no omp child, so a held prompt could never be sent.
|
|
50
|
+
// Refused with the reason instead of creating a row that waits forever.
|
|
51
|
+
if (isMockMode()) {
|
|
52
|
+
return json(
|
|
53
|
+
{ error: 'Held prompts need a real omp session — this console is running on demo data.', code: 'mock_mode' },
|
|
54
|
+
{ status: 409 },
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const body = asRecord(await request.json().catch(() => null));
|
|
58
|
+
const dependsOn = typeof body.dependsOn === 'string' ? body.dependsOn.trim() : '';
|
|
59
|
+
if (!dependsOn || !isSinglePathSegment(dependsOn)) {
|
|
60
|
+
return json({ error: 'A session to wait for is required', code: 'depends_on_required' }, { status: 400 });
|
|
61
|
+
}
|
|
62
|
+
const prompt = typeof body.prompt === 'string' ? body.prompt : '';
|
|
63
|
+
const attachments = Array.isArray(body.attachments) ? body.attachments : [];
|
|
64
|
+
// Same rule the composer's own send guard applies: an empty prompt is not a
|
|
65
|
+
// prompt. Without it a row would be created, spawn a session, and deliver
|
|
66
|
+
// nothing to it.
|
|
67
|
+
if (!prompt.trim() && !attachments.some((attachment) => attachmentHasPayload(attachment as never))) {
|
|
68
|
+
return json({ error: 'The prompt is empty', code: 'prompt_required' }, { status: 400 });
|
|
69
|
+
}
|
|
70
|
+
const cwd = typeof body.cwd === 'string' ? body.cwd.trim() : '';
|
|
71
|
+
// `isAbsolute`, not `startsWith('/')`: a Windows workspace names its directory
|
|
72
|
+
// `C:\…`, and the POSIX-only test refused every hold there with
|
|
73
|
+
// `cwd_required` — the prompt was sent immediately instead of being held
|
|
74
|
+
// (measured on the Windows CI leg of `waiting-prompt.e2e.ts`).
|
|
75
|
+
if (!isAbsolute(cwd)) {
|
|
76
|
+
return json({ error: 'An absolute working directory is required', code: 'cwd_required' }, { status: 400 });
|
|
77
|
+
}
|
|
78
|
+
if (await isDependencySettled(dependsOn)) {
|
|
79
|
+
return json(
|
|
80
|
+
{ error: 'That session has already finished — send the prompt without waiting.', code: 'dependency_idle' },
|
|
81
|
+
{ status: 409 },
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const created = await createWaitingPrompt({
|
|
86
|
+
pendingId: typeof body.pendingId === 'string' ? body.pendingId : null,
|
|
87
|
+
dependsOn,
|
|
88
|
+
dependsOnTitle: typeof body.dependsOnTitle === 'string' ? body.dependsOnTitle : null,
|
|
89
|
+
prompt,
|
|
90
|
+
attachments,
|
|
91
|
+
model: body.model,
|
|
92
|
+
modes: body.modes === undefined ? null : parseModeSelection(asRecord(body.modes)),
|
|
93
|
+
cwd,
|
|
94
|
+
folderId: typeof body.folderId === 'number' ? body.folderId : null,
|
|
95
|
+
});
|
|
96
|
+
emitRealtimeSignal('waiting-changed');
|
|
97
|
+
return json({ prompt: created });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** DELETE `?id=` — cancel a prompt that has not been dispatched yet. */
|
|
101
|
+
async function cancelPrompt(request: Request) {
|
|
102
|
+
const id = new URL(request.url).searchParams.get('id') ?? '';
|
|
103
|
+
if (!id) return json({ error: 'id is required', code: 'id_required' }, { status: 400 });
|
|
104
|
+
const existing = await getWaitingPrompt(id);
|
|
105
|
+
if (!existing) return json({ error: 'Waiting prompt not found' }, { status: 404 });
|
|
106
|
+
if (existing.status === 'dispatched') {
|
|
107
|
+
// The prompt has already become a session: Stop that session instead. Saying
|
|
108
|
+
// so is the difference between "cancel did nothing" and a next step.
|
|
109
|
+
return json(
|
|
110
|
+
{ error: 'That prompt has already been sent — stop its session instead.', code: 'already_dispatched' },
|
|
111
|
+
{ status: 409 },
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
const cancelled = await cancelWaitingPrompt(id);
|
|
115
|
+
if (cancelled) emitRealtimeSignal('waiting-changed');
|
|
116
|
+
return json({ cancelled, prompt: await getWaitingPrompt(id) });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function action({ request }: ActionFunctionArgs) {
|
|
120
|
+
if (request.method === 'POST') return createPrompt(request);
|
|
121
|
+
if (request.method === 'DELETE') return cancelPrompt(request);
|
|
122
|
+
return methodNotAllowed({ request, params: {} });
|
|
123
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `/api/waiting` POST — the refusals that guard the row.
|
|
8
|
+
*
|
|
9
|
+
* The case worth pinning is the WORKING DIRECTORY, because it is the one that
|
|
10
|
+
* only fails on a platform the author does not run: the route tested
|
|
11
|
+
* `cwd.startsWith('/')`, so every hold from a Windows workspace (`C:\…`) was
|
|
12
|
+
* refused with `cwd_required` and the prompt was sent immediately instead of
|
|
13
|
+
* being deferred — measured on the Windows CI leg of
|
|
14
|
+
* `waiting-prompt.e2e.ts`, where the browser showed the toast and the row was
|
|
15
|
+
* never created. The fix is `path.isAbsolute`, which is what this asserts on
|
|
16
|
+
* both platform shapes rather than only the one this run happens to be on.
|
|
17
|
+
*
|
|
18
|
+
* Everything else here is the validation order a stale client can reach: a
|
|
19
|
+
* missing dependency, an empty prompt, a mock install.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import os from 'node:os';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { isolateDb, releaseDb } from '@/test-support/isolated-db';
|
|
27
|
+
import { action } from '@/server/routes/waiting/index';
|
|
28
|
+
import { listWaitingPrompts } from '@/server/lib/waiting/store.server';
|
|
29
|
+
|
|
30
|
+
const ROOT = path.join(os.tmpdir(), `kabmo-waiting-routes-${process.pid}`);
|
|
31
|
+
|
|
32
|
+
function post(body: unknown): Request {
|
|
33
|
+
return new Request('http://localhost/api/waiting', {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'content-type': 'application/json' },
|
|
36
|
+
body: JSON.stringify(body),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function call(body: unknown): Promise<{ status: number; body: Record<string, unknown> }> {
|
|
41
|
+
const res = (await action({ request: post(body), params: {} } as never)) as Response;
|
|
42
|
+
return { status: res.status, body: (await res.json()) as Record<string, unknown> };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeAll(async () => {
|
|
46
|
+
fs.mkdirSync(ROOT, { recursive: true });
|
|
47
|
+
isolateDb();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterAll(async () => {
|
|
51
|
+
releaseDb();
|
|
52
|
+
fs.rmSync(ROOT, { recursive: true, force: true });
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('/api/waiting — working directory', () => {
|
|
56
|
+
// The rule is the SERVER's own platform rule (`path.isAbsolute`), because a
|
|
57
|
+
// hold's cwd is a directory on the machine that will spawn the session — the
|
|
58
|
+
// client sends the folder's stored `project_path`, which the server resolved.
|
|
59
|
+
//
|
|
60
|
+
// This table is derived from `path.isAbsolute` rather than written out, so it
|
|
61
|
+
// is a real regression net on BOTH legs: on Windows it feeds `C:\…`, which
|
|
62
|
+
// the old `startsWith('/')` refused, and on POSIX it feeds `/…`. Hardcoding
|
|
63
|
+
// either spelling would assert the platform this run happens to be on.
|
|
64
|
+
//
|
|
65
|
+
// The dependency must look UNFINISHED for a create to reach the store, and a
|
|
66
|
+
// session id nothing has ever streamed is settled by definition — so these
|
|
67
|
+
// cases assert the CWD verdict, which is decided BEFORE that read.
|
|
68
|
+
const shapes = [
|
|
69
|
+
path.join(ROOT, 'projects', 'kabmo'),
|
|
70
|
+
'/home/dev/projects/kabmo',
|
|
71
|
+
'C:\\Users\\dev\\projects\\kabmo',
|
|
72
|
+
'\\\\server\\share\\kabmo',
|
|
73
|
+
'projects/kabmo',
|
|
74
|
+
'C:relative',
|
|
75
|
+
'',
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
for (const cwd of shapes) {
|
|
79
|
+
const absolute = path.isAbsolute(cwd);
|
|
80
|
+
test(`${JSON.stringify(cwd)} is ${absolute ? 'accepted' : 'refused as relative'}`, async () => {
|
|
81
|
+
const res = await call({ dependsOn: 'sess-anything', prompt: 'held', cwd });
|
|
82
|
+
if (absolute) {
|
|
83
|
+
// `dependency_idle` is the next gate: the cwd passed.
|
|
84
|
+
expect(res.body.code).not.toBe('cwd_required');
|
|
85
|
+
} else {
|
|
86
|
+
expect(res.status).toBe(400);
|
|
87
|
+
expect(res.body.code).toBe('cwd_required');
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
test('a whitespace-only path is refused, never trimmed into a valid one', async () => {
|
|
93
|
+
const res = await call({ dependsOn: 'sess-anything', prompt: 'x', cwd: ' ' });
|
|
94
|
+
expect(res.status).toBe(400);
|
|
95
|
+
expect(res.body.code).toBe('cwd_required');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('/api/waiting — the rest of the validation order', () => {
|
|
100
|
+
test('a missing dependency is refused before the path is read', async () => {
|
|
101
|
+
const res = await call({ prompt: 'x', cwd: '/tmp/x' });
|
|
102
|
+
expect(res.status).toBe(400);
|
|
103
|
+
expect(res.body.code).toBe('depends_on_required');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('a dependency that could name a path is refused', async () => {
|
|
107
|
+
const res = await call({ dependsOn: '../../etc', prompt: 'x', cwd: '/tmp/x' });
|
|
108
|
+
expect(res.body.code).toBe('depends_on_required');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('an empty prompt with no attachment is refused', async () => {
|
|
112
|
+
const res = await call({ dependsOn: 'sess-anything', prompt: ' ', cwd: '/tmp/x' });
|
|
113
|
+
expect(res.status).toBe(400);
|
|
114
|
+
expect(res.body.code).toBe('prompt_required');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('a refusal writes no row', async () => {
|
|
118
|
+
await call({ dependsOn: 'sess-anything', prompt: 'x', cwd: 'relative/path' });
|
|
119
|
+
expect(await listWaitingPrompts()).toHaveLength(0);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The transcript's frame inset.
|
|
8
|
+
*
|
|
9
|
+
* One rule, four call sites — so it is stated here and asserted, not repeated
|
|
10
|
+
* as a class string. The bug it prevents was measured on a phone: every framed
|
|
11
|
+
* block carried `mx-3` on top of the timeline's own `px-3`, putting cards at
|
|
12
|
+
* 24px while the composer's card sat at 12px. The transcript read as two
|
|
13
|
+
* columns that never met.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, expect, test } from 'bun:test';
|
|
17
|
+
import { transcriptBlockPadding, transcriptGutter } from '@/shared/lib/chat/timeline/gutter';
|
|
18
|
+
|
|
19
|
+
describe('transcriptGutter', () => {
|
|
20
|
+
test('a phone adds no inset — the timeline container IS the gutter', () => {
|
|
21
|
+
expect(transcriptGutter(true)).toBe('');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('desktop keeps the 12px inset that pairs with the panel p-4', () => {
|
|
25
|
+
expect(transcriptGutter(false)).toBe('mx-3');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('transcriptBlockPadding', () => {
|
|
30
|
+
test('a phone pads to the shared 24px text column (12 frame + 12 padding)', () => {
|
|
31
|
+
expect(transcriptBlockPadding(true)).toBe('p-3');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('desktop keeps its roomier 14px padding', () => {
|
|
35
|
+
expect(transcriptBlockPadding(false)).toBe('p-3.5');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The horizontal frame inset of a transcript block — a tool card, a thinking
|
|
8
|
+
* accordion, a system notice, a user bubble.
|
|
9
|
+
*
|
|
10
|
+
* Desktop keeps `mx-3`: the timeline's own container is `p-4`, and the extra
|
|
11
|
+
* 12px is what gives a card the same optical margin as the prose beside it.
|
|
12
|
+
*
|
|
13
|
+
* A phone does NOT. Its timeline container is already `px-3`, and that is the
|
|
14
|
+
* same 12px the composer dock uses — so a block that added another `mx-3` put
|
|
15
|
+
* every card 12px inside the composer's own edge, and the transcript read as
|
|
16
|
+
* two columns that never met. On mobile the block's frame IS the gutter.
|
|
17
|
+
*
|
|
18
|
+
* Stated once, because the class was written out at four separate call sites
|
|
19
|
+
* and a missed one is a card sitting 12px off its neighbours.
|
|
20
|
+
*/
|
|
21
|
+
export function transcriptGutter(isMobile: boolean): string {
|
|
22
|
+
return isMobile ? '' : 'mx-3';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Padding INSIDE a framed transcript block (the user bubble, the provider
|
|
27
|
+
* error strip).
|
|
28
|
+
*
|
|
29
|
+
* The pair with `transcriptGutter` is what keeps a phone's text column on one
|
|
30
|
+
* line: 12px frame + 12px padding = 24, which is exactly where the prose rows
|
|
31
|
+
* (`px-3` inside the same container) and the composer's textarea already sit.
|
|
32
|
+
* Desktop keeps its roomier `p-3.5`, because there the frame is inset and the
|
|
33
|
+
* text column is not shared with an edge-to-edge composer.
|
|
34
|
+
*/
|
|
35
|
+
export function transcriptBlockPadding(isMobile: boolean): string {
|
|
36
|
+
return isMobile ? 'p-3' : 'p-3.5';
|
|
37
|
+
}
|
|
@@ -101,6 +101,16 @@ export const TOPIC_USAGE = 'usage';
|
|
|
101
101
|
/** Scheduled tasks. */
|
|
102
102
|
export const TOPIC_SCHEDULE = 'schedule';
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Prompts held until another session finishes.
|
|
106
|
+
*
|
|
107
|
+
* A global topic rather than per-session: the row belongs to the session that
|
|
108
|
+
* has NOT been created yet, and the dependency it names is a different session
|
|
109
|
+
* again. Both the sidebar (to draw the waiting row) and the composer (to offer
|
|
110
|
+
* the dependencies) read it.
|
|
111
|
+
*/
|
|
112
|
+
export const TOPIC_WAITING = 'waiting';
|
|
113
|
+
|
|
104
114
|
/** Installed panel plugins and their catalog. */
|
|
105
115
|
export const TOPIC_PANELS = 'panels';
|
|
106
116
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The LEFT dock's width, remembered PER VIEW.
|
|
8
|
+
*
|
|
9
|
+
* The left column used to hold one view, so one number was enough and it lives
|
|
10
|
+
* in `sidebar-width.ts` (`omp_sidebar_width`). The column is a dock now, and a
|
|
11
|
+
* plugin panel placed on it wants its own width: a session list reads at 280px
|
|
12
|
+
* and a table does not, and a column that snapped to the list's width the moment
|
|
13
|
+
* the user switched views would be a column with one width that is wrong for
|
|
14
|
+
* every second view.
|
|
15
|
+
*
|
|
16
|
+
* GLOBAL, like the sidebar's own width and the right panel's order — the dock
|
|
17
|
+
* lists the same views in every session, so a width stored per session would
|
|
18
|
+
* move the layout when the user picked a different chat.
|
|
19
|
+
*
|
|
20
|
+
* The sessions view keeps reading `sidebar-width.ts` as its fallback, so an
|
|
21
|
+
* install that has been dragging the sidebar for a year opens at the width it
|
|
22
|
+
* was left at rather than at this module's default.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readKabmoSetting, writeSetting } from '@/shared/lib/settings/client';
|
|
26
|
+
import { SESSIONS_VIEW_ID } from '@/shared/lib/workspace/left-dock';
|
|
27
|
+
import {
|
|
28
|
+
DEFAULT_LEFT_PANEL_WIDTH,
|
|
29
|
+
MAX_LEFT_PANEL_WIDTH,
|
|
30
|
+
MIN_LEFT_PANEL_WIDTH,
|
|
31
|
+
resolveSidebarWidth,
|
|
32
|
+
} from '@/shared/lib/workspace/sidebar-width';
|
|
33
|
+
|
|
34
|
+
/** `app_settings` key holding the dock's per-view widths, in pixels. */
|
|
35
|
+
export const LEFT_DOCK_WIDTHS_SETTING_KEY = 'omp_left_dock_widths';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Floor for a view that is not the session list.
|
|
39
|
+
*
|
|
40
|
+
* A plugin panel declares its own floor (320, `MIN_PLUGIN_PANEL_WIDTH`), and a
|
|
41
|
+
* column narrower than that clips the view it is showing. The list reads fine
|
|
42
|
+
* lower, which is why the two differ.
|
|
43
|
+
*/
|
|
44
|
+
export const MIN_LEFT_DOCK_PLUGIN_WIDTH = 320;
|
|
45
|
+
|
|
46
|
+
function clamped(viewId: string, px: number): number {
|
|
47
|
+
const floor = viewId === SESSIONS_VIEW_ID ? MIN_LEFT_PANEL_WIDTH : MIN_LEFT_DOCK_PLUGIN_WIDTH;
|
|
48
|
+
return Math.min(MAX_LEFT_PANEL_WIDTH, Math.max(floor, Math.round(px)));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readMap(): Record<string, number> {
|
|
52
|
+
const stored = readKabmoSetting<unknown>(LEFT_DOCK_WIDTHS_SETTING_KEY);
|
|
53
|
+
if (typeof stored !== 'object' || stored === null) return {};
|
|
54
|
+
const out: Record<string, number> = {};
|
|
55
|
+
for (const [view, value] of Object.entries(stored as Record<string, unknown>)) {
|
|
56
|
+
if (typeof value === 'number' && Number.isFinite(value)) out[view] = value;
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The width this view opens at.
|
|
63
|
+
*
|
|
64
|
+
* A remembered width wins; otherwise the session list falls back to the width
|
|
65
|
+
* `sidebar-width.ts` has always resolved (setting, then the retired global blob,
|
|
66
|
+
* then the session slot), and a plugin view opens at the dock's plugin default.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveLeftDockWidth(viewId: string): number {
|
|
69
|
+
const stored = readMap()[viewId];
|
|
70
|
+
if (stored !== undefined) return clamped(viewId, stored);
|
|
71
|
+
if (viewId === SESSIONS_VIEW_ID) return resolveSidebarWidth();
|
|
72
|
+
return clamped(viewId, DEFAULT_LEFT_PANEL_WIDTH);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Persist a dragged width for one view. */
|
|
76
|
+
export function saveLeftDockWidth(viewId: string, px: number): void {
|
|
77
|
+
writeSetting(LEFT_DOCK_WIDTHS_SETTING_KEY, { ...readMap(), [viewId]: clamped(viewId, px) });
|
|
78
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The left dock's placement rules.
|
|
8
|
+
*
|
|
9
|
+
* Three properties, and each fails SILENTLY when broken:
|
|
10
|
+
*
|
|
11
|
+
* - **The sessions view is always on the left and cannot be placed anywhere
|
|
12
|
+
* else.** It is the dock's own view, so a stored row naming it is dropped
|
|
13
|
+
* rather than accepted — otherwise a blob could leave the left column showing
|
|
14
|
+
* a plugin with no way back to the session list.
|
|
15
|
+
* - **A panel is on ONE side at a time.** The same id keys the enablement set,
|
|
16
|
+
* the width map and the stored order, so a panel present in both lists would
|
|
17
|
+
* be two buttons for one panel and one of them would draw nothing.
|
|
18
|
+
* - **A row this build does not recognise is dropped.** The list is not only a
|
|
19
|
+
* preference here: it decides which buttons the dock draws, so a stale id
|
|
20
|
+
* would be a button that resolves to nothing.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, expect, test, beforeEach } from 'bun:test';
|
|
24
|
+
import { primeKabmoSettings, readKabmoSetting } from '@/shared/lib/settings/client';
|
|
25
|
+
import {
|
|
26
|
+
LEFT_DOCK_SETTING_KEY,
|
|
27
|
+
SESSIONS_VIEW_ID,
|
|
28
|
+
isLeftDockPanelId,
|
|
29
|
+
leftPanelIds,
|
|
30
|
+
panelSideOf,
|
|
31
|
+
readLeftDock,
|
|
32
|
+
ridesSides,
|
|
33
|
+
setPanelSide,
|
|
34
|
+
writeLeftDock,
|
|
35
|
+
} from '@/shared/lib/workspace/left-dock';
|
|
36
|
+
import {
|
|
37
|
+
LEFT_DOCK_WIDTHS_SETTING_KEY,
|
|
38
|
+
MIN_LEFT_DOCK_PLUGIN_WIDTH,
|
|
39
|
+
resolveLeftDockWidth,
|
|
40
|
+
saveLeftDockWidth,
|
|
41
|
+
} from '@/shared/lib/workspace/left-dock-width';
|
|
42
|
+
import { MAX_LEFT_PANEL_WIDTH, MIN_LEFT_PANEL_WIDTH } from '@/shared/lib/workspace/sidebar-width';
|
|
43
|
+
|
|
44
|
+
beforeEach(() => primeKabmoSettings({}));
|
|
45
|
+
|
|
46
|
+
describe('readLeftDock', () => {
|
|
47
|
+
test('nothing stored means OFF with no placed panels', () => {
|
|
48
|
+
expect(readLeftDock()).toEqual({ enabled: false, panels: [] });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('a malformed row is filtered rather than keyed', () => {
|
|
52
|
+
primeKabmoSettings({
|
|
53
|
+
[LEFT_DOCK_SETTING_KEY]: { enabled: true, panels: ['plugin:a:rightPanel', 3, '', null, 'plugin:a:rightPanel', 'git', 'plugin:b:rightPanel'] },
|
|
54
|
+
});
|
|
55
|
+
// Built-in ids and duplicates are dropped: a bare `git` names a view whose
|
|
56
|
+
// floor and width slot were declared for the RIGHT column.
|
|
57
|
+
expect(readLeftDock()).toEqual({
|
|
58
|
+
enabled: true,
|
|
59
|
+
panels: ['plugin:a:rightPanel', 'plugin:b:rightPanel'],
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('the sessions view is never a placed panel', () => {
|
|
64
|
+
primeKabmoSettings({ [LEFT_DOCK_SETTING_KEY]: { enabled: true, panels: [SESSIONS_VIEW_ID] } });
|
|
65
|
+
expect(readLeftDock().panels).toEqual([]);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('a non-object row reads as OFF', () => {
|
|
69
|
+
primeKabmoSettings({ [LEFT_DOCK_SETTING_KEY]: 'yes' });
|
|
70
|
+
expect(readLeftDock()).toEqual({ enabled: false, panels: [] });
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('writeLeftDock / setPanelSide', () => {
|
|
75
|
+
test('the switch and the placement are independent halves', () => {
|
|
76
|
+
writeLeftDock({ enabled: true });
|
|
77
|
+
expect(readLeftDock()).toEqual({ enabled: true, panels: [] });
|
|
78
|
+
setPanelSide('plugin:a:rightPanel', 'left');
|
|
79
|
+
expect(readLeftDock()).toEqual({ enabled: true, panels: ['plugin:a:rightPanel'] });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('placing twice is idempotent, and moving back removes it', () => {
|
|
83
|
+
setPanelSide('plugin:a:rightPanel', 'left');
|
|
84
|
+
setPanelSide('plugin:a:rightPanel', 'left');
|
|
85
|
+
expect(readLeftDock().panels).toEqual(['plugin:a:rightPanel']);
|
|
86
|
+
setPanelSide('plugin:a:rightPanel', 'right');
|
|
87
|
+
expect(readLeftDock().panels).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('a built-in id and the sessions view are refused', () => {
|
|
91
|
+
setPanelSide('git', 'left');
|
|
92
|
+
setPanelSide(SESSIONS_VIEW_ID, 'left');
|
|
93
|
+
expect(readLeftDock().panels).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('placing a panel turns the rail on', () => {
|
|
98
|
+
test('a placement with the rail off enables it in the same state', () => {
|
|
99
|
+
// The rail exists to reach the panels on it: a successful placement with
|
|
100
|
+
// the rail still off is a move with no button and no view, which reads as
|
|
101
|
+
// nothing having happened.
|
|
102
|
+
expect(readLeftDock().enabled).toBe(false);
|
|
103
|
+
setPanelSide('plugin:a:rightPanel', 'left');
|
|
104
|
+
expect(readLeftDock()).toEqual({ enabled: true, panels: ['plugin:a:rightPanel'] });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('moving a panel back to the right leaves the rail as the user set it', () => {
|
|
108
|
+
// The switch is the user's, so only a PLACEMENT turns it on; a removal must
|
|
109
|
+
// not turn it off behind their back.
|
|
110
|
+
writeLeftDock({ enabled: true });
|
|
111
|
+
setPanelSide('plugin:a:rightPanel', 'left');
|
|
112
|
+
setPanelSide('plugin:a:rightPanel', 'right');
|
|
113
|
+
expect(readLeftDock()).toEqual({ enabled: true, panels: [] });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('a placement made BY HAND on a disabled dock stays off until the UI places it', () => {
|
|
117
|
+
// `writeLeftDock` is the raw store; the coupling lives at the placement
|
|
118
|
+
// action, so a blob written directly is not silently rewritten.
|
|
119
|
+
writeLeftDock({ enabled: false, panels: ['plugin:a:rightPanel'] });
|
|
120
|
+
expect(readLeftDock()).toEqual({ enabled: false, panels: ['plugin:a:rightPanel'] });
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('panelSideOf / leftPanelIds', () => {
|
|
125
|
+
test('the sessions view is left; a placed plugin is left; everything else right', () => {
|
|
126
|
+
setPanelSide('plugin:a:rightPanel', 'left');
|
|
127
|
+
expect(panelSideOf(SESSIONS_VIEW_ID)).toBe('left');
|
|
128
|
+
expect(panelSideOf('plugin:a:rightPanel')).toBe('left');
|
|
129
|
+
expect(panelSideOf('plugin:b:rightPanel')).toBe('right');
|
|
130
|
+
expect(panelSideOf('git')).toBe('right');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('the bar order is the sessions view then the placed panels', () => {
|
|
134
|
+
setPanelSide('plugin:b:rightPanel', 'left');
|
|
135
|
+
setPanelSide('plugin:a:rightPanel', 'left');
|
|
136
|
+
expect(leftPanelIds()).toEqual([SESSIONS_VIEW_ID, 'plugin:b:rightPanel', 'plugin:a:rightPanel']);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('slot rules', () => {
|
|
141
|
+
test('a view rides the rails; a takeover rides the editor column', () => {
|
|
142
|
+
// The rule is the SLOT's shape, not the plugin's identity: a `rightPanel` is
|
|
143
|
+
// an activity-bar view, a `panel` takes the editor column over whole.
|
|
144
|
+
expect(ridesSides('rightPanel')).toEqual(['left', 'right']);
|
|
145
|
+
expect(ridesSides('panel')).toEqual(['editor']);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('a takeover cannot be placed on a rail', () => {
|
|
149
|
+
// A button on a rail that opens a column somewhere else is a control that
|
|
150
|
+
// lies about where it goes.
|
|
151
|
+
setPanelSide('plugin:x:panel', 'left');
|
|
152
|
+
expect(readLeftDock().panels).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('a view cannot be placed in the editor column', () => {
|
|
156
|
+
setPanelSide('plugin:x:rightPanel', 'editor');
|
|
157
|
+
// Refused, and the refusal leaves the panel where it was: the right column.
|
|
158
|
+
expect(panelSideOf('plugin:x:rightPanel')).toBe('right');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('a takeover is in the editor column by default', () => {
|
|
162
|
+
expect(panelSideOf('plugin:x:panel')).toBe('editor');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('two panels of one plugin are placed independently', () => {
|
|
166
|
+
// The whole reason placement is per PANEL: moving the right panel onto the
|
|
167
|
+
// left dock must not drag the plugin's editor takeover with it.
|
|
168
|
+
setPanelSide('plugin:session-info:rightPanel', 'left');
|
|
169
|
+
expect(panelSideOf('plugin:session-info:rightPanel')).toBe('left');
|
|
170
|
+
expect(panelSideOf('plugin:session-info:panel')).toBe('editor');
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('isLeftDockPanelId', () => {
|
|
175
|
+
test('accepts the sessions view and a plugin key only', () => {
|
|
176
|
+
expect(isLeftDockPanelId(SESSIONS_VIEW_ID)).toBe(true);
|
|
177
|
+
expect(isLeftDockPanelId('plugin:a:rightPanel')).toBe(true);
|
|
178
|
+
expect(isLeftDockPanelId('files')).toBe(false);
|
|
179
|
+
expect(isLeftDockPanelId('plugin:')).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe('left dock widths', () => {
|
|
184
|
+
test('a remembered width wins and is clamped to the view floor', () => {
|
|
185
|
+
saveLeftDockWidth('plugin:a:rightPanel', 400);
|
|
186
|
+
expect(resolveLeftDockWidth('plugin:a:rightPanel')).toBe(400);
|
|
187
|
+
saveLeftDockWidth('plugin:a:rightPanel', 10);
|
|
188
|
+
expect(resolveLeftDockWidth('plugin:a:rightPanel')).toBe(MIN_LEFT_DOCK_PLUGIN_WIDTH);
|
|
189
|
+
saveLeftDockWidth('plugin:a:rightPanel', 10_000);
|
|
190
|
+
expect(resolveLeftDockWidth('plugin:a:rightPanel')).toBe(MAX_LEFT_PANEL_WIDTH);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test('the session list keeps its own floor, and the setting it already had', () => {
|
|
194
|
+
primeKabmoSettings({ omp_sidebar_width: 333 });
|
|
195
|
+
expect(resolveLeftDockWidth(SESSIONS_VIEW_ID)).toBe(333);
|
|
196
|
+
saveLeftDockWidth(SESSIONS_VIEW_ID, 10);
|
|
197
|
+
expect(resolveLeftDockWidth(SESSIONS_VIEW_ID)).toBe(MIN_LEFT_PANEL_WIDTH);
|
|
198
|
+
// The dock's map is its own key: a plugin's width never rewrites the list's.
|
|
199
|
+
saveLeftDockWidth('plugin:a:rightPanel', 500);
|
|
200
|
+
expect(resolveLeftDockWidth(SESSIONS_VIEW_ID)).toBe(MIN_LEFT_PANEL_WIDTH);
|
|
201
|
+
expect((readKabmoSetting<Record<string, number>>(LEFT_DOCK_WIDTHS_SETTING_KEY) ?? {})['plugin:a:rightPanel']).toBe(500);
|
|
202
|
+
});
|
|
203
|
+
});
|