Merge remote-tracking branch 'origin/farm/c42beb4b/fix-duplicate-todo-panels'
This commit is contained in:
@@ -83,6 +83,7 @@ export class ChatTranscriptBuilder {
|
||||
#pendingUsage: Usage | undefined;
|
||||
#lastAssistantUsage: Usage | undefined;
|
||||
#waitingPoll: ToolExecutionComponent | null = null;
|
||||
#todoSnapshot: ToolExecutionComponent | null = null;
|
||||
#expandables: Array<{ setExpanded(expanded: boolean): void }> = [];
|
||||
#expanded = false;
|
||||
|
||||
@@ -128,6 +129,7 @@ export class ChatTranscriptBuilder {
|
||||
this.#pendingUsage = undefined;
|
||||
this.#lastAssistantUsage = undefined;
|
||||
this.#waitingPoll = null;
|
||||
this.#todoSnapshot = null;
|
||||
this.#expandables = [];
|
||||
this.container.dispose();
|
||||
this.container.clear();
|
||||
@@ -153,6 +155,24 @@ export class ChatTranscriptBuilder {
|
||||
previous.seal();
|
||||
}
|
||||
|
||||
#resolveTodoSnapshot(nextToolName?: string): void {
|
||||
const previous = this.#todoSnapshot;
|
||||
if (!previous) return;
|
||||
if (!previous.isDisplaceableBlock()) {
|
||||
this.#todoSnapshot = null;
|
||||
return;
|
||||
}
|
||||
if (previous.canBeDisplacedBy(nextToolName)) {
|
||||
this.#todoSnapshot = null;
|
||||
this.container.removeChild(previous);
|
||||
previous.seal();
|
||||
return;
|
||||
}
|
||||
if (nextToolName !== undefined) return;
|
||||
this.#todoSnapshot = null;
|
||||
previous.seal();
|
||||
}
|
||||
|
||||
#ensureReadGroup(): ReadToolGroupComponent {
|
||||
if (!this.#readGroup) {
|
||||
this.#readGroup = new ReadToolGroupComponent({
|
||||
@@ -189,6 +209,7 @@ export class ChatTranscriptBuilder {
|
||||
case "developer": {
|
||||
// A user prompt closes the poll-displacement window, same as the live path.
|
||||
if (message.role === "user") this.#resolveWaitingPoll();
|
||||
if (message.role === "user") this.#resolveTodoSnapshot();
|
||||
const textContent = message.role === "user" ? userMessageText(message) : "";
|
||||
if (textContent) {
|
||||
const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false);
|
||||
@@ -345,6 +366,16 @@ export class ChatTranscriptBuilder {
|
||||
this.#pendingTools.delete(message.toolCallId);
|
||||
if (message.toolName === "job" && pending instanceof ToolExecutionComponent && pending.isDisplaceableBlock()) {
|
||||
this.#waitingPoll = pending;
|
||||
} else if (
|
||||
message.toolName === "todo" &&
|
||||
pending instanceof ToolExecutionComponent &&
|
||||
pending.canBeDisplacedBy("todo")
|
||||
) {
|
||||
// A successful todo result supersedes the prior live snapshot. Failed
|
||||
// follow-ups return false from canBeDisplacedBy("todo"), so the
|
||||
// last-good panel stays on screen.
|
||||
this.#resolveTodoSnapshot("todo");
|
||||
this.#todoSnapshot = pending;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
truncateToWidth,
|
||||
} from "../../tools/render-utils";
|
||||
import { toolRenderers } from "../../tools/renderers";
|
||||
import { TODO_STRIKE_TOTAL_FRAMES } from "../../tools/todo";
|
||||
import { TODO_STRIKE_TOTAL_FRAMES, type TodoToolDetails } from "../../tools/todo";
|
||||
import { isFramedBlockComponent, renderStatusLine, WidthAwareText } from "../../tui";
|
||||
import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
|
||||
import { renderDiff } from "./diff";
|
||||
@@ -75,6 +75,28 @@ function stripTrailingUnbalancedRemoval(diff: string | undefined): string | unde
|
||||
return lines.slice(0, lastAddIdx + 1).join("\n");
|
||||
}
|
||||
|
||||
type DisplaceableToolName = "job" | "todo";
|
||||
|
||||
function isTodoToolDetails(details: unknown): details is TodoToolDetails {
|
||||
return (
|
||||
typeof details === "object" &&
|
||||
details !== null &&
|
||||
"phases" in details &&
|
||||
Array.isArray((details as { phases?: unknown }).phases)
|
||||
);
|
||||
}
|
||||
|
||||
function displaceableToolName(
|
||||
toolName: string,
|
||||
result: { details?: unknown; isError?: boolean },
|
||||
isPartial: boolean,
|
||||
): DisplaceableToolName | undefined {
|
||||
if (result.isError === true) return undefined;
|
||||
if (toolName === "job" && isWaitingPollDetails(result.details)) return "job";
|
||||
if (toolName === "todo" && !isPartial && isTodoToolDetails(result.details)) return "todo";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function stabilizeStreamingPreviews(previews: PerFileDiffPreview[]): PerFileDiffPreview[] {
|
||||
let changed = false;
|
||||
const next = previews.map(preview => {
|
||||
@@ -234,11 +256,11 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
||||
// sealed the block stays in the transcript's repaintable live region so a
|
||||
// late result still repaints instead of stranding the streaming preview.
|
||||
#sealed = false;
|
||||
// A `job` poll result whose watched jobs are all still running. Such a
|
||||
// block never finalizes (stays in the transcript live region) so a
|
||||
// follow-up `job` call can displace it instead of stacking another
|
||||
// "waiting on N jobs" frame. Cleared by `seal()`.
|
||||
#displaceable = false;
|
||||
// Tool result snapshots that may be superseded by a later same-tool call
|
||||
// while still in the transcript live region. `job` uses this for repeated
|
||||
// all-running polls; `todo` uses it for per-turn state snapshots so only the
|
||||
// latest list remains visible.
|
||||
#displaceableByToolName: DisplaceableToolName | undefined;
|
||||
// Probe into the owning transcript (absent outside the interactive
|
||||
// transcript, e.g. in tests): whether this block is still repaintable.
|
||||
#liveRegion?: TranscriptLiveRegionProbe;
|
||||
@@ -427,11 +449,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
||||
this.#result = result;
|
||||
this.#resultVersion++;
|
||||
this.#isPartial = isPartial;
|
||||
// A `job` poll that found every watched job still running is transient
|
||||
// "still waiting" chrome; keep the block displaceable so the next `job`
|
||||
// call replaces it instead of stacking another waiting frame (see the
|
||||
// event controller's displaceable-poll bookkeeping).
|
||||
this.#displaceable = this.#toolName === "job" && result.isError !== true && isWaitingPollDetails(result.details);
|
||||
this.#displaceableByToolName = displaceableToolName(this.#toolName, result, isPartial);
|
||||
// When tool is complete, ensure args are marked complete so spinner stops
|
||||
if (!isPartial) {
|
||||
this.#argsComplete = true;
|
||||
@@ -490,10 +508,12 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
||||
}
|
||||
|
||||
/**
|
||||
* Start or stop spinner animation based on whether this is a partial task result.
|
||||
* Start or stop spinner animation for result states that visibly tick.
|
||||
*/
|
||||
#updateSpinnerAnimation(): void {
|
||||
// Spinner for: task tool with partial result, or edit/write while args streaming
|
||||
// Spinner for: task tool with partial result, edit/write while args
|
||||
// streaming, or a still-running job poll. Todo snapshots stay live for
|
||||
// displacement but should remain visually static.
|
||||
const isStreamingArgs = !this.#argsComplete && (isEditLikeToolName(this.#toolName) || this.#toolName === "write");
|
||||
const isBackgroundAsyncRunning =
|
||||
(this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
|
||||
@@ -502,7 +522,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
||||
// Detached async task progress rows are static now; progress snapshots
|
||||
// still call #maybeFreezeBackgroundTask before applying so rows settle
|
||||
// once the block leaves the live region.
|
||||
const needsSpinner = isStreamingArgs || isPartialTask || this.isDisplaceableBlock();
|
||||
const needsSpinner = isStreamingArgs || isPartialTask || this.#displaceableByToolName === "job";
|
||||
if (needsSpinner && !this.#spinnerInterval) {
|
||||
const frameCount = theme.spinnerFrames.length;
|
||||
const frame = sharedSpinnerFrame(frameCount);
|
||||
@@ -610,9 +630,9 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
||||
isTranscriptBlockFinalized(): boolean {
|
||||
if (this.#sealed) return true;
|
||||
if (this.#result === undefined) return false;
|
||||
// A displaceable waiting poll stays live: its rows are kept out of
|
||||
// native scrollback so a follow-up `job` call can remove the block.
|
||||
if (this.#displaceable) return false;
|
||||
// A displaceable snapshot stays live: its rows are kept out of native
|
||||
// scrollback so a follow-up tool call can remove the block.
|
||||
if (this.#displaceableByToolName) return false;
|
||||
if (!this.#isPartial) return true;
|
||||
// Partial result: a background async tool is accepted to freeze (the agent
|
||||
// continues while it runs and would otherwise pin an unbounded live region);
|
||||
@@ -630,7 +650,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
||||
* committed prefix survives the remaining transitions.
|
||||
*/
|
||||
isTranscriptBlockCommitStable(): boolean {
|
||||
if (this.#displaceable) return false;
|
||||
if (this.#displaceableByToolName) return false;
|
||||
if (this.isTranscriptBlockFinalized()) return true;
|
||||
// `provisionalPendingPreview` describes only the PENDING call preview
|
||||
// (`renderCall`, before any result): the result render may re-anchor it
|
||||
@@ -658,7 +678,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
||||
seal(): void {
|
||||
if (this.#sealed) return;
|
||||
this.#sealed = true;
|
||||
this.#displaceable = false;
|
||||
this.#displaceableByToolName = undefined;
|
||||
// A sealed detached task is abandoned history: settle its progress rows
|
||||
// on static gray.
|
||||
this.#backgroundTaskFrozen = true;
|
||||
@@ -668,14 +688,19 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this block is a waiting `job` poll (every watched job still
|
||||
* running) that has not been sealed. Such a block never finalized, so none
|
||||
* of its rows entered native scrollback (the ticking spinner keeps the
|
||||
* stable-prefix ratchet at zero) and the whole block can be removed when a
|
||||
* follow-up `job` call supersedes it.
|
||||
* Whether this block is a supersedable result snapshot that has not been
|
||||
* sealed. Such a block never finalized, so none of its rows entered native
|
||||
* scrollback and the whole block can be removed when a follow-up matching
|
||||
* tool call supersedes it.
|
||||
*/
|
||||
isDisplaceableBlock(): boolean {
|
||||
return this.#displaceable && !this.#sealed;
|
||||
return this.#displaceableByToolName !== undefined && !this.#sealed;
|
||||
}
|
||||
|
||||
canBeDisplacedBy(nextToolName: string | undefined): boolean {
|
||||
return (
|
||||
this.#displaceableByToolName !== undefined && this.#displaceableByToolName === nextToolName && !this.#sealed
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user