@kabmo-labs/kabmo 0.0.1 → 0.2.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 (127) hide show
  1. package/README.md +16 -11
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +333 -333
  4. package/dist/client/static/js/index-pdp5dwc8.css +1 -0
  5. package/dist/client/static/js/{index-4vyzz0z4.js → index-zsskckw7.js} +681 -681
  6. package/package.json +2 -1
  7. package/public/kabmo-wordmark.svg +31 -0
  8. package/src/cli/lib/commands/update.js +17 -2
  9. package/src/cli/lib/commands/update.test.js +36 -0
  10. package/src/client/bootstrap.d.ts +8 -0
  11. package/src/client/components/auth/login-screen/index.test.tsx +93 -0
  12. package/src/client/components/auth/login-screen/index.tsx +43 -22
  13. package/src/client/components/auth/login-screen/password-toggle.test.tsx +121 -0
  14. package/src/client/components/common/batch-selection/BatchActionBar.test.ts +132 -0
  15. package/src/client/components/common/batch-selection/BatchActionBar.tsx +118 -0
  16. package/src/client/components/common/batch-selection/BatchDeleteModal.tsx +177 -0
  17. package/src/client/components/common/batch-selection/SelectionCheckbox.tsx +58 -0
  18. package/src/client/components/common/batch-selection/folder-state.test.ts +130 -0
  19. package/src/client/components/common/batch-selection/folder-state.ts +101 -0
  20. package/src/client/components/common/batch-selection/types.ts +42 -0
  21. package/src/client/components/common/brand-wordmark/Mark.tsx +62 -0
  22. package/src/client/components/common/brand-wordmark/index.test.tsx +29 -7
  23. package/src/client/components/common/brand-wordmark/index.tsx +33 -11
  24. package/src/client/components/common/brand-wordmark/wordmark-svg.test.ts +88 -0
  25. package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
  26. package/src/client/components/common/header-controls/index.test.tsx +77 -11
  27. package/src/client/components/common/header-controls/index.tsx +53 -4
  28. package/src/client/components/common/session-state-provider/index.test.ts +22 -1
  29. package/src/client/components/common/workspace-options-menu/index.tsx +25 -11
  30. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +169 -0
  31. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +72 -91
  32. package/src/client/components/layout/session-sidebar/Footer.tsx +20 -2
  33. package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
  34. package/src/client/components/layout/session-sidebar/SessionItem.batch.test.ts +162 -0
  35. package/src/client/components/layout/session-sidebar/SessionItem.tsx +35 -6
  36. package/src/client/components/layout/session-sidebar/SessionList.tsx +10 -1
  37. package/src/client/components/layout/session-sidebar/Toolbar.test.ts +2 -0
  38. package/src/client/components/layout/session-sidebar/Toolbar.tsx +30 -34
  39. package/src/client/components/layout/session-sidebar/index.tsx +92 -0
  40. package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
  41. package/src/client/components/mobile/SessionSidebar.tsx +83 -0
  42. package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
  43. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +21 -1
  44. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +56 -12
  45. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +8 -0
  46. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +27 -6
  47. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +106 -0
  48. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +178 -93
  49. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
  50. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
  51. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
  52. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
  53. package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
  54. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
  55. package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
  56. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
  57. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
  58. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
  59. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
  60. package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
  65. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
  66. package/src/client/components/workspace/git-panel/index.test.ts +15 -0
  67. package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
  68. package/src/client/components/workspace/search-panel/Body.tsx +53 -9
  69. package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
  70. package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
  71. package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
  72. package/src/client/hooks/chat/timeline/index.ts +2 -1
  73. package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
  74. package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
  75. package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
  76. package/src/client/hooks/chat/timeline/scroll.ts +34 -1
  77. package/src/client/hooks/ui/media-query.ts +41 -0
  78. package/src/client/hooks/ui/updates/android-download.test.ts +73 -0
  79. package/src/client/hooks/ui/updates/android-download.ts +106 -0
  80. package/src/client/hooks/workspace/batch-delete-run.ts +95 -0
  81. package/src/client/hooks/workspace/batch-delete.ts +63 -0
  82. package/src/client/hooks/workspace/batch-selection.ts +98 -0
  83. package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
  84. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  85. package/src/client/hooks/workspace/session-state/index.ts +24 -1
  86. package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
  87. package/src/client/lib/native/back.test.ts +108 -0
  88. package/src/client/lib/native/back.ts +60 -0
  89. package/src/client/main.tsx +6 -0
  90. package/src/client/tailwind.css +6 -0
  91. package/src/server/lib/fs/git-actions-branch.ts +18 -1
  92. package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
  93. package/src/server/lib/fs/git-publish.test.ts +96 -0
  94. package/src/server/lib/fs/git-publish.ts +35 -3
  95. package/src/server/lib/fs/listing.test.ts +86 -0
  96. package/src/server/lib/fs/listing.ts +20 -6
  97. package/src/server/lib/lifecycle/restart.ts +52 -14
  98. package/src/server/lib/omp/session/delete-flow.server.ts +101 -0
  99. package/src/server/lib/updates/android-release.test.ts +92 -0
  100. package/src/server/lib/updates/android-release.ts +148 -0
  101. package/src/server/lib/updates/apply.ts +8 -0
  102. package/src/server/lib/updates/install-steps.ts +81 -0
  103. package/src/server/lib/updates/install.ts +19 -67
  104. package/src/server/lib/updates/installed-cli.test.ts +222 -0
  105. package/src/server/lib/updates/installed-cli.ts +139 -0
  106. package/src/server/lib/updates/omp.ts +1 -1
  107. package/src/server/plugins/ssr.ts +9 -2
  108. package/src/server/routes/folders/batch-delete.ts +68 -0
  109. package/src/server/routes/folders/index.ts +4 -0
  110. package/src/server/routes/fs/read.ts +24 -9
  111. package/src/server/routes/sessions/batch-delete.test.ts +142 -0
  112. package/src/server/routes/sessions/batch-delete.ts +60 -0
  113. package/src/server/routes/sessions/delete.ts +13 -51
  114. package/src/server/routes/sessions/index.ts +5 -0
  115. package/src/server/routes/updates/android.ts +22 -0
  116. package/src/server/routes/updates/apply.ts +1 -1
  117. package/src/server/routes/updates/index.ts +2 -0
  118. package/src/shared/lib/updates/shell-ua.test.ts +54 -0
  119. package/src/shared/lib/updates/shell-ua.ts +36 -0
  120. package/src/shared/lib/workspace/batch-select.test.ts +92 -0
  121. package/src/shared/lib/workspace/batch-select.ts +86 -0
  122. package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
  123. package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
  124. package/src/shared/lib/workspace/session-state/store.ts +13 -2
  125. package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
  126. package/src/shared/lib/workspace/sidebar-toolbar.ts +84 -0
  127. package/dist/client/static/js/index-x9cehh1a.css +0 -1
