Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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
65 changes: 61 additions & 4 deletions apps/web/src/components/ChatView.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { createMemoryHistory } from "@tanstack/react-router";
import { scopeProjectRef } from "@t3tools/client-runtime/environment";
import { ThreadId } from "@t3tools/contracts";
import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
import { RunId, ThreadId } from "@t3tools/contracts";
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
import type * as Context from "effect/Context";
import { expect, it, vi } from "vite-plus/test";
import { page } from "vite-plus/test/browser";
import { cdp, page } from "vite-plus/test/browser";

import { AppRoot } from "~/AppRoot";
// The route loads ChatView lazily, and its first transform (React Compiler over ~4k lines)
Expand All @@ -13,13 +13,18 @@ import "~/components/ChatView";
import { DraftId, useComposerDraftStore } from "~/composerDraftStore";
import { __setClientSettingsForTests } from "~/hooks/useSettings";
import { getRouter } from "~/router";
import { appAtomRegistry } from "~/rpc/atomRegistry";
import { environmentThreadDetails } from "~/state/threads";
import {
ENVIRONMENT_ID,
PROJECT_ID,
assistantItem,
emitThreadEvent,
installFakeEnvironment,
threadWithHistory,
type FakeDriver,
} from "../../test/fakeEnvironment";
import { render } from "../../test/render";
import { act, render } from "../../test/render";

// The socket is the only fake layer; see test/appScenarios.renders.tsx.
vi.mock("../../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => {
Expand Down Expand Up @@ -65,3 +70,55 @@ it("a new draft does not load its reserved thread before the server lists it", a
// A 404 here marks the thread deleted, and the view never loads it once the send creates it.
expect(threadRequests).toEqual([]);
});

it("a mounted thread view lets go of the thread structures it has replaced", async () => {
// Items are shared with the fixture, so a reopen's snapshot also carries the earlier updates.
const history = threadWithHistory("updated", 3);
const items = [...history.items];
const updated = { ...history, items };
const other = threadWithHistory("other", 3);
using _environment = installFakeEnvironment([updated, other]);
__setClientSettingsForTests({
...DEFAULT_CLIENT_SETTINGS,
onboardingCompletedAt: "2026-01-01T00:00:00.000Z",
});
const router = getRouter(createMemoryHistory({ initialEntries: ["/"] }));
await router.load();
await using _app = await render(<AppRoot router={router} />, { router: false });
const open = async (threadId: ThreadId, text: string) => {
await act(() =>
router.navigate({
to: "/$environmentId/$threadId",
params: { environmentId: ENVIRONMENT_ID, threadId },
}),
);
await expect.element(page.getByText(text).first()).toBeVisible();
};
const structureAtom = environmentThreadDetails.quietThreadAtom(
scopeThreadRef(ENVIRONMENT_ID, updated.id),
);

// Each round, a new answer arrives while the thread is shown, then the user switches away.
const shown: Array<WeakRef<object>> = [];
await open(other.id, "Answer 2 for other.");
for (let round = 0; round < 6; round++) {
await open(updated.id, round === 0 ? "Answer 2 for updated." : `Update ${round - 1}.`);
const update = assistantItem(
updated.id,
RunId.make("updated-run-2"),
`updated-update-${round}`,
6 + round,
`Update ${round}.`,
);
items.push(update);
await act(() => emitThreadEvent(updated.id, { type: "turn-item.updated", payload: update }));
await expect.element(page.getByText(`Update ${round}.`)).toBeVisible();
shown.push(new WeakRef(appAtomRegistry.get(structureAtom)!.projection));
await open(other.id, "Answer 2 for other.");
}
await cdp().send("HeapProfiler.collectGarbage");

// The thread keeps its latest structure, and the timeline may remember the one before it.
const kept = shown.slice(0, -2).flatMap((structure, round) => (structure.deref() ? [round] : []));
expect(kept).toEqual([]);
});
44 changes: 44 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import * as DateTime from "effect/DateTime";
import * as Option from "effect/Option";
import { afterEach, describe, expect, it, vi } from "vite-plus/test";
import { Atom } from "effect/reactivity";
import { rememberTimelinePosition } from "./chat/timelineScrollAnchoring";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { environmentThreadDetails } from "../state/threads";

Expand All @@ -41,6 +42,7 @@ import {
resolveComposerProviderSelection,
resolveWorktreeSetupProgress,
peekRememberedThreadTimeline,
releaseRememberedThreadTimeline,
rememberReadyThreadTimeline,
resetHeldThreadTimeline,
resolveThreadSwitchTimeline,
Expand Down Expand Up @@ -422,6 +424,48 @@ describe("resolveThreadSwitchTimeline", () => {
).toEqual(["a1", "a2"]);
});

