ubume 0.1.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 +88 -0
- package/bin/codexa-local-harness-bridge.js +3 -0
- package/bin/codexa.js +28 -0
- package/bin/ubume-local-harness-bridge.js +325 -0
- package/bin/ubume.js +398 -0
- package/package.json +66 -0
- package/src/app.tsx +5759 -0
- package/src/commands/handler.ts +889 -0
- package/src/config/appVersion.ts +69 -0
- package/src/config/buildInfo.ts +3 -0
- package/src/config/launchArgs.ts +196 -0
- package/src/config/layeredConfig.ts +853 -0
- package/src/config/legacyEnv.ts +16 -0
- package/src/config/persistence.ts +377 -0
- package/src/config/runtimeConfig.ts +558 -0
- package/src/config/settings.ts +405 -0
- package/src/config/toml-serialize.ts +109 -0
- package/src/config/trustStore.ts +84 -0
- package/src/config/updateCheckCache.ts +85 -0
- package/src/core/README.md +55 -0
- package/src/core/agent/loop.ts +464 -0
- package/src/core/agent/protocol.ts +345 -0
- package/src/core/agent/tools.ts +423 -0
- package/src/core/auth/codexAuth.ts +359 -0
- package/src/core/codex/codexExecArgs.ts +166 -0
- package/src/core/codex/codexLaunch.ts +163 -0
- package/src/core/codex/codexPrompt.ts +429 -0
- package/src/core/debug/inputDebug.ts +51 -0
- package/src/core/debug/localStreamDebug.ts +50 -0
- package/src/core/debug/modelStateDebug.ts +35 -0
- package/src/core/executables/antigravityExecutable.ts +48 -0
- package/src/core/executables/claudeExecutable.ts +63 -0
- package/src/core/executables/codexExecutable.ts +160 -0
- package/src/core/executables/executableResolver.ts +164 -0
- package/src/core/executables/geminiExecutable.ts +78 -0
- package/src/core/models/codexCapabilities.ts +97 -0
- package/src/core/models/codexModelCapabilities.ts +624 -0
- package/src/core/models/codexModelsCacheSeed.ts +153 -0
- package/src/core/models/modelSpecs.ts +303 -0
- package/src/core/models/providerModelCache.ts +94 -0
- package/src/core/perf/profiler.ts +125 -0
- package/src/core/perf/renderDebug.ts +398 -0
- package/src/core/process/CommandRunner.ts +280 -0
- package/src/core/process/processValidation.ts +111 -0
- package/src/core/providerLauncher/launcher.ts +220 -0
- package/src/core/providerLauncher/registry.ts +354 -0
- package/src/core/providerLauncher/types.ts +95 -0
- package/src/core/providerLauncher/workspaceConfig.ts +487 -0
- package/src/core/providerRuntime/anthropic.ts +580 -0
- package/src/core/providerRuntime/antigravity.ts +500 -0
- package/src/core/providerRuntime/capabilityProfile.ts +383 -0
- package/src/core/providerRuntime/claudeCodeDiscovery.ts +724 -0
- package/src/core/providerRuntime/claudeCodeDiscoveryDebug.ts +55 -0
- package/src/core/providerRuntime/codexaCupy.ts +97 -0
- package/src/core/providerRuntime/codexaNative.ts +425 -0
- package/src/core/providerRuntime/contextMetadata.ts +397 -0
- package/src/core/providerRuntime/gemini.ts +789 -0
- package/src/core/providerRuntime/lmstudio.ts +118 -0
- package/src/core/providerRuntime/local.ts +770 -0
- package/src/core/providerRuntime/localHarness/runtime.ts +1090 -0
- package/src/core/providerRuntime/localOutputBudget.ts +17 -0
- package/src/core/providerRuntime/mistralVibe.ts +667 -0
- package/src/core/providerRuntime/models.ts +175 -0
- package/src/core/providerRuntime/reasoning.ts +20 -0
- package/src/core/providerRuntime/registry.ts +286 -0
- package/src/core/providerRuntime/types.ts +142 -0
- package/src/core/providerRuntime/unsloth.ts +216 -0
- package/src/core/providers/codexJsonStream.ts +305 -0
- package/src/core/providers/codexSubprocess.ts +378 -0
- package/src/core/providers/codexTranscript.ts +695 -0
- package/src/core/providers/openaiNative.ts +13 -0
- package/src/core/providers/registry.ts +21 -0
- package/src/core/providers/types.ts +91 -0
- package/src/core/shared/attachments.ts +101 -0
- package/src/core/shared/cleanupFastFail.ts +67 -0
- package/src/core/shared/clipboard.ts +24 -0
- package/src/core/shared/clipboardImage.ts +111 -0
- package/src/core/shared/githubDiagnostics.ts +222 -0
- package/src/core/shared/hollowResponseFormat.ts +39 -0
- package/src/core/terminal/clearFrameBoundary.ts +852 -0
- package/src/core/terminal/frameLock.ts +110 -0
- package/src/core/terminal/inkRenderReset.ts +123 -0
- package/src/core/terminal/startupClear.ts +20 -0
- package/src/core/terminal/terminalCapabilities.ts +100 -0
- package/src/core/terminal/terminalControl.ts +169 -0
- package/src/core/terminal/terminalSanitize.ts +147 -0
- package/src/core/terminal/terminalTitle.ts +400 -0
- package/src/core/version/channel.ts +27 -0
- package/src/core/version/packageManager.ts +119 -0
- package/src/core/version/updateCheck.ts +203 -0
- package/src/core/workspace/appData.ts +107 -0
- package/src/core/workspace/conversationStore.ts +335 -0
- package/src/core/workspace/launchContext.ts +259 -0
- package/src/core/workspace/planStorage.ts +135 -0
- package/src/core/workspace/projectInstructions.ts +54 -0
- package/src/core/workspace/scratchDir.ts +64 -0
- package/src/core/workspace/workspaceActivity.ts +384 -0
- package/src/core/workspace/workspaceGuard.ts +377 -0
- package/src/core/workspace/workspaceRoot.ts +47 -0
- package/src/exec.ts +73 -0
- package/src/headless/execArgs.ts +296 -0
- package/src/headless/execRunner.ts +304 -0
- package/src/index.tsx +270 -0
- package/src/legacyEnvBootstrap.ts +5 -0
- package/src/session/appSession.ts +771 -0
- package/src/session/chatLifecycle.ts +994 -0
- package/src/session/conversation.ts +107 -0
- package/src/session/liveRenderScheduler.ts +214 -0
- package/src/session/persistedResponse.ts +93 -0
- package/src/session/planFlow.ts +159 -0
- package/src/session/planTranscript.ts +19 -0
- package/src/session/promptRunSchedule.ts +26 -0
- package/src/session/types.ts +234 -0
- package/src/test/runtimeTestUtils.ts +14 -0
- package/src/types/react-dom.d.ts +3 -0
- package/src/ui/chrome/ActivityBars.tsx +68 -0
- package/src/ui/chrome/ActivityIndicator.tsx +58 -0
- package/src/ui/chrome/AnimatedStatusText.tsx +69 -0
- package/src/ui/chrome/AppShell.tsx +474 -0
- package/src/ui/chrome/BottomComposer.tsx +1135 -0
- package/src/ui/chrome/DashCard.tsx +82 -0
- package/src/ui/chrome/RunFooter.tsx +65 -0
- package/src/ui/chrome/RuntimeStatusBar.tsx +108 -0
- package/src/ui/chrome/Spinner.tsx +25 -0
- package/src/ui/chrome/TopHeader.tsx +439 -0
- package/src/ui/chrome/UpdateAvailableCard.tsx +42 -0
- package/src/ui/chrome/busyStatusAnimation.ts +11 -0
- package/src/ui/input/commandNormalize.ts +66 -0
- package/src/ui/input/focus.ts +73 -0
- package/src/ui/input/imageAttachments.ts +18 -0
- package/src/ui/input/inputBuffer.ts +203 -0
- package/src/ui/input/pastedContent.ts +75 -0
- package/src/ui/input/rawArrowKeys.ts +28 -0
- package/src/ui/input/slashCommands.ts +43 -0
- package/src/ui/input/useStdinRawModeLease.ts +23 -0
- package/src/ui/layout.ts +560 -0
- package/src/ui/panels/AttachmentImportPanel.tsx +131 -0
- package/src/ui/panels/AuthPanel.tsx +149 -0
- package/src/ui/panels/BackendPicker.tsx +28 -0
- package/src/ui/panels/ModePicker.tsx +31 -0
- package/src/ui/panels/ModelPicker.tsx +31 -0
- package/src/ui/panels/ModelPickerScreen.tsx +761 -0
- package/src/ui/panels/ModelReasoningPicker.tsx +458 -0
- package/src/ui/panels/Panel.tsx +51 -0
- package/src/ui/panels/PermissionsPanel.tsx +78 -0
- package/src/ui/panels/PlanActionPicker.tsx +187 -0
- package/src/ui/panels/ProviderPicker.tsx +753 -0
- package/src/ui/panels/ProviderSetupPrompt.tsx +52 -0
- package/src/ui/panels/ReasoningPicker.tsx +46 -0
- package/src/ui/panels/ResumePicker.tsx +90 -0
- package/src/ui/panels/SelectionPanel.tsx +138 -0
- package/src/ui/panels/SettingsPanel.tsx +156 -0
- package/src/ui/panels/TextEntryPanel.tsx +139 -0
- package/src/ui/panels/ThemePicker.tsx +32 -0
- package/src/ui/panels/ToolApprovalPanel.tsx +46 -0
- package/src/ui/panels/UpdatePromptPanel.tsx +236 -0
- package/src/ui/panels/responsivePickerViewport.ts +64 -0
- package/src/ui/render/Markdown.tsx +331 -0
- package/src/ui/render/diffRenderer.ts +116 -0
- package/src/ui/render/logoVariants.ts +113 -0
- package/src/ui/render/modeDisplay.ts +52 -0
- package/src/ui/render/outputPipeline.ts +64 -0
- package/src/ui/render/runtimeDisplay.ts +128 -0
- package/src/ui/render/terminalAnswerFormat.ts +128 -0
- package/src/ui/render/textLayout.ts +392 -0
- package/src/ui/theme.tsx +274 -0
- package/src/ui/themeFlow.ts +41 -0
- package/src/ui/timeline/ActionRequiredBlock.tsx +38 -0
- package/src/ui/timeline/AgentBlock.tsx +130 -0
- package/src/ui/timeline/StaticIntroItem.tsx +54 -0
- package/src/ui/timeline/ThinkingBlock.tsx +100 -0
- package/src/ui/timeline/Timeline.tsx +1410 -0
- package/src/ui/timeline/TranscriptShell.tsx +302 -0
- package/src/ui/timeline/TurnGroup.tsx +673 -0
- package/src/ui/timeline/layoutListWindow.ts +145 -0
- package/src/ui/timeline/liveViewportWindow.ts +68 -0
- package/src/ui/timeline/progressEntries.ts +156 -0
- package/src/ui/timeline/runActivityView.ts +37 -0
- package/src/ui/timeline/staticTranscriptCache.ts +174 -0
- package/src/ui/timeline/streamCoalesce.ts +53 -0
- package/src/ui/timeline/timelineMeasure.ts +3273 -0
- package/src/ui/useThrottledValue.ts +31 -0
|
@@ -0,0 +1,1410 @@
|
|
|
1
|
+
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import {
|
|
4
|
+
getRunPlanText,
|
|
5
|
+
type AssistantEvent,
|
|
6
|
+
type ErrorEvent,
|
|
7
|
+
type RunEvent,
|
|
8
|
+
type ShellEvent,
|
|
9
|
+
type SystemEvent,
|
|
10
|
+
type TimelineEvent,
|
|
11
|
+
type UIState,
|
|
12
|
+
type UserPromptEvent,
|
|
13
|
+
} from "../../session/types.js";
|
|
14
|
+
import { APP_VERSION } from "../../config/settings.js";
|
|
15
|
+
import { formatUbumeVersionLabel } from "../../core/version/channel.js";
|
|
16
|
+
import type { CodexAuthState } from "../../core/auth/codexAuth.js";
|
|
17
|
+
import { getAuthStateLabel } from "../../core/auth/codexAuth.js";
|
|
18
|
+
import * as renderDebug from "../../core/perf/renderDebug.js";
|
|
19
|
+
import { getShellWidth, type Layout, type StartupHeaderMode } from "../layout.js";
|
|
20
|
+
import type { TimelineRow, TimelineSnapshot, TimelineTone } from "./timelineMeasure.js";
|
|
21
|
+
import { buildStableTimelineSnapshot, buildTimelineSnapshot } from "./timelineMeasure.js";
|
|
22
|
+
import { resolveTurnRunPhase, type TurnOpacity, type TurnRunPhase } from "./TurnGroup.js";
|
|
23
|
+
import { useTheme } from "../theme.js";
|
|
24
|
+
|
|
25
|
+
// ─── Types & constants ────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
interface TimelineProps {
|
|
28
|
+
staticEvents: TimelineEvent[];
|
|
29
|
+
activeEvents: TimelineEvent[];
|
|
30
|
+
layout: Layout;
|
|
31
|
+
uiState: UIState;
|
|
32
|
+
viewportRows: number;
|
|
33
|
+
verboseMode?: boolean;
|
|
34
|
+
authState?: CodexAuthState;
|
|
35
|
+
workspaceLabel?: string;
|
|
36
|
+
workspaceRoot?: string | null;
|
|
37
|
+
providerLabel?: string | null;
|
|
38
|
+
startupHeaderMode?: StartupHeaderMode;
|
|
39
|
+
showIntro?: boolean;
|
|
40
|
+
contentSized?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type StandaloneTimelineEvent = SystemEvent | ErrorEvent | ShellEvent;
|
|
44
|
+
|
|
45
|
+
interface TurnTimelineItem {
|
|
46
|
+
type: "turn";
|
|
47
|
+
turnId: number;
|
|
48
|
+
turnIndex: number;
|
|
49
|
+
user: UserPromptEvent | null;
|
|
50
|
+
run: RunEvent | null;
|
|
51
|
+
assistant: AssistantEvent | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface EventTimelineItem {
|
|
55
|
+
type: "event";
|
|
56
|
+
event: StandaloneTimelineEvent;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type TimelineItem = TurnTimelineItem | EventTimelineItem;
|
|
60
|
+
|
|
61
|
+
export interface TurnRenderState {
|
|
62
|
+
opacity: TurnOpacity;
|
|
63
|
+
question: string | null;
|
|
64
|
+
runPhase: TurnRunPhase;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface TurnRenderTimelineItem {
|
|
68
|
+
key: string;
|
|
69
|
+
type: "turn";
|
|
70
|
+
padded: boolean;
|
|
71
|
+
item: TurnTimelineItem;
|
|
72
|
+
renderState: TurnRenderState;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface EventRenderTimelineItem {
|
|
76
|
+
key: string;
|
|
77
|
+
type: "event";
|
|
78
|
+
padded: boolean;
|
|
79
|
+
event: StandaloneTimelineEvent;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface IntroRenderTimelineItem {
|
|
83
|
+
key: string;
|
|
84
|
+
type: "intro";
|
|
85
|
+
padded: boolean;
|
|
86
|
+
intro: {
|
|
87
|
+
version: string;
|
|
88
|
+
layoutMode: Layout["mode"];
|
|
89
|
+
startupHeaderMode?: StartupHeaderMode;
|
|
90
|
+
authLabel: string;
|
|
91
|
+
workspaceLabel: string;
|
|
92
|
+
providerLabel?: string | null;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type RenderTimelineItem = IntroRenderTimelineItem | TurnRenderTimelineItem | EventRenderTimelineItem;
|
|
97
|
+
|
|
98
|
+
// Re-enter follow-tail mode when the user scrolls within this many rows of the
|
|
99
|
+
// tail, preventing a "stuck just above bottom" state after a near-end wheel scroll.
|
|
100
|
+
const NEAR_BOTTOM_THRESHOLD = 3;
|
|
101
|
+
const STABLE_RENDER_ENABLED = process.env.UBUME_STABLE_RENDER !== "0";
|
|
102
|
+
|
|
103
|
+
export interface TimelineViewportState {
|
|
104
|
+
anchorRow: number;
|
|
105
|
+
followTail: boolean;
|
|
106
|
+
unseenItems: number;
|
|
107
|
+
unseenRows: number;
|
|
108
|
+
frozenSnapshot: TimelineSnapshot | null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface FinalizeContinuityOptions {
|
|
112
|
+
previousTotalRows: number;
|
|
113
|
+
viewportRows: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ─── Viewport helpers ────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
function isStandaloneEvent(event: TimelineEvent): event is StandaloneTimelineEvent {
|
|
119
|
+
return event.type === "system" || event.type === "error" || event.type === "shell";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getActiveTurnId(uiState: UIState): number | null {
|
|
123
|
+
return uiState.kind === "THINKING"
|
|
124
|
+
|| uiState.kind === "RESPONDING"
|
|
125
|
+
|| uiState.kind === "ANSWER_VISIBLE"
|
|
126
|
+
|| uiState.kind === "AWAITING_USER_ACTION"
|
|
127
|
+
|| uiState.kind === "ERROR"
|
|
128
|
+
? uiState.turnId
|
|
129
|
+
: null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isBusyUiState(uiState: UIState): boolean {
|
|
133
|
+
return uiState.kind === "THINKING"
|
|
134
|
+
|| uiState.kind === "RESPONDING"
|
|
135
|
+
|| uiState.kind === "ANSWER_VISIBLE"
|
|
136
|
+
|| uiState.kind === "SHELL_RUNNING";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getRunningTurnIds(events: TimelineEvent[]): number[] {
|
|
140
|
+
return events
|
|
141
|
+
.filter((event): event is RunEvent => event.type === "run" && event.status === "running")
|
|
142
|
+
.map((event) => event.turnId);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function getFinalizedTurnIds(events: TimelineEvent[]): number[] {
|
|
146
|
+
return events
|
|
147
|
+
.filter((event): event is RunEvent => event.type === "run" && event.status !== "running")
|
|
148
|
+
.map((event) => event.turnId);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function latestFinalizedRunEndsWithPlan(events: TimelineEvent[]): boolean {
|
|
152
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
153
|
+
const event = events[index];
|
|
154
|
+
if (!event || event.type !== "run" || event.status === "running") {
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const lastStreamItem = (event.streamItems ?? [])
|
|
159
|
+
.slice()
|
|
160
|
+
.sort((left, right) => left.streamSeq - right.streamSeq)
|
|
161
|
+
.at(-1);
|
|
162
|
+
return lastStreamItem?.kind === "plan"
|
|
163
|
+
&& event.plan?.status === "completed"
|
|
164
|
+
&& getRunPlanText(event.plan).trim().length > 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function hasFinalizeTransition(params: {
|
|
171
|
+
previousRunningTurnIds: number[];
|
|
172
|
+
nextRunningTurnIds: number[];
|
|
173
|
+
nextFinalizedTurnIds: number[];
|
|
174
|
+
previousBusy: boolean;
|
|
175
|
+
nextBusy: boolean;
|
|
176
|
+
}): boolean {
|
|
177
|
+
if (!params.previousBusy || params.nextBusy) return false;
|
|
178
|
+
return params.previousRunningTurnIds.some((turnId) =>
|
|
179
|
+
!params.nextRunningTurnIds.includes(turnId)
|
|
180
|
+
&& params.nextFinalizedTurnIds.includes(turnId)
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function clampAnchorRow(anchorRow: number, totalRows: number): number {
|
|
185
|
+
if (totalRows <= 0) {
|
|
186
|
+
return 0;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return Math.max(0, Math.min(anchorRow, totalRows - 1));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function isNearBottom(anchorRow: number, totalRows: number): boolean {
|
|
193
|
+
return totalRows <= 0 || anchorRow >= totalRows - 1 - NEAR_BOTTOM_THRESHOLD;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function getFirstPageAnchor(totalRows: number, viewportRows: number): number {
|
|
197
|
+
if (totalRows <= 0) {
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
return Math.min(totalRows - 1, Math.max(0, viewportRows - 1));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ─── Timeline item builders ───────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
export function buildTimelineItems(events: TimelineEvent[]): TimelineItem[] {
|
|
206
|
+
const items: TimelineItem[] = [];
|
|
207
|
+
const turns = new Map<number, TurnTimelineItem>();
|
|
208
|
+
let nextTurnIndex = 1;
|
|
209
|
+
|
|
210
|
+
for (const event of events) {
|
|
211
|
+
if (isStandaloneEvent(event)) {
|
|
212
|
+
items.push({ type: "event", event });
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const turnId = event.turnId;
|
|
217
|
+
let turn = turns.get(turnId);
|
|
218
|
+
if (!turn) {
|
|
219
|
+
turn = {
|
|
220
|
+
type: "turn",
|
|
221
|
+
turnId,
|
|
222
|
+
turnIndex: nextTurnIndex++,
|
|
223
|
+
user: null,
|
|
224
|
+
run: null,
|
|
225
|
+
assistant: null,
|
|
226
|
+
};
|
|
227
|
+
turns.set(turnId, turn);
|
|
228
|
+
items.push(turn);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (event.type === "user") {
|
|
232
|
+
turn.user = event;
|
|
233
|
+
} else if (event.type === "run") {
|
|
234
|
+
turn.run = event;
|
|
235
|
+
} else if (event.type === "assistant") {
|
|
236
|
+
turn.assistant = event;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return items.filter((item) => item.type === "event" || item.user !== null);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export type TurnOpacityResolver = (turnId: number, activeTurnId: number | null) => TurnOpacity;
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Build a resolver once per render pass so resolving every turn's opacity is
|
|
247
|
+
* O(turns) instead of O(turns²) (two indexOf scans per turn). Semantics match
|
|
248
|
+
* resolveTurnOpacity exactly, including the "absent id → index -1" behaviour.
|
|
249
|
+
*/
|
|
250
|
+
export function createTurnOpacityResolver(turnIds: number[]): TurnOpacityResolver {
|
|
251
|
+
const indexById = new Map<number, number>();
|
|
252
|
+
turnIds.forEach((id, index) => indexById.set(id, index));
|
|
253
|
+
const lastTurnId = turnIds[turnIds.length - 1];
|
|
254
|
+
return (turnId, activeTurnId) => {
|
|
255
|
+
if (turnIds.length === 0) return "dim";
|
|
256
|
+
if (activeTurnId === null) {
|
|
257
|
+
return turnId === lastTurnId ? "recent" : "dim";
|
|
258
|
+
}
|
|
259
|
+
const activeIndex = indexById.get(activeTurnId) ?? -1;
|
|
260
|
+
const currentIndex = indexById.get(turnId) ?? -1;
|
|
261
|
+
if (currentIndex === activeIndex) return "active";
|
|
262
|
+
if (currentIndex === activeIndex - 1) return "recent";
|
|
263
|
+
return "dim";
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function resolveTurnOpacity(turnIds: number[], turnId: number, activeTurnId: number | null): TurnOpacity {
|
|
268
|
+
return createTurnOpacityResolver(turnIds)(turnId, activeTurnId);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function createFollowTailViewport(totalRows: number): TimelineViewportState {
|
|
272
|
+
return {
|
|
273
|
+
anchorRow: Math.max(0, totalRows - 1),
|
|
274
|
+
followTail: true,
|
|
275
|
+
unseenItems: 0,
|
|
276
|
+
unseenRows: 0,
|
|
277
|
+
frozenSnapshot: null,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function createAnchoredViewport(snapshot: TimelineSnapshot, anchorRow: number): TimelineViewportState {
|
|
282
|
+
return {
|
|
283
|
+
anchorRow: clampAnchorRow(anchorRow, snapshot.totalRows),
|
|
284
|
+
followTail: false,
|
|
285
|
+
unseenItems: 0,
|
|
286
|
+
unseenRows: 0,
|
|
287
|
+
frozenSnapshot: snapshot,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function findFinalResponseStartRow(snapshot: TimelineSnapshot, previousTotalRows: number, viewportRows: number): number | null {
|
|
292
|
+
const searchFloor = Math.max(0, previousTotalRows - Math.max(1, viewportRows));
|
|
293
|
+
const responseIndex = snapshot.rows.findIndex((row, index) =>
|
|
294
|
+
index >= searchFloor && row.key.includes("-codex-response-")
|
|
295
|
+
);
|
|
296
|
+
return responseIndex >= 0 ? responseIndex : null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export function createFinalizeContinuityViewport(
|
|
300
|
+
snapshot: TimelineSnapshot,
|
|
301
|
+
options: FinalizeContinuityOptions,
|
|
302
|
+
): TimelineViewportState {
|
|
303
|
+
if (snapshot.totalRows === 0) {
|
|
304
|
+
return createFollowTailViewport(0);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const responseStartRow = findFinalResponseStartRow(
|
|
308
|
+
snapshot,
|
|
309
|
+
options.previousTotalRows,
|
|
310
|
+
options.viewportRows,
|
|
311
|
+
);
|
|
312
|
+
const answerPreviewRows = Math.min(3, Math.max(1, Math.floor(options.viewportRows / 4)));
|
|
313
|
+
const fallbackAnchor = options.previousTotalRows + answerPreviewRows - 1;
|
|
314
|
+
const anchorRow = responseStartRow === null
|
|
315
|
+
? fallbackAnchor
|
|
316
|
+
: responseStartRow + answerPreviewRows - 1;
|
|
317
|
+
|
|
318
|
+
return createAnchoredViewport(snapshot, Math.min(snapshot.totalRows - 1, Math.max(0, anchorRow)));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ─── Viewport operations ─────────────────────────────────────────────────────
|
|
322
|
+
|
|
323
|
+
function getFrozenSnapshot(
|
|
324
|
+
viewport: TimelineViewportState,
|
|
325
|
+
liveSnapshot: TimelineSnapshot,
|
|
326
|
+
): TimelineSnapshot {
|
|
327
|
+
return viewport.followTail || viewport.frozenSnapshot === null
|
|
328
|
+
? liveSnapshot
|
|
329
|
+
: viewport.frozenSnapshot;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function syncTimelineViewport(
|
|
333
|
+
viewport: TimelineViewportState,
|
|
334
|
+
liveSnapshot: TimelineSnapshot,
|
|
335
|
+
options: { finalizeContinuity?: FinalizeContinuityOptions } = {},
|
|
336
|
+
): TimelineViewportState {
|
|
337
|
+
if (liveSnapshot.totalRows === 0) {
|
|
338
|
+
// Preserve a frozen scroll offset through a transient empty-snapshot moment so
|
|
339
|
+
// the user's reading position is not reset to row 0. Only reset when the
|
|
340
|
+
// viewport is already in follow-tail mode (there is nothing meaningful to preserve).
|
|
341
|
+
return viewport.followTail ? createFollowTailViewport(0) : viewport;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (viewport.followTail) {
|
|
345
|
+
if (options.finalizeContinuity) {
|
|
346
|
+
return createFinalizeContinuityViewport(liveSnapshot, options.finalizeContinuity);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const nextAnchor = liveSnapshot.totalRows - 1;
|
|
350
|
+
if (
|
|
351
|
+
viewport.anchorRow === nextAnchor
|
|
352
|
+
&& viewport.unseenItems === 0
|
|
353
|
+
&& viewport.unseenRows === 0
|
|
354
|
+
&& viewport.frozenSnapshot === null
|
|
355
|
+
) {
|
|
356
|
+
return viewport;
|
|
357
|
+
}
|
|
358
|
+
return createFollowTailViewport(liveSnapshot.totalRows);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const frozenSnapshot = viewport.frozenSnapshot ?? liveSnapshot;
|
|
362
|
+
const anchorRow = clampAnchorRow(viewport.anchorRow, frozenSnapshot.totalRows);
|
|
363
|
+
const unseenItems = Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount);
|
|
364
|
+
const unseenRows = Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows);
|
|
365
|
+
|
|
366
|
+
if (
|
|
367
|
+
viewport.anchorRow === anchorRow
|
|
368
|
+
&& viewport.unseenItems === unseenItems
|
|
369
|
+
&& viewport.unseenRows === unseenRows
|
|
370
|
+
&& viewport.frozenSnapshot === frozenSnapshot
|
|
371
|
+
) {
|
|
372
|
+
return viewport;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
anchorRow,
|
|
377
|
+
followTail: false,
|
|
378
|
+
unseenItems,
|
|
379
|
+
unseenRows,
|
|
380
|
+
frozenSnapshot,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Returns the item index and row-within-item for the given absolute anchorRow
|
|
386
|
+
* inside a snapshot. Used by reflowTimelineViewport to translate a row-based
|
|
387
|
+
* anchor into a layout-independent (item, offset) anchor.
|
|
388
|
+
*/
|
|
389
|
+
export function findAnchorItem(
|
|
390
|
+
snapshot: TimelineSnapshot,
|
|
391
|
+
anchorRow: number,
|
|
392
|
+
): { itemIndex: number; rowWithinItem: number } {
|
|
393
|
+
let rowOffset = 0;
|
|
394
|
+
for (let i = 0; i < snapshot.items.length; i++) {
|
|
395
|
+
const item = snapshot.items[i]!;
|
|
396
|
+
if (rowOffset + item.rowCount > anchorRow) {
|
|
397
|
+
return { itemIndex: i, rowWithinItem: anchorRow - rowOffset };
|
|
398
|
+
}
|
|
399
|
+
rowOffset += item.rowCount;
|
|
400
|
+
}
|
|
401
|
+
// anchorRow is at or beyond the last item – clamp to end
|
|
402
|
+
const lastIdx = Math.max(0, snapshot.items.length - 1);
|
|
403
|
+
const lastItem = snapshot.items[lastIdx];
|
|
404
|
+
return {
|
|
405
|
+
itemIndex: lastIdx,
|
|
406
|
+
rowWithinItem: lastItem ? Math.max(0, lastItem.rowCount - 1) : 0,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Rebuilds the frozen viewport snapshot after a terminal width change.
|
|
412
|
+
*
|
|
413
|
+
* When the terminal is resized (width changes), all snapshot memos are
|
|
414
|
+
* rebuilt at the new snapshotWidth, so liveSnapshot already contains
|
|
415
|
+
* correctly reflowed rows. However syncTimelineViewport preserves the
|
|
416
|
+
* old frozenSnapshot (with old-width rows), causing visual corruption.
|
|
417
|
+
*
|
|
418
|
+
* This function replaces the stale frozen snapshot with a new one built
|
|
419
|
+
* from the same items as before (liveSnapshot.items[0..frozenItemCount]),
|
|
420
|
+
* now correctly wrapped at the new width. The anchorRow is translated
|
|
421
|
+
* from the old layout via an item-level anchor so the user's reading
|
|
422
|
+
* position is preserved across reflow.
|
|
423
|
+
*/
|
|
424
|
+
export function reflowTimelineViewport(
|
|
425
|
+
viewport: TimelineViewportState,
|
|
426
|
+
liveSnapshot: TimelineSnapshot,
|
|
427
|
+
): TimelineViewportState {
|
|
428
|
+
if (liveSnapshot.totalRows === 0) {
|
|
429
|
+
return createFollowTailViewport(0);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (viewport.followTail) {
|
|
433
|
+
return createFollowTailViewport(liveSnapshot.totalRows);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const oldFrozen = viewport.frozenSnapshot ?? liveSnapshot;
|
|
437
|
+
|
|
438
|
+
// Translate anchorRow → stable (item, rowWithinItem) anchor
|
|
439
|
+
const clampedAnchor = clampAnchorRow(viewport.anchorRow, oldFrozen.totalRows);
|
|
440
|
+
const { itemIndex: anchorItemIdx, rowWithinItem: anchorRowWithinItem } =
|
|
441
|
+
findAnchorItem(oldFrozen, clampedAnchor);
|
|
442
|
+
|
|
443
|
+
// Grab the same items from liveSnapshot (already reflowed at new width)
|
|
444
|
+
const frozenItemCount = oldFrozen.itemCount;
|
|
445
|
+
const newFrozenItems = liveSnapshot.items.slice(0, frozenItemCount);
|
|
446
|
+
|
|
447
|
+
if (newFrozenItems.length === 0) {
|
|
448
|
+
return createFollowTailViewport(liveSnapshot.totalRows);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Assemble a new frozen snapshot from the reflowed items
|
|
452
|
+
const newFrozenRows = newFrozenItems.flatMap((item) => item.rows);
|
|
453
|
+
const newFrozenSnapshot: TimelineSnapshot = {
|
|
454
|
+
items: newFrozenItems,
|
|
455
|
+
rows: newFrozenRows,
|
|
456
|
+
totalRows: newFrozenRows.length,
|
|
457
|
+
itemCount: frozenItemCount,
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
// Reconstruct anchorRow in the new layout
|
|
461
|
+
let newAnchorRow = 0;
|
|
462
|
+
for (let i = 0; i < anchorItemIdx; i++) {
|
|
463
|
+
newAnchorRow += newFrozenItems[i]!.rowCount;
|
|
464
|
+
}
|
|
465
|
+
const targetItem = newFrozenItems[anchorItemIdx];
|
|
466
|
+
if (targetItem) {
|
|
467
|
+
newAnchorRow += Math.min(anchorRowWithinItem, targetItem.rowCount - 1);
|
|
468
|
+
} else {
|
|
469
|
+
// Anchor item is beyond the new frozen range (unseen) – clamp to end
|
|
470
|
+
newAnchorRow = Math.max(0, newFrozenSnapshot.totalRows - 1);
|
|
471
|
+
}
|
|
472
|
+
newAnchorRow = clampAnchorRow(newAnchorRow, newFrozenSnapshot.totalRows);
|
|
473
|
+
|
|
474
|
+
const unseenItems = Math.max(0, liveSnapshot.itemCount - frozenItemCount);
|
|
475
|
+
const unseenRows = Math.max(0, liveSnapshot.totalRows - newFrozenSnapshot.totalRows);
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
anchorRow: newAnchorRow,
|
|
479
|
+
followTail: false,
|
|
480
|
+
unseenItems,
|
|
481
|
+
unseenRows,
|
|
482
|
+
frozenSnapshot: newFrozenSnapshot,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export function pageUpTimelineViewport(
|
|
487
|
+
viewport: TimelineViewportState,
|
|
488
|
+
liveSnapshot: TimelineSnapshot,
|
|
489
|
+
viewportRows: number,
|
|
490
|
+
): TimelineViewportState {
|
|
491
|
+
if (liveSnapshot.totalRows === 0) {
|
|
492
|
+
return createFollowTailViewport(0);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
const frozenSnapshot = getFrozenSnapshot(viewport, liveSnapshot);
|
|
496
|
+
const tailRow = Math.max(0, frozenSnapshot.totalRows - 1);
|
|
497
|
+
const currentAnchor = viewport.followTail
|
|
498
|
+
? tailRow
|
|
499
|
+
: clampAnchorRow(viewport.anchorRow, frozenSnapshot.totalRows);
|
|
500
|
+
const nextAnchor = Math.max(getFirstPageAnchor(frozenSnapshot.totalRows, viewportRows), currentAnchor - Math.max(1, viewportRows));
|
|
501
|
+
|
|
502
|
+
return {
|
|
503
|
+
anchorRow: nextAnchor,
|
|
504
|
+
followTail: false,
|
|
505
|
+
unseenItems: Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount),
|
|
506
|
+
unseenRows: Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows),
|
|
507
|
+
frozenSnapshot,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
export function pageDownTimelineViewport(
|
|
512
|
+
viewport: TimelineViewportState,
|
|
513
|
+
liveSnapshot: TimelineSnapshot,
|
|
514
|
+
viewportRows: number,
|
|
515
|
+
): TimelineViewportState {
|
|
516
|
+
if (liveSnapshot.totalRows === 0 || viewport.followTail) {
|
|
517
|
+
return viewport;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const frozenSnapshot = viewport.frozenSnapshot ?? liveSnapshot;
|
|
521
|
+
const tailRow = Math.max(0, frozenSnapshot.totalRows - 1);
|
|
522
|
+
const currentAnchor = clampAnchorRow(viewport.anchorRow, frozenSnapshot.totalRows);
|
|
523
|
+
if (currentAnchor >= tailRow) {
|
|
524
|
+
return createFollowTailViewport(liveSnapshot.totalRows);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const nextAnchor = Math.min(tailRow, currentAnchor + Math.max(1, viewportRows));
|
|
528
|
+
if (nextAnchor >= tailRow) {
|
|
529
|
+
return createFollowTailViewport(liveSnapshot.totalRows);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return {
|
|
533
|
+
anchorRow: nextAnchor,
|
|
534
|
+
followTail: false,
|
|
535
|
+
unseenItems: Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount),
|
|
536
|
+
unseenRows: Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows),
|
|
537
|
+
frozenSnapshot,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export function halfPageUpTimelineViewport(
|
|
542
|
+
viewport: TimelineViewportState,
|
|
543
|
+
liveSnapshot: TimelineSnapshot,
|
|
544
|
+
viewportRows: number,
|
|
545
|
+
): TimelineViewportState {
|
|
546
|
+
if (liveSnapshot.totalRows === 0) {
|
|
547
|
+
return createFollowTailViewport(0);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const frozenSnapshot = getFrozenSnapshot(viewport, liveSnapshot);
|
|
551
|
+
const tailRow = Math.max(0, frozenSnapshot.totalRows - 1);
|
|
552
|
+
const currentAnchor = viewport.followTail
|
|
553
|
+
? tailRow
|
|
554
|
+
: clampAnchorRow(viewport.anchorRow, frozenSnapshot.totalRows);
|
|
555
|
+
const halfPage = Math.max(1, Math.floor(viewportRows / 2));
|
|
556
|
+
const nextAnchor = Math.max(getFirstPageAnchor(frozenSnapshot.totalRows, viewportRows), currentAnchor - halfPage);
|
|
557
|
+
|
|
558
|
+
return {
|
|
559
|
+
anchorRow: nextAnchor,
|
|
560
|
+
followTail: false,
|
|
561
|
+
unseenItems: Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount),
|
|
562
|
+
unseenRows: Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows),
|
|
563
|
+
frozenSnapshot,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export function halfPageDownTimelineViewport(
|
|
568
|
+
viewport: TimelineViewportState,
|
|
569
|
+
liveSnapshot: TimelineSnapshot,
|
|
570
|
+
viewportRows: number,
|
|
571
|
+
): TimelineViewportState {
|
|
572
|
+
if (liveSnapshot.totalRows === 0 || viewport.followTail) {
|
|
573
|
+
return viewport;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const frozenSnapshot = viewport.frozenSnapshot ?? liveSnapshot;
|
|
577
|
+
const tailRow = Math.max(0, frozenSnapshot.totalRows - 1);
|
|
578
|
+
const currentAnchor = clampAnchorRow(viewport.anchorRow, frozenSnapshot.totalRows);
|
|
579
|
+
if (currentAnchor >= tailRow) {
|
|
580
|
+
return createFollowTailViewport(liveSnapshot.totalRows);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const halfPage = Math.max(1, Math.floor(viewportRows / 2));
|
|
584
|
+
const nextAnchor = Math.min(tailRow, currentAnchor + halfPage);
|
|
585
|
+
if (nextAnchor >= tailRow) {
|
|
586
|
+
return createFollowTailViewport(liveSnapshot.totalRows);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
anchorRow: nextAnchor,
|
|
591
|
+
followTail: false,
|
|
592
|
+
unseenItems: Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount),
|
|
593
|
+
unseenRows: Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows),
|
|
594
|
+
frozenSnapshot,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
export function stepUpTimelineViewport(
|
|
599
|
+
viewport: TimelineViewportState,
|
|
600
|
+
liveSnapshot: TimelineSnapshot,
|
|
601
|
+
viewportRows: number,
|
|
602
|
+
): TimelineViewportState {
|
|
603
|
+
if (liveSnapshot.totalRows === 0) {
|
|
604
|
+
return createFollowTailViewport(0);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const frozenSnapshot = getFrozenSnapshot(viewport, liveSnapshot);
|
|
608
|
+
const tailRow = Math.max(0, frozenSnapshot.totalRows - 1);
|
|
609
|
+
const currentAnchor = viewport.followTail
|
|
610
|
+
? tailRow
|
|
611
|
+
: clampAnchorRow(viewport.anchorRow, frozenSnapshot.totalRows);
|
|
612
|
+
const floor = getFirstPageAnchor(frozenSnapshot.totalRows, viewportRows);
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
anchorRow: Math.max(floor, currentAnchor - 1),
|
|
616
|
+
followTail: false,
|
|
617
|
+
unseenItems: Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount),
|
|
618
|
+
unseenRows: Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows),
|
|
619
|
+
frozenSnapshot,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
export function stepDownTimelineViewport(
|
|
624
|
+
viewport: TimelineViewportState,
|
|
625
|
+
liveSnapshot: TimelineSnapshot,
|
|
626
|
+
_viewportRows?: number,
|
|
627
|
+
): TimelineViewportState {
|
|
628
|
+
if (liveSnapshot.totalRows === 0 || viewport.followTail) {
|
|
629
|
+
return viewport;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const frozenSnapshot = viewport.frozenSnapshot ?? liveSnapshot;
|
|
633
|
+
const tailRow = Math.max(0, frozenSnapshot.totalRows - 1);
|
|
634
|
+
const currentAnchor = clampAnchorRow(viewport.anchorRow, frozenSnapshot.totalRows);
|
|
635
|
+
if (currentAnchor >= tailRow) {
|
|
636
|
+
return createFollowTailViewport(liveSnapshot.totalRows);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const nextAnchor = Math.min(tailRow, currentAnchor + 1);
|
|
640
|
+
if (nextAnchor >= tailRow) {
|
|
641
|
+
return createFollowTailViewport(liveSnapshot.totalRows);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
return {
|
|
645
|
+
anchorRow: nextAnchor,
|
|
646
|
+
followTail: false,
|
|
647
|
+
unseenItems: Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount),
|
|
648
|
+
unseenRows: Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows),
|
|
649
|
+
frozenSnapshot,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
export function scrollTimelineViewport(
|
|
654
|
+
viewport: TimelineViewportState,
|
|
655
|
+
liveSnapshot: TimelineSnapshot,
|
|
656
|
+
viewportRows: number,
|
|
657
|
+
deltaRows: number,
|
|
658
|
+
): TimelineViewportState {
|
|
659
|
+
if (liveSnapshot.totalRows === 0) {
|
|
660
|
+
return createFollowTailViewport(0);
|
|
661
|
+
}
|
|
662
|
+
if (deltaRows === 0) {
|
|
663
|
+
return viewport;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const frozenSnapshot = getFrozenSnapshot(viewport, liveSnapshot);
|
|
667
|
+
const tailRow = Math.max(0, frozenSnapshot.totalRows - 1);
|
|
668
|
+
|
|
669
|
+
if (deltaRows > 0 && viewport.followTail) {
|
|
670
|
+
return viewport;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const currentAnchor = viewport.followTail
|
|
674
|
+
? tailRow
|
|
675
|
+
: clampAnchorRow(viewport.anchorRow, frozenSnapshot.totalRows);
|
|
676
|
+
|
|
677
|
+
const floor = getFirstPageAnchor(frozenSnapshot.totalRows, viewportRows);
|
|
678
|
+
|
|
679
|
+
let nextAnchor = currentAnchor + deltaRows;
|
|
680
|
+
|
|
681
|
+
if (nextAnchor >= tailRow) {
|
|
682
|
+
return createFollowTailViewport(liveSnapshot.totalRows);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (nextAnchor < floor) {
|
|
686
|
+
nextAnchor = floor;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return {
|
|
690
|
+
anchorRow: nextAnchor,
|
|
691
|
+
followTail: false,
|
|
692
|
+
unseenItems: Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount),
|
|
693
|
+
unseenRows: Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows),
|
|
694
|
+
frozenSnapshot,
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
export function homeTimelineViewport(
|
|
699
|
+
viewport: TimelineViewportState,
|
|
700
|
+
liveSnapshot: TimelineSnapshot,
|
|
701
|
+
viewportRows: number,
|
|
702
|
+
): TimelineViewportState {
|
|
703
|
+
if (liveSnapshot.totalRows === 0) {
|
|
704
|
+
return createFollowTailViewport(0);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
const frozenSnapshot = getFrozenSnapshot(viewport, liveSnapshot);
|
|
708
|
+
return {
|
|
709
|
+
anchorRow: getFirstPageAnchor(frozenSnapshot.totalRows, viewportRows),
|
|
710
|
+
followTail: false,
|
|
711
|
+
unseenItems: Math.max(0, liveSnapshot.itemCount - frozenSnapshot.itemCount),
|
|
712
|
+
unseenRows: Math.max(0, liveSnapshot.totalRows - frozenSnapshot.totalRows),
|
|
713
|
+
frozenSnapshot,
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
export function endTimelineViewport(totalRows: number): TimelineViewportState {
|
|
718
|
+
return createFollowTailViewport(totalRows);
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ─── Row selection & render items ────────────────────────────────────────────
|
|
722
|
+
|
|
723
|
+
export function selectTimelineRows(
|
|
724
|
+
liveSnapshot: TimelineSnapshot,
|
|
725
|
+
viewport: TimelineViewportState,
|
|
726
|
+
viewportRows: number,
|
|
727
|
+
): {
|
|
728
|
+
sourceSnapshot: TimelineSnapshot;
|
|
729
|
+
visibleRows: TimelineRow[];
|
|
730
|
+
window: {
|
|
731
|
+
startRow: number;
|
|
732
|
+
endRow: number;
|
|
733
|
+
anchorRow: number;
|
|
734
|
+
};
|
|
735
|
+
} {
|
|
736
|
+
const sourceSnapshot = viewport.followTail || viewport.frozenSnapshot === null
|
|
737
|
+
? liveSnapshot
|
|
738
|
+
: viewport.frozenSnapshot;
|
|
739
|
+
const safeViewportRows = Math.max(1, viewportRows);
|
|
740
|
+
|
|
741
|
+
if (sourceSnapshot.totalRows === 0) {
|
|
742
|
+
return {
|
|
743
|
+
sourceSnapshot,
|
|
744
|
+
visibleRows: [],
|
|
745
|
+
window: { startRow: 0, endRow: 0, anchorRow: 0 },
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const anchorRow = viewport.followTail
|
|
750
|
+
? sourceSnapshot.totalRows - 1
|
|
751
|
+
: clampAnchorRow(viewport.anchorRow, sourceSnapshot.totalRows);
|
|
752
|
+
const endRow = Math.max(1, anchorRow + 1);
|
|
753
|
+
const startRow = Math.max(0, endRow - safeViewportRows);
|
|
754
|
+
|
|
755
|
+
const visibleRows = sourceSnapshot.rows.slice(startRow, endRow);
|
|
756
|
+
|
|
757
|
+
// Aggressive fallback: if slicing returned nothing but we have rows,
|
|
758
|
+
// return at least the last row to avoid a blank screen.
|
|
759
|
+
if (visibleRows.length === 0 && sourceSnapshot.totalRows > 0) {
|
|
760
|
+
const fallbackRow = sourceSnapshot.rows[sourceSnapshot.totalRows - 1]!;
|
|
761
|
+
return {
|
|
762
|
+
sourceSnapshot,
|
|
763
|
+
visibleRows: [fallbackRow],
|
|
764
|
+
window: {
|
|
765
|
+
startRow: sourceSnapshot.totalRows - 1,
|
|
766
|
+
endRow: sourceSnapshot.totalRows,
|
|
767
|
+
anchorRow: sourceSnapshot.totalRows - 1,
|
|
768
|
+
},
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
return {
|
|
773
|
+
sourceSnapshot,
|
|
774
|
+
visibleRows,
|
|
775
|
+
window: {
|
|
776
|
+
startRow,
|
|
777
|
+
endRow,
|
|
778
|
+
anchorRow,
|
|
779
|
+
},
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
export function buildStaticRenderItems(
|
|
784
|
+
items: TimelineItem[],
|
|
785
|
+
turnIds: number[],
|
|
786
|
+
activeTurnId: number | null,
|
|
787
|
+
questionTurnId: number | null,
|
|
788
|
+
question: string | null,
|
|
789
|
+
): RenderTimelineItem[] {
|
|
790
|
+
const resolveOpacity = createTurnOpacityResolver(turnIds);
|
|
791
|
+
return items.map((item) => {
|
|
792
|
+
if (item.type === "event") {
|
|
793
|
+
return {
|
|
794
|
+
key: `event-${item.event.id}`,
|
|
795
|
+
type: "event",
|
|
796
|
+
padded: false,
|
|
797
|
+
event: item.event,
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
return {
|
|
802
|
+
key: `turn-${item.turnId}`,
|
|
803
|
+
type: "turn",
|
|
804
|
+
padded: false,
|
|
805
|
+
item,
|
|
806
|
+
renderState: {
|
|
807
|
+
opacity: resolveOpacity(item.turnId, activeTurnId),
|
|
808
|
+
question: questionTurnId === item.turnId ? question : null,
|
|
809
|
+
runPhase: resolveTurnRunPhase(item.run, item.assistant, { kind: "IDLE" }, item.turnId),
|
|
810
|
+
},
|
|
811
|
+
};
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
export function buildActiveRenderItems(
|
|
816
|
+
items: TimelineItem[],
|
|
817
|
+
turnIds: number[],
|
|
818
|
+
uiState: UIState,
|
|
819
|
+
): RenderTimelineItem[] {
|
|
820
|
+
const activeTurnId = getActiveTurnId(uiState);
|
|
821
|
+
const questionTurnId = uiState.kind === "AWAITING_USER_ACTION" ? uiState.turnId : null;
|
|
822
|
+
const question = uiState.kind === "AWAITING_USER_ACTION" ? uiState.question : null;
|
|
823
|
+
const resolveOpacity = createTurnOpacityResolver(turnIds);
|
|
824
|
+
|
|
825
|
+
return items.map((item) => {
|
|
826
|
+
if (item.type === "event") {
|
|
827
|
+
return {
|
|
828
|
+
key: `event-${item.event.id}`,
|
|
829
|
+
type: "event",
|
|
830
|
+
padded: true,
|
|
831
|
+
event: item.event,
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
return {
|
|
836
|
+
key: `turn-${item.turnId}`,
|
|
837
|
+
type: "turn",
|
|
838
|
+
padded: false,
|
|
839
|
+
item,
|
|
840
|
+
renderState: {
|
|
841
|
+
opacity: resolveOpacity(item.turnId, activeTurnId),
|
|
842
|
+
question: questionTurnId === item.turnId ? question : null,
|
|
843
|
+
runPhase: resolveTurnRunPhase(item.run, item.assistant, uiState, item.turnId),
|
|
844
|
+
},
|
|
845
|
+
};
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function formatAuthLabel(authState: CodexAuthState): string {
|
|
850
|
+
const raw = getAuthStateLabel(authState);
|
|
851
|
+
return raw.length > 0 ? raw[0]!.toUpperCase() + raw.slice(1) : raw;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
export function buildIntroRenderItem(params: {
|
|
855
|
+
authState: CodexAuthState;
|
|
856
|
+
workspaceLabel: string;
|
|
857
|
+
layout: Layout;
|
|
858
|
+
startupHeaderMode?: StartupHeaderMode;
|
|
859
|
+
providerLabel?: string | null;
|
|
860
|
+
}): IntroRenderTimelineItem {
|
|
861
|
+
return {
|
|
862
|
+
key: "ubume-intro",
|
|
863
|
+
type: "intro",
|
|
864
|
+
padded: true,
|
|
865
|
+
intro: {
|
|
866
|
+
version: formatUbumeVersionLabel(APP_VERSION),
|
|
867
|
+
layoutMode: params.layout.mode,
|
|
868
|
+
startupHeaderMode: params.startupHeaderMode,
|
|
869
|
+
authLabel: formatAuthLabel(params.authState),
|
|
870
|
+
workspaceLabel: params.workspaceLabel,
|
|
871
|
+
providerLabel: params.providerLabel ?? null,
|
|
872
|
+
},
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// ─── Row rendering ────────────────────────────────────────────────────────────
|
|
877
|
+
|
|
878
|
+
function getToneColor(theme: ReturnType<typeof useTheme>, tone: TimelineTone | undefined): string | undefined {
|
|
879
|
+
switch (tone) {
|
|
880
|
+
case "text": return theme.text;
|
|
881
|
+
case "dim": return theme.textDim;
|
|
882
|
+
case "muted": return theme.textMuted;
|
|
883
|
+
case "accent": return theme.accent;
|
|
884
|
+
case "info": return theme.info;
|
|
885
|
+
case "error": return theme.error;
|
|
886
|
+
case "warning": return theme.warning;
|
|
887
|
+
case "success": return theme.success;
|
|
888
|
+
case "borderSubtle": return theme.border;
|
|
889
|
+
case "borderActive": return theme.borderFocused;
|
|
890
|
+
case "panel": return theme.surface;
|
|
891
|
+
case "star": return theme.warning;
|
|
892
|
+
case "logoPrimary": return theme.logoPrimary;
|
|
893
|
+
case "logoSecondary": return theme.logoSecondary;
|
|
894
|
+
case "logoShadow": return theme.logoShadow;
|
|
895
|
+
default: return undefined;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
export const TimelineRowView = memo(function TimelineRowView({ row }: { row: TimelineRow }) {
|
|
900
|
+
const isActionRow = row.key.includes("-action-");
|
|
901
|
+
renderDebug.useRenderDebug("TimelineRow", {
|
|
902
|
+
rowKey: row.key,
|
|
903
|
+
row,
|
|
904
|
+
});
|
|
905
|
+
if (isActionRow) {
|
|
906
|
+
renderDebug.traceFlickerEvent("timelineRowRender", {
|
|
907
|
+
rowKey: row.key,
|
|
908
|
+
spanToken: row.spans.map((span) => span.text).join("|"),
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
useEffect(() => {
|
|
913
|
+
if (!isActionRow) return;
|
|
914
|
+
renderDebug.traceFlickerEvent("timelineRowMount", { rowKey: row.key });
|
|
915
|
+
renderDebug.traceLifecycleEvent("ActionRow", "mount", { rowKey: row.key });
|
|
916
|
+
renderDebug.traceLifecycleEvent("ActionBlock", "mount", { rowKey: row.key });
|
|
917
|
+
return () => {
|
|
918
|
+
renderDebug.traceFlickerEvent("timelineRowUnmount", { rowKey: row.key });
|
|
919
|
+
renderDebug.traceLifecycleEvent("ActionRow", "unmount", { rowKey: row.key });
|
|
920
|
+
renderDebug.traceLifecycleEvent("ActionBlock", "unmount", { rowKey: row.key });
|
|
921
|
+
};
|
|
922
|
+
}, [isActionRow, row.key]);
|
|
923
|
+
|
|
924
|
+
const theme = useTheme();
|
|
925
|
+
|
|
926
|
+
return (
|
|
927
|
+
<Box width="100%" overflow="hidden">
|
|
928
|
+
<Text>
|
|
929
|
+
{row.spans.map((span, index) => (
|
|
930
|
+
<Text
|
|
931
|
+
key={index}
|
|
932
|
+
color={getToneColor(theme, span.tone)}
|
|
933
|
+
backgroundColor={getToneColor(theme, span.backgroundTone)}
|
|
934
|
+
bold={span.bold}
|
|
935
|
+
>
|
|
936
|
+
{span.text}
|
|
937
|
+
</Text>
|
|
938
|
+
))}
|
|
939
|
+
</Text>
|
|
940
|
+
</Box>
|
|
941
|
+
);
|
|
942
|
+
}, (prev, next) => prev.row === next.row);
|
|
943
|
+
|
|
944
|
+
function rowArraysEqual(left: TimelineRow[], right: TimelineRow[]): boolean {
|
|
945
|
+
if (left === right) return true;
|
|
946
|
+
if (left.length !== right.length) return false;
|
|
947
|
+
for (let index = 0; index < left.length; index += 1) {
|
|
948
|
+
if (left[index] !== right[index]) return false;
|
|
949
|
+
}
|
|
950
|
+
return true;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const TimelineRowsView = memo(function TimelineRowsView({ rows }: { rows: TimelineRow[] }) {
|
|
954
|
+
return (
|
|
955
|
+
<>
|
|
956
|
+
{rows.map((row) => (
|
|
957
|
+
<TimelineRowView key={row.key} row={row} />
|
|
958
|
+
))}
|
|
959
|
+
</>
|
|
960
|
+
);
|
|
961
|
+
}, (prev, next) => rowArraysEqual(prev.rows, next.rows));
|
|
962
|
+
|
|
963
|
+
// ─── Component ────────────────────────────────────────────────────────────────
|
|
964
|
+
|
|
965
|
+
export const Timeline = memo(function Timeline({
|
|
966
|
+
staticEvents,
|
|
967
|
+
activeEvents,
|
|
968
|
+
layout,
|
|
969
|
+
uiState,
|
|
970
|
+
viewportRows,
|
|
971
|
+
verboseMode = false,
|
|
972
|
+
authState = "checking",
|
|
973
|
+
workspaceLabel = "",
|
|
974
|
+
workspaceRoot = null,
|
|
975
|
+
providerLabel = null,
|
|
976
|
+
startupHeaderMode,
|
|
977
|
+
showIntro = true,
|
|
978
|
+
contentSized = false,
|
|
979
|
+
}: TimelineProps) {
|
|
980
|
+
renderDebug.useRenderDebug("Timeline", {
|
|
981
|
+
staticEvents,
|
|
982
|
+
activeEvents,
|
|
983
|
+
staticEventsLength: staticEvents.length,
|
|
984
|
+
activeEventsLength: activeEvents.length,
|
|
985
|
+
cols: layout.cols,
|
|
986
|
+
rows: layout.rows,
|
|
987
|
+
mode: layout.mode,
|
|
988
|
+
uiStateKind: uiState.kind,
|
|
989
|
+
viewportRows,
|
|
990
|
+
verboseMode,
|
|
991
|
+
authState,
|
|
992
|
+
workspaceLabel,
|
|
993
|
+
workspaceRoot,
|
|
994
|
+
});
|
|
995
|
+
renderDebug.useLifecycleDebug("Timeline", {
|
|
996
|
+
cols: layout.cols,
|
|
997
|
+
rows: layout.rows,
|
|
998
|
+
mode: layout.mode,
|
|
999
|
+
viewportRows,
|
|
1000
|
+
});
|
|
1001
|
+
renderDebug.traceLayoutValidity("Timeline", {
|
|
1002
|
+
cols: layout.cols,
|
|
1003
|
+
rows: layout.rows,
|
|
1004
|
+
viewportRows,
|
|
1005
|
+
});
|
|
1006
|
+
renderDebug.useFlickerDebug("timelineRender", {
|
|
1007
|
+
staticEvents,
|
|
1008
|
+
activeEvents,
|
|
1009
|
+
staticEventsLength: staticEvents.length,
|
|
1010
|
+
activeEventsLength: activeEvents.length,
|
|
1011
|
+
cols: layout.cols,
|
|
1012
|
+
rows: layout.rows,
|
|
1013
|
+
mode: layout.mode,
|
|
1014
|
+
uiStateKind: uiState.kind,
|
|
1015
|
+
viewportRows,
|
|
1016
|
+
verboseMode,
|
|
1017
|
+
authState,
|
|
1018
|
+
workspaceLabel,
|
|
1019
|
+
});
|
|
1020
|
+
renderDebug.useRenderDebug("Transcript", {
|
|
1021
|
+
staticEvents,
|
|
1022
|
+
activeEvents,
|
|
1023
|
+
staticEventsLength: staticEvents.length,
|
|
1024
|
+
activeEventsLength: activeEvents.length,
|
|
1025
|
+
cols: layout.cols,
|
|
1026
|
+
rows: layout.rows,
|
|
1027
|
+
mode: layout.mode,
|
|
1028
|
+
uiStateKind: uiState.kind,
|
|
1029
|
+
viewportRows,
|
|
1030
|
+
verboseMode,
|
|
1031
|
+
workspaceRoot,
|
|
1032
|
+
});
|
|
1033
|
+
|
|
1034
|
+
const theme = useTheme();
|
|
1035
|
+
|
|
1036
|
+
const staticItems = useMemo(() => buildTimelineItems(staticEvents), [staticEvents]);
|
|
1037
|
+
const activeItems = useMemo(() => buildTimelineItems(activeEvents), [activeEvents]);
|
|
1038
|
+
const activeTurnId = getActiveTurnId(uiState);
|
|
1039
|
+
const runningTurnIds = useMemo(() => getRunningTurnIds(activeEvents), [activeEvents]);
|
|
1040
|
+
const finalizedTurnIds = useMemo(() => getFinalizedTurnIds(staticEvents), [staticEvents]);
|
|
1041
|
+
const finalizedPlanAtTail = useMemo(() => latestFinalizedRunEndsWithPlan(staticEvents), [staticEvents]);
|
|
1042
|
+
const finalizeTransitionRef = useRef<{
|
|
1043
|
+
runningTurnIds: number[];
|
|
1044
|
+
finalizedTurnIds: number[];
|
|
1045
|
+
busy: boolean;
|
|
1046
|
+
}>({
|
|
1047
|
+
runningTurnIds,
|
|
1048
|
+
finalizedTurnIds,
|
|
1049
|
+
busy: isBusyUiState(uiState),
|
|
1050
|
+
});
|
|
1051
|
+
const finalizeTransition = hasFinalizeTransition({
|
|
1052
|
+
previousRunningTurnIds: finalizeTransitionRef.current.runningTurnIds,
|
|
1053
|
+
nextRunningTurnIds: runningTurnIds,
|
|
1054
|
+
nextFinalizedTurnIds: finalizedTurnIds,
|
|
1055
|
+
previousBusy: finalizeTransitionRef.current.busy,
|
|
1056
|
+
nextBusy: isBusyUiState(uiState),
|
|
1057
|
+
});
|
|
1058
|
+
const questionTurnId = uiState.kind === "AWAITING_USER_ACTION" ? uiState.turnId : null;
|
|
1059
|
+
const question = uiState.kind === "AWAITING_USER_ACTION" ? uiState.question : null;
|
|
1060
|
+
const staticTurnIds = useMemo(
|
|
1061
|
+
() => staticItems.filter((item): item is TurnTimelineItem => item.type === "turn").map((item) => item.turnId),
|
|
1062
|
+
[staticItems],
|
|
1063
|
+
);
|
|
1064
|
+
const activeTurnIds = useMemo(
|
|
1065
|
+
() => activeItems.filter((item): item is TurnTimelineItem => item.type === "turn").map((item) => item.turnId),
|
|
1066
|
+
[activeItems],
|
|
1067
|
+
);
|
|
1068
|
+
const allTurnIds = useMemo(
|
|
1069
|
+
() => [...staticTurnIds, ...activeTurnIds],
|
|
1070
|
+
[activeTurnIds, staticTurnIds],
|
|
1071
|
+
);
|
|
1072
|
+
const staticRenderItems = useMemo(
|
|
1073
|
+
() => [
|
|
1074
|
+
...(showIntro ? [buildIntroRenderItem({
|
|
1075
|
+
authState,
|
|
1076
|
+
workspaceLabel,
|
|
1077
|
+
layout,
|
|
1078
|
+
providerLabel,
|
|
1079
|
+
startupHeaderMode,
|
|
1080
|
+
})] : []),
|
|
1081
|
+
...buildStaticRenderItems(staticItems, allTurnIds, activeTurnId, questionTurnId, question),
|
|
1082
|
+
],
|
|
1083
|
+
[activeTurnId, allTurnIds, authState, layout, providerLabel, question, questionTurnId, showIntro, startupHeaderMode, staticItems, workspaceLabel],
|
|
1084
|
+
);
|
|
1085
|
+
const activeRenderItems = useMemo(
|
|
1086
|
+
() => buildActiveRenderItems(activeItems, allTurnIds, uiState),
|
|
1087
|
+
[activeItems, allTurnIds, uiState],
|
|
1088
|
+
);
|
|
1089
|
+
// ── Split snapshot building ──────────────────────────────────────────────
|
|
1090
|
+
// During streaming, activeEvents change every frame but staticEvents stay
|
|
1091
|
+
// the same. We further split the active items into "stable" (user prompt,
|
|
1092
|
+
// run header — don't change during streaming) and "streaming" (assistant
|
|
1093
|
+
// content — changes every frame). This gives us three cached tiers so only
|
|
1094
|
+
// the streaming assistant item is rebuilt each frame.
|
|
1095
|
+
const snapshotWidth = Math.max(10, getShellWidth(layout.cols) - 1);
|
|
1096
|
+
const staticSnapshot = useMemo(
|
|
1097
|
+
() => buildTimelineSnapshot(staticRenderItems, { totalWidth: snapshotWidth, verboseMode, debugLabel: "static", workspaceRoot }),
|
|
1098
|
+
[snapshotWidth, staticRenderItems, verboseMode, workspaceRoot],
|
|
1099
|
+
);
|
|
1100
|
+
// Partition active items: non-assistant items are stable during streaming
|
|
1101
|
+
const isStreaming = uiState.kind === "RESPONDING";
|
|
1102
|
+
const activeStableItems = useMemo(
|
|
1103
|
+
() => isStreaming
|
|
1104
|
+
? activeRenderItems.filter((item) => !(item.type === "turn" && item.item.assistant))
|
|
1105
|
+
: [],
|
|
1106
|
+
[activeRenderItems, isStreaming],
|
|
1107
|
+
);
|
|
1108
|
+
const activeStreamingItems = useMemo(
|
|
1109
|
+
() => isStreaming
|
|
1110
|
+
? activeRenderItems.filter((item) => item.type === "turn" && item.item.assistant)
|
|
1111
|
+
: activeRenderItems,
|
|
1112
|
+
[activeRenderItems, isStreaming],
|
|
1113
|
+
);
|
|
1114
|
+
const activeStableSnapshot = useMemo(
|
|
1115
|
+
() => STABLE_RENDER_ENABLED
|
|
1116
|
+
? { items: [], rows: [], totalRows: 0, itemCount: 0 }
|
|
1117
|
+
: activeStableItems.length > 0
|
|
1118
|
+
? buildTimelineSnapshot(activeStableItems, { totalWidth: snapshotWidth, verboseMode, debugLabel: "active-stable", workspaceRoot })
|
|
1119
|
+
: { items: [], rows: [], totalRows: 0, itemCount: 0 },
|
|
1120
|
+
[snapshotWidth, activeStableItems, verboseMode, workspaceRoot],
|
|
1121
|
+
);
|
|
1122
|
+
const activeStreamingSnapshot = useMemo(
|
|
1123
|
+
() => STABLE_RENDER_ENABLED
|
|
1124
|
+
? { items: [], rows: [], totalRows: 0, itemCount: 0 }
|
|
1125
|
+
: buildTimelineSnapshot(activeStreamingItems, { totalWidth: snapshotWidth, verboseMode, debugLabel: "active-streaming", workspaceRoot }),
|
|
1126
|
+
[snapshotWidth, activeStreamingItems, verboseMode, workspaceRoot],
|
|
1127
|
+
);
|
|
1128
|
+
const stableActiveSnapshot = useMemo(
|
|
1129
|
+
() => STABLE_RENDER_ENABLED
|
|
1130
|
+
? buildStableTimelineSnapshot(activeRenderItems, { totalWidth: snapshotWidth, verboseMode, debugLabel: "active-stable-render", workspaceRoot })
|
|
1131
|
+
: null,
|
|
1132
|
+
[snapshotWidth, activeRenderItems, verboseMode, workspaceRoot],
|
|
1133
|
+
);
|
|
1134
|
+
const liveSnapshot = useMemo(
|
|
1135
|
+
() => {
|
|
1136
|
+
if (stableActiveSnapshot) {
|
|
1137
|
+
return {
|
|
1138
|
+
items: [...staticSnapshot.items, ...stableActiveSnapshot.snapshot.items],
|
|
1139
|
+
rows: [...staticSnapshot.rows, ...stableActiveSnapshot.snapshot.rows],
|
|
1140
|
+
totalRows: staticSnapshot.totalRows + stableActiveSnapshot.snapshot.totalRows,
|
|
1141
|
+
itemCount: staticSnapshot.itemCount + stableActiveSnapshot.snapshot.itemCount,
|
|
1142
|
+
};
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
return {
|
|
1146
|
+
items: [...staticSnapshot.items, ...activeStableSnapshot.items, ...activeStreamingSnapshot.items],
|
|
1147
|
+
rows: [...staticSnapshot.rows, ...activeStableSnapshot.rows, ...activeStreamingSnapshot.rows],
|
|
1148
|
+
totalRows: staticSnapshot.totalRows + activeStableSnapshot.totalRows + activeStreamingSnapshot.totalRows,
|
|
1149
|
+
itemCount: staticSnapshot.itemCount + activeStableSnapshot.itemCount + activeStreamingSnapshot.itemCount,
|
|
1150
|
+
};
|
|
1151
|
+
},
|
|
1152
|
+
[staticSnapshot, stableActiveSnapshot, activeStableSnapshot, activeStreamingSnapshot],
|
|
1153
|
+
);
|
|
1154
|
+
|
|
1155
|
+
const lastNonEmptySnapshotRef = useRef<TimelineSnapshot>(liveSnapshot);
|
|
1156
|
+
if (liveSnapshot.totalRows > 0) {
|
|
1157
|
+
lastNonEmptySnapshotRef.current = liveSnapshot;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
const isBusy = uiState.kind === "THINKING"
|
|
1161
|
+
|| uiState.kind === "RESPONDING"
|
|
1162
|
+
|| uiState.kind === "ANSWER_VISIBLE"
|
|
1163
|
+
|| uiState.kind === "SHELL_RUNNING";
|
|
1164
|
+
|
|
1165
|
+
const transcriptEventCount = staticEvents.length + activeEvents.length;
|
|
1166
|
+
|
|
1167
|
+
const effectiveSnapshot = useMemo(() => {
|
|
1168
|
+
// If we have events but the live snapshot is empty, fallback to the last
|
|
1169
|
+
// known good snapshot to avoid blanking the screen during transitions.
|
|
1170
|
+
if (liveSnapshot.totalRows === 0 && transcriptEventCount > 0 && lastNonEmptySnapshotRef.current.totalRows > 0) {
|
|
1171
|
+
renderDebug.traceFlickerEvent("snapshotFallback", {
|
|
1172
|
+
reason: "empty-while-busy-with-events",
|
|
1173
|
+
uiStateKind: uiState.kind,
|
|
1174
|
+
previousTotalRows: lastNonEmptySnapshotRef.current.totalRows,
|
|
1175
|
+
transcriptEventCount,
|
|
1176
|
+
});
|
|
1177
|
+
return lastNonEmptySnapshotRef.current;
|
|
1178
|
+
}
|
|
1179
|
+
return liveSnapshot;
|
|
1180
|
+
}, [liveSnapshot, transcriptEventCount, uiState.kind]);
|
|
1181
|
+
const snapshotForViewport = effectiveSnapshot;
|
|
1182
|
+
|
|
1183
|
+
const [viewport, setViewport] = useState<TimelineViewportState>(() => createFollowTailViewport(snapshotForViewport.totalRows));
|
|
1184
|
+
// Tracks the previous snapshotWidth so we can detect width changes inside
|
|
1185
|
+
// the liveSnapshot effect and dispatch reflowTimelineViewport instead of
|
|
1186
|
+
// syncTimelineViewport when the terminal has been resized.
|
|
1187
|
+
const snapshotWidthRef = useRef(snapshotWidth);
|
|
1188
|
+
// Tracks previous totalRows so we can skip setViewport when no new rows
|
|
1189
|
+
// arrived — avoiding a second React render / Ink stdout write per streaming
|
|
1190
|
+
// flush when the viewport already reflects the correct state.
|
|
1191
|
+
const prevTotalRowsRef = useRef(effectiveSnapshot.totalRows);
|
|
1192
|
+
useEffect(() => {
|
|
1193
|
+
const widthChanged = snapshotWidthRef.current !== snapshotWidth;
|
|
1194
|
+
snapshotWidthRef.current = snapshotWidth;
|
|
1195
|
+
|
|
1196
|
+
const totalRows = snapshotForViewport.totalRows;
|
|
1197
|
+
const previousTotalRows = prevTotalRowsRef.current;
|
|
1198
|
+
const rowGrowth = totalRows - previousTotalRows;
|
|
1199
|
+
const totalRowsGrew = rowGrowth > 0;
|
|
1200
|
+
prevTotalRowsRef.current = totalRows;
|
|
1201
|
+
|
|
1202
|
+
setViewport((current) => {
|
|
1203
|
+
// Width change: always reflow or sync to wrap text at the new width.
|
|
1204
|
+
if (widthChanged) {
|
|
1205
|
+
const next = !current.followTail && current.frozenSnapshot !== null
|
|
1206
|
+
? reflowTimelineViewport(current, snapshotForViewport)
|
|
1207
|
+
: syncTimelineViewport(current, snapshotForViewport);
|
|
1208
|
+
renderDebug.traceFlickerEvent("viewportSync", {
|
|
1209
|
+
reason: "width-change",
|
|
1210
|
+
result: next === current ? "skipped" : "updated",
|
|
1211
|
+
previousTotalRows,
|
|
1212
|
+
totalRows,
|
|
1213
|
+
rowGrowth,
|
|
1214
|
+
previousAnchorRow: current.anchorRow,
|
|
1215
|
+
anchorRow: next.anchorRow,
|
|
1216
|
+
followTail: next.followTail,
|
|
1217
|
+
viewportRows,
|
|
1218
|
+
});
|
|
1219
|
+
return next;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
// Frozen (user scrolled up): keep the frozen snapshot fresh when rows
|
|
1223
|
+
// grow or the stream finalizes so the unseen-item counters and
|
|
1224
|
+
// jump-to-bottom affordance stay accurate.
|
|
1225
|
+
if (!current.followTail) {
|
|
1226
|
+
const next = totalRowsGrew || finalizeTransition
|
|
1227
|
+
? syncTimelineViewport(current, snapshotForViewport)
|
|
1228
|
+
: current;
|
|
1229
|
+
renderDebug.traceFlickerEvent("viewportSync", {
|
|
1230
|
+
reason: finalizeTransition
|
|
1231
|
+
? (totalRowsGrew ? "detached-finalize-growth" : "detached-finalize")
|
|
1232
|
+
: (totalRowsGrew ? "detached-growth" : "detached-no-growth"),
|
|
1233
|
+
result: next === current ? "skipped" : "updated",
|
|
1234
|
+
previousTotalRows,
|
|
1235
|
+
totalRows,
|
|
1236
|
+
rowGrowth,
|
|
1237
|
+
previousAnchorRow: current.anchorRow,
|
|
1238
|
+
anchorRow: next.anchorRow,
|
|
1239
|
+
followTail: next.followTail,
|
|
1240
|
+
viewportRows,
|
|
1241
|
+
});
|
|
1242
|
+
return next;
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
// Follow-tail: only advance anchorRow when content actually grew.
|
|
1246
|
+
// Streaming is append-only so shrinking rows is rare; if it happens the
|
|
1247
|
+
// anchorRow stays at the old position until the next growth tick.
|
|
1248
|
+
if (!totalRowsGrew) {
|
|
1249
|
+
renderDebug.traceFlickerEvent("viewportSync", {
|
|
1250
|
+
reason: "follow-tail-no-growth",
|
|
1251
|
+
result: "skipped",
|
|
1252
|
+
previousTotalRows,
|
|
1253
|
+
totalRows,
|
|
1254
|
+
rowGrowth,
|
|
1255
|
+
previousAnchorRow: current.anchorRow,
|
|
1256
|
+
anchorRow: current.anchorRow,
|
|
1257
|
+
followTail: current.followTail,
|
|
1258
|
+
viewportRows,
|
|
1259
|
+
});
|
|
1260
|
+
return current;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
const useFinalizeContinuity = finalizeTransition
|
|
1264
|
+
&& !finalizedPlanAtTail
|
|
1265
|
+
&& rowGrowth > Math.max(1, Math.floor(viewportRows / 2));
|
|
1266
|
+
const next = syncTimelineViewport(current, snapshotForViewport, {
|
|
1267
|
+
finalizeContinuity: useFinalizeContinuity
|
|
1268
|
+
? { previousTotalRows, viewportRows }
|
|
1269
|
+
: undefined,
|
|
1270
|
+
});
|
|
1271
|
+
renderDebug.traceFlickerEvent("viewportSync", {
|
|
1272
|
+
reason: useFinalizeContinuity ? "finalize-continuity" : "follow-tail-growth",
|
|
1273
|
+
result: next === current ? "skipped" : "updated",
|
|
1274
|
+
previousTotalRows,
|
|
1275
|
+
totalRows,
|
|
1276
|
+
rowGrowth,
|
|
1277
|
+
previousAnchorRow: current.anchorRow,
|
|
1278
|
+
anchorRow: next.anchorRow,
|
|
1279
|
+
followTail: next.followTail,
|
|
1280
|
+
viewportRows,
|
|
1281
|
+
});
|
|
1282
|
+
return next;
|
|
1283
|
+
});
|
|
1284
|
+
}, [finalizeTransition, finalizedPlanAtTail, snapshotForViewport, snapshotWidth, viewportRows]);
|
|
1285
|
+
|
|
1286
|
+
useEffect(() => {
|
|
1287
|
+
finalizeTransitionRef.current = {
|
|
1288
|
+
runningTurnIds,
|
|
1289
|
+
finalizedTurnIds,
|
|
1290
|
+
busy: isBusyUiState(uiState),
|
|
1291
|
+
};
|
|
1292
|
+
}, [finalizedTurnIds, runningTurnIds, uiState]);
|
|
1293
|
+
|
|
1294
|
+
const userPromptCount = useMemo(() => {
|
|
1295
|
+
return [...staticEvents, ...activeEvents].filter((event) => event.type === "user").length;
|
|
1296
|
+
}, [activeEvents, staticEvents]);
|
|
1297
|
+
|
|
1298
|
+
const prevUserPromptCountRef = useRef(userPromptCount);
|
|
1299
|
+
|
|
1300
|
+
useEffect(() => {
|
|
1301
|
+
if (userPromptCount > prevUserPromptCountRef.current) {
|
|
1302
|
+
setViewport(createFollowTailViewport(snapshotForViewport.totalRows));
|
|
1303
|
+
}
|
|
1304
|
+
prevUserPromptCountRef.current = userPromptCount;
|
|
1305
|
+
}, [userPromptCount, snapshotForViewport.totalRows]);
|
|
1306
|
+
|
|
1307
|
+
const { visibleRows } = useMemo(() => {
|
|
1308
|
+
const selection = selectTimelineRows(snapshotForViewport, viewport, viewportRows);
|
|
1309
|
+
renderDebug.traceEvent("viewport", "slice", {
|
|
1310
|
+
providerState: uiState.kind,
|
|
1311
|
+
visibleRows: selection.visibleRows.length,
|
|
1312
|
+
visibleStart: selection.window.startRow,
|
|
1313
|
+
visibleEnd: selection.window.endRow,
|
|
1314
|
+
anchorRow: selection.window.anchorRow,
|
|
1315
|
+
maxScrollOffset: Math.max(0, selection.sourceSnapshot.totalRows - viewportRows),
|
|
1316
|
+
followTail: viewport.followTail,
|
|
1317
|
+
totalItems: selection.sourceSnapshot.itemCount,
|
|
1318
|
+
totalRows: selection.sourceSnapshot.totalRows,
|
|
1319
|
+
availableTimelineRows: viewportRows,
|
|
1320
|
+
sliceEmpty: selection.visibleRows.length === 0 && selection.sourceSnapshot.totalRows > 0,
|
|
1321
|
+
fallbackSnapshot: snapshotForViewport !== liveSnapshot,
|
|
1322
|
+
});
|
|
1323
|
+
renderDebug.traceFlickerEvent("viewportSlice", {
|
|
1324
|
+
visibleRows: selection.visibleRows.length,
|
|
1325
|
+
startRow: selection.window.startRow,
|
|
1326
|
+
endRow: selection.window.endRow,
|
|
1327
|
+
anchorRow: selection.window.anchorRow,
|
|
1328
|
+
followTail: viewport.followTail,
|
|
1329
|
+
totalRows: selection.sourceSnapshot.totalRows,
|
|
1330
|
+
fallbackSnapshot: snapshotForViewport !== liveSnapshot,
|
|
1331
|
+
});
|
|
1332
|
+
return selection;
|
|
1333
|
+
}, [snapshotForViewport, viewport, viewportRows]);
|
|
1334
|
+
const lastNonEmptyVisibleRowsRef = useRef<TimelineRow[]>([]);
|
|
1335
|
+
if (visibleRows.length > 0) {
|
|
1336
|
+
lastNonEmptyVisibleRowsRef.current = visibleRows;
|
|
1337
|
+
}
|
|
1338
|
+
const usingLastGoodFrameFallback = visibleRows.length === 0 && transcriptEventCount > 0;
|
|
1339
|
+
if (usingLastGoodFrameFallback) {
|
|
1340
|
+
renderDebug.traceEvent("viewport", "lastGoodFrameFallback", {
|
|
1341
|
+
providerState: uiState.kind,
|
|
1342
|
+
totalRows: liveSnapshot.totalRows,
|
|
1343
|
+
availableTimelineRows: viewportRows,
|
|
1344
|
+
preservedRows: lastNonEmptyVisibleRowsRef.current.length,
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
const preservedVisibleRows = usingLastGoodFrameFallback
|
|
1348
|
+
? lastNonEmptyVisibleRowsRef.current
|
|
1349
|
+
: visibleRows;
|
|
1350
|
+
|
|
1351
|
+
const rowsForDisplay = preservedVisibleRows;
|
|
1352
|
+
|
|
1353
|
+
if (visibleRows.length === 0) {
|
|
1354
|
+
renderDebug.traceBlankFrame("Timeline", {
|
|
1355
|
+
reason: transcriptEventCount > 0 ? "visible-rows-zero-with-events" : "visible-rows-zero-no-events",
|
|
1356
|
+
staticEventsLength: staticEvents.length,
|
|
1357
|
+
activeEventsLength: activeEvents.length,
|
|
1358
|
+
transcriptEventCount,
|
|
1359
|
+
totalRows: liveSnapshot.totalRows,
|
|
1360
|
+
viewportRows,
|
|
1361
|
+
preservedRows: preservedVisibleRows.length,
|
|
1362
|
+
uiStateKind: uiState.kind,
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
if (preservedVisibleRows.length === 0) {
|
|
1367
|
+
renderDebug.traceBlankFrame("Timeline", {
|
|
1368
|
+
reason: "empty-root-layout-return",
|
|
1369
|
+
staticEventsLength: staticEvents.length,
|
|
1370
|
+
activeEventsLength: activeEvents.length,
|
|
1371
|
+
transcriptEventCount,
|
|
1372
|
+
totalRows: liveSnapshot.totalRows,
|
|
1373
|
+
effectiveTotalRows: snapshotForViewport.totalRows,
|
|
1374
|
+
viewportRows,
|
|
1375
|
+
uiStateKind: uiState.kind,
|
|
1376
|
+
});
|
|
1377
|
+
return <Box flexDirection="column" width="100%" height={contentSized ? undefined : Math.max(1, viewportRows)} />;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
return (
|
|
1381
|
+
<Box
|
|
1382
|
+
flexDirection="column"
|
|
1383
|
+
width="100%"
|
|
1384
|
+
height={contentSized ? undefined : Math.max(1, viewportRows)}
|
|
1385
|
+
overflow="hidden"
|
|
1386
|
+
>
|
|
1387
|
+
<TimelineRowsView rows={rowsForDisplay} />
|
|
1388
|
+
</Box>
|
|
1389
|
+
);
|
|
1390
|
+
}, (prev, next) => {
|
|
1391
|
+
return (
|
|
1392
|
+
prev.staticEvents === next.staticEvents &&
|
|
1393
|
+
prev.activeEvents === next.activeEvents &&
|
|
1394
|
+
prev.layout.cols === next.layout.cols &&
|
|
1395
|
+
prev.layout.rows === next.layout.rows &&
|
|
1396
|
+
prev.layout.mode === next.layout.mode &&
|
|
1397
|
+
prev.uiState === next.uiState &&
|
|
1398
|
+
prev.viewportRows === next.viewportRows &&
|
|
1399
|
+
prev.verboseMode === next.verboseMode &&
|
|
1400
|
+
prev.authState === next.authState &&
|
|
1401
|
+
prev.workspaceLabel === next.workspaceLabel &&
|
|
1402
|
+
prev.workspaceRoot === next.workspaceRoot &&
|
|
1403
|
+
prev.providerLabel === next.providerLabel &&
|
|
1404
|
+
prev.startupHeaderMode === next.startupHeaderMode &&
|
|
1405
|
+
prev.showIntro === next.showIntro &&
|
|
1406
|
+
prev.contentSized === next.contentSized
|
|
1407
|
+
);
|
|
1408
|
+
});
|
|
1409
|
+
|
|
1410
|
+
export { Timeline as ActiveTimeline };
|