From 1ce6d5c14d8b38597ca4da6a1f19d7be3c56b379 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Wed, 2 Sep 2026 09:38:24 +0700 Subject: [PATCH 1/2] fix(panel): skip live-append re-render while the full-message overlay is open (#85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every appended event on a live timeline rebuilt the whole panel — including a fresh SelectList for the open full-message overlay, resetting its scroll and selection. The overlay's content comes from the stable fullMessageEvent and nothing beneath it is visible, so the live subscription now skips renderShell while it is open. Data stays current (runTimeline/selectedEventIndex still update); closing the overlay triggers the next full render, which picks up every accumulated append. Behavioral tests (real RunLog + headless FleetPanel): overlay body-list identity stable across appends; tail-follow preserved when the overlay is closed; post-close render includes events appended underneath. 824/824. --- src/panel/fleet-panel.ts | 7 +- test/helpers/workflow-panel-fixture.mts | 2 +- test/panel-overlay-churn.test.mts | 149 ++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 test/panel-overlay-churn.test.mts diff --git a/src/panel/fleet-panel.ts b/src/panel/fleet-panel.ts index 46a6142..5594c69 100644 --- a/src/panel/fleet-panel.ts +++ b/src/panel/fleet-panel.ts @@ -168,7 +168,12 @@ export class FleetPanel extends Container { this.runTimeline = [...(this.runTimeline ?? []), ev]; const idx = this.liveState?.append(this.renderedTimelineCount()); this.selectedEventIndex = idx ?? null; - this.renderShell(); + // #85: while the full-message overlay is open, its content comes from the stable + // fullMessageEvent — rebuilding the panel here (fresh SelectList per append) churns + // overlay scroll/selection for nothing, and nothing beneath the overlay is visible. + // Skip the render; closing the overlay triggers the next full render, which picks up + // every accumulated append. Data above stays current (runTimeline/selectedEventIndex). + if (!this.fullMessageEvent) this.renderShell(); }); } this.renderShell(); diff --git a/test/helpers/workflow-panel-fixture.mts b/test/helpers/workflow-panel-fixture.mts index 223c3f6..84cb111 100644 --- a/test/helpers/workflow-panel-fixture.mts +++ b/test/helpers/workflow-panel-fixture.mts @@ -58,7 +58,7 @@ export function stripAnsi(s: string): string { // ── Minimal structural Theme fake ── -function fakeTheme(): Theme { +export function fakeTheme(): Theme { const id = (_color: string, s: string): string => s return { fg: id, diff --git a/test/panel-overlay-churn.test.mts b/test/panel-overlay-churn.test.mts new file mode 100644 index 0000000..91e3303 --- /dev/null +++ b/test/panel-overlay-churn.test.mts @@ -0,0 +1,149 @@ +// test/panel-overlay-churn.test.mts — #85 part 1: live-append churn under the +// full-message overlay. While the overlay is open on a LIVE timeline, every appended +// event used to rebuild the whole panel (fresh SelectList → scroll/selection reset). +// The overlay's content comes from the stable `fullMessageEvent`, so appends must not +// re-render while it is open. Data currency (runTimeline growth) and tail-follow of +// the timeline itself (overlay closed) are both preserved. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { RunLog, type MessageEvent } from "../src/runtime/run-log.ts"; +import { WorkflowRunStore } from "../src/workflows/runtime/run-store.ts"; +import { WorkflowRegistry } from "../src/workflows/registry.ts"; +import { FleetPanel } from "../src/panel/fleet-panel.ts"; +import { fakeTheme } from "./helpers/workflow-panel-fixture.mts"; + +const RUN_ID = "fl-churn"; + +function msg(turnIndex: number, text: string): MessageEvent { + return { type: "message", role: "assistant", text, turnIndex }; +} + +function metaEvent() { + return { + type: "run:meta" as const, runId: RUN_ID, agent: "scout", model: "test/model", + task: "churn fixture", startedAt: Date.now(), track: true, todoId: null, + }; +} + +interface Panelish { + handleInput(s: string): void; + messageBodyList: unknown; + timelineList: unknown; + runTimeline: unknown[] | null; + fullMessageEvent: unknown; + children: unknown[]; +} + +function churnPanel(): { panel: FleetPanel; panelish: Panelish; log: RunLog; cleanup: () => void } { + const dir = mkdtempSync(join(tmpdir(), "churn-")); + const log = new RunLog(dir); + log.append(RUN_ID, metaEvent()); + log.append(RUN_ID, msg(0, "first report")); + log.append(RUN_ID, msg(1, "second report")); + + const store = new WorkflowRunStore(); + const panel = new FleetPanel({ + theme: fakeTheme(), + deps: { + registry: new Map(), + runRegistry: { + subscribe: () => () => {}, + get: () => undefined, + list: () => [], + } as never, + lock: { acquire: () => {}, release: () => {} } as never, + todoSync: {} as never, + backendRegistry: { list: () => [], get: () => undefined } as never, + parentModel: { provider: "", id: "" }, + parentCwd: "", + lifecycleRegistry: new Map(), + lifecycleRuns: new Map(), + lifecycleDeps: {} as never, + workflowController: {} as never, + workflowStore: store, + workflowRegistry: new WorkflowRegistry(new Map()), + runLog: log, + }, + onDone: () => {}, + onNotify: () => {}, + }); + return { + panel, + panelish: panel as unknown as Panelish, + log, + cleanup: () => rmSync(dir, { recursive: true, force: true }), + }; +} + +test("#85: live append while full-message overlay is open does NOT rebuild the overlay", () => { + const { panel, panelish, log, cleanup } = churnPanel(); + try { + // fleet → lifecycle → runs + panel.handleInput("\t"); + panel.handleInput("\t"); + // Enter on the run row → live timeline (run:meta only → status running) + panel.handleInput("\r"); + assert.ok(panelish.timelineList, "timeline list built"); + // Enter on the first timeline event → full-message overlay + panel.handleInput("\r"); + assert.ok(panelish.fullMessageEvent, "full-message overlay open"); + const overlayBefore = panelish.messageBodyList; + const childrenBefore = panelish.children.length; + assert.ok(overlayBefore, "overlay body list captured"); + + // A live append arrives while the overlay is open — the churn case. + log.append(RUN_ID, msg(2, "third report — arrives under the overlay")); + + assert.equal(panelish.messageBodyList, overlayBefore, "overlay body list identity stable (no rebuild)"); + assert.equal(panelish.children.length, childrenBefore, "panel children untouched (no rebuild)"); + // Data currency is preserved even though the render was skipped: + // runTimeline = replay(meta + 2 msgs) + the appended msg = 4. + assert.equal(panelish.runTimeline?.length, 4, "runTimeline still grew (meta + 3 messages)"); + } finally { + cleanup(); + } +}); + +test("#85: live append with overlay CLOSED still tail-follows (timeline re-renders)", () => { + const { panel, panelish, log, cleanup } = churnPanel(); + try { + panel.handleInput("\t"); + panel.handleInput("\t"); + panel.handleInput("\r"); // live timeline + assert.ok(panelish.timelineList, "timeline list built"); + assert.equal(panelish.fullMessageEvent, null, "no overlay"); + const tlBefore = panelish.timelineList; + + log.append(RUN_ID, msg(2, "third report — overlay closed, tail-follow must work")); + + assert.notEqual(panelish.timelineList, tlBefore, "timeline rebuilt on append (tail-follow preserved)"); + assert.equal(panelish.runTimeline?.length, 4, "timeline grew (meta + 3 messages)"); + } finally { + cleanup(); + } +}); + +test("#85: closing the overlay re-renders and picks up events appended underneath", () => { + const { panel, panelish, log, cleanup } = churnPanel(); + try { + panel.handleInput("\t"); + panel.handleInput("\t"); + panel.handleInput("\r"); // timeline + panel.handleInput("\r"); // overlay + log.append(RUN_ID, msg(2, "third report — under the overlay")); + + panel.handleInput("\x1b"); // esc → close overlay, full rebuild + + assert.equal(panelish.fullMessageEvent, null, "overlay closed"); + assert.equal(panelish.messageBodyList, null, "overlay body list dropped"); + assert.ok(panelish.timelineList, "timeline restored"); + // The rebuild must reflect the appended event: timeline has 3 rows now. + const rendered = panel.render(120).join("\n"); + assert.match(rendered, /third report/, "post-close render includes the under-overlay event"); + } finally { + cleanup(); + } +}); From b73fc9e735353c6ddcd01116834040c7210245f7 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Wed, 2 Sep 2026 09:43:22 +0700 Subject: [PATCH 2/2] docs(panel): note the deliberate residual refresh() re-render path (#85 review NIT) --- src/panel/fleet-panel.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/panel/fleet-panel.ts b/src/panel/fleet-panel.ts index 5594c69..c1998c3 100644 --- a/src/panel/fleet-panel.ts +++ b/src/panel/fleet-panel.ts @@ -173,6 +173,8 @@ export class FleetPanel extends Container { // overlay scroll/selection for nothing, and nothing beneath the overlay is visible. // Skip the render; closing the overlay triggers the next full render, which picks up // every accumulated append. Data above stays current (runTimeline/selectedEventIndex). + // (refresh() — the store-mutation path — deliberately still re-renders mid-overlay: + // it mutates this.list, which the post-close render reads.) if (!this.fullMessageEvent) this.renderShell(); }); }