@kabmo-labs/kabmo 0.0.1 → 0.1.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 +323 -323
- package/dist/client/static/js/{index-4vyzz0z4.js → index-enkbbffs.js} +643 -643
- package/dist/client/static/js/index-yn17t04b.css +1 -0
- 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/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/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/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 +28 -1
- package/src/client/components/layout/session-sidebar/index.tsx +87 -0
- package/src/client/components/mobile/SessionSidebar.tsx +78 -0
- 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 +2 -0
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +26 -1
- 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/tailwind.css +6 -0
- 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/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/routes/folders/batch-delete.ts +68 -0
- package/src/server/routes/folders/index.ts +4 -0
- package/src/server/routes/sessions/batch-delete.test.ts +139 -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/apply.ts +1 -1
- 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/sidebar-toolbar.ts +6 -0
- package/dist/client/static/js/index-x9cehh1a.css +0 -1
|
@@ -3,6 +3,7 @@ import type { SessionItemData } from '@/shared/types';
|
|
|
3
3
|
import { useInlineRename } from '@/client/hooks/ui/inline-rename';
|
|
4
4
|
import { useSessionRenaming } from '@/client/hooks/workspace/renaming-sessions';
|
|
5
5
|
import { SessionActionsMenu, useRowMenu } from '@/client/components/common/session-actions-menu';
|
|
6
|
+
import { SelectionCheckbox } from '@/client/components/common/batch-selection/SelectionCheckbox';
|
|
6
7
|
|
|
7
8
|
export interface MobileSessionRowProps {
|
|
8
9
|
session: SessionItemData;
|
|
@@ -25,6 +26,11 @@ export interface MobileSessionRowProps {
|
|
|
25
26
|
/** Ask omp to name this session from its transcript. Omitted for a pending
|
|
26
27
|
* `new-…` chat, which has no transcript to derive a title from. */
|
|
27
28
|
onRenameWithAi?: () => void;
|
|
29
|
+
/** Draw a checkbox and let the row's tap toggle the selection instead of
|
|
30
|
+
* opening the session (batch mode). */
|
|
31
|
+
selectable?: boolean;
|
|
32
|
+
selected?: boolean;
|
|
33
|
+
onToggleSelect?: () => void;
|
|
28
34
|
}
|
|
29
35
|
|
|
30
36
|
export function MobileSessionRow({
|
|
@@ -41,6 +47,9 @@ export function MobileSessionRow({
|
|
|
41
47
|
onDelete,
|
|
42
48
|
onRename,
|
|
43
49
|
onRenameWithAi,
|
|
50
|
+
selectable = false,
|
|
51
|
+
selected = false,
|
|
52
|
+
onToggleSelect,
|
|
44
53
|
}: MobileSessionRowProps) {
|
|
45
54
|
const {
|
|
46
55
|
isEditing,
|
|
@@ -91,14 +100,16 @@ export function MobileSessionRow({
|
|
|
91
100
|
<div
|
|
92
101
|
data-testid="session-item"
|
|
93
102
|
data-session-id={String(session.id)}
|
|
94
|
-
onContextMenu={hasActions ? menu.openAtCursor : undefined}
|
|
103
|
+
onContextMenu={hasActions && !selectable ? menu.openAtCursor : undefined}
|
|
95
104
|
className={`w-full rounded-lg flex items-center transition-colors ${
|
|
96
|
-
|
|
105
|
+
selectable && selected
|
|
106
|
+
? 'bg-ink/10 ring-1 ring-inset ring-ink/20 text-ink'
|
|
107
|
+
: isActive ? 'bg-ink/10 font-medium text-ink' : 'hover:bg-ink/5 text-ink/85'
|
|
97
108
|
}`}
|
|
98
109
|
>
|
|
99
110
|
<button
|
|
100
111
|
type="button"
|
|
101
|
-
onClick={onSelect}
|
|
112
|
+
onClick={selectable ? onToggleSelect : onSelect}
|
|
102
113
|
className="flex-1 text-left pl-2 pr-3 py-2 flex items-center justify-between min-w-0"
|
|
103
114
|
>
|
|
104
115
|
<div className="flex items-center min-w-0 pr-2">
|
|
@@ -107,7 +118,16 @@ export function MobileSessionRow({
|
|
|
107
118
|
outranks the run spinner — the spinner says work is happening,
|
|
108
119
|
the question mark says it is YOUR turn (desktop parity). An
|
|
109
120
|
in-flight rename outranks both, for the same reason it does
|
|
110
|
-
there: it answers a click that is still pending.
|
|
121
|
+
there: it answers a click that is still pending.
|
|
122
|
+
In batch mode the slot carries the checkbox instead: a row that
|
|
123
|
+
is a choice should not also report a run. */}
|
|
124
|
+
{selectable ? (
|
|
125
|
+
<SelectionCheckbox
|
|
126
|
+
checked={selected}
|
|
127
|
+
label={`Select ${session.title}`}
|
|
128
|
+
onChange={() => onToggleSelect?.()}
|
|
129
|
+
/>
|
|
130
|
+
) : (
|
|
111
131
|
<span className="w-4 flex-shrink-0 flex items-center justify-center">
|
|
112
132
|
{renaming ? (
|
|
113
133
|
<span role="status" aria-label="Generating a name…">
|
|
@@ -121,6 +141,7 @@ export function MobileSessionRow({
|
|
|
121
141
|
<Check size={13} className="text-ink/50" />
|
|
122
142
|
) : null}
|
|
123
143
|
</span>
|
|
144
|
+
)}
|
|
124
145
|
<span className="text-xs truncate leading-snug ml-2">{title}</span>
|
|
125
146
|
</div>
|
|
126
147
|
|
|
@@ -134,7 +155,7 @@ export function MobileSessionRow({
|
|
|
134
155
|
column so the trailing glyph's centre stays 19px from the row's right
|
|
135
156
|
edge whether the last element is this trigger or the chevron. Without
|
|
136
157
|
it the glyphs step in and out from row to row. */}
|
|
137
|
-
{hasActions && (
|
|
158
|
+
{hasActions && !selectable && (
|
|
138
159
|
<button
|
|
139
160
|
type="button"
|
|
140
161
|
onClick={menu.openBelow}
|
|
@@ -151,7 +172,7 @@ export function MobileSessionRow({
|
|
|
151
172
|
{/* Last element, so its glyph sits in the folder header's chevron column.
|
|
152
173
|
`p-1.5 mr-1.5` centres a 14px glyph on that column while keeping a
|
|
153
174
|
touch-sized tap target. */}
|
|
154
|
-
{showChevron && (
|
|
175
|
+
{showChevron && !selectable && (
|
|
155
176
|
<button
|
|
156
177
|
type="button"
|
|
157
178
|
onClick={onToggleExpand}
|
|
@@ -72,6 +72,8 @@ async function mount(overrides: Partial<MobileSessionToolbarProps> = {}) {
|
|
|
72
72
|
onHideArchived: () => { calls.hidden += 1; },
|
|
73
73
|
onSearchChange: () => {},
|
|
74
74
|
onClearSearch: () => {},
|
|
75
|
+
onSelectWorkspaces: () => {},
|
|
76
|
+
selecting: false,
|
|
75
77
|
...overrides,
|
|
76
78
|
}), container);
|
|
77
79
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RefObject } from 'preact/compat';
|
|
2
|
-
import { Archive, ArrowUpDown, Calendar, FolderPlus, Plus, RefreshCw, Search, X } from 'lucide-preact';
|
|
2
|
+
import { Archive, ArrowUpDown, Calendar, FolderPlus, ListChecks, Plus, RefreshCw, Search, X } from 'lucide-preact';
|
|
3
3
|
import type { SessionSortOption } from '@/shared/types';
|
|
4
4
|
import { SortMenu } from '@/client/components/common/sort-menu';
|
|
5
5
|
|
|
@@ -24,6 +24,10 @@ export interface MobileSessionToolbarProps {
|
|
|
24
24
|
onHideArchived: () => void;
|
|
25
25
|
onSearchChange: (value: string) => void;
|
|
26
26
|
onClearSearch: () => void;
|
|
27
|
+
/** Enter the workspace batch-selection mode (checkbox on each workspace). */
|
|
28
|
+
onSelectWorkspaces: () => void;
|
|
29
|
+
/** True while a batch selection is active — the control reads pressed. */
|
|
30
|
+
selecting: boolean;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
export function MobileSessionToolbar({
|
|
@@ -45,6 +49,8 @@ export function MobileSessionToolbar({
|
|
|
45
49
|
onHideArchived,
|
|
46
50
|
onSearchChange,
|
|
47
51
|
onClearSearch,
|
|
52
|
+
onSelectWorkspaces,
|
|
53
|
+
selecting,
|
|
48
54
|
}: MobileSessionToolbarProps) {
|
|
49
55
|
return (
|
|
50
56
|
<div className="@container p-3 border-b border-ink/10 flex flex-col space-y-2.5 flex-shrink-0 bg-canvas">
|
|
@@ -123,6 +129,25 @@ export function MobileSessionToolbar({
|
|
|
123
129
|
<Archive size={16} strokeWidth={1.8} />
|
|
124
130
|
</button>
|
|
125
131
|
|
|
132
|
+
{/* Batch selection over the WORKSPACES. A mode, like search, so it
|
|
133
|
+
sits with the view controls and reports its pressed state — and it
|
|
134
|
+
is the only way into the workspace batch, because a workspace has
|
|
135
|
+
no row of its own to long-press. */}
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
onClick={onSelectWorkspaces}
|
|
139
|
+
aria-pressed={selecting}
|
|
140
|
+
className={`w-9.5 h-9.5 rounded-xl border active:scale-95 flex items-center justify-center transition-all flex-shrink-0 cursor-pointer shadow-xs ${
|
|
141
|
+
selecting
|
|
142
|
+
? 'bg-ink/10 text-ink border-ink/30'
|
|
143
|
+
: 'border-ink/15 bg-paper hover:bg-ink/5 text-ink/80 hover:text-ink'
|
|
144
|
+
}`}
|
|
145
|
+
title={selecting ? 'Cancel selection' : 'Select workspaces'}
|
|
146
|
+
aria-label={selecting ? 'Cancel selection' : 'Select workspaces'}
|
|
147
|
+
>
|
|
148
|
+
<ListChecks size={16} strokeWidth={1.8} />
|
|
149
|
+
</button>
|
|
150
|
+
|
|
126
151
|
{/* Refresh sessions button — same action the desktop toolbar's
|
|
127
152
|
RefreshCw performs: refetch the session list from the server.
|
|
128
153
|
The icon spins for the duration of that fetch, so the click has a
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The state machine behind a batch delete, shared by both sidebars.
|
|
8
|
+
*
|
|
9
|
+
* It exists so the two layouts cannot drift on the three follow-ups that are
|
|
10
|
+
* load-bearing, and which are easy to get subtly wrong once per sidebar:
|
|
11
|
+
*
|
|
12
|
+
* - a removed session's client cache is forgotten (`forgetSession`), or every
|
|
13
|
+
* panel keyed to that id keeps serving the state of a chat that is gone;
|
|
14
|
+
* - when the removed set includes the session in the URL, `?sessionId=` (and
|
|
15
|
+
* `?subagent=`, which belongs to it) is dropped — otherwise the chat renders
|
|
16
|
+
* its "not found" state over a row the user just deleted;
|
|
17
|
+
* - the sidebar is refreshed ONCE per batch, from the server's own republish.
|
|
18
|
+
*
|
|
19
|
+
* A refusal does not abort the batch: the server already deleted everything it
|
|
20
|
+
* could, and the dialog lists what survived with its reason.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { useCallback, useState } from 'preact/hooks';
|
|
24
|
+
import { useSearchParams } from '@/client/lib/router/search-params';
|
|
25
|
+
import { forgetSession } from '@/shared/lib/workspace/session-state/store';
|
|
26
|
+
import {
|
|
27
|
+
deleteSessionsBatch,
|
|
28
|
+
deleteWorkspacesBatch,
|
|
29
|
+
type BatchDeleteOutcome,
|
|
30
|
+
} from '@/client/hooks/workspace/batch-delete';
|
|
31
|
+
import type { BatchMode } from '@/shared/lib/workspace/batch-select';
|
|
32
|
+
|
|
33
|
+
export interface BatchDeleteRun {
|
|
34
|
+
/** The ids awaiting confirmation; empty when the dialog is closed. */
|
|
35
|
+
pending: string[];
|
|
36
|
+
isDeleting: boolean;
|
|
37
|
+
/** The settled result, or null while the request runs. */
|
|
38
|
+
outcome: BatchDeleteOutcome | null;
|
|
39
|
+
/** Open the confirmation for these ids. */
|
|
40
|
+
request: (ids: readonly string[]) => void;
|
|
41
|
+
/** Close it without deleting. */
|
|
42
|
+
cancel: () => void;
|
|
43
|
+
/** Issue the delete. Resolves with the settled outcome (null when nothing ran). */
|
|
44
|
+
confirm: (mode: BatchMode) => Promise<BatchDeleteOutcome | null>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function useBatchDeleteRun(refresh: () => void): BatchDeleteRun {
|
|
48
|
+
const [pending, setPending] = useState<string[]>([]);
|
|
49
|
+
const [isDeleting, setIsDeleting] = useState(false);
|
|
50
|
+
const [outcome, setOutcome] = useState<BatchDeleteOutcome | null>(null);
|
|
51
|
+
const [searchParams, setSearchParams] = useSearchParams();
|
|
52
|
+
|
|
53
|
+
const request = useCallback((ids: readonly string[]) => {
|
|
54
|
+
setOutcome(null);
|
|
55
|
+
setPending([...ids]);
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
const cancel = useCallback(() => {
|
|
59
|
+
setOutcome(null);
|
|
60
|
+
setPending([]);
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
const confirm = useCallback(async (mode: BatchMode): Promise<BatchDeleteOutcome | null> => {
|
|
64
|
+
if (pending.length === 0 || isDeleting) return null;
|
|
65
|
+
setIsDeleting(true);
|
|
66
|
+
setOutcome(null);
|
|
67
|
+
const ids = pending;
|
|
68
|
+
const result = mode === 'workspace'
|
|
69
|
+
? await deleteWorkspacesBatch(ids)
|
|
70
|
+
: await deleteSessionsBatch(ids);
|
|
71
|
+
|
|
72
|
+
if (mode === 'session' && result.removed.length > 0) {
|
|
73
|
+
for (const id of result.removed) forgetSession(id);
|
|
74
|
+
const openSession = searchParams.get('sessionId');
|
|
75
|
+
if (openSession && result.removed.includes(openSession)) {
|
|
76
|
+
setSearchParams((prev) => {
|
|
77
|
+
const next = new URLSearchParams(prev);
|
|
78
|
+
next.delete('sessionId');
|
|
79
|
+
next.delete('subagent');
|
|
80
|
+
return next;
|
|
81
|
+
}, { replace: true });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (result.removed.length > 0) refresh();
|
|
86
|
+
setOutcome(result);
|
|
87
|
+
setIsDeleting(false);
|
|
88
|
+
// The dialog STAYS open on a refusal so the reason can be read; a fully
|
|
89
|
+
// successful run closes itself, because the rows it named are gone.
|
|
90
|
+
if (result.removed.length === ids.length) setPending([]);
|
|
91
|
+
return result;
|
|
92
|
+
}, [pending, isDeleting, searchParams, setSearchParams, refresh]);
|
|
93
|
+
|
|
94
|
+
return { pending, isDeleting, outcome, request, cancel, confirm };
|
|
95
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The batch verbs, one call per noun.
|
|
8
|
+
*
|
|
9
|
+
* Both are server routes rather than a client loop: a loop of N single deletes
|
|
10
|
+
* would pay N round trips for one user action, and each of them republishes the
|
|
11
|
+
* sidebar on its own. The server answers ONE summary — what was removed, and
|
|
12
|
+
* per-id refusals with their own reason — which is what the confirmation dialog
|
|
13
|
+
* reports after it closes.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface BatchDeleteRefusal {
|
|
17
|
+
id: string;
|
|
18
|
+
error: string;
|
|
19
|
+
code?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BatchDeleteOutcome {
|
|
23
|
+
removed: string[];
|
|
24
|
+
refused: BatchDeleteRefusal[];
|
|
25
|
+
/** Set only when the request itself failed (network, 4xx, 5xx). */
|
|
26
|
+
error?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const EMPTY: BatchDeleteOutcome = { removed: [], refused: [] };
|
|
30
|
+
|
|
31
|
+
async function postBatch(url: string, ids: readonly string[]): Promise<BatchDeleteOutcome> {
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(url, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'content-type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({ ids }),
|
|
37
|
+
});
|
|
38
|
+
const body = (await res.json().catch(() => null)) as Partial<BatchDeleteOutcome> | null;
|
|
39
|
+
if (!res.ok) {
|
|
40
|
+
return { ...EMPTY, error: body?.error || `Could not delete (HTTP ${res.status}).` };
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
removed: Array.isArray(body?.removed) ? body.removed : [],
|
|
44
|
+
refused: Array.isArray(body?.refused) ? body.refused : [],
|
|
45
|
+
};
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return { ...EMPTY, error: error instanceof Error ? error.message : 'Could not reach the server.' };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Remove sessions, transcripts included. Pending `new-…` ids are refused by the server. */
|
|
52
|
+
export function deleteSessionsBatch(ids: readonly string[]): Promise<BatchDeleteOutcome> {
|
|
53
|
+
return postBatch('/api/sessions/batch-delete', ids);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Unregister workspaces. Same semantics as the single "Delete Workspace"
|
|
58
|
+
* action — the transcript files on disk are NOT touched, only the sidebar
|
|
59
|
+
* registration (and the tombstone that stops the sync re-adding it).
|
|
60
|
+
*/
|
|
61
|
+
export function deleteWorkspacesBatch(ids: readonly string[]): Promise<BatchDeleteOutcome> {
|
|
62
|
+
return postBatch('/api/folders/batch-delete', ids);
|
|
63
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One batch selection, for both sidebars and both nouns.
|
|
8
|
+
*
|
|
9
|
+
* The mode is the state that makes the checkboxes exist at all: `null` means
|
|
10
|
+
* the sidebar is behaving normally, and entering a mode is the user's explicit
|
|
11
|
+
* act (a workspace's `⋯` menu, or the toolbar's Select control). Leaving it —
|
|
12
|
+
* by Cancel, by Escape, or by finishing a delete — clears the selection, so a
|
|
13
|
+
* mode never comes back pre-filled with rows the user no longer remembers
|
|
14
|
+
* picking.
|
|
15
|
+
*
|
|
16
|
+
* The selection is pruned against what the sidebar can currently offer, but
|
|
17
|
+
* pruning is deliberately NOT done in an effect: a row that disappears for one
|
|
18
|
+
* render (a folder collapsing under a filter, a push mid-refresh) must not
|
|
19
|
+
* silently drop a choice. Callers pass the ids they are about to delete, and
|
|
20
|
+
* the delete path resolves them against the live list.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { useCallback, useMemo, useState } from 'preact/hooks';
|
|
24
|
+
import {
|
|
25
|
+
selectionSummary,
|
|
26
|
+
toggleSelection,
|
|
27
|
+
type BatchMode,
|
|
28
|
+
type SelectionSummary,
|
|
29
|
+
} from '@/shared/lib/workspace/batch-select';
|
|
30
|
+
|
|
31
|
+
export interface BatchSelectionHandle {
|
|
32
|
+
/** The active mode, or null when the sidebar is not selecting. */
|
|
33
|
+
mode: BatchMode | null;
|
|
34
|
+
/**
|
|
35
|
+
* The workspace a SESSION selection is scoped to. A session batch is started
|
|
36
|
+
* from one workspace's `⋯` menu, so the checkboxes belong to that workspace's
|
|
37
|
+
* rows only — the other folders stay ordinary lists, which is also what makes
|
|
38
|
+
* "select all" unambiguous. Null in workspace mode.
|
|
39
|
+
*/
|
|
40
|
+
scopeFolderId: number | null;
|
|
41
|
+
/** The ids selected in the active mode, in click order. */
|
|
42
|
+
selected: string[];
|
|
43
|
+
/** Enter a mode, replacing any previous selection. */
|
|
44
|
+
begin: (mode: BatchMode, scopeFolderId?: number) => void;
|
|
45
|
+
/** Leave the mode and forget the selection. */
|
|
46
|
+
end: () => void;
|
|
47
|
+
/** Add or remove one id. */
|
|
48
|
+
toggle: (id: string) => void;
|
|
49
|
+
/** Add every id in `ids` that is not already selected. */
|
|
50
|
+
selectAllOf: (ids: readonly string[]) => void;
|
|
51
|
+
/** Drop every id in `ids` from the selection. */
|
|
52
|
+
unselectAllOf: (ids: readonly string[]) => void;
|
|
53
|
+
/** Tri-state summary of the selection against a set of available ids. */
|
|
54
|
+
summaryOf: (available: readonly string[]) => SelectionSummary;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function useBatchSelection(): BatchSelectionHandle {
|
|
58
|
+
const [mode, setMode] = useState<BatchMode | null>(null);
|
|
59
|
+
const [scopeFolderId, setScopeFolderId] = useState<number | null>(null);
|
|
60
|
+
const [selected, setSelected] = useState<string[]>([]);
|
|
61
|
+
|
|
62
|
+
const begin = useCallback((next: BatchMode, folderId?: number) => {
|
|
63
|
+
setMode(next);
|
|
64
|
+
setScopeFolderId(next === 'session' ? folderId ?? null : null);
|
|
65
|
+
setSelected([]);
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
const end = useCallback(() => {
|
|
69
|
+
setMode(null);
|
|
70
|
+
setScopeFolderId(null);
|
|
71
|
+
setSelected([]);
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
const toggle = useCallback((id: string) => {
|
|
75
|
+
setSelected((prev) => toggleSelection(prev, id));
|
|
76
|
+
}, []);
|
|
77
|
+
|
|
78
|
+
const selectAllOf = useCallback((ids: readonly string[]) => {
|
|
79
|
+
setSelected((prev) => [...new Set([...prev, ...ids])]);
|
|
80
|
+
}, []);
|
|
81
|
+
|
|
82
|
+
const unselectAllOf = useCallback((ids: readonly string[]) => {
|
|
83
|
+
setSelected((prev) => {
|
|
84
|
+
const drop = new Set(ids);
|
|
85
|
+
return prev.filter((id) => !drop.has(id));
|
|
86
|
+
});
|
|
87
|
+
}, []);
|
|
88
|
+
|
|
89
|
+
const summaryOf = useCallback(
|
|
90
|
+
(available: readonly string[]) => selectionSummary(selected, available),
|
|
91
|
+
[selected],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return useMemo(
|
|
95
|
+
() => ({ mode, scopeFolderId, selected, begin, end, toggle, selectAllOf, unselectAllOf, summaryOf }),
|
|
96
|
+
[mode, scopeFolderId, selected, begin, end, toggle, selectAllOf, unselectAllOf, summaryOf],
|
|
97
|
+
);
|
|
98
|
+
}
|
package/src/client/tailwind.css
CHANGED
|
@@ -43,6 +43,12 @@
|
|
|
43
43
|
of the K's upper arm (`public/icon.svg`) — and it is the only chroma
|
|
44
44
|
outside the error red. */
|
|
45
45
|
--color-brand: #f26522;
|
|
46
|
+
/* The icon's tile and the ink on it, also not theme tokens: the wordmark's K
|
|
47
|
+
is the app icon (`public/icon.svg`), so its green plate and cream strokes
|
|
48
|
+
must be identical in every palette — including the dark ones, where a
|
|
49
|
+
themed surface would turn the plate into a hole. */
|
|
50
|
+
--color-brand-tile: #0b5d3b;
|
|
51
|
+
--color-brand-cream: #f7f4ea;
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
@layer base {
|
|
@@ -20,9 +20,6 @@
|
|
|
20
20
|
* launcher is what brings the new files up.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { existsSync } from 'node:fs';
|
|
24
|
-
import { join as joinPath } from 'node:path';
|
|
25
|
-
|
|
26
23
|
import { resolveBunBin } from '@/server/lib/lifecycle/bun';
|
|
27
24
|
import { packageDir } from '@/server/lib/fs/package-root';
|
|
28
25
|
import { listInstanceRecords } from '@/server/lib/lifecycle/instance';
|
|
@@ -30,6 +27,7 @@ import { isCliManaged, resolveLaunchMode, unmanagedReason } from '@/server/lib/l
|
|
|
30
27
|
import { capitalizeFirstLetter } from '@/shared/lib/chat/capitalize';
|
|
31
28
|
import { loadAuthConfig } from '@/server/lib/auth/guard';
|
|
32
29
|
import { PASSWORD_HANDOVER_ENV, PASSWORD_HANDOVER_STDIN, serializeHandover } from '@/shared/lib/auth/handover';
|
|
30
|
+
import { copyAt, type InstalledCopy } from '@/server/lib/updates/installed-cli';
|
|
33
31
|
import type { UpdateApplyResult } from '@/shared/types/updates';
|
|
34
32
|
|
|
35
33
|
/**
|
|
@@ -87,11 +85,46 @@ function ownInstance(): { port: number; launchMode: string } | null {
|
|
|
87
85
|
return { port, launchMode: resolveLaunchMode() };
|
|
88
86
|
}
|
|
89
87
|
|
|
90
|
-
/**
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
88
|
+
/**
|
|
89
|
+
* The marker the TARGET copy reads its credential through.
|
|
90
|
+
*
|
|
91
|
+
* The rename renamed the variable too (`OMPCHAMBER_PASSWORD_HANDOVER` →
|
|
92
|
+
* `KABMO_PASSWORD_HANDOVER`), so a restart that set only one spelling would
|
|
93
|
+
* hand a payload to a CLI that never looks for it — and the replacement would
|
|
94
|
+
* come back with authentication silently disabled. Keyed by package name
|
|
95
|
+
* because that is the only fact about the target this process can read.
|
|
96
|
+
*/
|
|
97
|
+
const HANDOVER_ENV_BY_PACKAGE: Record<string, string> = {
|
|
98
|
+
'ompchamber': 'OMPCHAMBER_PASSWORD_HANDOVER',
|
|
99
|
+
'@kabmo-labs/kabmo': PASSWORD_HANDOVER_ENV,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export interface RestartInvocation {
|
|
103
|
+
/** argv for the detached helper: `<bun> <cli> restart --port <port>`. */
|
|
104
|
+
cmd: string[];
|
|
105
|
+
/** The variable that CLI reads its credential marker from. */
|
|
106
|
+
handoverEnv: string;
|
|
107
|
+
/** Package root the helper runs in. */
|
|
108
|
+
root: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* How to restart the copy rooted at `root`.
|
|
113
|
+
*
|
|
114
|
+
* The CLI is that copy's OWN registered bin, resolved from its `package.json`,
|
|
115
|
+
* because the file name is not a constant across a rename: a renamed package
|
|
116
|
+
* ships only the bin it declares, so a hard-coded `kabmo.js` finds nothing in
|
|
117
|
+
* a copy that names it something else. Null when the root holds no runnable
|
|
118
|
+
* copy.
|
|
119
|
+
*/
|
|
120
|
+
export function restartInvocation(port: number, root: string): RestartInvocation | null {
|
|
121
|
+
const copy = copyAt(root);
|
|
122
|
+
if (!copy) return null;
|
|
123
|
+
return {
|
|
124
|
+
cmd: [resolveBunBin(), copy.cli, 'restart', '--port', String(port)],
|
|
125
|
+
handoverEnv: HANDOVER_ENV_BY_PACKAGE[copy.packageName] ?? PASSWORD_HANDOVER_ENV,
|
|
126
|
+
root: copy.root,
|
|
127
|
+
};
|
|
95
128
|
}
|
|
96
129
|
|
|
97
130
|
/**
|
|
@@ -127,7 +160,7 @@ export function withRestartPlan(result: UpdateApplyResult, plan: SelfRestartPlan
|
|
|
127
160
|
* helper is detached so this process exiting does not take it down, and it
|
|
128
161
|
* removes this port's record itself (`stopInstance`) before serving.
|
|
129
162
|
*/
|
|
130
|
-
export function scheduleSelfRestart(purpose: SelfRestartPurpose = 'update'): SelfRestartPlan {
|
|
163
|
+
export function scheduleSelfRestart(purpose: SelfRestartPurpose = 'update', installed: InstalledCopy | null = null): SelfRestartPlan {
|
|
131
164
|
const self = ownInstance();
|
|
132
165
|
if (!self) {
|
|
133
166
|
const nextStep = purpose === 'restart' ? 'restart it yourself' : 'restart it yourself to apply the update';
|
|
@@ -138,11 +171,16 @@ export function scheduleSelfRestart(purpose: SelfRestartPurpose = 'update'): Sel
|
|
|
138
171
|
return { restarting: false, message: skipRestartNote(self.launchMode, purpose) };
|
|
139
172
|
}
|
|
140
173
|
|
|
141
|
-
|
|
142
|
-
|
|
174
|
+
// The copy that must come back is the one the update INSTALLED. Falling back
|
|
175
|
+
// to this process's own root keeps `Restart` (which installs nothing) and a
|
|
176
|
+
// caller that could not read the install working as before.
|
|
177
|
+
const root = installed?.root ?? PKG_ROOT;
|
|
178
|
+
const invocation = restartInvocation(self.port, root);
|
|
179
|
+
if (!invocation) {
|
|
143
180
|
const nextStep = purpose === 'restart' ? 'restart it yourself' : 'restart it yourself to apply the update';
|
|
144
|
-
return { restarting: false, message: `The
|
|
181
|
+
return { restarting: false, message: `The CLI is missing from ${root} — ${nextStep}.` };
|
|
145
182
|
}
|
|
183
|
+
const { cmd, handoverEnv } = invocation;
|
|
146
184
|
|
|
147
185
|
// The replacement must come up with the SAME credential this process is
|
|
148
186
|
// serving under. Without it, a restart silently disables authentication —
|
|
@@ -160,7 +198,7 @@ export function scheduleSelfRestart(purpose: SelfRestartPurpose = 'update'): Sel
|
|
|
160
198
|
try {
|
|
161
199
|
const child = Bun.spawn({
|
|
162
200
|
cmd,
|
|
163
|
-
cwd:
|
|
201
|
+
cwd: root,
|
|
164
202
|
stdin: credential ? 'pipe' : 'ignore',
|
|
165
203
|
stdout: 'inherit',
|
|
166
204
|
stderr: 'inherit',
|
|
@@ -169,7 +207,7 @@ export function scheduleSelfRestart(purpose: SelfRestartPurpose = 'update'): Sel
|
|
|
169
207
|
// CLI forwards it to the server it spawns, which is the only place it
|
|
170
208
|
// is ever read.
|
|
171
209
|
env: credential
|
|
172
|
-
? { ...Bun.env, [
|
|
210
|
+
? { ...Bun.env, [handoverEnv]: PASSWORD_HANDOVER_STDIN }
|
|
173
211
|
: Bun.env,
|
|
174
212
|
});
|
|
175
213
|
if (credential && child.stdin) {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The one session-removal sequence, shared by the single DELETE route and the
|
|
8
|
+
* batch route.
|
|
9
|
+
*
|
|
10
|
+
* The ORDER is the contract and it is enforced here, not by the callers:
|
|
11
|
+
* 1. refuse a pending `new-…` chat and an id that is not one path segment —
|
|
12
|
+
* both name something other than the session they appear to;
|
|
13
|
+
* 2. 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
|
+
* 3. destroy the child and AWAIT its exit — omp writes session state on
|
|
17
|
+
* shutdown and would otherwise recreate the transcript just removed;
|
|
18
|
+
* 4. purge side questions, then the transcript, artifacts and Kabmo rows.
|
|
19
|
+
*
|
|
20
|
+
* A batch that re-implemented any of this would drift from the single delete,
|
|
21
|
+
* and the drift would be silent: the route answers per-id verdicts either way.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { getRpcSession } from '@/server/lib/omp/rpc/session-registry';
|
|
25
|
+
import { purgeSessionData } from '@/server/lib/omp/session/delete.server';
|
|
26
|
+
import { purgeBtwForSession } from '@/server/lib/btw/purge.server';
|
|
27
|
+
import { isSinglePathSegment } from '@/server/lib/fs/path-segment';
|
|
28
|
+
import { isMockMode } from '@/server/mock.server';
|
|
29
|
+
|
|
30
|
+
export interface SessionRemoval {
|
|
31
|
+
/** True when the session was removed (or would have been, in mock mode). */
|
|
32
|
+
ok: boolean;
|
|
33
|
+
/** HTTP status the single-session route answers with; batch callers use `error`. */
|
|
34
|
+
status: number;
|
|
35
|
+
code?: 'session_pending' | 'invalid_id' | 'session_busy' | 'not_found';
|
|
36
|
+
error?: string;
|
|
37
|
+
fileRemoved?: boolean;
|
|
38
|
+
artifactsRemoved?: boolean;
|
|
39
|
+
btwTopicsRemoved?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Remove one session, in the documented order. Never throws: every failure is
|
|
44
|
+
* a verdict, because a batch has to keep going after one refused id.
|
|
45
|
+
*/
|
|
46
|
+
export async function removeSession(sessionId: string): Promise<SessionRemoval> {
|
|
47
|
+
if (sessionId.startsWith('new-')) {
|
|
48
|
+
return {
|
|
49
|
+
ok: false,
|
|
50
|
+
status: 400,
|
|
51
|
+
code: 'session_pending',
|
|
52
|
+
error: 'This chat has not been created yet.',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// The id names a directory under the sessions root and another under the btw
|
|
56
|
+
// root, so it must be exactly one path segment — see `isSinglePathSegment`
|
|
57
|
+
// for the `.` case that made this load-bearing.
|
|
58
|
+
if (!isSinglePathSegment(sessionId)) {
|
|
59
|
+
return { ok: false, status: 400, code: 'invalid_id', error: 'Invalid session id' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
// A live child is the only other writer of the transcript. Busy means a
|
|
64
|
+
// turn, a live subagent, or a dialog omp is parked on.
|
|
65
|
+
const live = getRpcSession(sessionId);
|
|
66
|
+
if (live?.isAlive()) {
|
|
67
|
+
if (live.isBusy()) {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
status: 409,
|
|
71
|
+
code: 'session_busy',
|
|
72
|
+
error: 'This session is working. Stop the run before deleting it.',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
await live.destroyAndWait();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Side questions live outside the session file and are removed first: their
|
|
79
|
+
// children must be gone before the parent transcript they were snapshotted
|
|
80
|
+
// from disappears.
|
|
81
|
+
const btwTopicsRemoved = isMockMode() ? 0 : await purgeBtwForSession(sessionId);
|
|
82
|
+
const purged = await purgeSessionData(sessionId);
|
|
83
|
+
if (!purged.found) {
|
|
84
|
+
return { ok: false, status: 404, code: 'not_found', error: 'Session not found' };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
ok: true,
|
|
89
|
+
status: 200,
|
|
90
|
+
fileRemoved: purged.fileRemoved,
|
|
91
|
+
artifactsRemoved: purged.artifactsRemoved,
|
|
92
|
+
btwTopicsRemoved,
|
|
93
|
+
};
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
status: 500,
|
|
98
|
+
error: error instanceof Error ? error.message : String(error),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -12,6 +12,7 @@ import { updateKabmo } from '@/server/lib/updates/install';
|
|
|
12
12
|
import { applyOmpUpdate } from '@/server/lib/updates/omp';
|
|
13
13
|
import { withUpdateSlot } from '@/server/lib/updates/single-flight';
|
|
14
14
|
import { recycleStandbyProcesses } from '@/server/lib/omp/session/standby.server';
|
|
15
|
+
import type { InstalledCopy } from '@/server/lib/updates/installed-cli';
|
|
15
16
|
import type { UpdateApplyResult, UpdateTarget } from '@/shared/types/updates';
|
|
16
17
|
|
|
17
18
|
/** A stage label or a line of command output, as the update produces it. */
|
|
@@ -27,6 +28,12 @@ export interface UpdateProgressHooks {
|
|
|
27
28
|
export interface AppliedUpdate {
|
|
28
29
|
result: UpdateApplyResult;
|
|
29
30
|
updated: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* The copy this run left on disk, when it knows. The restart that follows
|
|
33
|
+
* launches THIS one — the running process's own directory is the build the
|
|
34
|
+
* update just replaced, which is how a rename came back as the old product.
|
|
35
|
+
*/
|
|
36
|
+
installed?: InstalledCopy | null;
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
/**
|
|
@@ -74,6 +81,7 @@ export async function applyUpdate(target: UpdateTarget, hooks: UpdateProgressHoo
|
|
|
74
81
|
output: result.output || undefined,
|
|
75
82
|
},
|
|
76
83
|
updated: result.updated,
|
|
84
|
+
installed: result.installed,
|
|
77
85
|
};
|
|
78
86
|
}
|
|
79
87
|
|