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
57 changes: 20 additions & 37 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,
resetRememberedThreadTimelines,
resolveThreadSwitchTimeline,
threadKeysShareEnvironment,
timelineHasEphemeralPreviewUrls,
scheduleEnvironmentReconnectWarning,
codexArtifactTemplatePromptToAppend,
Expand Down Expand Up @@ -1027,20 +1024,20 @@ describe("draft hero submission transition", () => {

describe("resolveThreadSwitchTimeline", () => {
afterEach(() => {
resetHeldThreadTimeline();
resetRememberedThreadTimelines();
});

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

it("keeps the previous thread's entries while the next thread is loading", () => {
it("clears the previous thread's entries while an uncached destination is loading", () => {
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,27 @@ 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",
});
expect(peekHeldThreadTimeline<string[]>()).toEqual({
...held,
markdownCwd: "/repo/a",
workspaceRoot: "/repo/a",
});
});

it("survives a ChatView remount by remembering the last ready timeline", () => {
it("only restores the selected thread during rapid switches", () => {
rememberReadyThreadTimeline(held);
expect(peekHeldThreadTimeline<string[]>()).toEqual(held);
rememberReadyThreadTimeline({ threadKey: "env-1:thread-b", entries: ["b1"] });
expect(
resolveThreadSwitchTimeline({
loading: true,
activeThreadKey: "env-1:thread-b",
activeThreadKey: "env-1:thread-c",
nextEntries: [],
}),
).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" });
).toEqual({ entries: [], displayThreadKey: "env-1:thread-c" });
expect(
resolveThreadSwitchTimeline({
loading: true,
activeThreadKey: "env-1:thread-a",
nextEntries: [],
}),
).toEqual({ entries: held.entries, displayThreadKey: held.threadKey });
});

it("paints a remembered destination instead of the last-viewed thread", () => {
Expand Down Expand Up @@ -1125,21 +1114,15 @@ describe("resolveThreadSwitchTimeline", () => {
).toEqual({ entries: [], displayThreadKey: "env-1:thread-a" });
});

it("does not hold another environment's timeline across a jump", () => {
expect(threadKeysShareEnvironment("env-1:thread-a", "env-2:thread-b")).toBe(false);
it("does not reuse a matching thread id from another environment", () => {
rememberReadyThreadTimeline(held);
expect(
resolveThreadSwitchTimeline({
loading: true,
activeThreadKey: "env-2:thread-b",
activeThreadKey: "env-2:thread-a",
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);
).toEqual({ entries: [], displayThreadKey: "env-2:thread-a" });
});

it("does not remember a timeline that still has handoff blob previews", () => {
Expand Down
72 changes: 8 additions & 64 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,30 +310,21 @@ 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 timelines across ChatView remounts so a cached destination can
* paint immediately. Loading threads may only show their own cached entries;
* the rest of the UI already refers to the selected thread.
*/
export type HeldThreadTimeline<T extends readonly unknown[]> = {
type RememberedThreadTimeline<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 rememberedThreadTimelines = new Map<string, RememberedThreadTimeline<readonly unknown[]>>();
let rememberedThreadTimelineOrder: string[] = [];
let lastReadyThreadKey: string | null = null;

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

export function rememberReadyThreadTimeline<T extends readonly unknown[]>(
held: HeldThreadTimeline<T>,
held: RememberedThreadTimeline<T>,
): void {
if (held.threadKey === null || held.entries.length === 0) {
return;
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 {
export function resetRememberedThreadTimelines(): 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,6 @@ 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 };
}
return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey };
}

Expand Down
Loading
Loading