@@ -0,0 +1,142 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The batch routes' request contract.
8
+ *
9
+ * These are pure request-shape checks and they must not reach the filesystem or
10
+ * spawn an omp child — which is half the point of asserting them: the batch
11
+ * verbs are reachable from a click, so a malformed body has to be refused
12
+ * before any id is resolved against the sessions root.
13
+ *
14
+ * The refusal half is the other point: a batch that names a pending `new-…`
15
+ * chat must answer a per-id verdict and still delete the rest, because the
16
+ * dialog reports counts rather than aborting.
17
+ */
18
+
19
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
20
+ import fs from 'fs';
21
+ import os from 'os';
22
+ import path from 'path';
23
+ import { batchDeleteSessions } from '@/server/routes/sessions/batch-delete';
24
+ import { batchDeleteFolders } from '@/server/routes/folders/batch-delete';
25
+ import { isolateDb, releaseDb } from '@/test-support/isolated-db';
26
+ import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
27
+
28
+ const AGENT_DIR = path.join(os.tmpdir(), `omc-batch-delete-${process.pid}`, 'agent');
29
+ let priorAgentDir: string | undefined;
30
+
31
+ beforeAll(() => {
32
+ // This suite reaches the database: `removeSession` → `purgeSessionData`
33
+ // resolves `getDb()`. Without isolating, it inherits whatever handle an
34
+ // earlier file left on the process-wide slot — and when that handle points at
35
+ // a temp directory its owner already deleted, SQLite answers `disk I/O error`
36
+ // and the verdict loses its code (measured on CI: `not_found` came back
37
+ // `undefined` while the same file passed alone).
38
+ isolateDb();
39
+ fs.rmSync(AGENT_DIR, { recursive: true, force: true });
40
+ fs.mkdirSync(path.join(AGENT_DIR, 'sessions'), { recursive: true });
41
+ priorAgentDir = Bun.env.PI_CODING_AGENT_DIR;
42
+ Bun.env.PI_CODING_AGENT_DIR = AGENT_DIR;
43
+ clearSessionFileCaches();
44
+ });
45
+
46
+ afterAll(() => {
47
+ clearSessionFileCaches();
48
+ if (priorAgentDir === undefined) delete Bun.env.PI_CODING_AGENT_DIR;
49
+ else Bun.env.PI_CODING_AGENT_DIR = priorAgentDir;
50
+ fs.rmSync(path.dirname(AGENT_DIR), { recursive: true, force: true });
51
+ releaseDb();
52
+ });
53
+
54
+ function post(body: unknown, url: string): Promise<Response> {
55
+ return batchDeleteSessions({
56
+ request: new Request(url, {
57
+ method: 'POST',
58
+ headers: { 'content-type': 'application/json' },
59
+ body: JSON.stringify(body),
60
+ }),
61
+ params: {},
62
+ } as never) as Promise<Response>;
63
+ }
64
+
65
+ function postFolder(body: unknown): Promise<Response> {
66
+ return batchDeleteFolders({
67
+ request: new Request('http://localhost/api/folders/batch-delete', {
68
+ method: 'POST',
69
+ headers: { 'content-type': 'application/json' },
70
+ body: JSON.stringify(body),
71
+ }),
72
+ params: {},
73
+ } as never) as Promise<Response>;
74
+ }
75
+
76
+ describe('batchDeleteSessions request shape', () => {
77
+ test('a wrong verb is a 405', async () => {
78
+ const res = (await batchDeleteSessions({
79
+ request: new Request('http://localhost/api/sessions/batch-delete', { method: 'DELETE' }),
80
+ params: {},
81
+ } as never)) as Response;
82
+ expect(res.status).toBe(405);
83
+ });
84
+
85
+ test('a non-array ids field is a 400', async () => {
86
+ const res = await post({ ids: 'a' }, 'http://localhost/api/sessions/batch-delete');
87
+ expect(res.status).toBe(400);
88
+ expect(await res.json()).toMatchObject({ error: 'ids must be an array of session ids' });
89
+ });
90
+
91
+ test('an empty selection is a 400 rather than a no-op success', async () => {
92
+ const res = await post({ ids: [] }, 'http://localhost/api/sessions/batch-delete');
93
+ expect(res.status).toBe(400);
94
+ });
95
+
96
+ test('an over-long selection is refused', async () => {
97
+ const ids = Array.from({ length: 501 }, (_, index) => `s-${index}`);
98
+ const res = await post({ ids }, 'http://localhost/api/sessions/batch-delete');
99
+ expect(res.status).toBe(400);
100
+ });
101
+
102
+ test('a pending chat is refused per id while the rest still runs', async () => {
103
+ // No files exist, so the real id answers not_found — the point is that the
104
+ // pending one is refused by its OWN rule and the batch continues.
105
+ const res = await post(
106
+ { ids: ['new-1758800000000', '00000000-0000-0000-0000-000000000000'] },
107
+ 'http://localhost/api/sessions/batch-delete',
108
+ );
109
+ expect(res.status).toBe(200);
110
+ const body = await res.json() as { success: boolean; removed: string[]; refused: { id: string; code?: string }[] };
111
+ expect(body.success).toBe(false);
112
+ expect(body.removed).toEqual([]);
113
+ expect(body.refused.map((entry) => entry.id)).toEqual([
114
+ 'new-1758800000000',
115
+ '00000000-0000-0000-0000-000000000000',
116
+ ]);
117
+ expect(body.refused[0].code).toBe('session_pending');
118
+ expect(body.refused[1].code).toBe('not_found');
119
+ });
120
+
121
+ test('a traversal id is refused without touching the tree', async () => {
122
+ const res = await post({ ids: ['..'] }, 'http://localhost/api/sessions/batch-delete');
123
+ const body = await res.json() as { refused: { code?: string }[] };
124
+ expect(body.refused[0].code).toBe('invalid_id');
125
+ });
126
+ });
127
+
128
+ describe('batchDeleteFolders request shape', () => {
129
+ test('a non-array ids field is a 400', async () => {
130
+ const res = await postFolder({ ids: 1 });
131
+ expect(res.status).toBe(400);
132
+ });
133
+
134
+ test('an unknown workspace is refused per id', async () => {
135
+ const res = await postFolder({ ids: ['424242'] });
136
+ expect(res.status).toBe(200);
137
+ const body = await res.json() as { success: boolean; removed: string[]; refused: { code?: string }[] };
138
+ expect(body.success).toBe(false);
139
+ expect(body.removed).toEqual([]);
140
+ expect(body.refused[0].code).toBe('not_found');
141
+ });
142
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * POST /api/sessions/batch-delete — remove several sessions in one request.
8
+ *
9
+ * The sidebar's batch mode is one user action over N rows, so it is one request:
10
+ * a client loop would pay N round trips and republish the sidebar N times, and
11
+ * the confirmation dialog would have to reassemble N answers into one summary.
12
+ *
13
+ * Each id goes through the SAME sequence as the single DELETE
14
+ * (`removeSession`), and a refusal is a verdict rather than an abort: a batch
15
+ * that contained one mid-run session must still delete the other nineteen, and
16
+ * the caller is told exactly which ids were refused and why. `removed` is what
17
+ * the dialog counts; `refused` is what it lists.
18
+ */
19
+
20
+ import { json } from '@/server/lib/remix-compat';
21
+ import type { ActionFunctionArgs } from '@/server/lib/remix-compat';
22
+ import { methodNotAllowed, parseJsonBody } from '@/server/lib/route-adapter';
23
+ import { removeSession } from '@/server/lib/omp/session/delete-flow.server';
24
+
25
+ /** One request cannot carry an unbounded selection. */
26
+ const MAX_BATCH = 500;
27
+
28
+ export async function batchDeleteSessions({ request }: ActionFunctionArgs) {
29
+ if (request.method !== 'POST') {
30
+ return methodNotAllowed({ request, params: {} });
31
+ }
32
+
33
+ const parsed = await parseJsonBody<{ ids?: unknown }>(request);
34
+ if (!parsed.ok) return json({ error: 'Invalid JSON body' }, { status: 400 });
35
+
36
+ const raw = parsed.body.ids;
37
+ if (!Array.isArray(raw) || raw.some((id) => typeof id !== 'string' || id.length === 0)) {
38
+ return json({ error: 'ids must be an array of session ids' }, { status: 400 });
39
+ }
40
+ const ids = [...new Set(raw as string[])];
41
+ if (ids.length === 0) return json({ error: 'No sessions selected' }, { status: 400 });
42
+ if (ids.length > MAX_BATCH) {
43
+ return json({ error: `At most ${MAX_BATCH} sessions can be deleted at once.` }, { status: 400 });
44
+ }
45
+
46
+ const removed: string[] = [];
47
+ const refused: { id: string; error: string; code?: string }[] = [];
48
+
49
+ // Sequential, deliberately: every removal may have to destroy an omp child
50
+ // and wait for its exit, and running those in parallel would open one
51
+ // transcript writer per session at once for no gain — the work is dominated
52
+ // by process teardown, not by the filesystem.
53
+ for (const id of ids) {
54
+ const result = await removeSession(id);
55
+ if (result.ok) removed.push(id);
56
+ else refused.push({ id, error: result.error ?? 'Could not delete the session.', code: result.code });
57
+ }
58
+
59
+ return json({ success: refused.length === 0, removed, refused });
60
+ }
@@ -9,26 +9,15 @@
9
9
  * transcript, this one is the destructive path the user opts into from the
10
10
  * sidebar's trash button.
11
11
  *
12
- * Order matters and is enforced here, not by the callers:
13
- * 1. refuse a session whose omp child is mid-turn (a run, a live subagent, or
14
- * a dialog the agent is blocked on) — the delete would kill work the user
15
- * can still see, and omp would flush the file back on shutdown anyway;
16
- * 2. destroy the child and AWAIT its exit — omp writes session state on
17
- * shutdown and would otherwise recreate the transcript just removed;
18
- * 3. purge side questions, then the transcript, artifacts and Kabmo rows.
19
- *
20
- * A pending `new-…` session is refused: it is a client-side placeholder with
21
- * nothing stored anywhere yet, so deleting it is the same as navigating away.
12
+ * The sequence (pending/id guard, busy refusal, child teardown, purge) lives in
13
+ * `removeSession` because the batch route performs the same one per id; this
14
+ * handler only maps the verdict onto a response.
22
15
  */
23
16
 
24
17
  import { json } from '@/server/lib/remix-compat';
25
18
  import type { ActionFunctionArgs } from '@/server/lib/remix-compat';
26
19
  import { methodNotAllowed } from '@/server/lib/route-adapter';
27
- import { getRpcSession } from '@/server/lib/omp/rpc/session-registry';
28
- import { purgeSessionData } from '@/server/lib/omp/session/delete.server';
29
- import { purgeBtwForSession } from '@/server/lib/btw/purge.server';
30
- import { isSinglePathSegment } from '@/server/lib/fs/path-segment';
31
- import { isMockMode } from '@/server/mock.server';
20
+ import { removeSession } from '@/server/lib/omp/session/delete-flow.server';
32
21
 
33
22
  export async function deleteSession({ request, params }: ActionFunctionArgs) {
34
23
  if (request.method !== 'DELETE') {
@@ -37,47 +26,20 @@ export async function deleteSession({ request, params }: ActionFunctionArgs) {
37
26
 
38
27
  const sessionId = params.sessionId;
39
28
  if (!sessionId) return json({ error: 'Missing session id' }, { status: 400 });
40
- if (sessionId.startsWith('new-')) {
41
- return json({ error: 'This chat has not been created yet.', code: 'session_pending' }, { status: 400 });
42
- }
43
- // The id names a directory under the sessions root and another under the btw
44
- // root, so it must be exactly one path segment — see `isSinglePathSegment`
45
- // for the `.` case that made this load-bearing. Both purges re-check it
46
- // independently; refusing here is what turns the attempt into a 400.
47
- if (!isSinglePathSegment(sessionId)) {
48
- return json({ error: 'Invalid session id' }, { status: 400 });
49
- }
50
-
51
- // A live child is the only other writer of the transcript. Busy means a turn,
52
- // a live subagent, or a dialog omp is parked on — all things the user can
53
- // still act on, so the delete is refused with a reason rather than silently
54
- // taking them down.
55
- const live = getRpcSession(sessionId);
56
- if (live?.isAlive()) {
57
- if (live.isBusy()) {
58
- return json(
59
- { error: 'This session is working. Stop the run before deleting it.', code: 'session_busy' },
60
- { status: 409 },
61
- );
62
- }
63
- await live.destroyAndWait();
64
- }
65
-
66
- // Side questions live outside the session file and are removed first: their
67
- // children must be gone before the parent transcript they were snapshotted
68
- // from disappears.
69
- const btwTopicsRemoved = isMockMode() ? 0 : await purgeBtwForSession(sessionId);
70
- const purged = await purgeSessionData(sessionId);
71
29
 
72
- if (!purged.found) {
73
- return json({ error: 'Session not found' }, { status: 404 });
30
+ const result = await removeSession(sessionId);
31
+ if (!result.ok) {
32
+ return json(
33
+ { error: result.error, code: result.code },
34
+ { status: result.status },
35
+ );
74
36
  }
75
37
 
76
38
  return json({
77
39
  success: true,
78
40
  sessionId,
79
- fileRemoved: purged.fileRemoved,
80
- artifactsRemoved: purged.artifactsRemoved,
81
- btwTopicsRemoved,
41
+ fileRemoved: result.fileRemoved,
42
+ artifactsRemoved: result.artifactsRemoved,
43
+ btwTopicsRemoved: result.btwTopicsRemoved,
82
44
  });
83
45
  }
@@ -14,9 +14,14 @@ import {
14
14
  import { listSubagents, readSubagentTranscript } from '@/server/routes/sessions/subagents';
15
15
  import { cancelSubagent, steerSubagent } from '@/server/routes/sessions/subagent-control';
16
16
  import { deleteSession } from '@/server/routes/sessions/delete';
17
+ import { batchDeleteSessions } from '@/server/routes/sessions/batch-delete';
17
18
 
18
19
  export const sessionsBindings: HandlerBinding[] = [
19
20
  ...bindingsFor(sessionsList, '/api/sessions/list'),
21
+ // Registered BEFORE the `:sessionId` routes: the segment is static, and the
22
+ // batch route is POST while `:sessionId` answers DELETE, so an id route
23
+ // winning the match would turn every batch into a 405.
24
+ { method: 'POST', path: '/api/sessions/batch-delete', handler: batchDeleteSessions },
20
25
  ...bindingsFor(sessionsFolder, '/api/sessions/:sessionId'),
21
26
  // DELETE-only on the same path the folder loader answers GET on: mounting it
22
27
  // through `actionBindings` would register a 405 GET fallback that Elysia
@@ -0,0 +1,22 @@
1
+ import { json, NO_STORE_HEADERS } from '@/server/lib/remix-compat';
2
+ import { fetchAndroidRelease, type AndroidRelease } from '@/server/lib/updates/android-release';
3
+
4
+ /**
5
+ * GET /api/updates/android — the newest APK a visitor can download.
6
+ *
7
+ * Its own route rather than a field on `/api/updates/check`: that one answers for
8
+ * the INSTALLED server (npm), this one answers for a different product (the
9
+ * Android shell), and folding them would make one probe's failure look like the
10
+ * other's. The client asks for it only when the sidebar renders, so a console
11
+ * that never shows the button never pays for the read.
12
+ *
13
+ * A failed probe is a valid answer (`url: null`), never a 500: the sidebar's job
14
+ * is to omit a button it cannot back with a link.
15
+ */
16
+ export async function loader() {
17
+ const release: AndroidRelease | null = await fetchAndroidRelease();
18
+ return json(
19
+ release ?? { url: null, latest: null, releaseUrl: null },
20
+ { headers: NO_STORE_HEADERS },
21
+ );
22
+ }
@@ -115,7 +115,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
115
115
  // still on the old build would mislead, and `restarting` is what tells
116
116
  // the console to wait for the replacement and reload.
117
117
  const result = target === 'kabmo' && applied.result.success && applied.updated
118
- ? withRestartPlan(applied.result, scheduleSelfRestart())
118
+ ? withRestartPlan(applied.result, scheduleSelfRestart('update', applied.installed ?? null))
119
119
  : applied.result;
120
120
  handlers.send('result', result);
121
121
  } catch (err) {
@@ -1,9 +1,11 @@
1
1
  import { bindingsFor, type HandlerBinding } from '@/server/lib/route-adapter';
2
+ import * as updatesAndroid from '@/server/routes/updates/android';
2
3
  import * as updatesApply from '@/server/routes/updates/apply';
3
4
  import * as updatesChangelog from '@/server/routes/updates/changelog';
4
5
  import * as updatesCheck from '@/server/routes/updates/check';
5
6
 
6
7
  export const updatesBindings: HandlerBinding[] = [
8
+ ...bindingsFor(updatesAndroid, '/api/updates/android'),
7
9
  ...bindingsFor(updatesApply, '/api/updates/apply'),
8
10
  ...bindingsFor(updatesChangelog, '/api/updates/changelog'),
9
11
  ...bindingsFor(updatesCheck, '/api/updates/check'),
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+
8
+ import { androidShellVersion, SHELL_UA_PREFIX } from '@/shared/lib/updates/shell-ua';
9
+
10
+ /**
11
+ * The token is the only signal that a page is running inside the Android app,
12
+ * and it decides whether a download button appears. Two failure modes matter:
13
+ * a browser mistaken for the app (a button in a place it does not belong) and a
14
+ * malformed token read as a version (a comparison against `''`).
15
+ */
16
+ describe('androidShellVersion', () => {
17
+ test('reads the version from a real WebView UA', () => {
18
+ const ua =
19
+ 'Mozilla/5.0 (Linux; Android 15; Pixel 6 Build/AP4A; wv) AppleWebKit/537.36 (KHTML, like Gecko) '
20
+ + `Version/4.0 Chrome/124.0.6367.219 Mobile Safari/537.36 ${SHELL_UA_PREFIX}0.1.0`;
21
+ expect(androidShellVersion(ua)).toBe('0.1.0');
22
+ });
23
+
24
+ test('is null for a plain browser', () => {
25
+ expect(androidShellVersion('Mozilla/5.0 (Macintosh) AppleWebKit/537.36 Chrome/124.0 Safari/537.36')).toBeNull();
26
+ expect(androidShellVersion('')).toBeNull();
27
+ expect(androidShellVersion(null)).toBeNull();
28
+ expect(androidShellVersion(undefined)).toBeNull();
29
+ });
30
+
31
+ test('is null when the token carries no usable version', () => {
32
+ // A forged or truncated token must not read as version '' — that would make
33
+ // `isNewer(latest, '')` true for every release and show a button always.
34
+ expect(androidShellVersion('Mozilla/5.0 KabmoAndroid/')).toBeNull();
35
+ expect(androidShellVersion('Mozilla/5.0 KabmoAndroid/abc')).toBeNull();
36
+ expect(androidShellVersion('Mozilla/5.0 KabmoAndroid/1.x.3')).toBeNull();
37
+ });
38
+
39
+ test('accepts one-, two- and three-part versions and a prerelease', () => {
40
+ expect(androidShellVersion('KabmoAndroid/1')).toBe('1');
41
+ expect(androidShellVersion('KabmoAndroid/1.2')).toBe('1.2');
42
+ expect(androidShellVersion('KabmoAndroid/1.2.3')).toBe('1.2.3');
43
+ expect(androidShellVersion('KabmoAndroid/1.2.3-beta.1')).toBe('1.2.3-beta.1');
44
+ });
45
+
46
+ test('takes the first token when the UA was crafted twice', () => {
47
+ expect(androidShellVersion('KabmoAndroid/1.0.0 KabmoAndroid/9.9.9')).toBe('1.0.0');
48
+ });
49
+
50
+ test('does not match a lookalike token', () => {
51
+ expect(androidShellVersion('KabmoAndroidX/1.0.0')).toBeNull();
52
+ expect(androidShellVersion('Mozilla KabmoAndroid')).toBeNull();
53
+ });
54
+ });
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The `KabmoAndroid/<version>` token in a request's User-Agent.
3
+ *
4
+ * The APK appends it (`KabmoWebView.configure`), and it is the console's only
5
+ * signal for two questions at once: "am I running inside the Android app" and
6
+ * "which build of it". The UA is the right channel because it crosses the very
7
+ * request that renders the shell, so the answer needs no bridge, no extra
8
+ * endpoint, and no round trip — the page is served already knowing.
9
+ *
10
+ * Parsed leniently and defensively: a browser can be made to send any UA, so a
11
+ * forged token buys nothing here (the value only decides whether to show a
12
+ * download button), and a malformed one must read as "not the app" rather than
13
+ * as version `''`.
14
+ */
15
+
16
+ /** The token's name, before the slash. */
17
+ export const SHELL_UA_PREFIX = 'KabmoAndroid/';
18
+
19
+ /**
20
+ * The APK version that sent this request, or null when it is not the app.
21
+ *
22
+ * The FIRST matching token wins: the suffix is appended once, and a duplicate
23
+ * would mean something else crafted the string.
24
+ */
25
+ export function androidShellVersion(userAgent: string | null | undefined): string | null {
26
+ if (!userAgent) return null;
27
+
28
+ for (const token of userAgent.split(/\s+/)) {
29
+ if (!token.startsWith(SHELL_UA_PREFIX)) continue;
30
+ const version = token.slice(SHELL_UA_PREFIX.length);
31
+ // A version, not an arbitrary string: `1`, `1.2`, `1.2.3`, optionally with a
32
+ // prerelease suffix. Anything else is a token this parser does not own.
33
+ if (/^\d+(\.\d+){0,2}(-[0-9A-Za-z.-]+)?$/.test(version)) return version;
34
+ }
35
+ return null;
36
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The batch-selection rules, in isolation.
8
+ *
9
+ * Each of these decides what a header checkbox reports or what a Delete sends,
10
+ * so the failure mode is a delete of the wrong rows — not a cosmetic bug. The
11
+ * archived rule is the one that matters most: a row the filter hides cannot be
12
+ * unselected, so "select all" must never reach it.
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+ import {
17
+ liveSelection,
18
+ selectableSessionIds,
19
+ selectionSummary,
20
+ toggleSelection,
21
+ } from '@/shared/lib/workspace/batch-select';
22
+
23
+ describe('toggleSelection', () => {
24
+ test('adds an id it does not hold, preserving click order', () => {
25
+ expect(toggleSelection(['a'], 'b')).toEqual(['a', 'b']);
26
+ });
27
+
28
+ test('removes an id it holds', () => {
29
+ expect(toggleSelection(['a', 'b', 'c'], 'b')).toEqual(['a', 'c']);
30
+ });
31
+
32
+ test('does not mutate the input', () => {
33
+ const selected = ['a'];
34
+ toggleSelection(selected, 'b');
35
+ expect(selected).toEqual(['a']);
36
+ });
37
+ });
38
+
39
+ describe('selectionSummary', () => {
40
+ test('reports all only when every available id is selected', () => {
41
+ expect(selectionSummary(['a', 'b'], ['a', 'b'])).toEqual({ count: 2, all: true, some: false });
42
+ });
43
+
44
+ test('reports some for a partial selection', () => {
45
+ expect(selectionSummary(['a'], ['a', 'b'])).toEqual({ count: 1, all: false, some: true });
46
+ });
47
+
48
+ test('an empty available list is neither all nor some', () => {
49
+ // A header checkbox over a workspace with no rows must not claim "all".
50
+ expect(selectionSummary([], [])).toEqual({ count: 0, all: false, some: false });
51
+ });
52
+
53
+ test('an id outside the available list does not count', () => {
54
+ expect(selectionSummary(['z'], ['a'])).toEqual({ count: 0, all: false, some: false });
55
+ });
56
+ });
57
+
58
+ describe('selectableSessionIds', () => {
59
+ const sessions = [
60
+ { id: 'a', is_archived: 0 },
61
+ { id: 'b', is_archived: 1 },
62
+ { id: 'c' },
63
+ ];
64
+
65
+ test('offers the live rows and skips the archived ones', () => {
66
+ expect(selectableSessionIds(sessions, false)).toEqual(['a', 'c']);
67
+ });
68
+
69
+ test('follows the archive filter when it is on', () => {
70
+ expect(selectableSessionIds(sessions, true)).toEqual(['b']);
71
+ });
72
+
73
+ test('ids are strings, matching what a checkbox reports', () => {
74
+ expect(selectableSessionIds([{ id: 7 }], false)).toEqual(['7']);
75
+ });
76
+ });
77
+
78
+ describe('liveSelection', () => {
79
+ test('drops an id that is no longer in the list', () => {
80
+ // The two-tab case: one tab deleted the session, so this bar must stop
81
+ // counting it and must never send it.
82
+ expect(liveSelection(['a', 'gone'], ['a', 'b'])).toEqual(['a']);
83
+ });
84
+
85
+ test('keeps the recorded order', () => {
86
+ expect(liveSelection(['b', 'a'], ['a', 'b'])).toEqual(['b', 'a']);
87
+ });
88
+
89
+ test('an empty available list empties the selection', () => {
90
+ expect(liveSelection(['a'], [])).toEqual([]);
91
+ });
92
+ });
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Batch selection, stated once for both sidebars and both nouns.
8
+ *
9
+ * The sidebar offers TWO selection modes and they are deliberately separate,
10
+ * because the thing being deleted is not the same noun:
11
+ *
12
+ * - `session` — started from a workspace's own `⋯` menu, and scoped to that
13
+ * workspace. Deleting removes the sessions; the workspace stays.
14
+ * - `workspace` — started from the top toolbar. Deleting removes the
15
+ * workspace registration (and, with it, the rows the sidebar drew for it).
16
+ *
17
+ * One mode is active at a time, and entering either one is an explicit act: the
18
+ * checkboxes do not exist until the user asks for them, because a sidebar that
19
+ * draws a checkbox on every row by default turns every click into a decision
20
+ * between selecting and opening.
21
+ *
22
+ * Selection is an ARRAY of ids rather than a `Set`: it is persisted nowhere and
23
+ * compared only against the rows on screen, so an immutable array keeps the
24
+ * hook's updates trivial and the helpers below pure and unit-testable.
25
+ */
26
+
27
+ export type BatchMode = 'session' | 'workspace';
28
+
29
+ /** Toggle one id in the selection, preserving order. */
30
+ export function toggleSelection(selected: readonly string[], id: string): string[] {
31
+ return selected.includes(id)
32
+ ? selected.filter((entry) => entry !== id)
33
+ : [...selected, id];
34
+ }
35
+
36
+ /**
37
+ * What a tri-state control (a header checkbox, a count) reports about a set of
38
+ * available ids. `all` is false for an empty list: a header checkbox over a
39
+ * workspace with no sessions must not claim everything is selected.
40
+ */
41
+ export interface SelectionSummary {
42
+ count: number;
43
+ all: boolean;
44
+ some: boolean;
45
+ }
46
+
47
+ export function selectionSummary(selected: readonly string[], available: readonly string[]): SelectionSummary {
48
+ const chosen = new Set(selected);
49
+ const count = available.filter((id) => chosen.has(id)).length;
50
+ return {
51
+ count,
52
+ all: available.length > 0 && count === available.length,
53
+ some: count > 0 && count < available.length,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Every session id a folder's rows can offer to a session-mode selection.
59
+ * Archived rows follow the sidebar's own filter, so "select all" can never
60
+ * select a row the user cannot see (and therefore cannot unselect).
61
+ */
62
+ export function selectableSessionIds(
63
+ sessions: readonly { id: number | string; is_archived?: number }[],
64
+ showArchived: boolean,
65
+ ): string[] {
66
+ return sessions
67
+ .filter((session) => (showArchived ? session.is_archived === 1 : session.is_archived !== 1))
68
+ .map((session) => String(session.id));
69
+ }
70
+
71
+ /**
72
+ * The selected ids that are still present in `available`, in the order the
73
+ * selection recorded them.
74
+ *
75
+ * A selection is deliberately NOT pruned as the list changes: a row can vanish
76
+ * for one render (a folder collapsing under a filter, a push mid-refresh), and
77
+ * an effect that dropped it would silently discard a choice. But the count the
78
+ * bar prints and the ids Delete sends must both be the LIVE set — measured with
79
+ * two tabs open: one deleted a session, and the other's bar went on reading
80
+ * "1 of 0 sessions selected" with Delete enabled for an id that no longer
81
+ * existed anywhere.
82
+ */
83
+ export function liveSelection(selected: readonly string[], available: readonly string[]): string[] {
84
+ const alive = new Set(available);
85
+ return selected.filter((id) => alive.has(id));
86
+ }