Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 49 additions & 29 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,10 @@ import {
resolveProactiveTurnDiffAction,
resolveDraftHeroState,
resolveWorktreeSetupProgress,
isPaintOnlyThreadTimeline,
peekHeldThreadTimeline,
peekRememberedThreadTimeline,
rememberReadyThreadTimeline,
resetHeldThreadTimeline,
resolveThreadSwitchTimeline,
threadKeysShareEnvironment,
timelineHasEphemeralPreviewUrls,
scheduleEnvironmentReconnectWarning,
codexArtifactTemplatePromptToAppend,
Expand Down Expand Up @@ -1032,15 +1029,15 @@ describe("resolveThreadSwitchTimeline", () => {

const held = { threadKey: "env-1:thread-a", entries: ["a1", "a2"] };

it("keeps the previous thread's entries while the next thread is loading", () => {
it("does not paint the previous transcript under a loading destination's header and layout", () => {
rememberReadyThreadTimeline(held);
expect(
resolveThreadSwitchTimeline({
loading: true,
activeThreadKey: "env-1:thread-b",
nextEntries: [],
lastReady: held,
}),
).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" });
).toEqual({ entries: [], displayThreadKey: "env-1:thread-b" });
});

it("shows the new thread once its detail is ready", () => {
Expand All @@ -1049,7 +1046,6 @@ describe("resolveThreadSwitchTimeline", () => {
loading: false,
activeThreadKey: "env-1:thread-b",
nextEntries: ["b1"],
lastReady: held,
}),
).toEqual({ entries: ["b1"], displayThreadKey: "env-1:thread-b" });
});
Expand All @@ -1060,34 +1056,64 @@ describe("resolveThreadSwitchTimeline", () => {
loading: true,
activeThreadKey: "env-1:thread-a",
nextEntries: [],
lastReady: null,
}),
).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" });
});

