@aragon-agent/cli 0.5.8 → 0.5.10
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/CHANGELOG.md +1157 -747
- package/README.md +1552 -1163
- package/dist/agent/coalesce.d.ts +13 -0
- package/dist/agent/coalesce.js +50 -11
- package/dist/agent/controller.d.ts +141 -2
- package/dist/agent/controller.js +396 -4
- package/dist/agent/headless.d.ts +8 -0
- package/dist/agent/headless.js +40 -0
- package/dist/agent/reducer.d.ts +245 -3
- package/dist/agent/reducer.js +490 -17
- package/dist/agent/retry-view.d.ts +68 -0
- package/dist/agent/retry-view.js +95 -0
- package/dist/agent/system-prompt.d.ts +11 -0
- package/dist/agent/system-prompt.js +2 -0
- package/dist/boot/guard.d.ts +135 -0
- package/dist/boot/guard.js +218 -0
- package/dist/boot/rollback.d.ts +86 -0
- package/dist/boot/rollback.js +185 -0
- package/dist/cli.js +368 -3
- package/dist/commands/builtins.d.ts +1 -1
- package/dist/commands/builtins.js +426 -2
- package/dist/commands/registry.d.ts +17 -0
- package/dist/config/app-paths.d.ts +31 -0
- package/dist/config/app-paths.js +37 -0
- package/dist/config/cli-commands.d.ts +79 -2
- package/dist/config/cli-commands.js +185 -3
- package/dist/config/env.d.ts +15 -3
- package/dist/config/env.js +143 -3
- package/dist/config/load.d.ts +66 -0
- package/dist/config/load.js +107 -1
- package/dist/config/schema.d.ts +268 -2
- package/dist/config/schema.js +288 -1
- package/dist/config/store.js +32 -1
- package/dist/fast/critique.d.ts +43 -0
- package/dist/fast/critique.js +62 -0
- package/dist/fast/digest.d.ts +42 -0
- package/dist/fast/digest.js +85 -0
- package/dist/fast/frames.d.ts +99 -0
- package/dist/fast/frames.js +180 -0
- package/dist/fast/limits.d.ts +98 -0
- package/dist/fast/limits.js +98 -0
- package/dist/fast/prompt.d.ts +72 -0
- package/dist/fast/prompt.js +94 -0
- package/dist/fast/resolve.d.ts +43 -0
- package/dist/fast/resolve.js +105 -0
- package/dist/fast/review-call.d.ts +99 -0
- package/dist/fast/review-call.js +130 -0
- package/dist/fast/reviewer.d.ts +248 -0
- package/dist/fast/reviewer.js +664 -0
- package/dist/fast/types.d.ts +128 -0
- package/dist/fast/types.js +14 -0
- package/dist/fast/wiring.d.ts +170 -0
- package/dist/fast/wiring.js +316 -0
- package/dist/launcher.d.ts +14 -0
- package/dist/launcher.js +43 -0
- package/dist/logging/install.d.ts +15 -1
- package/dist/logging/install.js +37 -4
- package/dist/logging/logger.d.ts +1 -1
- package/dist/session/persist.js +57 -1
- package/dist/team/normalize.d.ts +26 -1
- package/dist/team/normalize.js +23 -3
- package/dist/team/prompt.d.ts +11 -0
- package/dist/team/prompt.js +10 -0
- package/dist/team/report.d.ts +18 -0
- package/dist/team/report.js +27 -1
- package/dist/team/runtime.d.ts +13 -2
- package/dist/team/runtime.js +33 -6
- package/dist/team/subagent.d.ts +19 -1
- package/dist/team/subagent.js +72 -10
- package/dist/team/task-tool.d.ts +26 -0
- package/dist/team/task-tool.js +48 -4
- package/dist/team/types.d.ts +80 -1
- package/dist/tools/bash-tool.js +19 -3
- package/dist/tools/diff.d.ts +25 -5
- package/dist/tools/diff.js +72 -41
- package/dist/tools/file-change-store.d.ts +34 -0
- package/dist/tools/file-change-store.js +57 -0
- package/dist/tools/fs-tools.d.ts +26 -0
- package/dist/tools/fs-tools.js +62 -5
- package/dist/tools/index.d.ts +31 -0
- package/dist/tools/index.js +7 -1
- package/dist/tools/patch.d.ts +115 -0
- package/dist/tools/patch.js +453 -0
- package/dist/tools/terminal-output.d.ts +107 -0
- package/dist/tools/terminal-output.js +198 -0
- package/dist/tools/tool-output-store.d.ts +68 -0
- package/dist/tools/tool-output-store.js +102 -0
- package/dist/ui/ActivityLine.d.ts +48 -0
- package/dist/ui/ActivityLine.js +22 -0
- package/dist/ui/App.d.ts +11 -4
- package/dist/ui/App.js +359 -37
- package/dist/ui/BottomStatusRow.d.ts +65 -0
- package/dist/ui/BottomStatusRow.js +17 -0
- package/dist/ui/Composer.d.ts +1 -6
- package/dist/ui/Composer.js +2 -2
- package/dist/ui/PromptInput.d.ts +1 -15
- package/dist/ui/PromptInput.js +1 -38
- package/dist/ui/StatusBar.d.ts +37 -0
- package/dist/ui/StatusBar.js +5 -3
- package/dist/ui/TeamPanel.d.ts +5 -4
- package/dist/ui/TeamPanel.js +1 -1
- package/dist/ui/TodoPanel.d.ts +5 -4
- package/dist/ui/Transcript.d.ts +26 -3
- package/dist/ui/Transcript.js +76 -10
- package/dist/ui/UpdateLine.d.ts +44 -0
- package/dist/ui/UpdateLine.js +129 -0
- package/dist/ui/activity-phrases.d.ts +33 -0
- package/dist/ui/activity-phrases.js +50 -0
- package/dist/ui/entries/AssistantEntry.d.ts +20 -0
- package/dist/ui/entries/AssistantEntry.js +3 -2
- package/dist/ui/entries/DiffView.d.ts +67 -0
- package/dist/ui/entries/DiffView.js +103 -0
- package/dist/ui/entries/FastCard.d.ts +53 -0
- package/dist/ui/entries/FastCard.js +87 -0
- package/dist/ui/entries/RetryCard.d.ts +56 -0
- package/dist/ui/entries/RetryCard.js +60 -0
- package/dist/ui/entries/TeamCard.js +6 -1
- package/dist/ui/entries/ToolCard.d.ts +38 -1
- package/dist/ui/entries/ToolCard.js +68 -5
- package/dist/ui/glyphs.d.ts +9 -0
- package/dist/ui/glyphs.js +2 -0
- package/dist/ui/layout/AppShell.d.ts +13 -22
- package/dist/ui/layout/AppShell.js +3 -38
- package/dist/ui/layout/virtual-window.d.ts +1 -1
- package/dist/ui/layout/virtual-window.js +109 -5
- package/dist/ui/overlays/HelpOverlay.js +16 -2
- package/dist/ui/overlays/SettingsScreen.d.ts +82 -1
- package/dist/ui/overlays/SettingsScreen.js +147 -1
- package/dist/ui/transcript-text.js +27 -0
- package/dist/ui/use-wheel-routing.d.ts +16 -32
- package/dist/ui/use-wheel-routing.js +21 -38
- package/dist/update/classify-failure.d.ts +32 -0
- package/dist/update/classify-failure.js +60 -0
- package/dist/update/install-lock.d.ts +55 -0
- package/dist/update/install-lock.js +184 -0
- package/dist/update/install-source.d.ts +97 -0
- package/dist/update/install-source.js +218 -0
- package/dist/update/installer.d.ts +108 -0
- package/dist/update/installer.js +242 -0
- package/dist/update/limits.d.ts +111 -0
- package/dist/update/limits.js +111 -0
- package/dist/update/npm-view.d.ts +62 -0
- package/dist/update/npm-view.js +133 -0
- package/dist/update/registry.d.ts +52 -0
- package/dist/update/registry.js +161 -0
- package/dist/update/semver.d.ts +89 -0
- package/dist/update/semver.js +295 -0
- package/dist/update/service.d.ts +193 -0
- package/dist/update/service.js +636 -0
- package/dist/update/state.d.ts +103 -0
- package/dist/update/state.js +142 -0
- package/dist/update/types.d.ts +169 -0
- package/dist/update/types.js +68 -0
- package/package.json +3 -3
- package/dist/ui/layout/regions.d.ts +0 -55
- package/dist/ui/layout/regions.js +0 -52
package/dist/agent/reducer.js
CHANGED
|
@@ -15,14 +15,27 @@ import { computeCost } from './usage.js';
|
|
|
15
15
|
import { ENTRY_LIMITS, appendBounded, entryRetain, trimEntries } from './entry-limits.js';
|
|
16
16
|
/** Default auto-dismiss window for a toast. */
|
|
17
17
|
export const DEFAULT_TOAST_TTL_MS = 2500;
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* The initial view state, optionally seeded from config.
|
|
20
|
+
*
|
|
21
|
+
* THE DEFAULT IS `false`, NOT `true`, AND THAT IS THE WHOLE SAFETY ARGUMENT
|
|
22
|
+
* (D-2). There is exactly one production call site — `App`'s `useReducer`, which
|
|
23
|
+
* passes `cfg.showThinking`. Every other caller is a test. A future call site
|
|
24
|
+
* that forgets the seed fails CLOSED, toward the product default, rather than
|
|
25
|
+
* silently restoring the reasoning firehose for one code path with nothing
|
|
26
|
+
* reporting it.
|
|
27
|
+
*
|
|
28
|
+
* The seed is a PARAMETER rather than a post-mount dispatch so that `/resume`
|
|
29
|
+
* cannot flash a restored thinking block on frame 1.
|
|
30
|
+
*/
|
|
31
|
+
export function initialViewState(seed = {}) {
|
|
19
32
|
return {
|
|
20
33
|
entries: [],
|
|
21
34
|
status: 'idle',
|
|
22
35
|
usageTotal: { inputTokens: 0, outputTokens: 0, costUsd: 0 },
|
|
23
36
|
contextTokens: 0,
|
|
24
37
|
overlay: null,
|
|
25
|
-
thinkingVisible:
|
|
38
|
+
thinkingVisible: seed.thinkingVisible ?? false,
|
|
26
39
|
toasts: [],
|
|
27
40
|
expandedToolIds: {},
|
|
28
41
|
// Seeded to the default posture; the App overrides it once on mount from
|
|
@@ -31,9 +44,13 @@ export function initialViewState() {
|
|
|
31
44
|
pendingAgentMode: null,
|
|
32
45
|
team: null,
|
|
33
46
|
todos: null,
|
|
47
|
+
retry: null,
|
|
48
|
+
fast: null,
|
|
34
49
|
seq: 0,
|
|
35
50
|
teamEntryId: undefined,
|
|
36
51
|
todoEntryId: undefined,
|
|
52
|
+
retryEntryId: undefined,
|
|
53
|
+
fastEntryId: undefined,
|
|
37
54
|
streamingId: undefined,
|
|
38
55
|
turnProduced: false,
|
|
39
56
|
errorNoticed: false,
|
|
@@ -80,12 +97,18 @@ export function buildToolPreview(result) {
|
|
|
80
97
|
}
|
|
81
98
|
return preview;
|
|
82
99
|
}
|
|
100
|
+
/** The first line of a stored preview, without its trailing newline. */
|
|
101
|
+
function firstLine(text) {
|
|
102
|
+
const at = text.indexOf('\n');
|
|
103
|
+
return at === -1 ? text : text.slice(0, at);
|
|
104
|
+
}
|
|
83
105
|
/**
|
|
84
106
|
* Map one core `AgentEvent` to view actions. `cost` (the active model's cost
|
|
85
107
|
* table) lets `turn_end` accumulate USD cost; omit it in tests that only assert
|
|
86
|
-
* token totals.
|
|
108
|
+
* token totals. `patches` is the CLI-local diff side channel (§3.3.7); omitting
|
|
109
|
+
* it — every existing test, and headless mode — produces byte-identical actions.
|
|
87
110
|
*/
|
|
88
|
-
export function reduceEvent(event, cost) {
|
|
111
|
+
export function reduceEvent(event, cost, patches) {
|
|
89
112
|
switch (event.type) {
|
|
90
113
|
case 'agent_start':
|
|
91
114
|
return [{ type: 'runStart' }];
|
|
@@ -103,16 +126,27 @@ export function reduceEvent(event, cost) {
|
|
|
103
126
|
return [{ type: 'runEnd' }];
|
|
104
127
|
case 'tool_execution_start':
|
|
105
128
|
return [{ type: 'toolExecStart', toolCallId: event.toolCallId }];
|
|
106
|
-
case 'tool_execution_end':
|
|
129
|
+
case 'tool_execution_end': {
|
|
130
|
+
const patch = patches?.take(event.toolCallId);
|
|
131
|
+
const preview = buildToolPreview(event.result);
|
|
107
132
|
return [
|
|
108
133
|
{
|
|
109
134
|
type: 'toolExecEnd',
|
|
110
135
|
toolCallId: event.toolCallId,
|
|
111
136
|
isError: event.isError,
|
|
112
137
|
duration: event.duration,
|
|
113
|
-
|
|
138
|
+
// AN ENTRY CARRIES A PATCH OR A FULL PREVIEW, NEVER BOTH (D-19). This
|
|
139
|
+
// is not a budget compromise, it is deleting a duplicate: `ToolCard`
|
|
140
|
+
// renders `DiffView` INSTEAD of `ToolPreview` when a patch exists, so
|
|
141
|
+
// the stored preview is never drawn on that card — and for `edit_file`
|
|
142
|
+
// it is literally the same diff, as text. Keeping the first line
|
|
143
|
+
// preserves the one thing outside `DiffView` that still reads it,
|
|
144
|
+
// `entryRevision`'s `preview?.length` term.
|
|
145
|
+
preview: patch ? firstLine(preview) : preview,
|
|
146
|
+
...(patch ? { patch } : {}),
|
|
114
147
|
},
|
|
115
148
|
];
|
|
149
|
+
}
|
|
116
150
|
case 'message_update':
|
|
117
151
|
return reduceStreamEvent(event.streamEvent);
|
|
118
152
|
case 'code_execution_start':
|
|
@@ -168,6 +202,23 @@ function reduceStreamEvent(se) {
|
|
|
168
202
|
case 'done':
|
|
169
203
|
// Authoritative usage is redundant with `turn_end`; nothing to add.
|
|
170
204
|
return [];
|
|
205
|
+
// --- API retry (llm-api-retry-backoff §6.4) ---------------------------
|
|
206
|
+
case 'retry_scheduled':
|
|
207
|
+
return [
|
|
208
|
+
{
|
|
209
|
+
type: 'retryScheduled',
|
|
210
|
+
attempt: se.attempt,
|
|
211
|
+
maxRetries: se.maxRetries,
|
|
212
|
+
delayMs: se.delayMs,
|
|
213
|
+
resumeAt: se.resumeAt,
|
|
214
|
+
errorType: se.errorType,
|
|
215
|
+
message: se.message,
|
|
216
|
+
},
|
|
217
|
+
];
|
|
218
|
+
case 'retry_attempt':
|
|
219
|
+
return [{ type: 'retryAttempt', attempt: se.attempt, maxRetries: se.maxRetries }];
|
|
220
|
+
case 'stream_restart':
|
|
221
|
+
return [{ type: 'streamRestart', discardedToolCallIds: se.discardedToolCallIds }];
|
|
171
222
|
default:
|
|
172
223
|
return [];
|
|
173
224
|
}
|
|
@@ -204,6 +255,18 @@ function nextId(state) {
|
|
|
204
255
|
function mapEntry(entries, id, fn) {
|
|
205
256
|
return entries.map((e) => (e.id === id ? fn(e) : e));
|
|
206
257
|
}
|
|
258
|
+
/**
|
|
259
|
+
* Seal `thinkingMs`, once, as a spreadable patch (§3.1.2).
|
|
260
|
+
*
|
|
261
|
+
* Returns an EMPTY object when there is nothing to seal — no reasoning happened,
|
|
262
|
+
* or the clock has already stopped — so the three call sites can spread it
|
|
263
|
+
* unconditionally and none of them can re-seal a settled duration.
|
|
264
|
+
*/
|
|
265
|
+
function sealThinkingMs(entry) {
|
|
266
|
+
if (entry.thinkingStartedAt === undefined || entry.thinkingMs !== undefined)
|
|
267
|
+
return {};
|
|
268
|
+
return { thinkingMs: Math.max(0, Date.now() - entry.thinkingStartedAt) };
|
|
269
|
+
}
|
|
207
270
|
/**
|
|
208
271
|
* Append one entry and ring-trim the result (tui-render-performance L1).
|
|
209
272
|
*
|
|
@@ -221,7 +284,18 @@ function mapEntry(entries, id, fn) {
|
|
|
221
284
|
function appendEntry(state, entries, entry) {
|
|
222
285
|
const trimmed = trimEntries([...entries, entry], entryRetain());
|
|
223
286
|
if (trimmed.dropped > 0 && process.env.NODE_ENV !== 'production') {
|
|
224
|
-
|
|
287
|
+
// `retryEntryId` IS PART OF THIS LIST. A fourth live card that is not in it is
|
|
288
|
+
// a fourth card I-L1-1 does not cover — the trim would silently turn its
|
|
289
|
+
// `mapEntry` into a no-op instead of failing loudly.
|
|
290
|
+
const live = [
|
|
291
|
+
state.streamingId,
|
|
292
|
+
state.teamEntryId,
|
|
293
|
+
state.todoEntryId,
|
|
294
|
+
state.retryEntryId,
|
|
295
|
+
// `fastEntryId` IS PART OF THIS LIST for the reason the note above gives:
|
|
296
|
+
// a fifth live card left out of it is a card I-L1-1 does not cover.
|
|
297
|
+
state.fastEntryId,
|
|
298
|
+
].filter((id) => typeof id === 'string');
|
|
225
299
|
for (const id of live) {
|
|
226
300
|
if (!trimmed.entries.some((e) => e.id === id)) {
|
|
227
301
|
throw new Error(`trimEntries dropped a live entry (${id}) - see I-L1-1`);
|
|
@@ -233,6 +307,79 @@ function appendEntry(state, entries, entry) {
|
|
|
233
307
|
droppedEntries: state.droppedEntries + trimmed.dropped,
|
|
234
308
|
};
|
|
235
309
|
}
|
|
310
|
+
/**
|
|
311
|
+
* Settle the turn's retry card, if it has one (llm-api-retry-backoff §6.4).
|
|
312
|
+
*
|
|
313
|
+
* SETTLING IS DERIVED HERE, NOT SENT BY CORE. Core knows it scheduled a retry; it
|
|
314
|
+
* does not know whether the turn eventually succeeded, was interrupted, or ran out
|
|
315
|
+
* of budget — that is the consumer's own timeline.
|
|
316
|
+
*
|
|
317
|
+
* FOUR PATHS REACH THIS, and the fourth is what makes the set exhaustive rather
|
|
318
|
+
* than enumerated: `turnEnd` (recovered), an error `notice` (exhausted),
|
|
319
|
+
* `abortMark` (interrupted) and `runEnd` (interrupted). The rule is "every path
|
|
320
|
+
* that reaches `status: 'idle'` settles the card", because a card left at
|
|
321
|
+
* `phase: 'waiting'` pins `Transcript`'s MONOTONIC settled boundary and re-renders
|
|
322
|
+
* the tail every frame for the rest of the session (R-10).
|
|
323
|
+
*
|
|
324
|
+
* Returning `retry: null` / `retryEntryId: undefined` unconditionally is
|
|
325
|
+
* deliberate: both are already that when there is no card, so every caller can
|
|
326
|
+
* spread the result without a branch of its own.
|
|
327
|
+
*/
|
|
328
|
+
function settleRetryCard(state, entries, phase) {
|
|
329
|
+
if (!state.retryEntryId)
|
|
330
|
+
return { entries, retry: null, retryEntryId: undefined };
|
|
331
|
+
const now = Date.now();
|
|
332
|
+
return {
|
|
333
|
+
entries: mapEntry(entries, state.retryEntryId, (e) => e.kind === 'retry'
|
|
334
|
+
? {
|
|
335
|
+
...e,
|
|
336
|
+
phase,
|
|
337
|
+
totalRetries: e.attempt,
|
|
338
|
+
elapsedMs: Math.max(0, now - e.startedAt),
|
|
339
|
+
resumeAt: undefined,
|
|
340
|
+
}
|
|
341
|
+
: e),
|
|
342
|
+
retry: null,
|
|
343
|
+
retryEntryId: undefined,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* EVERY PATH THAT REACHES `status: 'idle'` RELEASES THE LIVE TAIL (D-35 / AC-40).
|
|
348
|
+
*
|
|
349
|
+
* `runEnd` finalizes only the streaming ASSISTANT entry and `abortMark` maps only
|
|
350
|
+
* assistant entries, so neither touches a tool entry stranded at
|
|
351
|
+
* `status: 'running'` -- the engine's own idle watchdog, a provider error mid-call
|
|
352
|
+
* and `Esc` all produce one. Today that costs a single spinner row. With a tail
|
|
353
|
+
* attached it costs a permanent multi-row card whose stall counter is frozen at
|
|
354
|
+
* its last value, because the 200 ms ticker that feeds `nowSec` is torn down the
|
|
355
|
+
* instant `state.status` leaves `running` -- and, worse, `computeSettledCount`
|
|
356
|
+
* breaks on a non-settled tool entry, so that card pins `Transcript`'s MONOTONIC
|
|
357
|
+
* settled boundary and re-renders the tail every frame for the rest of the
|
|
358
|
+
* session. The rule and the stakes are `settleRetryCard`'s, verbatim.
|
|
359
|
+
*
|
|
360
|
+
* THE STATUS IS DELIBERATELY NOT CHANGED. That is a different decision with
|
|
361
|
+
* different consequences for `bashBadge` and for the run's own bookkeeping, and
|
|
362
|
+
* it is not this round's to make. Only the tail is released.
|
|
363
|
+
*
|
|
364
|
+
* `liveSeq` is BUMPED rather than cleared, and that is not bookkeeping: clearing
|
|
365
|
+
* `live` is a non-append mutation, and with the status untouched nothing else in
|
|
366
|
+
* the tool revision moves -- so without the bump the height cache would keep
|
|
367
|
+
* serving the multi-row measurement for a card that now draws one row (I-L3-1).
|
|
368
|
+
* Entries with no tail are returned by identity, which is what keeps
|
|
369
|
+
* `EntryView`'s memo boundary intact for the rest of the transcript.
|
|
370
|
+
*/
|
|
371
|
+
function releaseLiveTails(entries) {
|
|
372
|
+
let touched = false;
|
|
373
|
+
const next = entries.map((e) => {
|
|
374
|
+
if (e.kind !== 'tool' || !e.live)
|
|
375
|
+
return e;
|
|
376
|
+
if (e.status !== 'running' && e.status !== 'pending')
|
|
377
|
+
return e;
|
|
378
|
+
touched = true;
|
|
379
|
+
return { ...e, live: undefined, lastOutputAt: undefined, liveSeq: (e.liveSeq ?? 0) + 1 };
|
|
380
|
+
});
|
|
381
|
+
return touched ? next : entries;
|
|
382
|
+
}
|
|
236
383
|
function findToolEntryId(state, toolCallId) {
|
|
237
384
|
for (let i = state.entries.length - 1; i >= 0; i -= 1) {
|
|
238
385
|
const e = state.entries[i];
|
|
@@ -255,6 +402,10 @@ export function viewReducer(state, action) {
|
|
|
255
402
|
// transcript. The PREVIOUS card stays exactly where it is — it is
|
|
256
403
|
// history, and history is not retracted.
|
|
257
404
|
todoEntryId: undefined,
|
|
405
|
+
// Same rule for the retry card. `runStart` clears NONE of the three (it
|
|
406
|
+
// never has), and `runEnd`'s settle rule below is what makes that safe
|
|
407
|
+
// rather than merely lucky.
|
|
408
|
+
retryEntryId: undefined,
|
|
258
409
|
turnProduced: false,
|
|
259
410
|
errorNoticed: false,
|
|
260
411
|
aborted: false,
|
|
@@ -285,7 +436,16 @@ export function viewReducer(state, action) {
|
|
|
285
436
|
return state;
|
|
286
437
|
return {
|
|
287
438
|
...state,
|
|
288
|
-
entries: mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant'
|
|
439
|
+
entries: mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant'
|
|
440
|
+
? {
|
|
441
|
+
...e,
|
|
442
|
+
thinking: e.thinking ?? '',
|
|
443
|
+
thinkingOpen: true,
|
|
444
|
+
// `Date.now()` in the reducer is not a new impurity class: the
|
|
445
|
+
// retry card's `startedAt` already reads the clock here.
|
|
446
|
+
thinkingStartedAt: e.thinkingStartedAt ?? Date.now(),
|
|
447
|
+
}
|
|
448
|
+
: e),
|
|
289
449
|
};
|
|
290
450
|
}
|
|
291
451
|
case 'thinkingDelta': {
|
|
@@ -304,7 +464,14 @@ export function viewReducer(state, action) {
|
|
|
304
464
|
return {
|
|
305
465
|
...state,
|
|
306
466
|
entries: mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant'
|
|
307
|
-
? {
|
|
467
|
+
? {
|
|
468
|
+
...e,
|
|
469
|
+
text: appendBounded(e.text, action.delta, ENTRY_LIMITS.text),
|
|
470
|
+
// FIRST TEXT DELTA ONLY. Thinking is over the moment the answer
|
|
471
|
+
// starts; re-sealing on every delta would make the number climb
|
|
472
|
+
// with the answer's length.
|
|
473
|
+
...sealThinkingMs(e),
|
|
474
|
+
}
|
|
308
475
|
: e),
|
|
309
476
|
};
|
|
310
477
|
}
|
|
@@ -352,6 +519,28 @@ export function viewReducer(state, action) {
|
|
|
352
519
|
entries: mapEntry(state.entries, id, (e) => e.kind === 'tool' ? { ...e, status: 'running' } : e),
|
|
353
520
|
};
|
|
354
521
|
}
|
|
522
|
+
/**
|
|
523
|
+
* THE STATUS GUARD IS WHAT KEEPS A LATE CHUNK FROM RESURRECTING A SETTLED
|
|
524
|
+
* CARD. The child's last write and its `close` event race, so a chunk can
|
|
525
|
+
* legitimately arrive after `tool_execution_end`; without the guard it would
|
|
526
|
+
* reattach a tail to a card whose authoritative preview is already drawn.
|
|
527
|
+
*/
|
|
528
|
+
case 'toolOutputDelta': {
|
|
529
|
+
const id = findToolEntryId(state, action.toolCallId);
|
|
530
|
+
if (!id)
|
|
531
|
+
return state;
|
|
532
|
+
return {
|
|
533
|
+
...state,
|
|
534
|
+
entries: mapEntry(state.entries, id, (e) => e.kind === 'tool' && (e.status === 'running' || e.status === 'pending')
|
|
535
|
+
? {
|
|
536
|
+
...e,
|
|
537
|
+
live: action.rows,
|
|
538
|
+
liveSeq: (e.liveSeq ?? 0) + 1,
|
|
539
|
+
lastOutputAt: action.at,
|
|
540
|
+
}
|
|
541
|
+
: e),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
355
544
|
case 'toolExecEnd': {
|
|
356
545
|
const id = findToolEntryId(state, action.toolCallId);
|
|
357
546
|
if (!id)
|
|
@@ -365,6 +554,13 @@ export function viewReducer(state, action) {
|
|
|
365
554
|
isError: action.isError,
|
|
366
555
|
durationMs: action.duration,
|
|
367
556
|
preview: action.preview,
|
|
557
|
+
...(action.patch ? { patch: action.patch } : {}),
|
|
558
|
+
// THE TAIL IS NEVER MERGED INTO `preview` (D-23): the
|
|
559
|
+
// authoritative result already contains everything it held, in
|
|
560
|
+
// full, and concatenating them would double the output and break
|
|
561
|
+
// `bashBadge`'s last-line parse (`ToolPreview.tsx:35-54`).
|
|
562
|
+
live: undefined,
|
|
563
|
+
lastOutputAt: undefined,
|
|
368
564
|
}
|
|
369
565
|
: e),
|
|
370
566
|
};
|
|
@@ -372,12 +568,20 @@ export function viewReducer(state, action) {
|
|
|
372
568
|
case 'turnEnd': {
|
|
373
569
|
const entries = state.streamingId
|
|
374
570
|
? mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant'
|
|
375
|
-
? {
|
|
571
|
+
? {
|
|
572
|
+
...e,
|
|
573
|
+
streaming: false,
|
|
574
|
+
thinkingOpen: false,
|
|
575
|
+
usage: action.usage,
|
|
576
|
+
// The turn that thought and then called a tool without emitting
|
|
577
|
+
// any text never saw a `textDelta`, so this is where its clock
|
|
578
|
+
// is sealed.
|
|
579
|
+
...sealThinkingMs(e),
|
|
580
|
+
}
|
|
376
581
|
: e)
|
|
377
582
|
: state.entries;
|
|
378
583
|
return {
|
|
379
584
|
...state,
|
|
380
|
-
entries,
|
|
381
585
|
streamingId: undefined,
|
|
382
586
|
turnProduced: true,
|
|
383
587
|
contextTokens: action.usage.inputTokens + action.usage.outputTokens,
|
|
@@ -386,12 +590,14 @@ export function viewReducer(state, action) {
|
|
|
386
590
|
outputTokens: state.usageTotal.outputTokens + action.usage.outputTokens,
|
|
387
591
|
costUsd: state.usageTotal.costUsd + action.costDelta,
|
|
388
592
|
},
|
|
593
|
+
// The turn produced an answer, so whatever it had to retry, it recovered.
|
|
594
|
+
...settleRetryCard(state, entries, 'recovered'),
|
|
389
595
|
};
|
|
390
596
|
}
|
|
391
597
|
case 'runEnd': {
|
|
392
598
|
// Finalize any still-streaming entry.
|
|
393
599
|
let entries = state.streamingId
|
|
394
|
-
? mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant' ? { ...e, streaming: false } : e)
|
|
600
|
+
? mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant' ? { ...e, streaming: false, ...sealThinkingMs(e) } : e)
|
|
395
601
|
: state.entries;
|
|
396
602
|
let seq = state.seq;
|
|
397
603
|
let droppedEntries = state.droppedEntries;
|
|
@@ -414,27 +620,61 @@ export function viewReducer(state, action) {
|
|
|
414
620
|
if (state.todoEntryId) {
|
|
415
621
|
entries = mapEntry(entries, state.todoEntryId, (e) => e.kind === 'todo' ? { ...e, live: false } : e);
|
|
416
622
|
}
|
|
417
|
-
return {
|
|
623
|
+
return {
|
|
624
|
+
...state,
|
|
625
|
+
seq,
|
|
626
|
+
droppedEntries,
|
|
627
|
+
status: 'idle',
|
|
628
|
+
streamingId: undefined,
|
|
629
|
+
// THE TERMINAL BACKSTOP, and it is not redundant with `abortMark`
|
|
630
|
+
// (llm-api-retry-backoff P1-8 / AC-25b). `abortMark` is dispatched only
|
|
631
|
+
// from the Esc handler, but the ENGINE also aborts itself: the idle
|
|
632
|
+
// watchdog calls `Agent.abort()` directly, the run unwinds, and the UI
|
|
633
|
+
// sees `agent_end` -> `runEnd` with no `abortMark`, no `turn_end` and no
|
|
634
|
+
// error notice. A card left at `phase: 'waiting'` then pins
|
|
635
|
+
// `Transcript`'s monotonic boundary and re-renders the tail every frame
|
|
636
|
+
// for the rest of the session. By the time this runs the three other
|
|
637
|
+
// settle paths have already cleared `retryEntryId`, so this only fires on
|
|
638
|
+
// a genuinely unsettled card.
|
|
639
|
+
//
|
|
640
|
+
// `releaseLiveTails` rides the same argument one entry kind over (D-35):
|
|
641
|
+
// a tool card stranded at `running` by this very path would otherwise
|
|
642
|
+
// keep a tail and a frozen stall clock for the rest of the session.
|
|
643
|
+
...settleRetryCard(state, releaseLiveTails(entries), 'interrupted'),
|
|
644
|
+
};
|
|
418
645
|
}
|
|
419
646
|
case 'notice': {
|
|
420
647
|
const { id, seq } = nextId(state);
|
|
421
648
|
const entry = { id, kind: 'notice', level: action.level, text: action.text };
|
|
649
|
+
const appended = appendEntry(state, state.entries, entry);
|
|
650
|
+
// A terminal error notice is what an EXHAUSTED ladder looks like from here:
|
|
651
|
+
// the retry wrapper forwards the provider's own error once it stops
|
|
652
|
+
// retrying, and that error always precedes any `turn_end` because a failed
|
|
653
|
+
// turn has none. A non-error notice leaves the card alone.
|
|
654
|
+
const settled = action.level === 'error'
|
|
655
|
+
? settleRetryCard(state, appended.entries, 'exhausted')
|
|
656
|
+
: { entries: appended.entries };
|
|
422
657
|
return {
|
|
423
658
|
...state,
|
|
424
659
|
seq,
|
|
425
|
-
|
|
660
|
+
droppedEntries: appended.droppedEntries,
|
|
426
661
|
errorNoticed: action.level === 'error' ? true : state.errorNoticed,
|
|
662
|
+
...settled,
|
|
427
663
|
};
|
|
428
664
|
}
|
|
429
665
|
case 'abortMark': {
|
|
430
666
|
// Mark the run aborted even when no assistant entry is streaming (e.g. an
|
|
431
667
|
// abort during a tool call) so the runEnd failure guard stays suppressed.
|
|
432
|
-
|
|
433
|
-
|
|
668
|
+
const marked = state.streamingId
|
|
669
|
+
? mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant' ? { ...e, streaming: false, aborted: true } : e)
|
|
670
|
+
: state.entries;
|
|
434
671
|
return {
|
|
435
672
|
...state,
|
|
436
673
|
aborted: true,
|
|
437
|
-
|
|
674
|
+
// An abort during a tool call is the COMMON way a tail is stranded --
|
|
675
|
+
// `Esc` while `npm test` runs -- so this is the clause manual row 9
|
|
676
|
+
// exercises (D-35 / AC-40).
|
|
677
|
+
...settleRetryCard(state, releaseLiveTails(marked), 'interrupted'),
|
|
438
678
|
};
|
|
439
679
|
}
|
|
440
680
|
// `teamEntryId` is dropped with the entries it pointed at. Keeping it would
|
|
@@ -457,6 +697,12 @@ export function viewReducer(state, action) {
|
|
|
457
697
|
// the conversation, so the model still believes in the plan, and a panel
|
|
458
698
|
// that disagrees with the model is worse than no panel.
|
|
459
699
|
todoEntryId: undefined,
|
|
700
|
+
// Same reasoning for the retry card: the entry is gone, so the id must go
|
|
701
|
+
// with it or `retryScheduled` would `mapEntry` an id that no longer
|
|
702
|
+
// exists. `retry` goes too — unlike `todos` it is not a belief the model
|
|
703
|
+
// holds, it is a projection of an entry that has just been erased.
|
|
704
|
+
retryEntryId: undefined,
|
|
705
|
+
retry: null,
|
|
460
706
|
};
|
|
461
707
|
case 'resetConversation':
|
|
462
708
|
return {
|
|
@@ -473,6 +719,8 @@ export function viewReducer(state, action) {
|
|
|
473
719
|
// the STORE; this clears the mirror.
|
|
474
720
|
todos: null,
|
|
475
721
|
todoEntryId: undefined,
|
|
722
|
+
retryEntryId: undefined,
|
|
723
|
+
retry: null,
|
|
476
724
|
};
|
|
477
725
|
case 'setOverlay':
|
|
478
726
|
return { ...state, overlay: action.overlay };
|
|
@@ -509,6 +757,10 @@ export function viewReducer(state, action) {
|
|
|
509
757
|
const runs = action.specs.map((spec) => ({
|
|
510
758
|
label: spec.label,
|
|
511
759
|
description: spec.description,
|
|
760
|
+
// Already downgraded by the normalizer when the tier was unavailable, so
|
|
761
|
+
// the card and the report agree from the first frame (fast-model-tier
|
|
762
|
+
// §3.4).
|
|
763
|
+
tier: spec.tier,
|
|
512
764
|
phase: 'queued',
|
|
513
765
|
turns: 0,
|
|
514
766
|
toolCalls: 0,
|
|
@@ -582,6 +834,12 @@ export function viewReducer(state, action) {
|
|
|
582
834
|
}
|
|
583
835
|
// --- Todo planning (todo-plan-execution §5.2) -------------------------
|
|
584
836
|
case 'todoUpdate': {
|
|
837
|
+
// TYPED AS THE TODO MEMBER, NOT AS `Entry`. The rewrite below spreads this
|
|
838
|
+
// and overrides `live: boolean`; while the return type was the whole union
|
|
839
|
+
// that spread also produced a TOOL entry carrying `live: boolean`, which
|
|
840
|
+
// stopped type-checking the moment the tool member gained its own `live`
|
|
841
|
+
// (agent-activity-presentation-live). Narrowing here is what keeps the
|
|
842
|
+
// override honest rather than accidentally legal.
|
|
585
843
|
const entry = (id) => ({
|
|
586
844
|
id,
|
|
587
845
|
kind: 'todo',
|
|
@@ -613,6 +871,212 @@ export function viewReducer(state, action) {
|
|
|
613
871
|
// live projection goes away, which is what unmounts the rail.
|
|
614
872
|
case 'todoCleared':
|
|
615
873
|
return { ...state, todos: null, todoEntryId: undefined };
|
|
874
|
+
// --- Fast model tier (fast-model-tier §5.4) ---------------------------
|
|
875
|
+
case 'fastStart': {
|
|
876
|
+
const { id, seq } = nextId(state);
|
|
877
|
+
const entry = {
|
|
878
|
+
id,
|
|
879
|
+
kind: 'fast',
|
|
880
|
+
reviewIndex: action.index,
|
|
881
|
+
model: action.model,
|
|
882
|
+
status: 'running',
|
|
883
|
+
turn: action.turn,
|
|
884
|
+
live: true,
|
|
885
|
+
};
|
|
886
|
+
return {
|
|
887
|
+
...state,
|
|
888
|
+
seq,
|
|
889
|
+
...appendEntry(state, state.entries, entry),
|
|
890
|
+
fastEntryId: id,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
case 'fastEnd': {
|
|
894
|
+
const { review } = action;
|
|
895
|
+
const settle = (e) => e.kind === 'fast'
|
|
896
|
+
? {
|
|
897
|
+
...e,
|
|
898
|
+
status: review.kind,
|
|
899
|
+
...(review.text !== undefined ? { text: review.text } : {}),
|
|
900
|
+
...(review.detail !== undefined ? { detail: review.detail } : {}),
|
|
901
|
+
durationMs: review.durationMs,
|
|
902
|
+
// SETTLED. `Transcript`'s boundary is monotonic, so a card that
|
|
903
|
+
// never settles is re-rendered on every frame for the rest of the
|
|
904
|
+
// session (C-5).
|
|
905
|
+
live: false,
|
|
906
|
+
}
|
|
907
|
+
: e;
|
|
908
|
+
// Rewrite the card this review opened when there is one; otherwise append
|
|
909
|
+
// a settled card. THE SECOND BRANCH IS NOT DEFENSIVE PADDING: a review
|
|
910
|
+
// that is dropped as `stranded` is emitted from `agent_end` and never had
|
|
911
|
+
// a `review_start`, and a run restored from `/resume` has no live id at
|
|
912
|
+
// all — "it did nothing" must never be the observable outcome (R-8).
|
|
913
|
+
if (state.fastEntryId) {
|
|
914
|
+
return {
|
|
915
|
+
...state,
|
|
916
|
+
entries: mapEntry(state.entries, state.fastEntryId, settle),
|
|
917
|
+
fastEntryId: undefined,
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
const { id, seq } = nextId(state);
|
|
921
|
+
const entry = {
|
|
922
|
+
id,
|
|
923
|
+
kind: 'fast',
|
|
924
|
+
reviewIndex: review.index,
|
|
925
|
+
model: review.model,
|
|
926
|
+
status: review.kind,
|
|
927
|
+
...(review.text !== undefined ? { text: review.text } : {}),
|
|
928
|
+
...(review.detail !== undefined ? { detail: review.detail } : {}),
|
|
929
|
+
turn: review.turn,
|
|
930
|
+
durationMs: review.durationMs,
|
|
931
|
+
live: false,
|
|
932
|
+
};
|
|
933
|
+
return { ...state, seq, ...appendEntry(state, state.entries, entry) };
|
|
934
|
+
}
|
|
935
|
+
case 'fastUsage':
|
|
936
|
+
// `usageTotal` AND NOTHING ELSE, exactly as `teamUsage` (§3.6 / AC-18). In
|
|
937
|
+
// particular NOT `contextTokens`: that gauge shows the LEAD's context
|
|
938
|
+
// occupancy against the lead model's window, and a review is a separate
|
|
939
|
+
// conversation with a different model entirely.
|
|
940
|
+
return {
|
|
941
|
+
...state,
|
|
942
|
+
usageTotal: {
|
|
943
|
+
inputTokens: state.usageTotal.inputTokens + action.usage.inputTokens,
|
|
944
|
+
outputTokens: state.usageTotal.outputTokens + action.usage.outputTokens,
|
|
945
|
+
costUsd: state.usageTotal.costUsd + action.costDelta,
|
|
946
|
+
},
|
|
947
|
+
};
|
|
948
|
+
case 'fastTier':
|
|
949
|
+
return { ...state, fast: action.snapshot };
|
|
950
|
+
// --- API retry (llm-api-retry-backoff §6.4) ---------------------------
|
|
951
|
+
case 'retryScheduled': {
|
|
952
|
+
const snapshot = {
|
|
953
|
+
attempt: action.attempt,
|
|
954
|
+
maxRetries: action.maxRetries,
|
|
955
|
+
resumeAt: action.resumeAt,
|
|
956
|
+
phase: 'waiting',
|
|
957
|
+
errorType: action.errorType,
|
|
958
|
+
};
|
|
959
|
+
// ONE CARD PER TURN, REWRITTEN IN PLACE. One `mapEntry` on the KNOWN id,
|
|
960
|
+
// never a scan — the same discipline `todoUpdate` follows.
|
|
961
|
+
if (state.retryEntryId) {
|
|
962
|
+
const known = state.retryEntryId;
|
|
963
|
+
return {
|
|
964
|
+
...state,
|
|
965
|
+
retry: snapshot,
|
|
966
|
+
entries: mapEntry(state.entries, known, (e) => e.kind === 'retry'
|
|
967
|
+
? {
|
|
968
|
+
...e,
|
|
969
|
+
attempt: action.attempt,
|
|
970
|
+
maxRetries: action.maxRetries,
|
|
971
|
+
errorType: action.errorType,
|
|
972
|
+
message: action.message,
|
|
973
|
+
delayMs: action.delayMs,
|
|
974
|
+
resumeAt: action.resumeAt,
|
|
975
|
+
phase: 'waiting',
|
|
976
|
+
}
|
|
977
|
+
: e),
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
const { id, seq } = nextId(state);
|
|
981
|
+
const entry = {
|
|
982
|
+
id,
|
|
983
|
+
kind: 'retry',
|
|
984
|
+
attempt: action.attempt,
|
|
985
|
+
maxRetries: action.maxRetries,
|
|
986
|
+
errorType: action.errorType,
|
|
987
|
+
message: action.message,
|
|
988
|
+
delayMs: action.delayMs,
|
|
989
|
+
resumeAt: action.resumeAt,
|
|
990
|
+
phase: 'waiting',
|
|
991
|
+
// The clock the settled card's `elapsedMs` is measured from: the FIRST
|
|
992
|
+
// retry of the turn, not the last.
|
|
993
|
+
startedAt: Date.now(),
|
|
994
|
+
};
|
|
995
|
+
return {
|
|
996
|
+
...state,
|
|
997
|
+
seq,
|
|
998
|
+
retry: snapshot,
|
|
999
|
+
...appendEntry(state, state.entries, entry),
|
|
1000
|
+
retryEntryId: id,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
case 'retryAttempt': {
|
|
1004
|
+
if (!state.retryEntryId)
|
|
1005
|
+
return state;
|
|
1006
|
+
const known = state.retryEntryId;
|
|
1007
|
+
return {
|
|
1008
|
+
...state,
|
|
1009
|
+
// `resumeAt` is CLEARED: the wait is over, so a countdown would tick
|
|
1010
|
+
// downward past zero while the request is already in flight.
|
|
1011
|
+
retry: state.retry ? { ...state.retry, phase: 'retrying', resumeAt: undefined } : null,
|
|
1012
|
+
entries: mapEntry(state.entries, known, (e) => e.kind === 'retry' ? { ...e, phase: 'retrying', resumeAt: undefined } : e),
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* Rewind the view for a mid-stream restart.
|
|
1017
|
+
*
|
|
1018
|
+
* THE ONLY ACTION IN THIS REDUCER THAT SHRINKS `entries`, and it is safe for
|
|
1019
|
+
* one specific reason (§6.4 / AC-22). `Transcript` clamps its settled boundary
|
|
1020
|
+
* with a MONOTONIC high-water mark: if `highWater` ever exceeded
|
|
1021
|
+
* `entries.length` the clamp would mark EVERYTHING settled — including the
|
|
1022
|
+
* still-streaming assistant entry — and `<Static>` would duplicate it.
|
|
1023
|
+
*
|
|
1024
|
+
* It cannot, and the ORDERING is why: the entries removed here are
|
|
1025
|
+
* `kind: 'tool'` cards created by `toolCallStart` AFTER `turnStart` created the
|
|
1026
|
+
* streaming assistant entry, so they sit strictly above it in the array; and
|
|
1027
|
+
* the boundary scan already breaks at that entry. Removing only entries above
|
|
1028
|
+
* the boundary is the invariant. A future action that removed a SETTLED entry
|
|
1029
|
+
* would break `<Static>` and nothing would report it.
|
|
1030
|
+
*/
|
|
1031
|
+
case 'streamRestart': {
|
|
1032
|
+
const discarded = new Set(action.discardedToolCallIds);
|
|
1033
|
+
const entries = state.entries.filter((e) => !(e.kind === 'tool' && discarded.has(e.toolCallId)));
|
|
1034
|
+
return {
|
|
1035
|
+
...state,
|
|
1036
|
+
entries: state.streamingId
|
|
1037
|
+
? mapEntry(entries, state.streamingId, (e) => e.kind === 'assistant'
|
|
1038
|
+
? {
|
|
1039
|
+
...e,
|
|
1040
|
+
text: '',
|
|
1041
|
+
thinking: undefined,
|
|
1042
|
+
thinkingOpen: false,
|
|
1043
|
+
// A restarted stream re-thinks from scratch. Carrying the old
|
|
1044
|
+
// clock forward would report a duration for reasoning that
|
|
1045
|
+
// was discarded.
|
|
1046
|
+
thinkingStartedAt: undefined,
|
|
1047
|
+
thinkingMs: undefined,
|
|
1048
|
+
}
|
|
1049
|
+
: e)
|
|
1050
|
+
: entries,
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* The 1 Hz countdown tick. NO VALUE CHANGES — the card is refreshed by
|
|
1055
|
+
* IDENTITY so that React re-renders exactly one entry.
|
|
1056
|
+
*
|
|
1057
|
+
* A FLAT NONCE ON `ViewState` WOULD NOT WORK, and this is the trap worth
|
|
1058
|
+
* naming: `EntryView` is `React.memo`'d with an explicit comparator whose
|
|
1059
|
+
* first clause is `a.entry === b.entry`, so a state field the card does not
|
|
1060
|
+
* receive as a prop changes nothing at all — the countdown would freeze at
|
|
1061
|
+
* whatever second it was first drawn on, and the transcript would keep
|
|
1062
|
+
* re-rendering around it for no benefit. Rewriting the entry is what breaks
|
|
1063
|
+
* that boundary, and `EntryView` reads the clock for this kind only.
|
|
1064
|
+
*
|
|
1065
|
+
* `entryRevision` is deliberately NOT extended: the card is one row in every
|
|
1066
|
+
* phase, so the cached height stays correct and no re-measure is wanted.
|
|
1067
|
+
*
|
|
1068
|
+
* Returning `state` unchanged when nothing is waiting is what keeps a stray
|
|
1069
|
+
* tick from costing a render at all.
|
|
1070
|
+
*/
|
|
1071
|
+
case 'retryTick': {
|
|
1072
|
+
if (state.retry?.phase !== 'waiting' || !state.retryEntryId)
|
|
1073
|
+
return state;
|
|
1074
|
+
const known = state.retryEntryId;
|
|
1075
|
+
return {
|
|
1076
|
+
...state,
|
|
1077
|
+
entries: mapEntry(state.entries, known, (e) => (e.kind === 'retry' ? { ...e } : e)),
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
616
1080
|
case 'restoreEntries': {
|
|
617
1081
|
// Adopt a seq beyond any restored id to keep future ids unique.
|
|
618
1082
|
const maxSeq = action.entries.reduce((acc, e) => {
|
|
@@ -633,6 +1097,15 @@ export function viewReducer(state, action) {
|
|
|
633
1097
|
// The LIST itself arrives separately, through `controller.restoreTodos`
|
|
634
1098
|
// and the store's own event (§3.13).
|
|
635
1099
|
todoEntryId: undefined,
|
|
1100
|
+
// Same for the retry card. `normalizeLoadedEntries` has already forced any
|
|
1101
|
+
// in-flight phase to `interrupted`, so nothing restored is live.
|
|
1102
|
+
retryEntryId: undefined,
|
|
1103
|
+
retry: null,
|
|
1104
|
+
// Same again for the fast card: `normalizeLoadedEntries` has already
|
|
1105
|
+
// forced `live: false`, so nothing restored is live. `state.fast` is
|
|
1106
|
+
// NOT cleared — it is the session's own tier projection, which a
|
|
1107
|
+
// `/resume` does not change.
|
|
1108
|
+
fastEntryId: undefined,
|
|
636
1109
|
};
|
|
637
1110
|
}
|
|
638
1111
|
default:
|