@kabmo-labs/kabmo 0.6.1 → 0.8.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 +403 -384
- package/dist/client/static/js/index-gtbpewr7.css +1 -0
- package/dist/client/static/js/{index-aq0e78gv.js → index-qwpvd9fv.js} +779 -779
- package/package.json +2 -1
- package/src/client/components/common/header-controls/index.tsx +24 -1
- package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
- package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
- package/src/client/components/layout/desktop-layout/index.tsx +8 -2
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +22 -16
- package/src/client/components/mobile/RightSidebar.tsx +21 -10
- package/src/client/components/mobile/SessionSidebar.tsx +8 -0
- package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
- package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
- package/src/client/components/mobile/worktree-session.ts +51 -0
- package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
- package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +140 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +29 -56
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
- package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +227 -0
- package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +260 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +24 -6
- package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
- package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
- package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
- package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
- package/src/client/components/workspace/file-explorer/filter.ts +47 -0
- package/src/client/components/workspace/file-explorer/index.tsx +77 -64
- package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
- package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
- package/src/client/hooks/chat/timeline/index.ts +9 -5
- package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.ts +11 -1
- package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
- package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
- package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
- package/src/client/hooks/ui/long-press.test.ts +174 -0
- package/src/client/hooks/ui/long-press.ts +112 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +74 -1
- package/src/client/hooks/ui/updates/shell-download.ts +112 -28
- package/src/client/hooks/ui/updates/shell-offer.test.ts +187 -0
- package/src/client/hooks/workspace/file-create.ts +41 -11
- package/src/client/hooks/workspace/file-listing.ts +56 -1
- package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
- package/src/client/hooks/workspace/worktree/index.ts +150 -0
- package/src/client/tailwind.css +19 -0
- package/src/server/index.ts +7 -2
- package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/worktree-registry.server.ts +91 -0
- package/src/server/lib/fs/canonical.ts +52 -0
- package/src/server/lib/fs/relative-path.ts +48 -0
- package/src/server/lib/fs/root-worktree.test.ts +116 -0
- package/src/server/lib/fs/root.ts +39 -10
- package/src/server/lib/fs/worktree/carry.ts +134 -0
- package/src/server/lib/fs/worktree/create.test.ts +249 -0
- package/src/server/lib/fs/worktree/create.ts +263 -0
- package/src/server/lib/fs/worktree/errors.ts +43 -0
- package/src/server/lib/fs/worktree/list.test.ts +207 -0
- package/src/server/lib/fs/worktree/list.ts +129 -0
- package/src/server/lib/fs/worktree/location.test.ts +206 -0
- package/src/server/lib/fs/worktree/location.ts +114 -0
- package/src/server/lib/fs/worktree/project-id.ts +53 -0
- package/src/server/lib/fs/worktree/remove.test.ts +242 -0
- package/src/server/lib/fs/worktree/remove.ts +152 -0
- package/src/server/lib/fs/worktree/settings.server.ts +56 -0
- package/src/server/lib/fs/worktree/topology.ts +142 -0
- package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
- package/src/server/lib/panels/local-edits.server.ts +33 -0
- package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
- package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
- package/src/server/lib/panels/sync.server.test.ts +125 -147
- package/src/server/lib/panels/sync.server.ts +153 -48
- package/src/server/lib/updates/shell-assets.test.ts +114 -0
- package/src/server/lib/updates/shell-assets.ts +109 -0
- package/src/server/lib/updates/shell-pages.test.ts +156 -0
- package/src/server/lib/updates/shell-pages.ts +145 -0
- package/src/server/lib/updates/shell-release.test.ts +126 -50
- package/src/server/lib/updates/shell-release.ts +139 -88
- package/src/server/routes/fs/action-create.test.ts +18 -13
- package/src/server/routes/fs/action-nested.test.ts +184 -0
- package/src/server/routes/fs/action.ts +47 -16
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/updates/desktop.ts +37 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/worktrees/create.ts +138 -0
- package/src/server/routes/worktrees/index.ts +25 -0
- package/src/server/routes/worktrees/list.ts +124 -0
- package/src/server/routes/worktrees/remove.ts +102 -0
- package/src/server/routes/worktrees/routes.test.ts +309 -0
- package/src/server/routes/worktrees/shared.ts +90 -0
- package/src/shared/lib/fs/file-tree.test.ts +183 -0
- package/src/shared/lib/fs/file-tree.ts +159 -0
- package/src/shared/lib/fs/paths.test.ts +24 -1
- package/src/shared/lib/fs/paths.ts +17 -0
- package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
- package/src/shared/lib/omp/session/prewarm.ts +7 -0
- package/src/shared/lib/updates/shell-ua.ts +24 -4
- package/src/shared/lib/workspace/active-project.ts +23 -0
- package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
- package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
- package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
- package/src/shared/lib/workspace/worktree/types.ts +104 -0
- package/src/shared/types/fs.ts +10 -0
- package/src/shared/types/workspace.ts +14 -0
- package/src/test-support/file-explorer-disk.test-util.ts +76 -0
- package/src/test-support/file-explorer.test-util.ts +71 -40
- package/dist/client/static/js/index-nzyjf92m.css +0 -1
|
@@ -160,6 +160,7 @@ function makeSendHarness(options: {
|
|
|
160
160
|
const params: string[] = [];
|
|
161
161
|
const seeds: SessionSeed[] = [];
|
|
162
162
|
const spawnSelectionRef = { current: { plan: true, goal: false } };
|
|
163
|
+
const spawnWorktreeRef = { current: null as string | null };
|
|
163
164
|
const adoptedSessionIdRef = { current: null };
|
|
164
165
|
const deps: ChatTimelineSendDeps = {
|
|
165
166
|
folders: options.folders ?? [],
|
|
@@ -190,6 +191,7 @@ function makeSendHarness(options: {
|
|
|
190
191
|
deferredComposerPickRef: options.deferredPick ?? { current: null },
|
|
191
192
|
accessModeRef: { current: 'always-ask' },
|
|
192
193
|
spawnSelectionRef,
|
|
194
|
+
spawnWorktreeRef,
|
|
193
195
|
abortControllerRef: { current: null },
|
|
194
196
|
setInputValue: (v) => {
|
|
195
197
|
drafts.push(v);
|
|
@@ -151,6 +151,7 @@ function makeSendHarness(options: {
|
|
|
151
151
|
const params: string[] = [];
|
|
152
152
|
const seeds: SessionSeed[] = [];
|
|
153
153
|
const spawnSelectionRef = { current: { plan: true, goal: false } };
|
|
154
|
+
const spawnWorktreeRef = { current: null as string | null };
|
|
154
155
|
const adoptedSessionIdRef = { current: null };
|
|
155
156
|
const deps: ChatTimelineSendDeps = {
|
|
156
157
|
folders: options.folders ?? [],
|
|
@@ -181,6 +182,7 @@ function makeSendHarness(options: {
|
|
|
181
182
|
deferredComposerPickRef: options.deferredPick ?? { current: null },
|
|
182
183
|
accessModeRef: { current: 'always-ask' },
|
|
183
184
|
spawnSelectionRef,
|
|
185
|
+
spawnWorktreeRef,
|
|
184
186
|
abortControllerRef: { current: null },
|
|
185
187
|
setInputValue: (v) => {
|
|
186
188
|
drafts.push(v);
|
|
@@ -151,6 +151,7 @@ function makeSendHarness(options: {
|
|
|
151
151
|
const params: string[] = [];
|
|
152
152
|
const seeds: SessionSeed[] = [];
|
|
153
153
|
const spawnSelectionRef = { current: { plan: true, goal: false } };
|
|
154
|
+
const spawnWorktreeRef = { current: null as string | null };
|
|
154
155
|
const adoptedSessionIdRef = { current: null };
|
|
155
156
|
const deps: ChatTimelineSendDeps = {
|
|
156
157
|
folders: options.folders ?? [],
|
|
@@ -181,6 +182,7 @@ function makeSendHarness(options: {
|
|
|
181
182
|
deferredComposerPickRef: options.deferredPick ?? { current: null },
|
|
182
183
|
accessModeRef: { current: 'always-ask' },
|
|
183
184
|
spawnSelectionRef,
|
|
185
|
+
spawnWorktreeRef,
|
|
184
186
|
abortControllerRef: { current: null },
|
|
185
187
|
setInputValue: (v) => {
|
|
186
188
|
drafts.push(v);
|
|
@@ -58,6 +58,13 @@ export interface ChatTimelineSendDeps {
|
|
|
58
58
|
* outside the render that produced the pick, and it is the FIRST moment a
|
|
59
59
|
* child exists to carry the selection. */
|
|
60
60
|
spawnSelectionRef: { current: { plan: boolean; goal: boolean } | null };
|
|
61
|
+
/**
|
|
62
|
+
* The worktree the pending chat picked (`chat.spawnTarget`), read through a
|
|
63
|
+
* REF because the composer is remounted by a stretch, a tab switch and a
|
|
64
|
+
* reload while this handler is created once per deps record — the same reason
|
|
65
|
+
* the waiting pick keeps one.
|
|
66
|
+
*/
|
|
67
|
+
spawnWorktreeRef: { current: string | null };
|
|
61
68
|
/** Picks the composer made while a turn was streaming. Pushed onto the
|
|
62
69
|
* session here, immediately before the prompt they were meant for. */
|
|
63
70
|
deferredComposerPickRef: DeferredModelStore;
|
|
@@ -104,6 +111,7 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
|
|
|
104
111
|
deferredComposerPickRef,
|
|
105
112
|
accessModeRef,
|
|
106
113
|
spawnSelectionRef,
|
|
114
|
+
spawnWorktreeRef,
|
|
107
115
|
abortControllerRef,
|
|
108
116
|
setInputValue,
|
|
109
117
|
setSearchParams,
|
|
@@ -234,7 +242,9 @@ export function useChatTimelineSend(deps: ChatTimelineSendDeps): ChatTimelineSen
|
|
|
234
242
|
// when the spawn fails (e.g. MOCK=true or no workspace context).
|
|
235
243
|
if (!isOmpSession) {
|
|
236
244
|
const currentFolder = folders.find(f => String(f.id) === String(selectedFolderId));
|
|
237
|
-
|
|
245
|
+
// A worktree pick wins: the chat is BORN there, so no relocation is ever
|
|
246
|
+
// needed and the child's cwd is right for its whole life.
|
|
247
|
+
const cwd = spawnWorktreeRef.current || currentFolder?.project_path || currentFolder?.name;
|
|
238
248
|
if (cwd) {
|
|
239
249
|
const images = collectImages(attachments);
|
|
240
250
|
const promptText = await buildPromptText(text, textFiles);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The worktree a pending chat picked, as a ref the SEND path can read.
|
|
8
|
+
*
|
|
9
|
+
* The composer is remounted by a stretch, a tab switch and a reload, while the
|
|
10
|
+
* send handler is created once per deps record — so a pick read from render
|
|
11
|
+
* state there would be missed, which is the one outcome the pick exists to
|
|
12
|
+
* prevent. The persisted copy is `chat.spawnTarget` (it survives a reload, and
|
|
13
|
+
* a second tab sees it); the ref is its render-visible mirror.
|
|
14
|
+
*
|
|
15
|
+
* Same shape as the waiting pick's ref (`waiting/choice.ts`), for the same
|
|
16
|
+
* reason: a value written before the send and read at send time cannot travel
|
|
17
|
+
* through props that rebuild.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useEffect, useRef } from 'preact/hooks';
|
|
21
|
+
import { useSessionState } from '@/client/hooks/workspace/session-state/index';
|
|
22
|
+
|
|
23
|
+
/** Where the chat that has not started will run. */
|
|
24
|
+
export interface SpawnTarget {
|
|
25
|
+
folderId: number | null;
|
|
26
|
+
/** Absolute worktree path, or null for the folder's own checkout. */
|
|
27
|
+
worktreePath: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `chat.spawnTarget` — the persisted pick, shared by both layouts' composers. */
|
|
31
|
+
export const SPAWN_TARGET_KEY = 'chat.spawnTarget';
|
|
32
|
+
|
|
33
|
+
export interface SpawnTargetBinding {
|
|
34
|
+
/** The pick, for a control that draws it (the composer's target picker). */
|
|
35
|
+
target: SpawnTarget | null;
|
|
36
|
+
setTarget: (next: SpawnTarget | null) => void;
|
|
37
|
+
/** The send path's view: set before the request goes out. */
|
|
38
|
+
worktreePathRef: { current: string | null };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function useSpawnTarget(sessionId: string | null): SpawnTargetBinding {
|
|
42
|
+
const [target, setTarget] = useSessionState<SpawnTarget | null>(SPAWN_TARGET_KEY, null);
|
|
43
|
+
const worktreePathRef = useRef<string | null>(null);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
// A pick for ANOTHER session must not leak into this one: the store is keyed
|
|
47
|
+
// by session, so a change of session id re-reads and this effect re-mirrors.
|
|
48
|
+
worktreePathRef.current = target?.worktreePath ?? null;
|
|
49
|
+
}, [target, sessionId]);
|
|
50
|
+
|
|
51
|
+
return { target, setTarget, worktreePathRef };
|
|
52
|
+
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
|
21
21
|
import { Window } from 'happy-dom';
|
|
22
22
|
import { h, render } from 'preact';
|
|
23
|
+
import { useState } from 'preact/hooks';
|
|
23
24
|
import { act } from 'preact/test-utils';
|
|
24
25
|
import { useToolOpenState, TOOL_OPEN_STATE_KEY, type ToolOpenState } from '@/client/hooks/chat/timeline/tool-open';
|
|
25
26
|
import { SessionStateProvider } from '@/client/components/common/session-state-provider';
|
|
@@ -139,3 +140,76 @@ describe('useToolOpenState', () => {
|
|
|
139
140
|
expect(remounted.current?.openMap.a).toBe(true);
|
|
140
141
|
});
|
|
141
142
|
});
|
|
143
|
+
|
|
144
|
+
describe('useToolOpenState — a streaming eval opens itself', () => {
|
|
145
|
+
/**
|
|
146
|
+
* Mount a probe whose tools can be REPLACED, so a status transition is
|
|
147
|
+
* observable: the reported bug is about a card that was open for the run and
|
|
148
|
+
* must not snap shut when the call returns.
|
|
149
|
+
*/
|
|
150
|
+
async function mountMutable(initial: ToolCallData[]) {
|
|
151
|
+
const api: { current: ToolOpenState | null; setTools: (next: ToolCallData[]) => void } = {
|
|
152
|
+
current: null,
|
|
153
|
+
setTools: () => {},
|
|
154
|
+
};
|
|
155
|
+
function Probe() {
|
|
156
|
+
const [tools, setTools] = useState(initial);
|
|
157
|
+
api.setTools = setTools;
|
|
158
|
+
api.current = useToolOpenState(tools, false);
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
container = document.createElement('div');
|
|
162
|
+
document.body.appendChild(container);
|
|
163
|
+
await act(async () => {
|
|
164
|
+
render(h(SessionStateProvider, { sessionId: SESSION, children: h(Probe, {}) }), container);
|
|
165
|
+
});
|
|
166
|
+
await act(async () => {
|
|
167
|
+
for (let i = 0; i < 8; i++) await Promise.resolve();
|
|
168
|
+
});
|
|
169
|
+
return api;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const evalTool = (partial: Partial<ToolCallData> = {}) =>
|
|
173
|
+
tool('e1', { type: 'eval', name: 'eval', title: 'Counting lines', ...partial });
|
|
174
|
+
|
|
175
|
+
test('a running eval is open, so its streamed output is visible', async () => {
|
|
176
|
+
const api = await mountMutable([evalTool({ status: 'running' })]);
|
|
177
|
+
expect(api.current?.openMap.e1).toBe(true);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('it STAYS open once the call settles', async () => {
|
|
181
|
+
const api = await mountMutable([evalTool({ status: 'running' })]);
|
|
182
|
+
await act(async () => {
|
|
183
|
+
api.setTools([evalTool({ status: 'success', output: 'line 1' })]);
|
|
184
|
+
});
|
|
185
|
+
// Re-deriving from `status` alone closed the card at the exact moment the
|
|
186
|
+
// reader wants the result — the `wait` lesson, restated for eval.
|
|
187
|
+
expect(api.current?.openMap.e1).toBe(true);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('a settled eval from a reloaded transcript stays closed', async () => {
|
|
191
|
+
// History is not a live run: opening every past eval would bury the
|
|
192
|
+
// conversation under scripts nobody asked to see again.
|
|
193
|
+
const api = await mountMutable([evalTool({ status: 'success', output: 'line 1' })]);
|
|
194
|
+
expect(api.current?.openMap.e1).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('a running bash is still folded', async () => {
|
|
198
|
+
// The auto-open is for the card whose body IS the answer, not for every
|
|
199
|
+
// streaming call — a bash log belongs folded.
|
|
200
|
+
const api = await mountMutable([tool('b1', { status: 'running' })]);
|
|
201
|
+
expect(api.current?.openMap.b1).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('the reader can still close a running eval, and it stays closed', async () => {
|
|
205
|
+
const api = await mountMutable([evalTool({ status: 'running' })]);
|
|
206
|
+
await act(async () => {
|
|
207
|
+
api.current?.toggle('e1');
|
|
208
|
+
});
|
|
209
|
+
expect(api.current?.openMap.e1).toBe(false);
|
|
210
|
+
await act(async () => {
|
|
211
|
+
api.setTools([evalTool({ status: 'success', output: 'line 1' })]);
|
|
212
|
+
});
|
|
213
|
+
expect(api.current?.openMap.e1).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* session blob.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { useCallback, useMemo } from 'preact/hooks';
|
|
21
|
+
import { useCallback, useEffect, useMemo, useRef } from 'preact/hooks';
|
|
22
22
|
import { useSessionState } from '@/client/hooks/workspace/session-state';
|
|
23
23
|
import { isSkippedTool } from '@/shared/lib/chat/tool-status';
|
|
24
24
|
import type { ToolCallData } from '@/shared/types/chat';
|
|
@@ -42,6 +42,22 @@ export const TOOL_OPEN_STATE_KEY = 'chat.toolOpen';
|
|
|
42
42
|
* belongs folded, while a wait's is three short rows.
|
|
43
43
|
*
|
|
44
44
|
* A stored choice still wins, so closing it stays closed.
|
|
45
|
+
*
|
|
46
|
+
* An `eval` is the second such card, and it is the one a reader reported: a
|
|
47
|
+
* script cell streams its stdout as it runs, and a collapsed card showed
|
|
48
|
+
* `Running` with the output invisible for the whole call — the panel had drawn
|
|
49
|
+
* it all along, folded away, and only expanding by hand (or waiting for the
|
|
50
|
+
* call to return) revealed it. It differs from `bash` the same way `wait` does:
|
|
51
|
+
* an eval's output IS the answer, not a log to consult afterwards, and the
|
|
52
|
+
* reader is watching it arrive.
|
|
53
|
+
*
|
|
54
|
+
* A card that was OPENED because it was running stays open once it settles —
|
|
55
|
+
* `watchedIds` records that, in memory only. Collapsing at the settle would
|
|
56
|
+
* take the output away at the exact moment the reader wants to read it (the
|
|
57
|
+
* `wait` lesson above), while re-deriving from `status` alone would do exactly
|
|
58
|
+
* that. The record is deliberately not persisted: a reloaded transcript is
|
|
59
|
+
* history, and every historical eval reopening itself would bury the
|
|
60
|
+
* conversation under scripts nobody asked to see again.
|
|
45
61
|
*/
|
|
46
62
|
function defaultOpen(tool: ToolCallData, autoOpenFirst: boolean, index: number): boolean {
|
|
47
63
|
if (isSkippedTool(tool)) return false;
|
|
@@ -50,6 +66,12 @@ function defaultOpen(tool: ToolCallData, autoOpenFirst: boolean, index: number):
|
|
|
50
66
|
return autoOpenFirst && index === 0;
|
|
51
67
|
}
|
|
52
68
|
|
|
69
|
+
/** True while this call is a script cell that is still producing output. */
|
|
70
|
+
function isStreamingEval(tool: ToolCallData): boolean {
|
|
71
|
+
if (tool.status !== 'running') return false;
|
|
72
|
+
return (tool.name || tool.type) === 'eval';
|
|
73
|
+
}
|
|
74
|
+
|
|
53
75
|
export interface ToolOpenState {
|
|
54
76
|
/** Resolved openness per tool id — stored choice first, default second. */
|
|
55
77
|
openMap: Record<string, boolean>;
|
|
@@ -59,28 +81,50 @@ export interface ToolOpenState {
|
|
|
59
81
|
|
|
60
82
|
export function useToolOpenState(tools: ToolCallData[], autoOpenFirst: boolean): ToolOpenState {
|
|
61
83
|
const [stored, setStored] = useSessionState<Record<string, boolean>>(TOOL_OPEN_STATE_KEY, {});
|
|
84
|
+
/** Ids opened BECAUSE they were streaming; see `defaultOpen`'s eval note. */
|
|
85
|
+
const watchedRef = useRef<Set<string>>(new Set());
|
|
86
|
+
|
|
87
|
+
const streamingEvalIds = useMemo(
|
|
88
|
+
() => tools.filter(isStreamingEval).map((tool) => tool.id),
|
|
89
|
+
[tools],
|
|
90
|
+
);
|
|
91
|
+
// Recorded after the render that opened them, so the open survives the call
|
|
92
|
+
// settling. In memory only: a reloaded transcript is history.
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
for (const id of streamingEvalIds) watchedRef.current.add(id);
|
|
95
|
+
}, [streamingEvalIds]);
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The default a card is RENDERING, before any stored choice. One function for
|
|
99
|
+
* the map and for `toggle`, because a toggle that flips a different default
|
|
100
|
+
* than the one on screen takes two clicks to close an auto-opened card — the
|
|
101
|
+
* streaming eval made that reachable, since its default now depends on more
|
|
102
|
+
* than `status`.
|
|
103
|
+
*/
|
|
104
|
+
const renderedDefault = useCallback(
|
|
105
|
+
(tool: ToolCallData, index: number): boolean =>
|
|
106
|
+
isStreamingEval(tool) || watchedRef.current.has(tool.id) || defaultOpen(tool, autoOpenFirst, index),
|
|
107
|
+
[autoOpenFirst],
|
|
108
|
+
);
|
|
62
109
|
|
|
63
110
|
const openMap = useMemo(() => {
|
|
64
111
|
const map: Record<string, boolean> = {};
|
|
65
112
|
tools.forEach((tool, index) => {
|
|
66
113
|
const explicit = stored[tool.id];
|
|
67
|
-
map[tool.id] = explicit === undefined ?
|
|
114
|
+
map[tool.id] = explicit === undefined ? renderedDefault(tool, index) : explicit;
|
|
68
115
|
});
|
|
69
116
|
return map;
|
|
70
|
-
}, [tools, stored,
|
|
117
|
+
}, [tools, stored, renderedDefault]);
|
|
71
118
|
|
|
72
119
|
const toggle = useCallback(
|
|
73
120
|
(toolId: string) => {
|
|
74
121
|
setStored((prev) => {
|
|
75
122
|
const tool = tools.find((entry) => entry.id === toolId);
|
|
76
|
-
|
|
77
|
-
// not an absent key — otherwise an auto-opened error would need two
|
|
78
|
-
// clicks to close.
|
|
79
|
-
const current = prev[toolId] ?? (tool ? defaultOpen(tool, autoOpenFirst, tools.indexOf(tool)) : false);
|
|
123
|
+
const current = prev[toolId] ?? (tool ? renderedDefault(tool, tools.indexOf(tool)) : false);
|
|
80
124
|
return { ...prev, [toolId]: !current };
|
|
81
125
|
});
|
|
82
126
|
},
|
|
83
|
-
[setStored, tools,
|
|
127
|
+
[setStored, tools, renderedDefault],
|
|
84
128
|
);
|
|
85
129
|
|
|
86
130
|
return { openMap, toggle };
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Long press: the only way a finger reaches a Files-panel row's menu.
|
|
8
|
+
*
|
|
9
|
+
* Three rules are load-bearing and each was a way the gesture would misbehave
|
|
10
|
+
* on a phone:
|
|
11
|
+
*
|
|
12
|
+
* 1. **A press that MOVES is a scroll.** The tree is a scroller, and a hold that
|
|
13
|
+
* begins a flick must cancel — otherwise every scroll that starts on a row
|
|
14
|
+
* opens a menu.
|
|
15
|
+
* 2. **The click the same gesture produces is swallowed.** Lifting the finger
|
|
16
|
+
* after the menu opened would otherwise also toggle the folder it opened for;
|
|
17
|
+
* the platform raises both events for one gesture and marks neither.
|
|
18
|
+
* 3. **It is inert without a finger.** A desktop holding its primary button down
|
|
19
|
+
* is a drag, and a mouse already has a right button.
|
|
20
|
+
*
|
|
21
|
+
* Time is driven, never slept through: a 450 ms hold is a real wait, and the
|
|
22
|
+
* hook's decisions are all about WHICH event arrives, never about how long the
|
|
23
|
+
* test took.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'bun:test';
|
|
27
|
+
import { Window } from 'happy-dom';
|
|
28
|
+
import { h, render } from 'preact';
|
|
29
|
+
import { act } from 'preact/test-utils';
|
|
30
|
+
import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
|
|
31
|
+
import { useLongPress, type LongPressHandlers } from '@/client/hooks/ui/long-press';
|
|
32
|
+
|
|
33
|
+
let win: Window;
|
|
34
|
+
let container: HTMLElement;
|
|
35
|
+
let handlers: LongPressHandlers | null = null;
|
|
36
|
+
const fired: Array<{ x: number; y: number }> = [];
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
vi.useFakeTimers();
|
|
40
|
+
win = new Window({ url: 'http://localhost' });
|
|
41
|
+
installDomGlobals(win);
|
|
42
|
+
container = document.createElement('div');
|
|
43
|
+
document.body.appendChild(container);
|
|
44
|
+
fired.length = 0;
|
|
45
|
+
handlers = null;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
render(null, container);
|
|
50
|
+
container.remove();
|
|
51
|
+
restoreDomGlobals();
|
|
52
|
+
vi.useRealTimers();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
/** Mount the hook; `enabled` is what a touch device decides. */
|
|
56
|
+
async function mount(enabled: boolean): Promise<void> {
|
|
57
|
+
const Probe = () => {
|
|
58
|
+
handlers = useLongPress({ enabled, onLongPress: (x, y) => fired.push({ x, y }) });
|
|
59
|
+
return null;
|
|
60
|
+
};
|
|
61
|
+
await act(async () => {
|
|
62
|
+
render(h(Probe, {}), container);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** A touch event carrying one finger at `(x, y)`. */
|
|
67
|
+
function touch(type: string, x: number, y: number): globalThis.TouchEvent {
|
|
68
|
+
const event = new win.Event(type, { bubbles: true, cancelable: true }) as unknown as globalThis.TouchEvent;
|
|
69
|
+
Object.defineProperty(event, 'touches', { value: type === 'touchend' || type === 'touchcancel' ? [] : [{ clientX: x, clientY: y }] });
|
|
70
|
+
return event;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Advance the hook's own timer, without waiting on a real clock. */
|
|
74
|
+
async function holdFor(ms: number): Promise<void> {
|
|
75
|
+
await act(async () => {
|
|
76
|
+
vi.advanceTimersByTime(ms);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe('useLongPress', () => {
|
|
81
|
+
test('a hold fires at the press point', async () => {
|
|
82
|
+
await mount(true);
|
|
83
|
+
|
|
84
|
+
await act(async () => {
|
|
85
|
+
handlers!.onTouchStart(touch('touchstart', 40, 80));
|
|
86
|
+
});
|
|
87
|
+
await holdFor(520);
|
|
88
|
+
|
|
89
|
+
expect(fired).toEqual([{ x: 40, y: 80 }]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a press released before the threshold never fires', async () => {
|
|
93
|
+
await mount(true);
|
|
94
|
+
|
|
95
|
+
await act(async () => {
|
|
96
|
+
handlers!.onTouchStart(touch('touchstart', 40, 80));
|
|
97
|
+
});
|
|
98
|
+
await act(async () => {
|
|
99
|
+
handlers!.onTouchEnd();
|
|
100
|
+
});
|
|
101
|
+
await holdFor(520);
|
|
102
|
+
|
|
103
|
+
expect(fired).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('a press that MOVES is a scroll, not a press', async () => {
|
|
107
|
+
await mount(true);
|
|
108
|
+
|
|
109
|
+
await act(async () => {
|
|
110
|
+
handlers!.onTouchStart(touch('touchstart', 40, 80));
|
|
111
|
+
// Past the slop: the first few pixels of a flick arrive immediately, and
|
|
112
|
+
// a menu that opened on them would make the tree unscrollable.
|
|
113
|
+
handlers!.onTouchMove(touch('touchmove', 40, 140));
|
|
114
|
+
});
|
|
115
|
+
await holdFor(520);
|
|
116
|
+
|
|
117
|
+
expect(fired).toEqual([]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('a small drift inside the slop still counts as a hold', async () => {
|
|
121
|
+
await mount(true);
|
|
122
|
+
|
|
123
|
+
await act(async () => {
|
|
124
|
+
handlers!.onTouchStart(touch('touchstart', 40, 80));
|
|
125
|
+
handlers!.onTouchMove(touch('touchmove', 42, 83));
|
|
126
|
+
});
|
|
127
|
+
await holdFor(520);
|
|
128
|
+
|
|
129
|
+
expect(fired).toHaveLength(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('a cancelled press never fires', async () => {
|
|
133
|
+
await mount(true);
|
|
134
|
+
|
|
135
|
+
await act(async () => {
|
|
136
|
+
handlers!.onTouchStart(touch('touchstart', 40, 80));
|
|
137
|
+
handlers!.onTouchCancel();
|
|
138
|
+
});
|
|
139
|
+
await holdFor(520);
|
|
140
|
+
|
|
141
|
+
expect(fired).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('the click after a fired press is consumed exactly once', async () => {
|
|
145
|
+
await mount(true);
|
|
146
|
+
|
|
147
|
+
await act(async () => {
|
|
148
|
+
handlers!.onTouchStart(touch('touchstart', 40, 80));
|
|
149
|
+
});
|
|
150
|
+
await holdFor(520);
|
|
151
|
+
|
|
152
|
+
// The first click belongs to the gesture that opened the menu; a second
|
|
153
|
+
// click is the user's own and must work.
|
|
154
|
+
expect(handlers!.consume()).toBe(true);
|
|
155
|
+
expect(handlers!.consume()).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('no press means nothing to consume', async () => {
|
|
159
|
+
await mount(true);
|
|
160
|
+
|
|
161
|
+
expect(handlers!.consume()).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test('it is inert without a finger', async () => {
|
|
165
|
+
await mount(false);
|
|
166
|
+
|
|
167
|
+
await act(async () => {
|
|
168
|
+
handlers!.onTouchStart(touch('touchstart', 40, 80));
|
|
169
|
+
});
|
|
170
|
+
await holdFor(520);
|
|
171
|
+
|
|
172
|
+
expect(fired).toEqual([]);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Long press, for the surfaces that have no right-click.
|
|
8
|
+
*
|
|
9
|
+
* A phone's Files panel offered no way to reach a row's context menu: the menu
|
|
10
|
+
* opens on `contextmenu`, which a touch device only raises after a press that
|
|
11
|
+
* the browser has already claimed for something else — a native drag (the row
|
|
12
|
+
* is a drag source) or a text selection of the whole panel. Measured on a
|
|
13
|
+
* phone: holding a file row selected the tree's text and started a drag ghost,
|
|
14
|
+
* and no menu ever appeared.
|
|
15
|
+
*
|
|
16
|
+
* Three rules make this usable rather than a second way to break the panel:
|
|
17
|
+
*
|
|
18
|
+
* - **A press that MOVES is a scroll, not a press.** The tree is a scroller, and
|
|
19
|
+
* a hold that begins a scroll must cancel — otherwise every flick that starts
|
|
20
|
+
* on a row opens a menu. The threshold is small (`SLOP_PX`) because the first
|
|
21
|
+
* few pixels of a scroll arrive within the same tick.
|
|
22
|
+
* - **A fired press suppresses the CLICK it would otherwise produce.** Lifting
|
|
23
|
+
* the finger after the menu opened would otherwise also toggle the folder the
|
|
24
|
+
* menu was opened for. The caller asks `consume()` from its click handler,
|
|
25
|
+
* which is the only place the two can be told apart — the platform raises both
|
|
26
|
+
* events for one gesture and does not mark either.
|
|
27
|
+
* - **It is inert without a touch device.** A mouse already has a right button,
|
|
28
|
+
* and a desktop holding its primary button down over a row is a drag.
|
|
29
|
+
*
|
|
30
|
+
* The handler is deliberately not `onContextMenu`-based: some Android builds DO
|
|
31
|
+
* raise `contextmenu` after a hold, and the browser's own press has already
|
|
32
|
+
* started a selection by then, so the menu would open over a half-selected tree.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { useCallback, useEffect, useRef } from 'preact/hooks';
|
|
36
|
+
|
|
37
|
+
/** How long a press must be held before it counts. */
|
|
38
|
+
const LONG_PRESS_MS = 450;
|
|
39
|
+
/** Movement that turns a press into a scroll, in CSS pixels. */
|
|
40
|
+
const SLOP_PX = 8;
|
|
41
|
+
/** How long a fired press keeps suppressing the click it produces. */
|
|
42
|
+
const CLICK_SUPPRESS_MS = 700;
|
|
43
|
+
|
|
44
|
+
export interface LongPressOptions {
|
|
45
|
+
/** False on a device with a pointer: the right button already does this. */
|
|
46
|
+
enabled: boolean;
|
|
47
|
+
/** Where the press landed, in viewport coordinates — a menu anchor. */
|
|
48
|
+
onLongPress: (x: number, y: number) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface LongPressHandlers {
|
|
52
|
+
onTouchStart: (event: globalThis.TouchEvent) => void;
|
|
53
|
+
onTouchMove: (event: globalThis.TouchEvent) => void;
|
|
54
|
+
onTouchEnd: () => void;
|
|
55
|
+
onTouchCancel: () => void;
|
|
56
|
+
/**
|
|
57
|
+
* True exactly once after a press fired, so the caller's click handler can
|
|
58
|
+
* ignore the click the same gesture produces.
|
|
59
|
+
*/
|
|
60
|
+
consume: () => boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function useLongPress({ enabled, onLongPress }: LongPressOptions): LongPressHandlers {
|
|
64
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
65
|
+
const originRef = useRef<{ x: number; y: number } | null>(null);
|
|
66
|
+
const firedAtRef = useRef(0);
|
|
67
|
+
/** The latest callback, so a re-render never leaves a stale one armed. */
|
|
68
|
+
const handlerRef = useRef(onLongPress);
|
|
69
|
+
handlerRef.current = onLongPress;
|
|
70
|
+
|
|
71
|
+
const cancel = useCallback(() => {
|
|
72
|
+
if (timerRef.current !== null) {
|
|
73
|
+
clearTimeout(timerRef.current);
|
|
74
|
+
timerRef.current = null;
|
|
75
|
+
}
|
|
76
|
+
originRef.current = null;
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
useEffect(() => cancel, [cancel]);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
onTouchStart: (event) => {
|
|
83
|
+
if (!enabled) return;
|
|
84
|
+
const touch = event.touches[0];
|
|
85
|
+
if (!touch) return;
|
|
86
|
+
cancel();
|
|
87
|
+
originRef.current = { x: touch.clientX, y: touch.clientY };
|
|
88
|
+
timerRef.current = setTimeout(() => {
|
|
89
|
+
timerRef.current = null;
|
|
90
|
+
const origin = originRef.current;
|
|
91
|
+
originRef.current = null;
|
|
92
|
+
if (!origin) return;
|
|
93
|
+
firedAtRef.current = Date.now();
|
|
94
|
+
handlerRef.current(origin.x, origin.y);
|
|
95
|
+
}, LONG_PRESS_MS);
|
|
96
|
+
},
|
|
97
|
+
onTouchMove: (event) => {
|
|
98
|
+
const origin = originRef.current;
|
|
99
|
+
const touch = event.touches[0];
|
|
100
|
+
if (!origin || !touch) return;
|
|
101
|
+
if (Math.abs(touch.clientX - origin.x) > SLOP_PX || Math.abs(touch.clientY - origin.y) > SLOP_PX) cancel();
|
|
102
|
+
},
|
|
103
|
+
onTouchEnd: cancel,
|
|
104
|
+
onTouchCancel: cancel,
|
|
105
|
+
consume: () => {
|
|
106
|
+
if (firedAtRef.current === 0) return false;
|
|
107
|
+
const recent = Date.now() - firedAtRef.current < CLICK_SUPPRESS_MS;
|
|
108
|
+
firedAtRef.current = 0;
|
|
109
|
+
return recent;
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|