Skip to content
Merged
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
181 changes: 181 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ import {
resolveSendEnvMode,
threadShellHasStarted,
resolveDraftHeroState,
isPaintOnlyThreadTimeline,
peekHeldThreadTimeline,
peekRememberedThreadTimeline,
rememberReadyThreadTimeline,
resetHeldThreadTimeline,
resolveThreadSwitchTimeline,
threadKeysShareEnvironment,
timelineHasEphemeralPreviewUrls,
scheduleEnvironmentReconnectWarning,
startNewThreadForProject,
codexArtifactTemplatePromptToAppend,
Expand Down Expand Up @@ -565,6 +573,179 @@ describe("draft hero submission transition", () => {
});
});

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

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

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

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

it("does not invent a timeline on the first open of a thread", () => {
expect(
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" });
});

it("paints a remembered destination instead of the last-viewed thread", () => {
rememberReadyThreadTimeline(held);
rememberReadyThreadTimeline({ threadKey: "env-1:thread-b", entries: ["b1", "b2"] });
expect(peekRememberedThreadTimeline<string[]>("env-1:thread-a")).toEqual(["a1", "a2"]);
expect(
resolveThreadSwitchTimeline({
loading: true,
activeThreadKey: "env-1:thread-a",
nextEntries: [],
}),
).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" });
});

it("prefers live entries over a remembered snapshot", () => {
rememberReadyThreadTimeline({ threadKey: "env-1:thread-b", entries: ["stale-b"] });
expect(
resolveThreadSwitchTimeline({
loading: false,
activeThreadKey: "env-1:thread-b",
nextEntries: ["fresh-b"],
}),
).toEqual({ entries: ["fresh-b"], displayThreadKey: "env-1:thread-b" });
});

it("does not keep a remembered snapshot on a resolved empty thread", () => {
rememberReadyThreadTimeline(held);
expect(
resolveThreadSwitchTimeline({
loading: false,
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);
});

it("does not remember a timeline that still has handoff blob previews", () => {
expect(
timelineHasEphemeralPreviewUrls([
{
kind: "message",
message: {
id: MessageId.make("preview-message"),
role: "user",
text: "Preview",
turnId: null,
streaming: false,
createdAt: "2026-09-10T12:00:00.000Z",
updatedAt: "2026-09-10T12:00:00.000Z",
attachments: [
{
type: "image",
id: "preview",
name: "preview.png",
mimeType: "image/png",
sizeBytes: 1,
previewUrl: "blob:handoff",
},
],
},
},
]),
).toBe(true);
expect(
timelineHasEphemeralPreviewUrls([
{
kind: "message",
message: {
id: MessageId.make("preview-message"),
role: "user",
text: "Preview",
turnId: null,
streaming: false,
createdAt: "2026-09-10T12:00:00.000Z",
updatedAt: "2026-09-10T12:00:00.000Z",
attachments: [
{
type: "image",
id: "preview",
name: "preview.png",
mimeType: "image/png",
sizeBytes: 1,
previewUrl: "https://cdn.example/a.png",
},
],
},
},
]),
).toBe(false);
});
});

describe("shouldReleaseTimelineAnchorForToolActivity", () => {
const activeTurnId = TurnId.make("active-turn");
const anchorMessageId = MessageId.make("anchored-message");
Expand Down
141 changes: 141 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type ThreadLinkedPullRequest,
type TurnId,
} from "@t3tools/contracts";
import { parseScopedThreadKey } from "@t3tools/client-runtime/environment";
import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets";
import {
squashAtomCommandFailure,
Expand Down Expand Up @@ -262,6 +263,135 @@ 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.
*/
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) {
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;
}
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;
}

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

const rememberedForActive =
input.rememberedForActive ?? peekRememberedThreadTimeline<T>(input.activeThreadKey);
if (input.loading && rememberedForActive !== null && rememberedForActive.length > 0) {
return { entries: rememberedForActive, displayThreadKey: input.activeThreadKey };
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}

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

export function resolveDraftPromotionNavigationTarget(input: {
serverThreadRef: ScopedThreadRef | null;
serverThread: Pick<Thread, "latestTurn" | "session"> | null | undefined;
Expand Down Expand Up @@ -612,6 +742,17 @@ export function revokeUserMessagePreviewUrls(message: ChatMessage): void {
}
}

export function timelineHasEphemeralPreviewUrls(
entries: ReadonlyArray<Pick<TimelineEntry, "kind"> & { message?: ChatMessage }>,
): boolean {
return entries.some(
(entry) =>
entry.kind === "message" &&
entry.message !== undefined &&
collectUserMessageBlobPreviewUrls(entry.message).length > 0,
);
}

export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[] {
if (message.role !== "user" || !message.attachments) {
return [];
Expand Down
Loading
Loading