@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.
Files changed (135) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +412 -383
  3. package/dist/client/static/js/index-3qamd33d.css +1 -0
  4. package/dist/client/static/js/{index-28z316qk.js → index-twsk4wfa.js} +791 -791
  5. package/package.json +1 -1
  6. package/src/client/components/common/batch-selection/BatchActionBar.tsx +8 -1
  7. package/src/client/components/common/session-actions-menu/index.tsx +23 -2
  8. package/src/client/components/layout/{RightActivityBar.test.ts → ActivityBar.test.ts} +5 -5
  9. package/src/client/components/layout/{RightActivityBar.tsx → ActivityBar.tsx} +87 -52
  10. package/src/client/components/layout/LeftActivityBar.test.ts +201 -0
  11. package/src/client/components/layout/LeftDock.tsx +119 -0
  12. package/src/client/components/layout/desktop-layout/LeftColumn.tsx +172 -0
  13. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +22 -5
  14. package/src/client/components/layout/desktop-layout/index.tsx +88 -61
  15. package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
  16. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
  17. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
  18. package/src/client/components/layout/session-sidebar/SessionItem.tsx +30 -5
  19. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  20. package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
  21. package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
  22. package/src/client/components/mobile/RightSidebar.tsx +8 -5
  23. package/src/client/components/mobile/SessionSidebar.tsx +6 -0
  24. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -3
  25. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
  26. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
  27. package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
  28. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +41 -13
  29. package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
  30. package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
  31. package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
  32. package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
  33. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
  34. package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
  35. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
  36. package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
  37. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
  38. package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
  39. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
  40. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
  41. package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
  42. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
  43. package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
  44. package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
  45. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
  46. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +14 -9
  47. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
  48. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
  49. package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +154 -0
  50. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
  51. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
  52. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
  53. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
  54. package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
  55. package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
  56. package/src/client/components/workspace/editor/index.tsx +1 -0
  57. package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
  58. package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
  59. package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
  60. package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
  61. package/src/client/components/workspace/panels/builtin.tsx +8 -0
  62. package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
  63. package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
  64. package/src/client/hooks/chat/composer/compact.ts +119 -0
  65. package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
  66. package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
  67. package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
  68. package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
  69. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
  70. package/src/client/hooks/chat/timeline/actions.ts +34 -35
  71. package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
  72. package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
  73. package/src/client/hooks/chat/timeline/index.ts +15 -19
  74. package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
  75. package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
  76. package/src/client/hooks/chat/waiting/cancel.ts +34 -0
  77. package/src/client/hooks/chat/waiting/choice.ts +53 -0
  78. package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
  79. package/src/client/hooks/chat/waiting/held.ts +42 -0
  80. package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
  81. package/src/client/hooks/chat/waiting/hold.ts +156 -0
  82. package/src/client/hooks/chat/waiting/store.ts +100 -0
  83. package/src/client/hooks/workspace/left-dock.ts +132 -0
  84. package/src/client/hooks/workspace/panel-registry.ts +13 -0
  85. package/src/client/tailwind.css +22 -0
  86. package/src/server/db.server.test.ts +52 -1
  87. package/src/server/db.server.ts +49 -1
  88. package/src/server/index.ts +9 -0
  89. package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
  90. package/src/server/lib/assets/compressed-cache.server.ts +88 -0
  91. package/src/server/lib/db/schema.test.ts +1 -0
  92. package/src/server/lib/db/schema.ts +3 -0
  93. package/src/server/lib/fs/fs-path.test.ts +1 -1
  94. package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
  95. package/src/server/lib/omp/session/delete.server.ts +8 -0
  96. package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
  97. package/src/server/lib/panels/registry.server.ts +2 -2
  98. package/src/server/lib/queue/delivery.server.ts +31 -39
  99. package/src/server/lib/queue/payload.ts +73 -0
  100. package/src/server/lib/realtime/signals.server.ts +10 -1
  101. package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
  102. package/src/server/lib/realtime/topics/global.server.ts +5 -0
  103. package/src/server/lib/realtime/topics.server.ts +2 -0
  104. package/src/server/lib/util/with-timeout.ts +29 -0
  105. package/src/server/lib/waiting/row-clock.server.ts +37 -0
  106. package/src/server/lib/waiting/runtime.server.test.ts +278 -0
  107. package/src/server/lib/waiting/runtime.server.ts +313 -0
  108. package/src/server/lib/waiting/schema.server.ts +46 -0
  109. package/src/server/lib/waiting/settle.test.ts +84 -0
  110. package/src/server/lib/waiting/settle.ts +77 -0
  111. package/src/server/lib/waiting/store.server.test.ts +167 -0
  112. package/src/server/lib/waiting/store.server.ts +268 -0
  113. package/src/server/lib/waiting/sweep-trigger.ts +35 -0
  114. package/src/server/plugins/compress.ts +18 -6
  115. package/src/server/plugins/ssr.ts +16 -8
  116. package/src/server/plugins/static.test.ts +59 -0
  117. package/src/server/plugins/static.ts +56 -14
  118. package/src/server/routes/index.ts +2 -0
  119. package/src/server/routes/waiting/bindings.ts +14 -0
  120. package/src/server/routes/waiting/index.ts +123 -0
  121. package/src/server/routes/waiting/routes.test.ts +121 -0
  122. package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
  123. package/src/shared/lib/chat/timeline/gutter.ts +37 -0
  124. package/src/shared/lib/realtime/protocol.ts +10 -0
  125. package/src/shared/lib/workspace/left-dock-width.ts +78 -0
  126. package/src/shared/lib/workspace/left-dock.test.ts +203 -0
  127. package/src/shared/lib/workspace/left-dock.ts +197 -0
  128. package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
  129. package/src/shared/lib/workspace/panel-ids.ts +114 -26
  130. package/src/shared/lib/workspace/sidebar-width.ts +4 -0
  131. package/src/shared/types/index.ts +1 -0
  132. package/src/shared/types/waiting.ts +54 -0
  133. package/src/shared/types/workspace.ts +8 -0
  134. package/src/test-support/isolated-db.ts +3 -4
  135. 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
+ });