@kabmo-labs/kabmo 0.1.0 → 0.3.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/dist/client/index.html +1 -1
- package/dist/client/index.js +285 -285
- package/dist/client/static/js/{index-enkbbffs.js → index-8x5j8zrr.js} +671 -671
- package/dist/client/static/js/index-pdp5dwc8.css +1 -0
- package/package.json +1 -1
- package/src/client/bootstrap.d.ts +17 -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/layout/session-sidebar/Footer.tsx +24 -2
- package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +2 -33
- package/src/client/components/layout/session-sidebar/index.tsx +8 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
- package/src/client/components/mobile/SessionSidebar.tsx +8 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +29 -1
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +104 -0
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +166 -106
- 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/data/settings/omp-schema.json +89 -4
- 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 +112 -0
- package/src/client/hooks/ui/updates/android-download.ts +143 -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/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/updates/android-release.test.ts +138 -0
- package/src/server/lib/updates/android-release.ts +183 -0
- package/src/server/plugins/ssr.ts +14 -2
- package/src/server/routes/fs/read.ts +24 -9
- package/src/server/routes/sessions/batch-delete.test.ts +14 -11
- package/src/server/routes/updates/android.ts +22 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/shared/lib/updates/shell-ua.test.ts +121 -0
- package/src/shared/lib/updates/shell-ua.ts +56 -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 +78 -0
- package/dist/client/static/js/index-yn17t04b.css +0 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the sidebar should offer for the Android shell, and why.
|
|
3
|
+
*
|
|
4
|
+
* Two DIFFERENT offers, from two different facts:
|
|
5
|
+
*
|
|
6
|
+
* - **`update`** — this page is running inside the APK (`shellVersion` from the
|
|
7
|
+
* bootstrap) and a newer one is published. An up-to-date app offers nothing:
|
|
8
|
+
* a button that downloads the build already installed is worse than no button.
|
|
9
|
+
* - **`install`** — this page is an Android BROWSER (Chrome, Brave, Firefox) and
|
|
10
|
+
* an APK is published. The reader may not know a native shell exists, and
|
|
11
|
+
* telling them is the whole point of the offer; there is no installed version
|
|
12
|
+
* to compare against, so the newest published one is what it names.
|
|
13
|
+
*
|
|
14
|
+
* A desktop browser gets neither: `isAndroid` is false, an APK cannot be
|
|
15
|
+
* installed there, and the app's own update badge already covers the server.
|
|
16
|
+
*
|
|
17
|
+
* The probe is lazy: it runs only on an Android device, never on a desktop, so a
|
|
18
|
+
* console that can never show the button makes no request for it.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
22
|
+
import { isNewer } from '@/shared/lib/updates/semver';
|
|
23
|
+
|
|
24
|
+
/** Which offer this is — the two carry different wording. */
|
|
25
|
+
export type AndroidOfferKind = 'update' | 'install';
|
|
26
|
+
|
|
27
|
+
export interface AndroidDownload {
|
|
28
|
+
/** The asset to download. */
|
|
29
|
+
url: string;
|
|
30
|
+
/** The version that asset ships. */
|
|
31
|
+
latest: string;
|
|
32
|
+
/** The release page, for a "what changed" link. */
|
|
33
|
+
releaseUrl: string | null;
|
|
34
|
+
/** An update to an installed APK, or a first install from a browser. */
|
|
35
|
+
kind: AndroidOfferKind;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface ReleasePayload {
|
|
39
|
+
url?: unknown;
|
|
40
|
+
latest?: unknown;
|
|
41
|
+
releaseUrl?: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read the release payload defensively.
|
|
46
|
+
*
|
|
47
|
+
* The route answers `{url: null, latest: null, releaseUrl: null}` on a failed
|
|
48
|
+
* probe, so a shape check that only looks at the version would offer a button
|
|
49
|
+
* with no link.
|
|
50
|
+
*/
|
|
51
|
+
export function parseAndroidRelease(payload: unknown): Omit<AndroidDownload, 'kind'> | null {
|
|
52
|
+
if (typeof payload !== 'object' || payload === null) return null;
|
|
53
|
+
const body = payload as ReleasePayload;
|
|
54
|
+
if (typeof body.url !== 'string' || !body.url) return null;
|
|
55
|
+
if (typeof body.latest !== 'string' || !body.latest) return null;
|
|
56
|
+
return {
|
|
57
|
+
url: body.url,
|
|
58
|
+
latest: body.latest,
|
|
59
|
+
releaseUrl: typeof body.releaseUrl === 'string' ? body.releaseUrl : null,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The offer, or null.
|
|
65
|
+
*
|
|
66
|
+
* `shellVersion` is null in every browser — that is what makes the two branches
|
|
67
|
+
* mutually exclusive — and `isAndroid` is the wider fact that admits a browser
|
|
68
|
+
* ON ANDROID. Both come from the bootstrap rather than a client-side UA sniff:
|
|
69
|
+
* the server already parsed them for the very request that served the page.
|
|
70
|
+
*
|
|
71
|
+
* An `install` needs no version comparison, because nothing is installed. It
|
|
72
|
+
* does require a published APK: `release` being null is a failed probe, and a
|
|
73
|
+
* button the console cannot back with a link must not appear.
|
|
74
|
+
*/
|
|
75
|
+
export function shouldOfferAndroidDownload(
|
|
76
|
+
shellVersion: string | null | undefined,
|
|
77
|
+
isAndroid: boolean | null | undefined,
|
|
78
|
+
release: Omit<AndroidDownload, 'kind'> | null,
|
|
79
|
+
): AndroidDownload | null {
|
|
80
|
+
if (!release) return null;
|
|
81
|
+
|
|
82
|
+
// Inside the APK: an update, and only when one exists.
|
|
83
|
+
if (shellVersion) {
|
|
84
|
+
return isNewer(release.latest, shellVersion) ? { ...release, kind: 'update' } : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A browser on an Android device: the app is something to install.
|
|
88
|
+
if (isAndroid) return { ...release, kind: 'install' };
|
|
89
|
+
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The wording of the offer, in one place.
|
|
95
|
+
*
|
|
96
|
+
* Both layouts draw this control and they must say the same thing: an installed
|
|
97
|
+
* app that is behind is an UPDATE, and a browser on Android is an INSTALL —
|
|
98
|
+
* "Download" for both would leave a first-time reader unsure whether they
|
|
99
|
+
* already have the app. The version is in the label because the asset is a
|
|
100
|
+
* specific build, not "the app".
|
|
101
|
+
*/
|
|
102
|
+
export function androidDownloadLabel(download: AndroidDownload): string {
|
|
103
|
+
const verb = download.kind === 'update' ? 'Update' : 'Install';
|
|
104
|
+
return `${verb} Kabmo ${download.latest} for Android`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Fetch the published APK and decide what to offer.
|
|
109
|
+
*
|
|
110
|
+
* `fetchImpl` is a seam for the tests: the cases that matter (no release, a
|
|
111
|
+
* failed probe, a version equal to the installed one) cannot be produced by a
|
|
112
|
+
* live GitHub call.
|
|
113
|
+
*/
|
|
114
|
+
export function useAndroidDownload(
|
|
115
|
+
shellVersion: string | null | undefined,
|
|
116
|
+
isAndroid: boolean | null | undefined,
|
|
117
|
+
fetchImpl: typeof fetch = fetch,
|
|
118
|
+
): AndroidDownload | null {
|
|
119
|
+
const [release, setRelease] = useState<Omit<AndroidDownload, 'kind'> | null>(null);
|
|
120
|
+
const wanted = Boolean(shellVersion) || Boolean(isAndroid);
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!wanted) return;
|
|
124
|
+
|
|
125
|
+
let cancelled = false;
|
|
126
|
+
void (async () => {
|
|
127
|
+
try {
|
|
128
|
+
const response = await fetchImpl('/api/updates/android');
|
|
129
|
+
if (!response.ok) return;
|
|
130
|
+
const payload: unknown = await response.json();
|
|
131
|
+
if (!cancelled) setRelease(parseAndroidRelease(payload));
|
|
132
|
+
} catch {
|
|
133
|
+
// A probe that cannot answer is a button that must not appear.
|
|
134
|
+
}
|
|
135
|
+
})();
|
|
136
|
+
|
|
137
|
+
return () => {
|
|
138
|
+
cancelled = true;
|
|
139
|
+
};
|
|
140
|
+
}, [wanted, fetchImpl]);
|
|
141
|
+
|
|
142
|
+
return shouldOfferAndroidDownload(shellVersion, isAndroid, release);
|
|
143
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterEach, describe, expect, test } from 'bun:test';
|
|
7
|
+
import { Window } from 'happy-dom';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
BACK_PROPERTY,
|
|
11
|
+
dispatchBack,
|
|
12
|
+
installBackDispatcher,
|
|
13
|
+
registerBackHandler,
|
|
14
|
+
} from '@/client/lib/native/back';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The stack is module state, so every case unregisters what it added. The
|
|
18
|
+
* shell's contract is exactly two facts: last-registered wins, and the first
|
|
19
|
+
* handler that answers `true` stops the walk.
|
|
20
|
+
*/
|
|
21
|
+
const cleanups: Array<() => void> = [];
|
|
22
|
+
|
|
23
|
+
function onBack(handler: () => boolean): () => boolean {
|
|
24
|
+
const remove = registerBackHandler(handler);
|
|
25
|
+
cleanups.push(remove);
|
|
26
|
+
return handler;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
while (cleanups.length > 0) cleanups.pop()!();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('dispatchBack', () => {
|
|
34
|
+
test('reports false when nothing is registered', () => {
|
|
35
|
+
expect(dispatchBack()).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('the last registered handler runs first', () => {
|
|
39
|
+
const order: string[] = [];
|
|
40
|
+
onBack(() => {
|
|
41
|
+
order.push('first');
|
|
42
|
+
return false;
|
|
43
|
+
});
|
|
44
|
+
onBack(() => {
|
|
45
|
+
order.push('second');
|
|
46
|
+
return true;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(dispatchBack()).toBe(true);
|
|
50
|
+
expect(order).toEqual(['second']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('the first true stops the walk', () => {
|
|
54
|
+
const order: string[] = [];
|
|
55
|
+
onBack(() => {
|
|
56
|
+
order.push('inner');
|
|
57
|
+
return true;
|
|
58
|
+
});
|
|
59
|
+
onBack(() => {
|
|
60
|
+
order.push('outer');
|
|
61
|
+
return false;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
expect(dispatchBack()).toBe(true);
|
|
65
|
+
expect(order).toEqual(['outer', 'inner']);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('a throwing handler is skipped, and the next one still gets the press', () => {
|
|
69
|
+
onBack(() => {
|
|
70
|
+
throw new Error('broken handler');
|
|
71
|
+
});
|
|
72
|
+
onBack(() => true);
|
|
73
|
+
|
|
74
|
+
expect(dispatchBack()).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('unregistering removes the handler', () => {
|
|
78
|
+
let calls = 0;
|
|
79
|
+
const remove = registerBackHandler(() => {
|
|
80
|
+
calls += 1;
|
|
81
|
+
return false;
|
|
82
|
+
});
|
|
83
|
+
remove();
|
|
84
|
+
|
|
85
|
+
expect(dispatchBack()).toBe(false);
|
|
86
|
+
expect(calls).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe('installBackDispatcher', () => {
|
|
91
|
+
test('exposes window.__kabmoBack and is idempotent', () => {
|
|
92
|
+
const win = new Window({ url: 'http://localhost/' });
|
|
93
|
+
const target = globalThis as unknown as Record<string, unknown>;
|
|
94
|
+
const previous = target.window;
|
|
95
|
+
target.window = win;
|
|
96
|
+
try {
|
|
97
|
+
installBackDispatcher();
|
|
98
|
+
installBackDispatcher();
|
|
99
|
+
|
|
100
|
+
const installed = (win as unknown as Record<string, unknown>)[BACK_PROPERTY];
|
|
101
|
+
expect(typeof installed).toBe('function');
|
|
102
|
+
expect((installed as () => boolean)()).toBe(false);
|
|
103
|
+
} finally {
|
|
104
|
+
target.window = previous;
|
|
105
|
+
win.close();
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The Android hardware back button, delegated to the page.
|
|
8
|
+
*
|
|
9
|
+
* The shell asks `window.__kabmoBack()` before it walks WebView history, and
|
|
10
|
+
* that indirection exists because of a real mismatch: Kabmo's phone layout keeps
|
|
11
|
+
* its screens in React state (`MobileLayoutWrapper`'s `currentScreen`), not in
|
|
12
|
+
* the URL. `history.back()` therefore reopens a previous session while leaving
|
|
13
|
+
* the drawer that opened it on screen — the user presses back and the app
|
|
14
|
+
* appears to do something unrelated.
|
|
15
|
+
*
|
|
16
|
+
* Handlers are a STACK, last-registered first: a settings dialog mounted over the
|
|
17
|
+
* layout gets the press before the layout does, which is the order the user sees.
|
|
18
|
+
* Each handler answers whether it consumed the press; the first `true` wins.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type BackHandler = () => boolean;
|
|
22
|
+
|
|
23
|
+
/** The property the shell calls. Named once so both sides agree. */
|
|
24
|
+
export const BACK_PROPERTY = '__kabmoBack';
|
|
25
|
+
|
|
26
|
+
const handlers: BackHandler[] = [];
|
|
27
|
+
|
|
28
|
+
/** Register a handler; the returned function removes it. */
|
|
29
|
+
export function registerBackHandler(handler: BackHandler): () => void {
|
|
30
|
+
handlers.push(handler);
|
|
31
|
+
return () => {
|
|
32
|
+
const index = handlers.indexOf(handler);
|
|
33
|
+
if (index >= 0) handlers.splice(index, 1);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Ask the page to consume a back press.
|
|
39
|
+
*
|
|
40
|
+
* Exported for tests and for a caller that wants the same precedence without the
|
|
41
|
+
* native shell (a keyboard shortcut). Reverse order is the whole contract, and a
|
|
42
|
+
* throwing handler is skipped rather than allowed to strand the user in a screen
|
|
43
|
+
* they cannot leave.
|
|
44
|
+
*/
|
|
45
|
+
export function dispatchBack(): boolean {
|
|
46
|
+
for (let index = handlers.length - 1; index >= 0; index -= 1) {
|
|
47
|
+
try {
|
|
48
|
+
if (handlers[index]()) return true;
|
|
49
|
+
} catch {
|
|
50
|
+
// Fall through to the next handler; history is the final fallback.
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Install `window.__kabmoBack`. Idempotent — a hot reload re-runs it. */
|
|
57
|
+
export function installBackDispatcher(): void {
|
|
58
|
+
if (typeof window === 'undefined') return;
|
|
59
|
+
(window as unknown as Record<string, unknown>)[BACK_PROPERTY] = dispatchBack;
|
|
60
|
+
}
|
package/src/client/main.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { installAuthFetchBridge, primeAuthState } from '@/client/hooks/ui/auth';
|
|
|
8
8
|
import { installPluginRuntime } from '@/client/lib/plugins/runtime';
|
|
9
9
|
import { installUiKit } from '@/client/lib/plugins/kit';
|
|
10
10
|
import { installScrollbarActivity } from '@/client/lib/scrollbar-activity';
|
|
11
|
+
import { installBackDispatcher } from '@/client/lib/native/back';
|
|
11
12
|
import { installFileLaunchHandler } from '@/client/lib/launch/file-handlers';
|
|
12
13
|
|
|
13
14
|
import '@/client/tailwind.css';
|
|
@@ -53,6 +54,11 @@ installUiKit();
|
|
|
53
54
|
// already covered.
|
|
54
55
|
installScrollbarActivity();
|
|
55
56
|
|
|
57
|
+
// The native shell asks `window.__kabmoBack()` before walking WebView history.
|
|
58
|
+
// Installed before the first render for the same reason the listener above is:
|
|
59
|
+
// a back press during hydration must find the dispatcher, not `undefined`.
|
|
60
|
+
installBackDispatcher();
|
|
61
|
+
|
|
56
62
|
// A file the OS handed to Kabmo (PWA File Handling) arrives as a
|
|
57
63
|
// `FileSystemFileHandle` on `launchQueue`, and the consumer must be installed
|
|
58
64
|
// before the first render: launches that happened before the script ran are
|
|
@@ -102,8 +102,25 @@ export const GIT_BRANCH_ACTIONS: Record<string, GitActionHandler> = {
|
|
|
102
102
|
},
|
|
103
103
|
|
|
104
104
|
async sync({ targetDir }) {
|
|
105
|
+
// A merge/rebase already stopped on a conflict is a state the operation
|
|
106
|
+
// bar owns: a pull there is refused by git ("You have not concluded your
|
|
107
|
+
// merge"), and letting that refusal through would report the conflict as
|
|
108
|
+
// a failed sync. Naming it points the user at the bar instead.
|
|
109
|
+
const before = await readGitState(targetDir);
|
|
110
|
+
if (before.operation) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Finish the ${OPERATION_LABELS[before.operation]} first — Sync is unavailable while an operation is in progress.`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
105
115
|
await pullBranch(targetDir);
|
|
106
|
-
|
|
116
|
+
// A pull that stopped on a conflict has nothing to push yet: the merge is
|
|
117
|
+
// unresolved, so `git push` refuses ("Updates were rejected because the
|
|
118
|
+
// remote contains work…") and the route would report a failed Sync over a
|
|
119
|
+
// conflict the panel is about to offer to resolve. The operation bar is
|
|
120
|
+
// the surface that has to be reached, so the push is skipped and the
|
|
121
|
+
// working tree is left exactly as the pull left it.
|
|
122
|
+
const after = await readGitState(targetDir);
|
|
123
|
+
if (!after.operation) await pushBranch(targetDir);
|
|
107
124
|
// Both commands already moved the tracking ref; the next read must not
|
|
108
125
|
// fetch again to see a state it just produced.
|
|
109
126
|
markRemoteRefsFresh(targetDir);
|