@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.
- package/README.md +16 -11
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +333 -333
- package/dist/client/static/js/index-pdp5dwc8.css +1 -0
- package/dist/client/static/js/{index-4vyzz0z4.js → index-zsskckw7.js} +681 -681
- package/package.json +2 -1
- package/public/kabmo-wordmark.svg +31 -0
- package/src/cli/lib/commands/update.js +17 -2
- package/src/cli/lib/commands/update.test.js +36 -0
- package/src/client/bootstrap.d.ts +8 -0
- package/src/client/components/auth/login-screen/index.test.tsx +93 -0
- package/src/client/components/auth/login-screen/index.tsx +43 -22
- package/src/client/components/auth/login-screen/password-toggle.test.tsx +121 -0
- package/src/client/components/common/batch-selection/BatchActionBar.test.ts +132 -0
- package/src/client/components/common/batch-selection/BatchActionBar.tsx +118 -0
- package/src/client/components/common/batch-selection/BatchDeleteModal.tsx +177 -0
- package/src/client/components/common/batch-selection/SelectionCheckbox.tsx +58 -0
- package/src/client/components/common/batch-selection/folder-state.test.ts +130 -0
- package/src/client/components/common/batch-selection/folder-state.ts +101 -0
- package/src/client/components/common/batch-selection/types.ts +42 -0
- package/src/client/components/common/brand-wordmark/Mark.tsx +62 -0
- package/src/client/components/common/brand-wordmark/index.test.tsx +29 -7
- package/src/client/components/common/brand-wordmark/index.tsx +33 -11
- package/src/client/components/common/brand-wordmark/wordmark-svg.test.ts +88 -0
- package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
- package/src/client/components/common/header-controls/index.test.tsx +77 -11
- package/src/client/components/common/header-controls/index.tsx +53 -4
- package/src/client/components/common/session-state-provider/index.test.ts +22 -1
- package/src/client/components/common/workspace-options-menu/index.tsx +25 -11
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +169 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +72 -91
- package/src/client/components/layout/session-sidebar/Footer.tsx +20 -2
- package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
- package/src/client/components/layout/session-sidebar/SessionItem.batch.test.ts +162 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +35 -6
- package/src/client/components/layout/session-sidebar/SessionList.tsx +10 -1
- package/src/client/components/layout/session-sidebar/Toolbar.test.ts +2 -0
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +30 -34
- package/src/client/components/layout/session-sidebar/index.tsx +92 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
- package/src/client/components/mobile/SessionSidebar.tsx +83 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +21 -1
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +56 -12
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +8 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +27 -6
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +106 -0
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +178 -93
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
- package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
- package/src/client/components/workspace/git-panel/index.test.ts +15 -0
- package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
- package/src/client/components/workspace/search-panel/Body.tsx +53 -9
- package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
- package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
- package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
- package/src/client/hooks/chat/timeline/index.ts +2 -1
- package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
- package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
- package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
- package/src/client/hooks/chat/timeline/scroll.ts +34 -1
- package/src/client/hooks/ui/media-query.ts +41 -0
- package/src/client/hooks/ui/updates/android-download.test.ts +73 -0
- package/src/client/hooks/ui/updates/android-download.ts +106 -0
- package/src/client/hooks/workspace/batch-delete-run.ts +95 -0
- package/src/client/hooks/workspace/batch-delete.ts +63 -0
- package/src/client/hooks/workspace/batch-selection.ts +98 -0
- package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/session-state/index.ts +24 -1
- package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
- package/src/client/lib/native/back.test.ts +108 -0
- package/src/client/lib/native/back.ts +60 -0
- package/src/client/main.tsx +6 -0
- package/src/client/tailwind.css +6 -0
- package/src/server/lib/fs/git-actions-branch.ts +18 -1
- package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
- package/src/server/lib/fs/git-publish.test.ts +96 -0
- package/src/server/lib/fs/git-publish.ts +35 -3
- package/src/server/lib/fs/listing.test.ts +86 -0
- package/src/server/lib/fs/listing.ts +20 -6
- package/src/server/lib/lifecycle/restart.ts +52 -14
- package/src/server/lib/omp/session/delete-flow.server.ts +101 -0
- package/src/server/lib/updates/android-release.test.ts +92 -0
- package/src/server/lib/updates/android-release.ts +148 -0
- package/src/server/lib/updates/apply.ts +8 -0
- package/src/server/lib/updates/install-steps.ts +81 -0
- package/src/server/lib/updates/install.ts +19 -67
- package/src/server/lib/updates/installed-cli.test.ts +222 -0
- package/src/server/lib/updates/installed-cli.ts +139 -0
- package/src/server/lib/updates/omp.ts +1 -1
- package/src/server/plugins/ssr.ts +9 -2
- package/src/server/routes/folders/batch-delete.ts +68 -0
- package/src/server/routes/folders/index.ts +4 -0
- package/src/server/routes/fs/read.ts +24 -9
- package/src/server/routes/sessions/batch-delete.test.ts +142 -0
- package/src/server/routes/sessions/batch-delete.ts +60 -0
- package/src/server/routes/sessions/delete.ts +13 -51
- package/src/server/routes/sessions/index.ts +5 -0
- package/src/server/routes/updates/android.ts +22 -0
- package/src/server/routes/updates/apply.ts +1 -1
- package/src/server/routes/updates/index.ts +2 -0
- package/src/shared/lib/updates/shell-ua.test.ts +54 -0
- package/src/shared/lib/updates/shell-ua.ts +36 -0
- package/src/shared/lib/workspace/batch-select.test.ts +92 -0
- package/src/shared/lib/workspace/batch-select.ts +86 -0
- package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
- package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
- package/src/shared/lib/workspace/session-state/store.ts +13 -2
- package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
- package/src/shared/lib/workspace/sidebar-toolbar.ts +84 -0
- 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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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 {
|
|
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
|
-
|
|
73
|
-
|
|
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:
|
|
80
|
-
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
|
+
}
|