@kabmo-labs/kabmo 0.7.0 → 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.
Files changed (130) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +403 -384
  3. package/dist/client/static/js/index-gtbpewr7.css +1 -0
  4. package/dist/client/static/js/{index-0wvbca0b.js → index-qwpvd9fv.js} +779 -779
  5. package/package.json +2 -1
  6. package/src/client/components/common/header-controls/index.tsx +24 -1
  7. package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
  8. package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
  9. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
  10. package/src/client/components/layout/desktop-layout/index.tsx +8 -2
  11. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
  12. package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
  13. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
  14. package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
  15. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  16. package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
  17. package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
  18. package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
  19. package/src/client/components/mobile/LayoutWrapper.tsx +22 -16
  20. package/src/client/components/mobile/RightSidebar.tsx +21 -10
  21. package/src/client/components/mobile/SessionSidebar.tsx +8 -0
  22. package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
  23. package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
  24. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
  25. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
  26. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
  27. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
  28. package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
  29. package/src/client/components/mobile/worktree-session.ts +51 -0
  30. package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
  31. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +140 -0
  32. package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
  33. package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
  34. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +29 -56
  35. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
  36. package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
  37. package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +227 -0
  38. package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +260 -0
  39. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
  40. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +24 -6
  41. package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
  42. package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
  43. package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
  44. package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
  45. package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
  46. package/src/client/components/workspace/file-explorer/filter.ts +47 -0
  47. package/src/client/components/workspace/file-explorer/index.tsx +77 -64
  48. package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
  49. package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
  50. package/src/client/hooks/chat/timeline/index.ts +9 -5
  51. package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
  52. package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
  53. package/src/client/hooks/chat/timeline/send.test.ts +2 -0
  54. package/src/client/hooks/chat/timeline/send.ts +11 -1
  55. package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
  56. package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
  57. package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
  58. package/src/client/hooks/ui/long-press.test.ts +174 -0
  59. package/src/client/hooks/ui/long-press.ts +112 -0
  60. package/src/client/hooks/ui/updates/shell-download.test.ts +73 -0
  61. package/src/client/hooks/ui/updates/shell-download.ts +112 -28
  62. package/src/client/hooks/ui/updates/shell-offer.test.ts +187 -0
  63. package/src/client/hooks/workspace/file-create.ts +41 -11
  64. package/src/client/hooks/workspace/file-listing.ts +56 -1
  65. package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
  66. package/src/client/hooks/workspace/worktree/index.ts +150 -0
  67. package/src/client/tailwind.css +19 -0
  68. package/src/server/index.ts +7 -2
  69. package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
  70. package/src/server/lib/db/schema.ts +4 -0
  71. package/src/server/lib/db/worktree-registry.server.ts +91 -0
  72. package/src/server/lib/fs/canonical.ts +52 -0
  73. package/src/server/lib/fs/relative-path.ts +48 -0
  74. package/src/server/lib/fs/root-worktree.test.ts +116 -0
  75. package/src/server/lib/fs/root.ts +39 -10
  76. package/src/server/lib/fs/worktree/carry.ts +134 -0
  77. package/src/server/lib/fs/worktree/create.test.ts +249 -0
  78. package/src/server/lib/fs/worktree/create.ts +263 -0
  79. package/src/server/lib/fs/worktree/errors.ts +43 -0
  80. package/src/server/lib/fs/worktree/list.test.ts +207 -0
  81. package/src/server/lib/fs/worktree/list.ts +129 -0
  82. package/src/server/lib/fs/worktree/location.test.ts +206 -0
  83. package/src/server/lib/fs/worktree/location.ts +114 -0
  84. package/src/server/lib/fs/worktree/project-id.ts +53 -0
  85. package/src/server/lib/fs/worktree/remove.test.ts +242 -0
  86. package/src/server/lib/fs/worktree/remove.ts +152 -0
  87. package/src/server/lib/fs/worktree/settings.server.ts +56 -0
  88. package/src/server/lib/fs/worktree/topology.ts +142 -0
  89. package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
  90. package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
  91. package/src/server/lib/panels/local-edits.server.ts +33 -0
  92. package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
  93. package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
  94. package/src/server/lib/panels/sync.server.test.ts +125 -147
  95. package/src/server/lib/panels/sync.server.ts +153 -48
  96. package/src/server/lib/updates/shell-assets.test.ts +114 -0
  97. package/src/server/lib/updates/shell-assets.ts +109 -0
  98. package/src/server/lib/updates/shell-pages.test.ts +156 -0
  99. package/src/server/lib/updates/shell-pages.ts +145 -0
  100. package/src/server/lib/updates/shell-release.test.ts +75 -70
  101. package/src/server/lib/updates/shell-release.ts +120 -136
  102. package/src/server/routes/fs/action-create.test.ts +18 -13
  103. package/src/server/routes/fs/action-nested.test.ts +184 -0
  104. package/src/server/routes/fs/action.ts +47 -16
  105. package/src/server/routes/index.ts +2 -0
  106. package/src/server/routes/updates/desktop.ts +37 -0
  107. package/src/server/routes/updates/index.ts +2 -0
  108. package/src/server/routes/worktrees/create.ts +138 -0
  109. package/src/server/routes/worktrees/index.ts +25 -0
  110. package/src/server/routes/worktrees/list.ts +124 -0
  111. package/src/server/routes/worktrees/remove.ts +102 -0
  112. package/src/server/routes/worktrees/routes.test.ts +309 -0
  113. package/src/server/routes/worktrees/shared.ts +90 -0
  114. package/src/shared/lib/fs/file-tree.test.ts +183 -0
  115. package/src/shared/lib/fs/file-tree.ts +159 -0
  116. package/src/shared/lib/fs/paths.test.ts +24 -1
  117. package/src/shared/lib/fs/paths.ts +17 -0
  118. package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
  119. package/src/shared/lib/omp/session/prewarm.ts +7 -0
  120. package/src/shared/lib/updates/shell-ua.ts +24 -4
  121. package/src/shared/lib/workspace/active-project.ts +23 -0
  122. package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
  123. package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
  124. package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
  125. package/src/shared/lib/workspace/worktree/types.ts +104 -0
  126. package/src/shared/types/fs.ts +10 -0
  127. package/src/shared/types/workspace.ts +14 -0
  128. package/src/test-support/file-explorer-disk.test-util.ts +76 -0
  129. package/src/test-support/file-explorer.test-util.ts +71 -40
  130. 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
- const cwd = currentFolder?.project_path || currentFolder?.name;
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 ? defaultOpen(tool, autoOpenFirst, index) : explicit;
114
+ map[tool.id] = explicit === undefined ? renderedDefault(tool, index) : explicit;
68
115
  });
69
116
  return map;
70
- }, [tools, stored, autoOpenFirst]);
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
- // The first toggle of an untouched call has to flip its DERIVED default,
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, autoOpenFirst],
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
+ }