@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,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether the sidebar should offer an APK download.
|
|
3
|
+
*
|
|
4
|
+
* Two independent facts, and the button needs both:
|
|
5
|
+
*
|
|
6
|
+
* - **Running inside the Android app** (`shellVersion`, read from the bootstrap).
|
|
7
|
+
* In a browser there is no app to update, and a "Download APK" button there
|
|
8
|
+
* would be a second, unrelated offer sitting next to the app's own update
|
|
9
|
+
* badge.
|
|
10
|
+
* - **A newer APK than the one running it** (`latest` > `shellVersion`). The
|
|
11
|
+
* published asset is attached to the app's release, so it moves on its own
|
|
12
|
+
* schedule; an up-to-date app must show nothing at all rather than a button
|
|
13
|
+
* that downloads the build already installed.
|
|
14
|
+
*
|
|
15
|
+
* The probe is lazy: it is fetched once when a shell is detected, never in a
|
|
16
|
+
* browser, so a desktop console makes no request it cannot use.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
20
|
+
import { isNewer } from '@/shared/lib/updates/semver';
|
|
21
|
+
|
|
22
|
+
export interface AndroidDownload {
|
|
23
|
+
/** The asset to download, or null when there is nothing to offer. */
|
|
24
|
+
url: string;
|
|
25
|
+
/** The version that asset ships. */
|
|
26
|
+
latest: string;
|
|
27
|
+
/** The release page, for a "what changed" link. */
|
|
28
|
+
releaseUrl: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ReleasePayload {
|
|
32
|
+
url?: unknown;
|
|
33
|
+
latest?: unknown;
|
|
34
|
+
releaseUrl?: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Read the release payload defensively.
|
|
39
|
+
*
|
|
40
|
+
* The route answers `{url: null, latest: null, releaseUrl: null}` on a failed
|
|
41
|
+
* probe, so a shape check that only looks at the version would offer a button
|
|
42
|
+
* with no link.
|
|
43
|
+
*/
|
|
44
|
+
export function parseAndroidRelease(payload: unknown): AndroidDownload | null {
|
|
45
|
+
if (typeof payload !== 'object' || payload === null) return null;
|
|
46
|
+
const body = payload as ReleasePayload;
|
|
47
|
+
if (typeof body.url !== 'string' || !body.url) return null;
|
|
48
|
+
if (typeof body.latest !== 'string' || !body.latest) return null;
|
|
49
|
+
return {
|
|
50
|
+
url: body.url,
|
|
51
|
+
latest: body.latest,
|
|
52
|
+
releaseUrl: typeof body.releaseUrl === 'string' ? body.releaseUrl : null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The download to offer, or null.
|
|
58
|
+
*
|
|
59
|
+
* `shellVersion` is null in a browser — that is the whole gate, and it is why
|
|
60
|
+
* this takes the bootstrap value rather than sniffing the UA again on the
|
|
61
|
+
* client: the server already parsed it for the very request that served the
|
|
62
|
+
* page.
|
|
63
|
+
*/
|
|
64
|
+
export function shouldOfferAndroidDownload(
|
|
65
|
+
shellVersion: string | null | undefined,
|
|
66
|
+
release: AndroidDownload | null,
|
|
67
|
+
): AndroidDownload | null {
|
|
68
|
+
if (!shellVersion || !release) return null;
|
|
69
|
+
return isNewer(release.latest, shellVersion) ? release : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Fetch the published APK and compare it with the running shell.
|
|
74
|
+
*
|
|
75
|
+
* `fetchImpl` is a seam for the tests: the cases that matter (no release, a
|
|
76
|
+
* failed probe, a version equal to the installed one) cannot be produced by a
|
|
77
|
+
* live GitHub call.
|
|
78
|
+
*/
|
|
79
|
+
export function useAndroidDownload(
|
|
80
|
+
shellVersion: string | null | undefined,
|
|
81
|
+
fetchImpl: typeof fetch = fetch,
|
|
82
|
+
): AndroidDownload | null {
|
|
83
|
+
const [release, setRelease] = useState<AndroidDownload | null>(null);
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (!shellVersion) return;
|
|
87
|
+
|
|
88
|
+
let cancelled = false;
|
|
89
|
+
void (async () => {
|
|
90
|
+
try {
|
|
91
|
+
const response = await fetchImpl('/api/updates/android');
|
|
92
|
+
if (!response.ok) return;
|
|
93
|
+
const payload: unknown = await response.json();
|
|
94
|
+
if (!cancelled) setRelease(parseAndroidRelease(payload));
|
|
95
|
+
} catch {
|
|
96
|
+
// A probe that cannot answer is a button that must not appear.
|
|
97
|
+
}
|
|
98
|
+
})();
|
|
99
|
+
|
|
100
|
+
return () => {
|
|
101
|
+
cancelled = true;
|
|
102
|
+
};
|
|
103
|
+
}, [shellVersion, fetchImpl]);
|
|
104
|
+
|
|
105
|
+
return shouldOfferAndroidDownload(shellVersion, release);
|
|
106
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `useFileTabs` — how an "open this file" request resolves to a TAB.
|
|
8
|
+
*
|
|
9
|
+
* The bug this pins: the same path can reach the editor under two different
|
|
10
|
+
* ids, because the explorer passes a row's own `id` (its relative path) while a
|
|
11
|
+
* search row or a chat link computes `fileTabId(path)`. `handleOpenFile` used
|
|
12
|
+
* to activate the id it had COMPUTED even when the updater had just matched an
|
|
13
|
+
* existing tab under another id, so the editor found no active file and drew
|
|
14
|
+
* "Select a tab to view content" over the file that was already open — while
|
|
15
|
+
* the tab strip still showed it selected-by-nothing.
|
|
16
|
+
*
|
|
17
|
+
* Mounted through the REAL session-state provider, because the store is what
|
|
18
|
+
* the request is resolved against; a local stub would not exercise the
|
|
19
|
+
* read-back the fix depends on.
|
|
20
|
+
*
|
|
21
|
+
* The Preact modules are imported DYNAMICALLY after the happy-dom globals are
|
|
22
|
+
* installed: Preact binds its environment at evaluation time, so a static import
|
|
23
|
+
* would capture Node's globals and render nothing.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
27
|
+
import { Window } from 'happy-dom';
|
|
28
|
+
import type { h as PreactH, render as PreactRender } from 'preact';
|
|
29
|
+
import type { act as PreactAct } from 'preact/test-utils';
|
|
30
|
+
import type { useFileTabs as UseFileTabsHook } from '@/client/hooks/workspace/file-tabs';
|
|
31
|
+
import type { OpenFileInput } from '@/client/hooks/workspace/file-tabs';
|
|
32
|
+
import type { OpenedFile } from '@/shared/types/fs';
|
|
33
|
+
import { fileTabId } from '@/shared/lib/workspace/file-tab-id';
|
|
34
|
+
import { forgetSession } from '@/shared/lib/workspace/session-state/store';
|
|
35
|
+
import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
|
|
36
|
+
|
|
37
|
+
let useFileTabs: typeof UseFileTabsHook;
|
|
38
|
+
let h: typeof PreactH;
|
|
39
|
+
let render: typeof PreactRender;
|
|
40
|
+
let act: typeof PreactAct;
|
|
41
|
+
|
|
42
|
+
const ROOT = '/ws';
|
|
43
|
+
const SESSION = 'new-file-tabs';
|
|
44
|
+
const nativeFetch = Bun.fetch;
|
|
45
|
+
|
|
46
|
+
let container: HTMLElement;
|
|
47
|
+
let handleOpenFile: ((file: OpenFileInput) => void) | null = null;
|
|
48
|
+
let openedFiles: OpenedFile[] = [];
|
|
49
|
+
let activeFileId: number | string | null = null;
|
|
50
|
+
|
|
51
|
+
async function settle() {
|
|
52
|
+
for (let i = 0; i < 8; i++) {
|
|
53
|
+
await act(async () => {
|
|
54
|
+
for (let turn = 0; turn < 8; turn++) await Promise.resolve();
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Mount the hook inside the provider, with `stored` as the session's blob. */
|
|
60
|
+
async function mount(stored: Record<string, unknown> = {}) {
|
|
61
|
+
globalThis.fetch = (async () => ({
|
|
62
|
+
ok: true,
|
|
63
|
+
json: async () => ({ sessionId: SESSION, state: stored }),
|
|
64
|
+
text: async () => JSON.stringify({ sessionId: SESSION, state: stored }),
|
|
65
|
+
})) as unknown as typeof fetch;
|
|
66
|
+
const { SessionStateProvider } = await import('@/client/components/common/session-state-provider/index');
|
|
67
|
+
const Probe = () => {
|
|
68
|
+
const tabs = useFileTabs(ROOT, SESSION);
|
|
69
|
+
handleOpenFile = tabs.handleOpenFile;
|
|
70
|
+
openedFiles = tabs.openedFiles;
|
|
71
|
+
activeFileId = tabs.activeFileId;
|
|
72
|
+
return null;
|
|
73
|
+
};
|
|
74
|
+
await act(async () => {
|
|
75
|
+
render(h(SessionStateProvider, { sessionId: SESSION, children: h(Probe, {}) }), container);
|
|
76
|
+
});
|
|
77
|
+
await settle();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
beforeAll(async () => {
|
|
81
|
+
installDomGlobals(new Window({ url: 'http://localhost' }));
|
|
82
|
+
({ useFileTabs } = await import('@/client/hooks/workspace/file-tabs'));
|
|
83
|
+
({ h, render } = await import('preact'));
|
|
84
|
+
({ act } = await import('preact/test-utils'));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterAll(() => {
|
|
88
|
+
globalThis.fetch = nativeFetch;
|
|
89
|
+
restoreDomGlobals();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
handleOpenFile = null;
|
|
94
|
+
openedFiles = [];
|
|
95
|
+
activeFileId = null;
|
|
96
|
+
globalThis.fetch = (async () => ({
|
|
97
|
+
ok: true,
|
|
98
|
+
json: async () => ({ state: {} }),
|
|
99
|
+
text: async () => JSON.stringify({ state: {} }),
|
|
100
|
+
})) as unknown as typeof fetch;
|
|
101
|
+
container = document.createElement('div');
|
|
102
|
+
document.body.appendChild(container);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
if (container) render(null, container);
|
|
107
|
+
container?.remove();
|
|
108
|
+
forgetSession(SESSION);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/** What the explorer sends: the row's own id, which is its relative path. */
|
|
112
|
+
function explorerPayload(path: string): OpenFileInput {
|
|
113
|
+
return { id: path, name: path.split('/').pop() ?? path, path, root: ROOT, repo: '.' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** What a search row sends: no id, so the hook computes `fileTabId(path)`. */
|
|
117
|
+
function searchPayload(path: string): OpenFileInput {
|
|
118
|
+
return { name: path.split('/').pop() ?? path, path, root: ROOT, repo: '.' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
describe('useFileTabs open resolution', () => {
|
|
122
|
+
test('opening a file the explorer already has open keeps ITS id active', async () => {
|
|
123
|
+
await mount({ 'layout.openedFiles': [explorerPayload('notes.md')], 'layout.activeFileId': 'notes.md' });
|
|
124
|
+
|
|
125
|
+
await act(async () => {
|
|
126
|
+
handleOpenFile?.(searchPayload('notes.md'));
|
|
127
|
+
});
|
|
128
|
+
await settle();
|
|
129
|
+
|
|
130
|
+
// The id the search row computed names no tab; activating it is what blanked
|
|
131
|
+
// the editor. The existing tab's own id must stay the active one.
|
|
132
|
+
expect(activeFileId).toBe('notes.md');
|
|
133
|
+
expect(activeFileId).not.toBe(fileTabId('notes.md'));
|
|
134
|
+
expect(openedFiles).toHaveLength(1);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a file opened from the search panel alone gets the computed id', async () => {
|
|
138
|
+
await mount();
|
|
139
|
+
|
|
140
|
+
await act(async () => {
|
|
141
|
+
handleOpenFile?.(searchPayload('src/a.ts'));
|
|
142
|
+
});
|
|
143
|
+
await settle();
|
|
144
|
+
|
|
145
|
+
expect(openedFiles).toHaveLength(1);
|
|
146
|
+
expect(openedFiles[0]?.id).toBe(fileTabId('src/a.ts'));
|
|
147
|
+
expect(activeFileId).toBe(fileTabId('src/a.ts'));
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('reopening under the computed id activates the tab rather than adding one', async () => {
|
|
151
|
+
await mount();
|
|
152
|
+
|
|
153
|
+
await act(async () => {
|
|
154
|
+
handleOpenFile?.(searchPayload('src/a.ts'));
|
|
155
|
+
});
|
|
156
|
+
await settle();
|
|
157
|
+
await act(async () => {
|
|
158
|
+
handleOpenFile?.(explorerPayload('src/a.ts'));
|
|
159
|
+
});
|
|
160
|
+
await settle();
|
|
161
|
+
|
|
162
|
+
expect(openedFiles).toHaveLength(1);
|
|
163
|
+
expect(activeFileId).toBe(fileTabId('src/a.ts'));
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -79,19 +79,32 @@ export function useFileTabs(
|
|
|
79
79
|
id: file.id ?? fileTabId(file.path),
|
|
80
80
|
root: file.root ?? activeProjectPath ?? undefined,
|
|
81
81
|
};
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
82
|
+
// Resolved against the STORE, outside the updater: a state updater may run
|
|
83
|
+
// more than once, and activating a tab is not something that may be applied
|
|
84
|
+
// twice. The store is the authoritative copy (every write goes through
|
|
85
|
+
// `setSessionKey`), and reading it instead of the hook's own value keeps
|
|
86
|
+
// this callback's identity stable — a caller that memoizes on it (the
|
|
87
|
+
// search panel's rows do) would otherwise re-render on every tab change.
|
|
88
|
+
const current = getSessionValue<OpenedFile[]>(sessionId, 'layout.openedFiles') ?? [];
|
|
89
|
+
const existing = current.find(
|
|
90
|
+
f => f.id === fileEntry.id || (!f.isDiff && f.path && fileEntry.path && f.path === fileEntry.path)
|
|
91
|
+
);
|
|
92
|
+
if (existing) {
|
|
93
|
+
// The tab on screen may carry a DIFFERENT id for the same path — the
|
|
94
|
+
// explorer hands a row's own `id` (its relative path) through, while a
|
|
95
|
+
// search row or a chat link computes `fileTabId(path)`. Activating the
|
|
96
|
+
// computed id here is what blanked the editor: the id named no tab, so
|
|
97
|
+
// `Editor` found no active file and drew "Select a tab to view content"
|
|
98
|
+
// over the file that was already open. Only a tab that is CREATED can be
|
|
99
|
+
// activated by the id it was created with.
|
|
100
|
+
setActiveFileId(existing.id);
|
|
101
|
+
onOpenTab?.();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
setOpenedFiles(prev => [...prev, fileEntry]);
|
|
92
105
|
setActiveFileId(fileEntry.id);
|
|
93
106
|
onOpenTab?.();
|
|
94
|
-
}, [activeProjectPath, onOpenTab, setOpenedFiles, setActiveFileId]);
|
|
107
|
+
}, [activeProjectPath, onOpenTab, sessionId, setOpenedFiles, setActiveFileId]);
|
|
95
108
|
|
|
96
109
|
const handleOpenDiff = useCallback((diffInfo: {
|
|
97
110
|
file: string;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
|
2
2
|
import { getSessionValue, hydrateSession, setSessionKey } from '@/shared/lib/workspace/session-state/store';
|
|
3
|
-
import { subscribeSessionKey } from '@/shared/lib/workspace/session-state/listeners';
|
|
3
|
+
import { subscribeSessionKey, subscribeSessionReplaced } from '@/shared/lib/workspace/session-state/listeners';
|
|
4
4
|
import { useSessionStateContext } from '@/client/hooks/workspace/session-state/context';
|
|
5
5
|
|
|
6
6
|
type LoadedState = Record<string, unknown>;
|
|
@@ -13,6 +13,16 @@ type LoadedState = Record<string, unknown>;
|
|
|
13
13
|
* Returns `[value, setValue, ready]` — `ready` lets panels defer their first
|
|
14
14
|
* fetch/render until restore completed (mirrors the DesktopLayout editor
|
|
15
15
|
* "blip" concern).
|
|
16
|
+
*
|
|
17
|
+
* The copy is PRIVATE by design: two callers of the same key never observe
|
|
18
|
+
* each other's writes (a panel width is owned by one component). It is NOT
|
|
19
|
+
* private about the store being replaced wholesale — a blob landing, or a
|
|
20
|
+
* spawn adoption migrating the pending slot onto the real id — because there
|
|
21
|
+
* the value the copy holds was never written by anyone, it is the fallback
|
|
22
|
+
* this component read on the commit the session id changed. `ready` does not
|
|
23
|
+
* move across an adoption (it was already true), so without the second
|
|
24
|
+
* subscription a pending chat's opened panels and picked view were reset to
|
|
25
|
+
* their defaults at the exact moment the spawn renamed the session.
|
|
16
26
|
*/
|
|
17
27
|
export function useSessionState<T>(key: string, fallback: T): [T, (value: T | ((prev: T) => T)) => void, boolean] {
|
|
18
28
|
const { sessionId, ready } = useSessionStateContext();
|
|
@@ -32,6 +42,19 @@ export function useSessionState<T>(key: string, fallback: T): [T, (value: T | ((
|
|
|
32
42
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
33
43
|
}, [sessionId, ready, key]);
|
|
34
44
|
|
|
45
|
+
// The store replaced this session's blob under the copy (a load landing, an
|
|
46
|
+
// adoption migrate, a delete). `ready` may not move, so re-read on the event.
|
|
47
|
+
useEffect(
|
|
48
|
+
() =>
|
|
49
|
+
subscribeSessionReplaced(sessionId, () => {
|
|
50
|
+
const next = getSessionValue<T>(sessionId, key);
|
|
51
|
+
setLocal(next === undefined ? fallback : next);
|
|
52
|
+
}),
|
|
53
|
+
// `key` and `fallback` are stable per call site.
|
|
54
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
55
|
+
[sessionId, key],
|
|
56
|
+
);
|
|
57
|
+
|
|
35
58
|
const setValue = (value: T | ((prev: T) => T)) => {
|
|
36
59
|
const resolved = typeof value === 'function' ? (value as (prev: T) => T)(getSessionValue<T>(sessionId, key) ?? local) : value;
|
|
37
60
|
setLocal(resolved);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The toolbar row's CONTENT width, kept current as its container is resized.
|
|
8
|
+
*
|
|
9
|
+
* Both session-sidebar toolbars decide their visible set from measured width
|
|
10
|
+
* rather than a media query — the desktop sidebar is dragged, and the phone's
|
|
11
|
+
* drawer is whatever the device gives it — so both need the same measurement.
|
|
12
|
+
* A `@container` breakpoint cannot answer this: it would keep hiding controls
|
|
13
|
+
* after the user widened the panel.
|
|
14
|
+
*
|
|
15
|
+
* The width is a LAYOUT effect, so the first measure lands BEFORE paint: with a
|
|
16
|
+
* passive effect the row drew every control and then dropped the ones that did
|
|
17
|
+
* not fit on the next frame.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useLayoutEffect, useRef, useState } from 'preact/hooks';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param paddingPx Padding the row's `clientWidth` includes but the capacity
|
|
24
|
+
* budget must not (the desktop row's own `px-1`). Pass `0` for a row that is
|
|
25
|
+
* padded by its container instead, which is what the phone's drawer does.
|
|
26
|
+
*/
|
|
27
|
+
export function useToolbarRowWidth(paddingPx: number) {
|
|
28
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
29
|
+
const [width, setWidth] = useState(0);
|
|
30
|
+
|
|
31
|
+
useLayoutEffect(() => {
|
|
32
|
+
const element = ref.current;
|
|
33
|
+
if (!element) return;
|
|
34
|
+
const measure = () => setWidth(Math.max(0, element.clientWidth - paddingPx));
|
|
35
|
+
measure();
|
|
36
|
+
if (typeof ResizeObserver === 'undefined') return;
|
|
37
|
+
const observer = new ResizeObserver(measure);
|
|
38
|
+
observer.observe(element);
|
|
39
|
+
return () => observer.disconnect();
|
|
40
|
+
}, [paddingPx]);
|
|
41
|
+
|
|
42
|
+
return { ref, width };
|
|
43
|
+
}
|