@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
|
@@ -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,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The PWA titlebar insets, evaluated against the two platform geometries.
|
|
8
|
+
*
|
|
9
|
+
* The bug this pins is arithmetic, so asserting the STRING would pass while the
|
|
10
|
+
* wrong edge was reserved. The expressions are therefore substituted with real
|
|
11
|
+
* env values and evaluated:
|
|
12
|
+
*
|
|
13
|
+
* - macOS puts the window controls on the LEFT: `titlebar-area-x` is ~76px and
|
|
14
|
+
* `titlebar-area-width` is the rest of the window. The right inset must be
|
|
15
|
+
* the BASE padding — the old form `100vw - width` reserved 76px of dead
|
|
16
|
+
* space between the last navbar control and the window's right edge.
|
|
17
|
+
* - Windows/Linux put them on the RIGHT: `x` is 0, so both forms agree and the
|
|
18
|
+
* right inset is the overlay's own width.
|
|
19
|
+
* - A browser tab has neither: both env variables fall back, and the base
|
|
20
|
+
* padding is all that is left.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { describe, expect, test } from 'bun:test';
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
TITLEBAR_OVERLAY_RIGHT,
|
|
27
|
+
titlebarPaddingLeft,
|
|
28
|
+
titlebarPaddingRight,
|
|
29
|
+
} from '@/shared/lib/ui/titlebar';
|
|
30
|
+
|
|
31
|
+
const REM = 16;
|
|
32
|
+
const VIEWPORT = 1440;
|
|
33
|
+
|
|
34
|
+
interface Geometry {
|
|
35
|
+
/** `titlebar-area-x`; absent means the browser tab's fallback. */
|
|
36
|
+
x?: number;
|
|
37
|
+
/** `titlebar-area-width`; absent means the fallback. */
|
|
38
|
+
width?: number;
|
|
39
|
+
safeLeft?: number;
|
|
40
|
+
safeRight?: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Evaluate one of the returned declarations.
|
|
45
|
+
*
|
|
46
|
+
* `env(name, fallback)` resolves to the geometry's value or its fallback, `vw`
|
|
47
|
+
* resolves to the viewport, and `max()`/`calc()` become their JS equivalents —
|
|
48
|
+
* which is exactly what a browser computes, minus unit conversion.
|
|
49
|
+
*/
|
|
50
|
+
function evaluate(expression: string, geometry: Geometry): number {
|
|
51
|
+
const values: Record<string, number | undefined> = {
|
|
52
|
+
'titlebar-area-x': geometry.x,
|
|
53
|
+
'titlebar-area-width': geometry.width,
|
|
54
|
+
'safe-area-inset-left': geometry.safeLeft,
|
|
55
|
+
'safe-area-inset-right': geometry.safeRight,
|
|
56
|
+
};
|
|
57
|
+
const substituted = expression
|
|
58
|
+
// `vw` first: the fallback inside an `env()` can itself be a viewport unit
|
|
59
|
+
// (`env(titlebar-area-width, 100vw)`), and resolving it afterwards would
|
|
60
|
+
// hand `parseLength` a unit it cannot read.
|
|
61
|
+
.replace(/([\d.]+)vw/g, (_match, value: string) => `${(Number(value) / 100) * VIEWPORT}px`)
|
|
62
|
+
.replace(
|
|
63
|
+
/env\(\s*([a-z-]+)\s*,\s*([^)]+)\)/g,
|
|
64
|
+
(_match, name: string, fallback: string) => `${values[name] ?? parseLength(fallback)}px`,
|
|
65
|
+
)
|
|
66
|
+
.replace(/max\(/g, 'Math.max(')
|
|
67
|
+
.replace(/calc\(/g, '(');
|
|
68
|
+
const js = substituted.replace(/([\d.]+)(px|rem)/g, (_match, value: string, unit: string) =>
|
|
69
|
+
unit === 'rem' ? String(Number(value) * REM) : value,
|
|
70
|
+
);
|
|
71
|
+
return Function(`return (${js})`)() as number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function parseLength(value: string): number {
|
|
75
|
+
const match = /^([\d.]+)(px|rem)?$/.exec(value.trim());
|
|
76
|
+
if (!match) throw new Error(`Unsupported length: ${value}`);
|
|
77
|
+
return match[2] === 'rem' ? Number(match[1]) * REM : Number(match[1]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** macOS: traffic lights on the left, ~76px of them. */
|
|
81
|
+
const MACOS: Geometry = { x: 76, width: VIEWPORT - 76 };
|
|
82
|
+
/** Windows: controls and the app menu on the right, ~138px of them. */
|
|
83
|
+
const WINDOWS: Geometry = { x: 0, width: VIEWPORT - 138 };
|
|
84
|
+
/** A browser tab: no WCO at all, so both variables take their fallbacks. */
|
|
85
|
+
const TAB: Geometry = {};
|
|
86
|
+
|
|
87
|
+
describe('titlebar insets', () => {
|
|
88
|
+
test('the right inset is the space AFTER the title bar area, not before it', () => {
|
|
89
|
+
// The overlay sits between the area's far edge and the window's right edge.
|
|
90
|
+
expect(TITLEBAR_OVERLAY_RIGHT).toContain('env(titlebar-area-x');
|
|
91
|
+
expect(TITLEBAR_OVERLAY_RIGHT).toContain('env(titlebar-area-width');
|
|
92
|
+
|
|
93
|
+
// macOS: the traffic lights are on the LEFT, so nothing may be reserved on
|
|
94
|
+
// the right. This is the reported gap.
|
|
95
|
+
expect(evaluate(titlebarPaddingRight('1rem'), MACOS)).toBe(REM);
|
|
96
|
+
// Windows: the overlay is on the right, and its width is what clears it.
|
|
97
|
+
expect(evaluate(titlebarPaddingRight('1rem'), WINDOWS)).toBe(138);
|
|
98
|
+
// No WCO: the base padding, unchanged.
|
|
99
|
+
expect(evaluate(titlebarPaddingRight('1rem'), TAB)).toBe(REM);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('the left inset is the title bar area origin', () => {
|
|
103
|
+
expect(evaluate(titlebarPaddingLeft('1rem'), MACOS)).toBe(76);
|
|
104
|
+
expect(evaluate(titlebarPaddingLeft('1rem'), WINDOWS)).toBe(REM);
|
|
105
|
+
expect(evaluate(titlebarPaddingLeft('1rem'), TAB)).toBe(REM);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('the phone variants keep the notch insets', () => {
|
|
109
|
+
expect(evaluate(titlebarPaddingLeft('0.75rem', true), { ...MACOS, safeLeft: 44 })).toBe(76);
|
|
110
|
+
expect(evaluate(titlebarPaddingLeft('0.75rem', true), { x: 0, safeLeft: 44 })).toBe(44);
|
|
111
|
+
// A landscape phone's right notch clears the edge on its own, and the
|
|
112
|
+
// overlay's width is still honoured when it is the larger of the two.
|
|
113
|
+
expect(evaluate(titlebarPaddingRight('0.75rem', true), { x: 0, width: VIEWPORT - 138, safeRight: 44 })).toBe(138);
|
|
114
|
+
expect(evaluate(titlebarPaddingRight('0.75rem', true), { ...MACOS, safeRight: 44 })).toBe(44);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('the old form is the bug, stated as a control', () => {
|
|
118
|
+
// `100vw - titlebar-area-width` on macOS: the traffic lights' 76px charged
|
|
119
|
+
// to the wrong edge, which is the strip the report showed.
|
|
120
|
+
const buggy = `max(1rem, calc(100vw - env(titlebar-area-width, 100vw)))`;
|
|
121
|
+
expect(evaluate(buggy, MACOS)).toBe(76);
|
|
122
|
+
expect(evaluate(buggy, WINDOWS)).toBe(138);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The insets that keep a header's controls clear of the PWA window controls
|
|
8
|
+
* (Window Controls Overlay), stated ONCE because four headers draw them.
|
|
9
|
+
*
|
|
10
|
+
* The `titlebar-area-*` env variables describe the area the page may use: it
|
|
11
|
+
* runs from the frame edge to the innermost edge of the overlay that houses the
|
|
12
|
+
* window controls. Which SIDE that overlay is on is the platform's choice, and
|
|
13
|
+
* `titlebar-area-x` is the answer:
|
|
14
|
+
*
|
|
15
|
+
* - macOS puts the traffic lights on the LEFT, so `x` is ~76px and
|
|
16
|
+
* `titlebar-area-width` is `viewport - 76`;
|
|
17
|
+
* - Windows and Linux put the controls (and Chrome's `⋮` app menu) on the
|
|
18
|
+
* RIGHT, so `x` is `0` and `titlebar-area-width` is `viewport - overlay`.
|
|
19
|
+
*
|
|
20
|
+
* So the LEFT inset is `x`, and the RIGHT inset is whatever is left over after
|
|
21
|
+
* the area ENDS — `viewport - (x + width)` — never `viewport - width`. The
|
|
22
|
+
* second form is what the headers used to compute, and on macOS it reserved the
|
|
23
|
+
* traffic lights' width on the wrong edge: a 76px dead strip between the last
|
|
24
|
+
* navbar control and the window's right edge, with no control anywhere near it.
|
|
25
|
+
* On Windows the two forms agree, which is why the bug only showed on one
|
|
26
|
+
* platform.
|
|
27
|
+
*
|
|
28
|
+
* `safeArea` adds the iOS/Android notch insets, which the phone headers carry.
|
|
29
|
+
* A desktop PWA has them at 0, so one helper serves both.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The width of the overlay that sits to the RIGHT of the title bar area.
|
|
34
|
+
*
|
|
35
|
+
* Both env variables are read with a fallback, so this is `0px` in a browser
|
|
36
|
+
* tab (where WCO is not active) and on macOS, and the platform's real control
|
|
37
|
+
* width on Windows/Linux.
|
|
38
|
+
*/
|
|
39
|
+
export const TITLEBAR_OVERLAY_RIGHT =
|
|
40
|
+
'calc(100vw - env(titlebar-area-x, 0px) - env(titlebar-area-width, 100vw))';
|
|
41
|
+
|
|
42
|
+
/** `base`-sized padding that also clears the window controls on the left. */
|
|
43
|
+
export function titlebarPaddingLeft(base: string, safeArea = false): string {
|
|
44
|
+
return safeArea
|
|
45
|
+
? `max(${base}, env(safe-area-inset-left, 0px), env(titlebar-area-x, 0px))`
|
|
46
|
+
: `max(${base}, env(titlebar-area-x, 0px))`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `base`-sized padding that also clears the window controls on the right. */
|
|
50
|
+
export function titlebarPaddingRight(base: string, safeArea = false): string {
|
|
51
|
+
return safeArea
|
|
52
|
+
? `max(${base}, env(safe-area-inset-right, 0px), ${TITLEBAR_OVERLAY_RIGHT})`
|
|
53
|
+
: `max(${base}, ${TITLEBAR_OVERLAY_RIGHT})`;
|
|
54
|
+
}
|
|
@@ -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
|
+
}
|