describe("a released thread", () => {
const entries = Array.from({ length: 200 }, (_, index) => `entry-${index}`);
const threadKey = "env-1:released";

it("keeps only the end of its remembered timeline", () => {
rememberReadyThreadTimeline({ threadKey, entries });
releaseRememberedThreadTimeline(threadKey);
const kept = peekRememberedThreadTimeline<string[]>(threadKey)!;
expect(kept.length).toBeLessThan(100);
expect(kept).toEqual(entries.slice(-kept.length));
expect(
resolveThreadSwitchTimeline({ loading: true, activeThreadKey: threadKey, nextEntries: [] }),
).toEqual(kept);
});

it("leaves other threads' remembered timelines whole", () => {
rememberReadyThreadTimeline({ threadKey: "env-1:other", entries });
rememberReadyThreadTimeline({ threadKey, entries });
releaseRememberedThreadTimeline(threadKey);
expect(peekRememberedThreadTimeline<string[]>("env-1:other")).toEqual(entries);
});

it("keeps none when the reader's place is further up the timeline", () => {
rememberReadyThreadTimeline({ threadKey, entries });
rememberTimelinePosition(
threadKey,
{ rowId: "entry-3", offsetWithinRow: 0, scrollOffset: 0, atEnd: false },
false,
);
releaseRememberedThreadTimeline(threadKey);
expect(peekRememberedThreadTimeline(threadKey)).toBeNull();
});

it("still remembers sixteen threads", () => {
const keys = Array.from({ length: 18 }, (_, index) => `env-1:thread-${index}`);
for (const key of keys) rememberReadyThreadTimeline({ threadKey: key, entries: [key] });
expect(keys.map((key) => peekRememberedThreadTimeline(key) !== null)).toEqual(
keys.map((_, index) => index >= 2),
);
});
});

