Skip to content
Open
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
68 changes: 11 additions & 57 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,10 @@ import {
resolveDraftHeroState,
resolveExplicitDiffOpenOnActivate,
resolveWorktreeSetupProgress,
isPaintOnlyThreadTimeline,
peekHeldThreadTimeline,
peekRememberedThreadTimeline,
rememberReadyThreadTimeline,
resetHeldThreadTimeline,
resetRememberedThreadTimelines,
resolveThreadSwitchTimeline,
threadKeysShareEnvironment,
timelineHasEphemeralPreviewUrls,
scheduleEnvironmentReconnectWarning,
codexArtifactTemplatePromptToAppend,
Expand Down Expand Up @@ -1038,31 +1035,31 @@ 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("leaves the destination empty while its first snapshot 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([]);
});

it("shows the new thread once its detail is ready", () => {
rememberReadyThreadTimeline(held);
expect(
resolveThreadSwitchTimeline({
loading: false,
activeThreadKey: "env-1:thread-b",
nextEntries: ["b1"],
lastReady: held,
}),
).toEqual({ entries: ["b1"], displayThreadKey: "env-1:thread-b" });
).toEqual(["b1"]);
});

it("does not invent a timeline on the first open of a thread", () => {
Expand All @@ -1071,34 +1068,8 @@ 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", () => {
rememberReadyThreadTimeline(held);
expect(peekHeldThreadTimeline<string[]>()).toEqual(held);
expect(
resolveThreadSwitchTimeline({
loading: true,
activeThreadKey: "env-1:thread-b",
nextEntries: [],
}),
).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" });
).toEqual([]);
});

it("paints a remembered destination instead of the last-viewed thread", () => {
Expand All @@ -1111,7 +1082,7 @@ describe("resolveThreadSwitchTimeline", () => {
activeThreadKey: "env-1:thread-a",
nextEntries: [],
}),
).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" });
).toEqual(["a1", "a2"]);
});

it("prefers live entries over a remembered snapshot", () => {
Expand All @@ -1122,7 +1093,7 @@ describe("resolveThreadSwitchTimeline", () => {
activeThreadKey: "env-1:thread-b",
nextEntries: ["fresh-b"],
}),
).toEqual({ entries: ["fresh-b"], displayThreadKey: "env-1:thread-b" });
).toEqual(["fresh-b"]);
});

it("does not keep a remembered snapshot on a resolved empty thread", () => {
Expand All @@ -1133,24 +1104,7 @@ describe("resolveThreadSwitchTimeline", () => {
activeThreadKey: "env-1:thread-a",
nextEntries: [],
}),
).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);
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);
).toEqual([]);
});

it("does not remember a timeline that still has handoff blob previews", () => {
Expand Down
123 changes: 18 additions & 105 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,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 @@ -320,126 +319,40 @@ export function resolveDraftHeroState(input: {
);
}

/**
* 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.
*/
export type HeldThreadTimeline<T extends readonly unknown[]> = {
threadKey: string | null;
entries: T;
markdownCwd?: string | null;
workspaceRoot?: string | null;
};

const MAX_REMEMBERED_THREAD_TIMELINES = 16;
const rememberedThreadTimelines = new Map<string, readonly unknown[]>();

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) {
return;
}
rememberedThreadTimelines.set(held.threadKey, held);
rememberedThreadTimelineOrder = [
...rememberedThreadTimelineOrder.filter((key) => key !== held.threadKey),
held.threadKey,
];
while (rememberedThreadTimelineOrder.length > MAX_REMEMBERED_THREAD_TIMELINES) {
const evicted = rememberedThreadTimelineOrder.shift();
if (evicted !== undefined) {
rememberedThreadTimelines.delete(evicted);
}
}
lastReadyThreadKey = held.threadKey;
}

export function rememberReadyThreadTimeline<T extends readonly unknown[]>(
held: HeldThreadTimeline<T>,
): void {
if (held.threadKey === null || held.entries.length === 0) {
return;
export function rememberReadyThreadTimeline<T extends readonly unknown[]>(input: {
threadKey: string | null;
entries: T;
}): void {
if (input.threadKey === null || input.entries.length === 0) return;
rememberedThreadTimelines.delete(input.threadKey);
rememberedThreadTimelines.set(input.threadKey, input.entries);
if (rememberedThreadTimelines.size > MAX_REMEMBERED_THREAD_TIMELINES) {
const oldest = rememberedThreadTimelines.keys().next().value;
if (oldest !== undefined) rememberedThreadTimelines.delete(oldest);
}
rememberThreadTimelineEntries(held);
}

export function peekRememberedThreadTimeline<T extends readonly unknown[]>(
threadKey: string | null,
): T | null {
if (threadKey === null) {
return null;
}
return (rememberedThreadTimelines.get(threadKey)?.entries as T | undefined) ?? null;
if (threadKey === null) return null;
return (rememberedThreadTimelines.get(threadKey) 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;
}

export function isPaintOnlyThreadTimeline(
displayThreadKey: string | null,
activeThreadKey: string | null,
): boolean {
return (
displayThreadKey !== null && activeThreadKey !== null && displayThreadKey !== activeThreadKey
);
export function resetRememberedThreadTimelines(): void {
rememberedThreadTimelines.clear();
}

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 };
}

const rememberedForActive =
input.rememberedForActive ?? peekRememberedThreadTimeline<T>(input.activeThreadKey);
if (input.loading && rememberedForActive !== null && rememberedForActive.length > 0) {
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 };
}): T {
if (input.nextEntries.length > 0 || !input.loading) return input.nextEntries;
return peekRememberedThreadTimeline<T>(input.activeThreadKey) ?? input.nextEntries;
}

export function resolveDraftPromotionNavigationTarget(input: {
Expand Down
Loading
Loading