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