it("does not paint the last-viewed thread for a destination with nothing remembered", () => {
rememberReadyThreadTimeline(held);
expect(
Expand Down
28 changes: 27 additions & 1 deletion apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ import {
} from "../composerDraftStore";
import * as Schema from "effect/Schema";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { environmentThreadDetails } from "../state/threads";
import { environmentThreadDetails, onLeftThreadReleased } from "../state/threads";
import { readTimelinePosition } from "./chat/timelineScrollAnchoring";
import {
filterTerminalContextsWithText,
formatTerminalContextLabel,
Expand Down Expand Up @@ -377,6 +378,31 @@ export function peekRememberedThreadTimeline<T extends readonly unknown[]>(
return (rememberedThreadTimelines.get(threadKey)?.entries as T | undefined) ?? null;
}

// Fills the pane at the end of a timeline until the thread is back from the cache.
const RELEASED_THREAD_TIMELINE_ENTRIES = 60;

/**
* These rows point at the thread's items, so a released thread keeps only its last few. A reader's
* place further up can only be restored on the whole timeline, so that thread keeps none.
*/
export function releaseRememberedThreadTimeline(threadKey: string): void {
const held = rememberedThreadTimelines.get(threadKey);
if (held === undefined) return;
if (readTimelinePosition(threadKey)?.atEnd === false) {
rememberedThreadTimelines.delete(threadKey);
rememberedThreadTimelineOrder = rememberedThreadTimelineOrder.filter(
(key) => key !== threadKey,
);
return;
}
rememberedThreadTimelines.set(threadKey, {
threadKey,
entries: held.entries.slice(-RELEASED_THREAD_TIMELINE_ENTRIES),
});
}

onLeftThreadReleased(releaseRememberedThreadTimeline);

export function resetHeldThreadTimeline(): void {
rememberedThreadTimelines = new Map();
rememberedThreadTimelineOrder = [];
Expand Down
19 changes: 5 additions & 14 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,6 @@ import { isEditableFocused } from "../lib/editableFocus";
import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
import { shortcutLabelForCommand } from "../keybindings";
import { cn } from "~/lib/utils";
import { isModelPickerOpen } from "../modelPickerVisibility";
import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
import { toastManager } from "./ui/toast";
import { projectScriptIdFromCommand } from "~/projectScripts";
Expand Down Expand Up @@ -342,6 +341,7 @@ import {
} from "./ChatView.logic";
import { useLocalStorage } from "~/hooks/useLocalStorage";
import { useComposerHandleContext } from "../composerHandleContext";
import { useComposerFocus } from "./chat/useComposerFocus";
import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { assetEnvironment } from "../state/assets";
Expand Down Expand Up @@ -1787,10 +1787,8 @@ export default function ChatView(props: ChatViewProps) {
sendInFlightRef,
});

const focusComposer = () => {
composerRef.current?.focusAtEnd();
};
const focusComposerStable = useStableCallback(focusComposer);
const { focusComposer: focusComposerStable, scheduleComposerFocus: scheduleComposerFocusStable } =
useComposerFocus(composerRef);
const canInterruptRunningThread =
canOperateThread && deriveCanInterruptRunningThread(activeThread !== undefined, activeRuntime);
const onInterrupt = async () => {
Expand All @@ -1817,13 +1815,6 @@ export default function ChatView(props: ChatViewProps) {
() => (embedded ? undefined : subscribeSnapShotComposerFocus(focusComposerStable)),
[embedded, focusComposerStable],
);
const scheduleComposerFocus = () => {
window.requestAnimationFrame(() => {
// The model picker stays open through its own changes, so it keeps focus.
if (!isModelPickerOpen()) focusComposer();
});
};
const scheduleComposerFocusStable = useStableCallback(scheduleComposerFocus);
const insertArtifactTemplate = (template: CodexArtifactTemplate) => {
const composer = composerRef.current;
if (!composer) return;
Expand All @@ -1838,7 +1829,7 @@ export default function ChatView(props: ChatViewProps) {
});
return;
}
scheduleComposerFocus();
scheduleComposerFocusStable();
};
const insertArtifactTemplateStable = useStableCallback(insertArtifactTemplate);
const sendAppMessage = async (text: string) => {
Expand Down Expand Up @@ -2843,7 +2834,7 @@ export default function ChatView(props: ChatViewProps) {
canOverrideServerThreadEnvMode,
setPendingServerThreadEnvModeEntry,
setPendingServerThreadStartFromOriginByThreadId,
scheduleComposerFocus,
scheduleComposerFocus: scheduleComposerFocusStable,
});
const { onResume, isResuming } = useComposerResume({
environmentId,
Expand Down
49 changes: 49 additions & 0 deletions apps/web/src/components/chat/MessagesTimeline.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1260,6 +1260,55 @@ describe("MessagesTimeline thread find", () => {
expect(top).toBeLessThan(bounds.bottom);
});
});

it("lets go of a timeline once find has no match in it", async () => {
setup();
const base = baseProps();
await using screen = await renderElement(
<MessagesTimeline {...base} timelineEntries={[message("Short thread")]} />,
);
// In its own function, so nothing in this test's frame holds the entries after the switch.
const revealMatch = async () => {
const replies = Array.from({ length: 40 }, (_, index) =>
message(
`${index === 5 ? "Needle here" : `Reply ${index}`}\n\n${"line\n\n".repeat(8)}`,
{ role: "assistant" },
`r-${index}`,
),
);
await screen.rerender(
<MessagesTimeline
{...base}
routeThreadKey="environment-local:thread-find-left"
timelineEntries={replies}
liveFollowEnabled={false}
findOpen
findExpanded
findQuery="Needle"
activeFindMatch={{ entryId: "entry-r-5", runId: null, occurrence: 0 }}
/>,
);
const viewport = base.listRef.current!.getScrollableNode()!;
await vi.waitFor(() => {
const row = screen.container.querySelector('[data-timeline-row-id="entry-r-5"]');
expect(row).not.toBeNull();
const bounds = viewport.getBoundingClientRect();
const top = row!.getBoundingClientRect().top;
expect(top).toBeGreaterThanOrEqual(bounds.top);
expect(top).toBeLessThan(bounds.bottom);
});
return new WeakRef(replies);
};
const shown = await revealMatch();

// Twice, because React keeps the previous render's props until the next render.
for (const text of ["Another thread", "Another thread, updated"]) {
await screen.rerender(<MessagesTimeline {...base} timelineEntries={[message(text)]} />);
}
await cdp().send("HeapProfiler.collectGarbage");

expect(shown.deref()).toBeUndefined();
});
});

describe("compact reasoning math", () => {
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/components/chat/ThreadTimeline.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import type { ScopedThreadRef } from "@t3tools/contracts";
import { type ComponentProps, useLayoutEffect } from "react";
import { type ComponentProps, use, useLayoutEffect } from "react";
import { useIncrementalTimelineEntries } from "../../hooks/useIncrementalTimelineEntries";
import type { TimelineEntriesInput, TimelineEntry } from "../../session-logic";
import { useTimelineVisibleTurnItems } from "../../state/entities";
import { markThreadVisited } from "../../state/threads";
import {
peekRememberedThreadTimeline,
rememberReadyThreadTimeline,
resolveThreadSwitchTimeline,
timelineHasEphemeralPreviewUrls,
} from "../ChatView.logic";
import { BackgroundThreadContext } from "./backgroundThread";
import { MessagesTimeline } from "./MessagesTimeline";

/**
Expand Down Expand Up @@ -47,6 +49,12 @@ export function ThreadTimeline({
nextEntries: entries,
rememberedForActive: peekRememberedThreadTimeline<typeof entries>(activeThreadKey),
});
// A side chat shows its thread beside the one the user navigated to.
const backgroundThread = use(BackgroundThreadContext);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- tells the left-thread budget which thread the user navigated to, once it is on screen
useLayoutEffect(() => {
if (!backgroundThread && threadDetailRef !== null) markThreadVisited(threadDetailRef);
}, [backgroundThread, threadDetailRef]);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- stores the committed ready timeline in the module-level cache used during thread switches
useLayoutEffect(() => {
if (detailLoading || entries.length === 0 || timelineHasEphemeralPreviewUrls(entries)) {
Expand Down
Loading
Loading