it("keeps the held thread workspace cwd with the snapshot", () => {
rememberReadyThreadTimeline({
...held,
markdownCwd: "/repo/a",
workspaceRoot: "/repo/a",
it("does not borrow a different thread's cached history after a ChatView remount", () => {
rememberReadyThreadTimeline(held);
expect(peekRememberedThreadTimeline<string[]>(held.threadKey)).toEqual(held.entries);
expect(
resolveThreadSwitchTimeline({
loading: true,
activeThreadKey: "env-1:thread-b",
nextEntries: [],
}),
).toEqual({ entries: [], displayThreadKey: "env-1:thread-b" });
});

it("keeps cold-load and ready frames scoped to the destination, then restores a cached return", () => {
rememberReadyThreadTimeline(held);
const loading = resolveThreadSwitchTimeline<string[]>({
loading: true,
activeThreadKey: "env-1:thread-b",
nextEntries: [],
});
expect(peekHeldThreadTimeline<string[]>()).toEqual({
...held,
markdownCwd: "/repo/a",
workspaceRoot: "/repo/a",
const ready = resolveThreadSwitchTimeline({
loading: false,
activeThreadKey: "env-1:thread-b",
nextEntries: ["b1"],
});
rememberReadyThreadTimeline({ threadKey: ready.displayThreadKey, entries: ready.entries });
const returning = resolveThreadSwitchTimeline<string[]>({
loading: true,
activeThreadKey: "env-1:thread-a",
nextEntries: [],
});

expect([loading, ready, returning]).toEqual([
{ entries: [], displayThreadKey: "env-1:thread-b" },
{ entries: ["b1"], displayThreadKey: "env-1:thread-b" },
{ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" },
]);
});

it("survives a ChatView remount by remembering the last ready timeline", () => {
it("does not substitute the last-viewed thread when the destination has left the cache", () => {
rememberReadyThreadTimeline(held);
expect(peekHeldThreadTimeline<string[]>()).toEqual(held);
for (let index = 0; index < 20; index += 1) {
rememberReadyThreadTimeline({
threadKey: `env-1:other-${index}`,
entries: [`other-message-${index}`],
});
}
expect(peekRememberedThreadTimeline("env-1:thread-a")).toBeNull();
expect(
resolveThreadSwitchTimeline({
resolveThreadSwitchTimeline<string[]>({
loading: true,
activeThreadKey: "env-1:thread-b",
activeThreadKey: "env-1:thread-a",
nextEntries: [],
}),
).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" });
).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" });
});

it("paints a remembered destination instead of the last-viewed thread", () => {
Expand Down Expand Up @@ -1126,22 +1152,16 @@ describe("resolveThreadSwitchTimeline", () => {
});

it("does not hold another environment's timeline across a jump", () => {
expect(threadKeysShareEnvironment("env-1:thread-a", "env-2:thread-b")).toBe(false);
rememberReadyThreadTimeline(held);
expect(
resolveThreadSwitchTimeline({
loading: true,
activeThreadKey: "env-2:thread-b",
nextEntries: [],
lastReady: held,
}),
).toEqual({ entries: [], displayThreadKey: "env-2:thread-b" });
});

it("treats a foreign held timeline as paint-only", () => {
expect(isPaintOnlyThreadTimeline("env-1:thread-a", "env-1:thread-b")).toBe(true);
expect(isPaintOnlyThreadTimeline("env-1:thread-b", "env-1:thread-b")).toBe(false);
});

it("does not remember a timeline that still has handoff blob previews", () => {
expect(
timelineHasEphemeralPreviewUrls([
Expand Down
64 changes: 5 additions & 59 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import {
} from "@t3tools/contracts";
import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
import * as DateTime from "effect/DateTime";
import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
import {
squashAtomCommandFailure,
Expand Down Expand Up @@ -311,28 +310,19 @@ export function resolveDraftHeroState(input: {
}

/**
* Keep painted timelines on screen across thread jumps. Remounting LegendList
* (or handing it an empty first paint) punches a hole through the chat pane —
* white in light mode — so cmd+1/2/3 spam flashes even when the destination
* is already cached.
*
* Stored at module scope because ChatView remounts when the thread route
* changes (same pattern as the thread-error banner session dismissals).
* Remember more than the last thread so jumping back to cmd+1 does not show
* cmd+3's messages, and so a cached destination can paint on the first frame.
* Remember each thread's ready timeline so a cached destination can paint
* immediately, including after a ChatView remount. An uncached destination
* uses the loading surface until its own history arrives.
*/
export type HeldThreadTimeline<T extends readonly unknown[]> = {
threadKey: string | null;
entries: T;
markdownCwd?: string | null;
workspaceRoot?: string | null;
};

const MAX_REMEMBERED_THREAD_TIMELINES = 16;

let rememberedThreadTimelines = new Map<string, HeldThreadTimeline<readonly unknown[]>>();
let rememberedThreadTimelineOrder: string[] = [];
let lastReadyThreadKey: string | null = null;

function rememberThreadTimelineEntries(held: HeldThreadTimeline<readonly unknown[]>): void {
if (held.threadKey === null) {
Expand All @@ -349,7 +339,6 @@ function rememberThreadTimelineEntries(held: HeldThreadTimeline<readonly unknown
rememberedThreadTimelines.delete(evicted);
}
}
lastReadyThreadKey = held.threadKey;
}

export function rememberReadyThreadTimeline<T extends readonly unknown[]>(
Expand All @@ -370,50 +359,16 @@ export function peekRememberedThreadTimeline<T extends readonly unknown[]>(
return (rememberedThreadTimelines.get(threadKey)?.entries as T | undefined) ?? null;
}

export function peekHeldThreadTimeline<
T extends readonly unknown[],
>(): HeldThreadTimeline<T> | null {
if (lastReadyThreadKey === null) {
return null;
}
const held = rememberedThreadTimelines.get(lastReadyThreadKey);
if (held === undefined || held.entries.length === 0) {
return null;
}
return held as HeldThreadTimeline<T>;
}

export function resetHeldThreadTimeline(): void {
rememberedThreadTimelines = new Map();
rememberedThreadTimelineOrder = [];
lastReadyThreadKey = null;
}

export function threadKeysShareEnvironment(left: string | null, right: string | null): boolean {
if (left === null || right === null) {
return false;
}
const leftRef = parseScopedThreadKey(left);
const rightRef = parseScopedThreadKey(right);
return leftRef !== null && rightRef !== null && leftRef.environmentId === rightRef.environmentId;
}

/** True while we still paint another thread's last snapshot. */
export function isPaintOnlyThreadTimeline(
displayThreadKey: string | null,
activeThreadKey: string | null,
): boolean {
return (
displayThreadKey !== null && activeThreadKey !== null && displayThreadKey !== activeThreadKey
);
}

export function resolveThreadSwitchTimeline<T extends readonly unknown[]>(input: {
loading: boolean;
activeThreadKey: string | null;
nextEntries: T;
rememberedForActive?: T | null;
lastReady?: HeldThreadTimeline<T> | null;
}): { entries: T; displayThreadKey: string | null } {
if (input.nextEntries.length > 0) {
return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey };
Expand All @@ -425,17 +380,8 @@ export function resolveThreadSwitchTimeline<T extends readonly unknown[]>(input:
return { entries: rememberedForActive, displayThreadKey: input.activeThreadKey };
}

const lastReady = input.lastReady ?? peekHeldThreadTimeline<T>();
if (
input.loading &&
lastReady !== null &&
lastReady.threadKey !== null &&
lastReady.threadKey !== input.activeThreadKey &&
lastReady.entries.length > 0 &&
threadKeysShareEnvironment(lastReady.threadKey, input.activeThreadKey)
) {
return { entries: lastReady.entries, displayThreadKey: lastReady.threadKey };
}
// The header and pane widths already belong to the destination. Painting
// another thread here flashes its text and reflows it before replacing it.
return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey };
}

Expand Down
Loading
Loading