@kabmo-labs/kabmo 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +314 -314
- package/dist/client/static/js/{index-qwpvd9fv.js → index-bpptfkz2.js} +922 -892
- package/dist/client/static/js/index-k315p1e8.css +1 -0
- package/package.json +9 -1
- package/src/client/components/common/code-surface/documents.test.ts +120 -0
- package/src/client/components/common/code-surface/documents.ts +201 -0
- package/src/client/components/common/code-surface/extensions.test.ts +110 -0
- package/src/client/components/common/code-surface/extensions.ts +162 -0
- package/src/client/components/common/code-surface/handle.ts +102 -0
- package/src/client/components/common/code-surface/index.tsx +253 -314
- package/src/client/components/common/code-surface/keymap.test.ts +133 -0
- package/src/client/components/common/code-surface/keymap.ts +267 -0
- package/src/client/components/common/code-surface/marks.ts +74 -0
- package/src/client/components/common/code-surface/shiki.ts +234 -0
- package/src/client/components/common/code-surface/sync.test.ts +185 -0
- package/src/client/components/common/code-surface/sync.ts +58 -0
- package/src/client/components/common/code-surface/theme.test.ts +202 -0
- package/src/client/components/common/code-surface/theme.ts +111 -0
- package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
- package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
- package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
- package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
- package/src/client/components/common/shell-update-button/index.tsx +125 -0
- package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
- package/src/client/components/common/toolbar-row/index.tsx +189 -0
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
- package/src/client/components/layout/desktop-layout/index.tsx +47 -67
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
- package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
- package/src/client/components/mobile/LayoutWrapper.tsx +1 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
- package/src/client/components/settings/Modal.tsx +8 -0
- package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
- package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +8 -2
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +16 -8
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +8 -11
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
- package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
- package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
- package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
- package/src/client/components/workspace/diff-panel/index.tsx +76 -145
- package/src/client/components/workspace/diff-panel/merge.ts +315 -0
- package/src/client/components/workspace/editor/Body.tsx +4 -7
- package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
- package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
- package/src/client/components/workspace/editor/FilePane.tsx +4 -7
- package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
- package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
- package/src/client/components/workspace/editor/index.tsx +7 -9
- package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
- package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
- package/src/client/components/workspace/search-panel/Body.tsx +1 -1
- package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
- package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
- package/src/client/hooks/chat/omp/reconnect.ts +187 -0
- package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
- package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
- package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
- package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
- package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
- package/src/client/hooks/chat/omp/session-list.tsx +69 -9
- package/src/client/hooks/chat/timeline/generating.ts +19 -2
- package/src/client/hooks/chat/timeline/index.ts +19 -24
- package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
- package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
- package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
- package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
- package/src/client/hooks/chat/timeline/session-load.ts +69 -37
- package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
- package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
- package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
- package/src/client/hooks/code/deferred-highlight.ts +129 -0
- package/src/client/hooks/editor/find-state.ts +1 -1
- package/src/client/hooks/editor/theme.test.ts +206 -0
- package/src/client/hooks/editor/theme.ts +82 -0
- package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
- package/src/client/hooks/editor/use-editor-drop.ts +67 -14
- package/src/client/hooks/editor/use-editor-find.ts +7 -7
- package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
- package/src/client/hooks/editor/use-outline-view.ts +94 -7
- package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
- package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
- package/src/client/hooks/editor/use-search-reveal.ts +1 -1
- package/src/client/hooks/ui/settings-modal.ts +56 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +2 -0
- package/src/client/hooks/ui/updates/shell-download.ts +55 -14
- package/src/client/hooks/ui/updates/shell-offer.test.ts +26 -0
- package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
- package/src/client/hooks/ui/updates/shell-update.ts +119 -0
- package/src/client/hooks/workspace/diff-file.ts +29 -10
- package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
- package/src/client/hooks/workspace/editor-view.ts +61 -0
- package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
- package/src/client/hooks/workspace/worktree/index.ts +20 -5
- package/src/client/lib/desktop/bridge.ts +45 -0
- package/src/client/lib/search-reveal.ts +1 -1
- package/src/client/main.tsx +5 -1
- package/src/client/tailwind.css +37 -8
- package/src/server/lib/auth/guard.test.ts +28 -0
- package/src/server/lib/auth/guard.ts +10 -0
- package/src/server/lib/fs/worktree/create.ts +2 -3
- package/src/server/lib/fs/worktree/list.ts +13 -0
- package/src/server/lib/fs/worktree/remove.ts +3 -3
- package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
- package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
- package/src/server/lib/lifecycle/port-guard.ts +39 -7
- package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
- package/src/server/lib/lifecycle/probe.test.ts +77 -164
- package/src/server/lib/lifecycle/probe.ts +121 -13
- package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
- package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
- package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
- package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
- package/src/server/lib/omp/session/telemetry.test.ts +33 -0
- package/src/server/lib/realtime/signals.server.ts +10 -0
- package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
- package/src/server/lib/realtime/topics.server.ts +16 -0
- package/src/server/lib/updates/shell-assets.ts +94 -3
- package/src/server/lib/updates/shell-cache.ts +105 -0
- package/src/server/lib/updates/shell-pages.test.ts +33 -2
- package/src/server/lib/updates/shell-pages.ts +17 -5
- package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
- package/src/server/lib/updates/shell-release.test.ts +10 -27
- package/src/server/lib/updates/shell-release.ts +107 -111
- package/src/server/routes/fs/git-merge.test.ts +11 -1
- package/src/server/routes/fs/read.test.ts +24 -7
- package/src/server/routes/fs/scope.test.ts +15 -10
- package/src/server/routes/fs/scope.ts +3 -14
- package/src/server/routes/updates/android.ts +6 -1
- package/src/server/routes/updates/desktop.ts +29 -7
- package/src/server/routes/updates/ios.ts +5 -1
- package/src/server/routes/worktrees/list.ts +11 -1
- package/src/server/routes/worktrees/routes.test.ts +17 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
- package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
- package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
- package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
- package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
- package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
- package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
- package/src/shared/lib/code/editor/find-scan.ts +177 -0
- package/src/shared/lib/code/editor/keymap.ts +3 -3
- package/src/shared/lib/code/editor/lines.test.ts +7 -55
- package/src/shared/lib/code/editor/lines.ts +8 -48
- package/src/shared/lib/code/editor/marks.test.ts +4 -5
- package/src/shared/lib/code/editor/marks.ts +13 -25
- package/src/shared/lib/code/editor/theme-loader.ts +97 -0
- package/src/shared/lib/code/editor/themes.generated.ts +156 -0
- package/src/shared/lib/code/editor/themes.test.ts +165 -0
- package/src/shared/lib/code/editor/themes.ts +165 -0
- package/src/shared/lib/code/highlight-memo.test.ts +103 -0
- package/src/shared/lib/code/highlight-memo.ts +149 -0
- package/src/shared/lib/code/highlighter-warmup.ts +112 -0
- package/src/shared/lib/code/highlighter.ts +3 -1
- package/src/shared/lib/code/idle.ts +37 -0
- package/src/shared/lib/code/outline/rows.test.ts +96 -0
- package/src/shared/lib/code/outline/rows.ts +94 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
- package/src/shared/lib/code/syntax-highlight.ts +2 -3
- package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
- package/src/shared/lib/fs/file-kind.ts +47 -13
- package/src/shared/lib/fs/file-url.test.ts +52 -0
- package/src/shared/lib/fs/file-url.ts +37 -0
- package/src/shared/lib/fs/patch-sides.test.ts +114 -0
- package/src/shared/lib/fs/patch-sides.ts +139 -0
- package/src/shared/lib/realtime/protocol.ts +16 -0
- package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
- package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
- package/src/shared/lib/updates/shell-arch.ts +54 -0
- package/src/shared/lib/updates/shell-ua.ts +4 -4
- package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
- package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
- package/src/shared/lib/workspace/panel-widths.ts +25 -5
- package/src/shared/lib/workspace/session-state/cache.ts +159 -0
- package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
- package/src/shared/lib/workspace/session-state/merge.ts +100 -0
- package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
- package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
- package/src/shared/lib/workspace/session-state/store.ts +92 -130
- package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
- package/src/shared/types/realtime.ts +29 -0
- package/src/shared/types/settings/state.ts +10 -0
- package/src/test-support/loopback.ts +183 -0
- package/dist/client/static/js/index-gtbpewr7.css +0 -1
- package/src/client/components/common/code-editor/handler.test.ts +0 -274
- package/src/client/components/common/code-editor/handler.ts +0 -175
- package/src/client/components/common/code-editor/index.tsx +0 -314
- package/src/client/components/common/code-surface/measure.ts +0 -148
- package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +0 -227
- package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +0 -260
- package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
- package/src/client/hooks/editor/scroll-container.test.ts +0 -102
- package/src/client/hooks/editor/scroll-container.ts +0 -26
- package/src/client/hooks/editor/use-code-window.ts +0 -100
- package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
- package/src/client/hooks/editor/use-editor-history.ts +0 -134
- package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
- package/src/client/hooks/editor/use-occurrences.ts +0 -98
- package/src/shared/lib/code/editor/commands.test.ts +0 -155
- package/src/shared/lib/code/editor/commands.ts +0 -260
- package/src/shared/lib/code/editor/edits.test.ts +0 -100
- package/src/shared/lib/code/editor/edits.ts +0 -122
- package/src/shared/lib/code/editor/history.test.ts +0 -118
- package/src/shared/lib/code/editor/history.ts +0 -101
- package/src/shared/lib/code/editor/layers.test.ts +0 -77
- package/src/shared/lib/code/editor/layers.ts +0 -67
- package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
- package/src/shared/lib/code/editor/line-edits.ts +0 -159
- package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
- package/src/shared/lib/code/editor/occurrences.ts +0 -111
- package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
- package/src/shared/lib/code/editor/reset-css.ts +0 -32
- package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
- package/src/shared/lib/code/editor/text-commands.ts +0 -154
- package/src/shared/lib/code/lazy-window.test.ts +0 -161
- package/src/shared/lib/code/lazy-window.ts +0 -156
- package/src/shared/lib/code/windowed-highlight.ts +0 -114
- package/src/shared/lib/fs/diff-parser.test.ts +0 -116
- package/src/shared/lib/fs/diff-parser.ts +0 -213
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The harness both session-load suites share.
|
|
8
|
+
*
|
|
9
|
+
* Split out when the suite passed the per-file ceiling. It mounts the REAL
|
|
10
|
+
* `useSessionLoad` through a `Probe` component and gates every `/api/chat/:id`
|
|
11
|
+
* response, so a suite decides when — and with what — a fetch lands.
|
|
12
|
+
*
|
|
13
|
+
* The committed-history contract itself:
|
|
14
|
+
*
|
|
15
|
+
* While a run is live the timeline's rows are the STREAM's, and a committed
|
|
16
|
+
* fetch is a lagging snapshot: for a fresh spawn there may be no transcript on
|
|
17
|
+
* disk at all (omp buffers its writes until the first assistant message
|
|
18
|
+
* settles), and it never carries a segment still streaming. Applying one
|
|
19
|
+
* mid-run cost the operator their own turn in both directions, measured on real
|
|
20
|
+
* sessions:
|
|
21
|
+
*
|
|
22
|
+
* - an empty payload EMPTIED the list, so the turn they had just sent
|
|
23
|
+
* vanished until a reload;
|
|
24
|
+
* - a payload carrying the file's own copy of that turn was merged UNDER the
|
|
25
|
+
* live rows, whose ids differ (`msg-…-user` vs the echoed id), so the turn
|
|
26
|
+
* rendered twice.
|
|
27
|
+
*
|
|
28
|
+
* Both shapes needed the same hole: the guard was keyed on the AI placeholder,
|
|
29
|
+
* which the stream RELEASES at the first assistant `message_end` while the run
|
|
30
|
+
* continues. These cases pin the run as the ownership signal, and pin the two
|
|
31
|
+
* directions the guard must still let through when no run is live.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { afterAll, afterEach, beforeAll } from 'bun:test';
|
|
35
|
+
import { Window } from 'happy-dom';
|
|
36
|
+
import { h, render } from 'preact';
|
|
37
|
+
import { act } from 'preact/test-utils';
|
|
38
|
+
import { useCallback, useRef, useState } from 'preact/hooks';
|
|
39
|
+
import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
|
|
40
|
+
import { useSessionLoad } from '@/client/hooks/chat/timeline/session-load';
|
|
41
|
+
import type { ChatMessageData } from '@/shared/types';
|
|
42
|
+
|
|
43
|
+
/** The runner's own fetch, reached through `Bun` so a leak is not mistaken for it. */
|
|
44
|
+
const originalFetch = Bun.fetch;
|
|
45
|
+
|
|
46
|
+
/** The optimistic bubble `executeSend` mounts for a locally sent turn. */
|
|
47
|
+
export const OPTIMISTIC_USER: ChatMessageData = { id: 'msg-1-user', role: 'user', content: 'halo', date: 'Today, 12:22 PM' };
|
|
48
|
+
/** The empty assistant row the stream fills. */
|
|
49
|
+
export const AI_PLACEHOLDER: ChatMessageData = { id: 'msg-2-ai', role: 'ai', content: '' };
|
|
50
|
+
/** The same turn as the transcript records it, under its own id. */
|
|
51
|
+
export const ECHOED_TURN: ChatMessageData = { id: 'omp-echo', role: 'user', content: 'halo' };
|
|
52
|
+
|
|
53
|
+
interface LoadGate {
|
|
54
|
+
url: string;
|
|
55
|
+
resolve: (body: unknown) => void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const gates: LoadGate[] = [];
|
|
59
|
+
|
|
60
|
+
/** One mount's observable state: what the timeline holds and what drives it. */
|
|
61
|
+
export interface ProbeApi {
|
|
62
|
+
messages: ChatMessageData[];
|
|
63
|
+
adoptedSessionIdRef: { current: string | null };
|
|
64
|
+
setMessages: (next: ChatMessageData[]) => void;
|
|
65
|
+
isGeneratingRef: { current: boolean };
|
|
66
|
+
ownRunRef: { current: boolean };
|
|
67
|
+
aiPlaceholderIdRef: { current: string | null };
|
|
68
|
+
optimisticUserIdRef: { current: string | null };
|
|
69
|
+
generatingCalls: boolean[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function makeApi(): ProbeApi {
|
|
73
|
+
return {
|
|
74
|
+
messages: [],
|
|
75
|
+
adoptedSessionIdRef: { current: null },
|
|
76
|
+
setMessages: () => {},
|
|
77
|
+
isGeneratingRef: { current: false },
|
|
78
|
+
ownRunRef: { current: false },
|
|
79
|
+
aiPlaceholderIdRef: { current: null },
|
|
80
|
+
optimisticUserIdRef: { current: null },
|
|
81
|
+
generatingCalls: [],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function Probe({ sessionId, api }: { sessionId: string | null; api: ProbeApi }) {
|
|
86
|
+
const [messages, setMessages] = useState<ChatMessageData[]>([]);
|
|
87
|
+
const isGeneratingRef = useRef(false);
|
|
88
|
+
const ownRunRef = useRef(false);
|
|
89
|
+
const streamRunRef = useRef(false);
|
|
90
|
+
const serverRunRef = useRef(false);
|
|
91
|
+
const aiPlaceholderIdRef = useRef<string | null>(null);
|
|
92
|
+
const optimisticUserIdRef = useRef<string | null>(null);
|
|
93
|
+
const pendingUserDisplaysRef = useRef<{ sent: string; display: string }[]>([]);
|
|
94
|
+
const metaRefreshedRef = useRef<string | null>(null);
|
|
95
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
96
|
+
const jumpActiveRef = useRef(false);
|
|
97
|
+
const jumpCountRef = useRef(0);
|
|
98
|
+
// Stable across renders, like the timeline's own `setGenerating` throat: a
|
|
99
|
+
// fresh identity would re-run the load effect on every render.
|
|
100
|
+
const setGenerating = useCallback((v: boolean) => {
|
|
101
|
+
isGeneratingRef.current = v;
|
|
102
|
+
api.generatingCalls.push(v);
|
|
103
|
+
}, [api]);
|
|
104
|
+
|
|
105
|
+
api.messages = messages;
|
|
106
|
+
api.setMessages = setMessages;
|
|
107
|
+
api.isGeneratingRef = isGeneratingRef;
|
|
108
|
+
api.ownRunRef = ownRunRef;
|
|
109
|
+
api.aiPlaceholderIdRef = aiPlaceholderIdRef;
|
|
110
|
+
api.optimisticUserIdRef = optimisticUserIdRef;
|
|
111
|
+
|
|
112
|
+
const { adoptedSessionIdRef } = useSessionLoad({
|
|
113
|
+
sessionId,
|
|
114
|
+
setLocalMessages: setMessages,
|
|
115
|
+
setGenerating,
|
|
116
|
+
isGeneratingRef,
|
|
117
|
+
ownRunRef,
|
|
118
|
+
streamRunRef,
|
|
119
|
+
serverRunRef,
|
|
120
|
+
aiPlaceholderIdRef,
|
|
121
|
+
optimisticUserIdRef,
|
|
122
|
+
pendingUserDisplaysRef,
|
|
123
|
+
cancelStreamingCoalescer: () => {},
|
|
124
|
+
metaRefreshedRef,
|
|
125
|
+
scrollRef,
|
|
126
|
+
jumpActiveRef,
|
|
127
|
+
jumpCountRef,
|
|
128
|
+
});
|
|
129
|
+
api.adoptedSessionIdRef = adoptedSessionIdRef;
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let container: HTMLElement | undefined;
|
|
134
|
+
|
|
135
|
+
beforeAll(() => {
|
|
136
|
+
installDomGlobals(new Window({ url: 'http://localhost' }));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
afterAll(() => {
|
|
140
|
+
restoreDomGlobals();
|
|
141
|
+
globalThis.fetch = originalFetch;
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
afterEach(() => {
|
|
145
|
+
if (container) render(null, container);
|
|
146
|
+
container?.remove();
|
|
147
|
+
container = undefined;
|
|
148
|
+
gates.length = 0;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/** Stub the loader so the test decides when (and with what) the fetch lands. */
|
|
152
|
+
export function stubChatFetch(): void {
|
|
153
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
154
|
+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
155
|
+
const { promise, resolve } = Promise.withResolvers<Response>();
|
|
156
|
+
gates.push({ url, resolve: (body) => resolve(new Response(JSON.stringify(body))) });
|
|
157
|
+
return promise;
|
|
158
|
+
}) as typeof fetch;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Mount the real hook and let its session-load effect register its fetch. */
|
|
162
|
+
export async function mount(sessionId: string, api: ProbeApi): Promise<void> {
|
|
163
|
+
container ??= document.body.appendChild(document.createElement('div'));
|
|
164
|
+
await act(async () => {
|
|
165
|
+
render(h(Probe, { sessionId, api }), container as HTMLElement);
|
|
166
|
+
});
|
|
167
|
+
await act(async () => {});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Land the registered fetch, as the server's response would. */
|
|
171
|
+
export async function resolveLoad(messages: unknown[], index = 0): Promise<void> {
|
|
172
|
+
const gate = gates[index];
|
|
173
|
+
if (!gate) throw new Error('no session fetch was registered');
|
|
174
|
+
await act(async () => {
|
|
175
|
+
gate.resolve({ session: { id: 'sess-1', messages }, hasMore: false, oldestIndex: 0 });
|
|
176
|
+
});
|
|
177
|
+
for (let i = 0; i < 3; i += 1) await act(async () => {});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Re-render the mounted Probe with a new session id, which is what the spawn
|
|
182
|
+
* adoption (`new-…` → UUID) does when the URL flips while the child streams:
|
|
183
|
+
* the session-load effect re-runs and ISSUES its history fetch at that moment,
|
|
184
|
+
* mid-run. The fetch registered before this call is `gates[0]`; the one this
|
|
185
|
+
* issues is `gates[1]`.
|
|
186
|
+
*/
|
|
187
|
+
export async function switchSession(sessionId: string, api: ProbeApi): Promise<void> {
|
|
188
|
+
await act(async () => {
|
|
189
|
+
render(h(Probe, { sessionId, api }), container as HTMLElement);
|
|
190
|
+
});
|
|
191
|
+
await act(async () => {});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A run this page started, with its optimistic rows already on screen. */
|
|
195
|
+
export async function startLiveRun(api: ProbeApi): Promise<void> {
|
|
196
|
+
await act(async () => {
|
|
197
|
+
api.isGeneratingRef.current = true;
|
|
198
|
+
api.ownRunRef.current = true;
|
|
199
|
+
api.optimisticUserIdRef.current = OPTIMISTIC_USER.id;
|
|
200
|
+
api.setMessages([OPTIMISTIC_USER, AI_PLACEHOLDER]);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export const ids = (api: ProbeApi) => api.messages.map((m) => m.id);
|
|
205
|
+
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* The committed-history fetch vs a run this page started.
|
|
7
|
+
* The committed-history fetch vs a run this page started, and a spawn's arrival.
|
|
8
8
|
*
|
|
9
9
|
* While a run is live the timeline's rows are the STREAM's, and a committed
|
|
10
10
|
* fetch is a lagging snapshot: for a fresh spawn there may be no transcript on
|
|
@@ -23,170 +23,25 @@
|
|
|
23
23
|
* which the stream RELEASES at the first assistant `message_end` while the run
|
|
24
24
|
* continues. These cases pin the run as the ownership signal, and pin the two
|
|
25
25
|
* directions the guard must still let through when no run is live.
|
|
26
|
+
*
|
|
27
|
+
* The harness is `session-load.test-util.ts`.
|
|
26
28
|
*/
|
|
27
29
|
|
|
28
|
-
import {
|
|
29
|
-
import { Window } from 'happy-dom';
|
|
30
|
-
import { h, render } from 'preact';
|
|
30
|
+
import { describe, expect, test } from 'bun:test';
|
|
31
31
|
import { act } from 'preact/test-utils';
|
|
32
|
-
import {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const ECHOED_TURN: ChatMessageData = { id: 'omp-echo', role: 'user', content: 'halo' };
|
|
46
|
-
|
|
47
|
-
interface LoadGate {
|
|
48
|
-
url: string;
|
|
49
|
-
resolve: (body: unknown) => void;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const gates: LoadGate[] = [];
|
|
53
|
-
|
|
54
|
-
/** One mount's observable state: what the timeline holds and what drives it. */
|
|
55
|
-
interface ProbeApi {
|
|
56
|
-
messages: ChatMessageData[];
|
|
57
|
-
setMessages: (next: ChatMessageData[]) => void;
|
|
58
|
-
isGeneratingRef: { current: boolean };
|
|
59
|
-
ownRunRef: { current: boolean };
|
|
60
|
-
aiPlaceholderIdRef: { current: string | null };
|
|
61
|
-
optimisticUserIdRef: { current: string | null };
|
|
62
|
-
generatingCalls: boolean[];
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function makeApi(): ProbeApi {
|
|
66
|
-
return {
|
|
67
|
-
messages: [],
|
|
68
|
-
setMessages: () => {},
|
|
69
|
-
isGeneratingRef: { current: false },
|
|
70
|
-
ownRunRef: { current: false },
|
|
71
|
-
aiPlaceholderIdRef: { current: null },
|
|
72
|
-
optimisticUserIdRef: { current: null },
|
|
73
|
-
generatingCalls: [],
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function Probe({ sessionId, api }: { sessionId: string | null; api: ProbeApi }) {
|
|
78
|
-
const [messages, setMessages] = useState<ChatMessageData[]>([]);
|
|
79
|
-
const isGeneratingRef = useRef(false);
|
|
80
|
-
const ownRunRef = useRef(false);
|
|
81
|
-
const aiPlaceholderIdRef = useRef<string | null>(null);
|
|
82
|
-
const optimisticUserIdRef = useRef<string | null>(null);
|
|
83
|
-
const metaRefreshedRef = useRef<string | null>(null);
|
|
84
|
-
const scrollRef = useRef<HTMLDivElement>(null);
|
|
85
|
-
const jumpActiveRef = useRef(false);
|
|
86
|
-
const jumpCountRef = useRef(0);
|
|
87
|
-
// Stable across renders, like the timeline's own `setGenerating` throat: a
|
|
88
|
-
// fresh identity would re-run the load effect on every render.
|
|
89
|
-
const setGenerating = useCallback((v: boolean) => {
|
|
90
|
-
isGeneratingRef.current = v;
|
|
91
|
-
api.generatingCalls.push(v);
|
|
92
|
-
}, [api]);
|
|
93
|
-
|
|
94
|
-
api.messages = messages;
|
|
95
|
-
api.setMessages = setMessages;
|
|
96
|
-
api.isGeneratingRef = isGeneratingRef;
|
|
97
|
-
api.ownRunRef = ownRunRef;
|
|
98
|
-
api.aiPlaceholderIdRef = aiPlaceholderIdRef;
|
|
99
|
-
api.optimisticUserIdRef = optimisticUserIdRef;
|
|
100
|
-
|
|
101
|
-
useSessionLoad({
|
|
102
|
-
sessionId,
|
|
103
|
-
setLocalMessages: setMessages,
|
|
104
|
-
setGenerating,
|
|
105
|
-
isGeneratingRef,
|
|
106
|
-
ownRunRef,
|
|
107
|
-
aiPlaceholderIdRef,
|
|
108
|
-
optimisticUserIdRef,
|
|
109
|
-
cancelStreamingCoalescer: () => {},
|
|
110
|
-
metaRefreshedRef,
|
|
111
|
-
scrollRef,
|
|
112
|
-
jumpActiveRef,
|
|
113
|
-
jumpCountRef,
|
|
114
|
-
});
|
|
115
|
-
return null;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
let container: HTMLElement | undefined;
|
|
119
|
-
|
|
120
|
-
beforeAll(() => {
|
|
121
|
-
installDomGlobals(new Window({ url: 'http://localhost' }));
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
afterAll(() => {
|
|
125
|
-
restoreDomGlobals();
|
|
126
|
-
globalThis.fetch = originalFetch;
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
afterEach(() => {
|
|
130
|
-
if (container) render(null, container);
|
|
131
|
-
container?.remove();
|
|
132
|
-
container = undefined;
|
|
133
|
-
gates.length = 0;
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
/** Stub the loader so the test decides when (and with what) the fetch lands. */
|
|
137
|
-
function stubChatFetch(): void {
|
|
138
|
-
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
139
|
-
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
140
|
-
const { promise, resolve } = Promise.withResolvers<Response>();
|
|
141
|
-
gates.push({ url, resolve: (body) => resolve(new Response(JSON.stringify(body))) });
|
|
142
|
-
return promise;
|
|
143
|
-
}) as typeof fetch;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Mount the real hook and let its session-load effect register its fetch. */
|
|
147
|
-
async function mount(sessionId: string, api: ProbeApi): Promise<void> {
|
|
148
|
-
container ??= document.body.appendChild(document.createElement('div'));
|
|
149
|
-
await act(async () => {
|
|
150
|
-
render(h(Probe, { sessionId, api }), container as HTMLElement);
|
|
151
|
-
});
|
|
152
|
-
await act(async () => {});
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** Land the registered fetch, as the server's response would. */
|
|
156
|
-
async function resolveLoad(messages: unknown[], index = 0): Promise<void> {
|
|
157
|
-
const gate = gates[index];
|
|
158
|
-
if (!gate) throw new Error('no session fetch was registered');
|
|
159
|
-
await act(async () => {
|
|
160
|
-
gate.resolve({ session: { id: 'sess-1', messages }, hasMore: false, oldestIndex: 0 });
|
|
161
|
-
});
|
|
162
|
-
for (let i = 0; i < 3; i += 1) await act(async () => {});
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Re-render the mounted Probe with a new session id, which is what the spawn
|
|
167
|
-
* adoption (`new-…` → UUID) does when the URL flips while the child streams:
|
|
168
|
-
* the session-load effect re-runs and ISSUES its history fetch at that moment,
|
|
169
|
-
* mid-run. The fetch registered before this call is `gates[0]`; the one this
|
|
170
|
-
* issues is `gates[1]`.
|
|
171
|
-
*/
|
|
172
|
-
async function switchSession(sessionId: string, api: ProbeApi): Promise<void> {
|
|
173
|
-
await act(async () => {
|
|
174
|
-
render(h(Probe, { sessionId, api }), container as HTMLElement);
|
|
175
|
-
});
|
|
176
|
-
await act(async () => {});
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/** A run this page started, with its optimistic rows already on screen. */
|
|
180
|
-
async function startLiveRun(api: ProbeApi): Promise<void> {
|
|
181
|
-
await act(async () => {
|
|
182
|
-
api.isGeneratingRef.current = true;
|
|
183
|
-
api.ownRunRef.current = true;
|
|
184
|
-
api.optimisticUserIdRef.current = OPTIMISTIC_USER.id;
|
|
185
|
-
api.setMessages([OPTIMISTIC_USER, AI_PLACEHOLDER]);
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
const ids = (api: ProbeApi) => api.messages.map((m) => m.id);
|
|
32
|
+
import {
|
|
33
|
+
AI_PLACEHOLDER,
|
|
34
|
+
ECHOED_TURN,
|
|
35
|
+
OPTIMISTIC_USER,
|
|
36
|
+
ids,
|
|
37
|
+
makeApi,
|
|
38
|
+
mount,
|
|
39
|
+
resolveLoad,
|
|
40
|
+
gates,
|
|
41
|
+
startLiveRun,
|
|
42
|
+
stubChatFetch,
|
|
43
|
+
switchSession,
|
|
44
|
+
} from '@/client/hooks/chat/timeline/session-load.test-util';
|
|
190
45
|
|
|
191
46
|
describe('a committed fetch while a run this page started is live', () => {
|
|
192
47
|
test('keeps the live timeline when the payload carries the turn under its own id', async () => {
|
|
@@ -271,6 +126,50 @@ describe('a committed fetch while a run this page started is live', () => {
|
|
|
271
126
|
});
|
|
272
127
|
});
|
|
273
128
|
|
|
129
|
+
describe('a spawn that adopted its id after the run ended', () => {
|
|
130
|
+
/**
|
|
131
|
+
* The chat opened from a WORKSPACE (`?folderId=` and no `?sessionId=`), so the
|
|
132
|
+
* first send starts from a null session id and the URL flips to the child's
|
|
133
|
+
* UUID when the spawn answers. `isSpawnAdopt` only recognises a `new-…` →
|
|
134
|
+
* UUID move, and `isAdoptingInFlight` also required the run to still be
|
|
135
|
+
* generating — so a FAST child (the scripted one answers in milliseconds)
|
|
136
|
+
* finished before the URL flip, the reset branch ran, and the turn that had
|
|
137
|
+
* just rendered was cleared. Measured in CI on three specs at once
|
|
138
|
+
* (`rename.e2e.ts`, `editor.e2e.ts`'s drop case, `chat.e2e.ts`'s settle
|
|
139
|
+
* case): the answer was on screen and then the timeline was empty.
|
|
140
|
+
*/
|
|
141
|
+
test('keeps the optimistic rows when the adopted id arrives after the run settled', async () => {
|
|
142
|
+
stubChatFetch();
|
|
143
|
+
const api = makeApi();
|
|
144
|
+
await mount('', api);
|
|
145
|
+
// The send: optimistic rows mounted, the run owned by this page…
|
|
146
|
+
await act(async () => {
|
|
147
|
+
api.isGeneratingRef.current = true;
|
|
148
|
+
api.ownRunRef.current = true;
|
|
149
|
+
api.optimisticUserIdRef.current = OPTIMISTIC_USER.id;
|
|
150
|
+
api.setMessages([OPTIMISTIC_USER, AI_PLACEHOLDER]);
|
|
151
|
+
});
|
|
152
|
+
// …and it ENDED before the spawn's id reached the URL.
|
|
153
|
+
await act(async () => {
|
|
154
|
+
api.isGeneratingRef.current = false;
|
|
155
|
+
api.ownRunRef.current = false;
|
|
156
|
+
});
|
|
157
|
+
// The spawn answered: the send path records the id it adopted, and the URL
|
|
158
|
+
// moves to it.
|
|
159
|
+
api.adoptedSessionIdRef.current = 'sess-1';
|
|
160
|
+
|
|
161
|
+
await switchSession('sess-1', api);
|
|
162
|
+
// The fetch the URL flip issued answers with the transcript omp has not
|
|
163
|
+
// written yet — the second half of the same bug, and the one that emptied
|
|
164
|
+
// the timeline even once the reset was skipped.
|
|
165
|
+
// `gates[0]`: the mount was a null session, so this flip issued the FIRST
|
|
166
|
+
// read.
|
|
167
|
+
await resolveLoad([], 0);
|
|
168
|
+
|
|
169
|
+
expect(ids(api)).toEqual([OPTIMISTIC_USER.id, AI_PLACEHOLDER.id]);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
274
173
|
describe('a committed fetch with no local run', () => {
|
|
275
174
|
test('lands the transcript, which is the reload-mid-run and open-a-session path', async () => {
|
|
276
175
|
stubChatFetch();
|
|
@@ -23,6 +23,8 @@ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
|
23
23
|
import type { ChatMessageData } from '@/shared/types';
|
|
24
24
|
import { SESSION_META_RETRY_SCHEDULE_MS } from '@/shared/lib/workspace/refresh-cadence';
|
|
25
25
|
import { useSessionPagination } from '@/client/hooks/chat/timeline/session-pagination';
|
|
26
|
+
import { useLostRunRecovery } from '@/client/hooks/chat/timeline/lost-run';
|
|
27
|
+
import { isOwnSpawn, isSpawnArrival } from '@/client/hooks/chat/timeline/session-switch';
|
|
26
28
|
|
|
27
29
|
export interface SessionDataShape {
|
|
28
30
|
id?: string;
|
|
@@ -52,6 +54,12 @@ export interface UseSessionLoadDeps {
|
|
|
52
54
|
* UI sets `isGeneratingRef` too, and treating that as ownership discarded
|
|
53
55
|
* the whole transcript, leaving an empty timeline over a running session. */
|
|
54
56
|
ownRunRef: { current: boolean };
|
|
57
|
+
/** The stream has announced the run in flight (see `streamRunRef`). */
|
|
58
|
+
streamRunRef: { current: boolean };
|
|
59
|
+
/** The SERVER reports a run for this session (the `stream` row). Together
|
|
60
|
+
* with `streamRunRef` this is the evidence the reconnect recovery needs: a
|
|
61
|
+
* run whose FIRST frame was lost has no `agent_start` on this client. */
|
|
62
|
+
serverRunRef: { current: boolean };
|
|
55
63
|
/** Live AI placeholder ref: the row the streaming answer is filling. Read on
|
|
56
64
|
* a session switch (cleared with the optimistic user id) and by the stream
|
|
57
65
|
* callbacks; it is NOT the timeline-ownership signal — the placeholder is
|
|
@@ -60,6 +68,10 @@ export interface UseSessionLoadDeps {
|
|
|
60
68
|
/** Live optimistic user bubble ref: cleared together with the placeholder on
|
|
61
69
|
* a session switch so stale ids cannot survive into the next session. */
|
|
62
70
|
optimisticUserIdRef: { current: string | null };
|
|
71
|
+
/** Raw composer text of steer/follow-up turns awaiting omp's echo. Cleared
|
|
72
|
+
* when a lost run is settled, or a later echo could be re-labelled with an
|
|
73
|
+
* earlier message's text. */
|
|
74
|
+
pendingUserDisplaysRef: { current: { sent: string; display: string }[] };
|
|
63
75
|
/** Drop queued stream updates belonging to the previous session (coalescer). */
|
|
64
76
|
cancelStreamingCoalescer: () => void;
|
|
65
77
|
metaRefreshedRef: { current: string | null };
|
|
@@ -73,7 +85,7 @@ export interface UseSessionLoadDeps {
|
|
|
73
85
|
}
|
|
74
86
|
|
|
75
87
|
export function useSessionLoad(deps: UseSessionLoadDeps) {
|
|
76
|
-
const { sessionId, setLocalMessages, setGenerating, isGeneratingRef, ownRunRef, aiPlaceholderIdRef, optimisticUserIdRef, cancelStreamingCoalescer, metaRefreshedRef, scrollRef, jumpActiveRef, jumpCountRef } = deps;
|
|
88
|
+
const { sessionId, setLocalMessages, setGenerating, isGeneratingRef, ownRunRef, streamRunRef, serverRunRef, aiPlaceholderIdRef, optimisticUserIdRef, pendingUserDisplaysRef, cancelStreamingCoalescer, metaRefreshedRef, scrollRef, jumpActiveRef, jumpCountRef } = deps;
|
|
77
89
|
|
|
78
90
|
const [sessionData, setSessionData] = useState<SessionDataShape | null>(null);
|
|
79
91
|
const [sessionLoading, setSessionLoading] = useState(false);
|
|
@@ -163,19 +175,16 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
|
|
|
163
175
|
* A committed fetch is a LAGGING snapshot while a run is live. For a fresh
|
|
164
176
|
* spawn there may be no transcript on disk at all (omp buffers its writes
|
|
165
177
|
* until the first assistant message settles), and it never carries a segment
|
|
166
|
-
* still streaming. Applying one mid-run costs the operator their own turn
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* (`msg-…-user` vs the echoed id), so the turn rendered twice.
|
|
178
|
+
* still streaming. Applying one mid-run costs the operator their own turn —
|
|
179
|
+
* measured: an empty payload emptied the list, and a payload carrying the
|
|
180
|
+
* file's own copy of that turn was merged UNDER the live rows, whose ids
|
|
181
|
+
* differ, so the turn rendered twice.
|
|
171
182
|
*
|
|
172
183
|
* Keyed on the RUN THIS PAGE STARTED, never on `isGeneratingRef` alone: the
|
|
173
|
-
* mount probe that resumes a live run
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
* into a list whose head was empty. A run adopted that way must still load
|
|
178
|
-
* its history; the stream resume continues from there. */
|
|
184
|
+
* mount probe that resumes a live run sets that flag too, so keying on it
|
|
185
|
+
* made OPENING a streaming session discard its whole committed transcript
|
|
186
|
+
* (measured: 0 rows, only a "Load earlier messages" button). An adopted run
|
|
187
|
+
* must still load its history; the resume continues from there. */
|
|
179
188
|
const timelineOwnedByLiveRun = useCallback(
|
|
180
189
|
() => ownRunRef.current && isGeneratingRef.current,
|
|
181
190
|
[isGeneratingRef, ownRunRef],
|
|
@@ -193,40 +202,57 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
|
|
|
193
202
|
[isGeneratingRef],
|
|
194
203
|
);
|
|
195
204
|
|
|
205
|
+
// A channel that comes back after the run's `message_end`/`agent_end` were
|
|
206
|
+
// lost with it: the timeline holds the stream's last partial segment and
|
|
207
|
+
// nothing else will ever finalize it (see `lost-run.ts`). A run that is
|
|
208
|
+
// STILL LIVE at reconnect is left alone here, but its lost segments are
|
|
209
|
+
// remembered and re-read when it settles.
|
|
210
|
+
const { reloadMessages, needsReloadRef } = useLostRunRecovery({
|
|
211
|
+
sessionId,
|
|
212
|
+
sessionIdRef,
|
|
213
|
+
adoptedSessionIdRef,
|
|
214
|
+
isGeneratingRef,
|
|
215
|
+
streamRunRef,
|
|
216
|
+
serverRunRef,
|
|
217
|
+
setGenerating,
|
|
218
|
+
setLocalMessages,
|
|
219
|
+
applyWindow,
|
|
220
|
+
aiPlaceholderIdRef,
|
|
221
|
+
optimisticUserIdRef,
|
|
222
|
+
pendingUserDisplaysRef,
|
|
223
|
+
cancelStreamingCoalescer,
|
|
224
|
+
});
|
|
225
|
+
|
|
196
226
|
// Fetch session messages and details from API
|
|
197
227
|
useEffect(() => {
|
|
198
228
|
let active = true;
|
|
199
229
|
setSessionLoading(true);
|
|
200
|
-
// Track the previous
|
|
201
|
-
//
|
|
202
|
-
// transition (new-… → real UUID) keeps its bubbles.
|
|
230
|
+
// Track the previous id so a real switch clears the timeline while a
|
|
231
|
+
// spawn's arrival keeps its bubbles (see `session-switch.ts`).
|
|
203
232
|
if (sessionId !== prevSessionIdRef.current) {
|
|
204
|
-
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
const
|
|
209
|
-
|
|
233
|
+
// A spawn's arrival keeps the optimistic rows; anything else is a switch
|
|
234
|
+
// to another session. The two shapes — `new-…` → UUID and a chat opened
|
|
235
|
+
// from a workspace whose URL moves straight to the child's UUID — and why
|
|
236
|
+
// the second one used to clear a finished turn are in `session-switch.ts`.
|
|
237
|
+
const spawnArrival = isSpawnArrival({
|
|
238
|
+
sessionId,
|
|
239
|
+
previousSessionId: prevSessionIdRef.current,
|
|
240
|
+
adoptedSessionId: adoptedSessionIdRef.current,
|
|
241
|
+
});
|
|
242
|
+
if (!spawnArrival) {
|
|
210
243
|
setLocalMessages([]);
|
|
211
244
|
setGenerating(false);
|
|
212
245
|
adoptedSessionIdRef.current = null;
|
|
213
246
|
seededModelRef.current = null;
|
|
214
|
-
// Metadata
|
|
215
|
-
// in place let a
|
|
216
|
-
// composer — display a level that session never ran with, until the
|
|
217
|
-
// fetch replaced it.
|
|
247
|
+
// Metadata and the thinking seed belong to ONE session: leaving either
|
|
248
|
+
// in place let a composer display a level its session never ran with.
|
|
218
249
|
setSessionData(null);
|
|
219
|
-
// The thinking seed belongs to the previous session's spawn; keeping it
|
|
220
|
-
// would report that level for the newly opened session.
|
|
221
250
|
seededThinkingLevelRef.current = null;
|
|
222
251
|
// The placeholder/optimistic ids belong to the PREVIOUS session's
|
|
223
|
-
// in-flight send
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
// the run ownership this page held — the guard reads `ownRunRef`, so a
|
|
228
|
-
// stale value there is what used to discard a mid-run session's
|
|
229
|
-
// committed history on return.)
|
|
252
|
+
// in-flight send, and `message_end` never arrives after a switch away.
|
|
253
|
+
// (`setGenerating(false)` above releases the run ownership this page
|
|
254
|
+
// held — the guard reads `ownRunRef`, so a stale value there is what
|
|
255
|
+
// discarded a mid-run session's committed history on return.)
|
|
230
256
|
aiPlaceholderIdRef.current = null;
|
|
231
257
|
optimisticUserIdRef.current = null;
|
|
232
258
|
// Drop coalesced message_update frames queued by the previous
|
|
@@ -259,6 +285,10 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
|
|
|
259
285
|
// means this payload is a lagging snapshot of a timeline the stream owns.
|
|
260
286
|
const ownedAtIssue = timelineOwnedByLiveRun();
|
|
261
287
|
const ownedByLiveRun = () => ownedAtIssue || timelineOwnedByLiveRun();
|
|
288
|
+
/** Its transcript may legitimately be missing — omp buffers its writes
|
|
289
|
+
* until the first assistant message settles — so an empty payload is
|
|
290
|
+
* "not written yet", never "nothing happened". */
|
|
291
|
+
const ownSpawn = () => isOwnSpawn(sessionId, adoptedSessionIdRef.current);
|
|
262
292
|
fetch(`/api/chat/${sessionId}`)
|
|
263
293
|
.then(res => res.json())
|
|
264
294
|
.then(data => {
|
|
@@ -273,13 +303,13 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
|
|
|
273
303
|
// operator's turn (see `timelineOwnedByLiveRun`).
|
|
274
304
|
if (!ownedByLiveRun()) {
|
|
275
305
|
if (fetched.length > 0) setLocalMessages(fetched);
|
|
276
|
-
//
|
|
277
|
-
else if (!
|
|
306
|
+
// An own spawn has nothing committed YET (see `isOwnSpawn`).
|
|
307
|
+
else if (!ownSpawn()) setLocalMessages([]);
|
|
278
308
|
}
|
|
279
309
|
applyWindow(data.hasMore, data.oldestIndex);
|
|
280
310
|
} else {
|
|
281
311
|
setSessionData(seededSessionData());
|
|
282
|
-
if (!ownedByLiveRun()) setLocalMessages([]);
|
|
312
|
+
if (!ownedByLiveRun() && !ownSpawn()) setLocalMessages([]);
|
|
283
313
|
}
|
|
284
314
|
})
|
|
285
315
|
.catch(err => {
|
|
@@ -313,5 +343,7 @@ export function useSessionLoad(deps: UseSessionLoadDeps) {
|
|
|
313
343
|
adoptedSessionIdRef,
|
|
314
344
|
refreshSessionMeta,
|
|
315
345
|
seedSession,
|
|
346
|
+
reloadMessages,
|
|
347
|
+
needsReloadRef,
|
|
316
348
|
};
|
|
317
349
|
}
|