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
1 change: 1 addition & 0 deletions apps/desktop/src/settings/DesktopClientSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const clientSettings: ClientSettings = {
autoOpenPlanSidebar: false,
confirmThreadArchive: true,
confirmThreadDelete: false,
defaultOpenChangedFiles: true,
dismissedProviderUpdateNotificationKeys: [],
diffIgnoreWhitespace: true,
diffWordWrap: true,
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -637,6 +637,7 @@
);
const timestampFormat = settings.timestampFormat;
const autoOpenPlanSidebar = settings.autoOpenPlanSidebar;
const defaultOpenChangedFiles = settings.defaultOpenChangedFiles;
const navigate = useNavigate();
const rawSearch = useSearch({
strict: false,
Expand Down Expand Up @@ -1780,7 +1781,7 @@
);

const focusComposer = useCallback(() => {
composerRef.current?.focusAtEnd();

Check warning on line 1784 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
}, []);
const scheduleComposerFocus = useCallback(() => {
window.requestAnimationFrame(() => {
Expand All @@ -1788,7 +1789,7 @@
});
}, [focusComposer]);
const addTerminalContextToDraft = useCallback((selection: TerminalContextSelection) => {
composerRef.current?.addTerminalContext(selection);

Check warning on line 1792 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
}, []);
const setTerminalOpen = useCallback(
(open: boolean) => {
Expand Down Expand Up @@ -2467,7 +2468,7 @@
const shortcutContext = {
terminalFocus: isTerminalFocused(),
terminalOpen: Boolean(terminalState.terminalOpen),
modelPickerOpen: composerRef.current?.isModelPickerOpen() ?? false,

Check warning on line 2471 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

react-hooks(exhaustive-deps)

React Hook useEffect has a missing dependency: 'composerRef.current'
};

const command = resolveShortcutCommand(event, keybindings, {
Expand Down Expand Up @@ -3019,7 +3020,7 @@
};
});
promptRef.current = "";
composerRef.current?.resetCursorState({ cursor: 0 });

Check warning on line 3023 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
},
[activePendingProgress?.activeQuestion, activePendingUserInput],
);
Expand All @@ -3046,7 +3047,7 @@
),
},
}));
const snapshot = composerRef.current?.readSnapshot();

Check warning on line 3050 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
if (
snapshot?.value !== value ||
snapshot.cursor !== nextCursor ||
Expand Down Expand Up @@ -3109,7 +3110,7 @@
return;
}

const sendCtx = composerRef.current?.getSendContext();

Check warning on line 3113 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
if (!sendCtx) {
return;
}
Expand Down Expand Up @@ -3246,7 +3247,7 @@
return;
}

const sendCtx = composerRef.current?.getSendContext();

Check warning on line 3250 in apps/web/src/components/ChatView.tsx

View workflow job for this annotation

GitHub Actions / Format, Lint, Typecheck, Test, Browser Test, Build

react-hooks(exhaustive-deps)

React Hook useCallback has a missing dependency: 'composerRef.current'
if (!sendCtx) {
return;
}
Expand Down Expand Up @@ -3574,6 +3575,7 @@
markdownCwd={gitCwd ?? undefined}
resolvedTheme={resolvedTheme}
timestampFormat={timestampFormat}
defaultOpenChangedFiles={defaultOpenChangedFiles}
workspaceRoot={activeWorkspaceRoot}
skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS}
onIsAtEndChange={onIsAtEndChange}
Expand Down
174 changes: 173 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.browser.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import "../../index.css";

import { EnvironmentId } from "@t3tools/contracts";
import { EnvironmentId, MessageId, TurnId } from "@t3tools/contracts";
import { createRef } from "react";
import type { LegendListRef } from "@legendapp/list/react";
import { page } from "vitest/browser";
import { afterEach, describe, expect, it, vi } from "vitest";
import { render } from "vitest-browser-react";

import { useUiStateStore } from "../../uiStateStore";

const scrollToEndSpy = vi.fn();
const getStateSpy = vi.fn(() => ({ isAtEnd: true }));

Expand Down Expand Up @@ -70,6 +72,7 @@ function buildProps() {
markdownCwd: undefined,
resolvedTheme: "dark" as const,
timestampFormat: "24-hour" as const,
defaultOpenChangedFiles: true,
workspaceRoot: undefined,
onIsAtEndChange: vi.fn(),
};
Expand Down Expand Up @@ -101,6 +104,13 @@ describe("MessagesTimeline", () => {
scrollToEndSpy.mockReset();
getStateSpy.mockClear();
vi.restoreAllMocks();
localStorage.clear();
useUiStateStore.setState({
projectExpandedById: {},
projectOrder: [],
threadLastVisitedAtById: {},
threadChangedFilesExpandedById: {},
});
document.body.innerHTML = "";
});

Expand Down Expand Up @@ -181,6 +191,119 @@ describe("MessagesTimeline", () => {
}
});

it("keeps changed-files collapse scoped to the selected turn", async () => {
const props = buildProps();
const firstAssistantMessageId = MessageId.make("assistant-1");
const secondAssistantMessageId = MessageId.make("assistant-2");
const firstTurnId = TurnId.make("turn-1");
const secondTurnId = TurnId.make("turn-2");
const firstTimelineEntry = {
id: "assistant-entry-1",
kind: "message" as const,
createdAt: "2026-04-13T12:00:00.000Z",
message: {
id: firstAssistantMessageId,
role: "assistant" as const,
text: "Updated files in the first turn",
turnId: firstTurnId,
createdAt: "2026-04-13T12:00:00.000Z",
completedAt: "2026-04-13T12:00:05.000Z",
streaming: false,
},
};
const secondTimelineEntry = {
id: "assistant-entry-2",
kind: "message" as const,
createdAt: "2026-04-13T12:01:00.000Z",
message: {
id: secondAssistantMessageId,
role: "assistant" as const,
text: "Updated files in the second turn",
turnId: secondTurnId,
createdAt: "2026-04-13T12:01:00.000Z",
completedAt: "2026-04-13T12:01:05.000Z",
streaming: false,
},
};
const screen = await render(
<MessagesTimeline
{...props}
timelineEntries={[firstTimelineEntry]}
turnDiffSummaryByAssistantMessageId={
new Map([
[
firstAssistantMessageId,
{
turnId: firstTurnId,
completedAt: "2026-04-13T12:00:05.000Z",
files: [{ path: "src/first.ts", additions: 5, deletions: 2 }],
assistantMessageId: firstAssistantMessageId,
},
],
])
}
/>,
);

try {
const collapseButton = page.getByRole("button", { name: "Collapse all" });
await expect.element(collapseButton).toBeVisible();
await collapseButton.click();

await vi.waitFor(
() => {
const expandAllButtons = [...document.querySelectorAll("button")].filter(
(button) => button.textContent?.trim() === "Expand all",
);
expect(expandAllButtons).toHaveLength(1);
},
{ timeout: 8_000, interval: 16 },
);

await screen.rerender(
<MessagesTimeline
{...props}
timelineEntries={[firstTimelineEntry, secondTimelineEntry]}
turnDiffSummaryByAssistantMessageId={
new Map([
[
firstAssistantMessageId,
{
turnId: firstTurnId,
completedAt: "2026-04-13T12:00:05.000Z",
files: [{ path: "src/first.ts", additions: 5, deletions: 2 }],
assistantMessageId: firstAssistantMessageId,
},
],
[
secondAssistantMessageId,
{
turnId: secondTurnId,
completedAt: "2026-04-13T12:01:05.000Z",
files: [{ path: "src/second.ts", additions: 3, deletions: 1 }],
assistantMessageId: secondAssistantMessageId,
},
],
])
}
/>,
);

await vi.waitFor(
() => {
const buttonLabels = [...document.querySelectorAll("button")].map((button) =>
button.textContent?.trim(),
);
expect(buttonLabels.filter((label) => label === "Expand all")).toHaveLength(1);
expect(buttonLabels.filter((label) => label === "Collapse all")).toHaveLength(1);
},
{ timeout: 8_000, interval: 16 },
);
} finally {
await screen.unmount();
}
});

it("starts long user messages collapsed by default", async () => {
const screen = await render(
<MessagesTimeline
Expand Down Expand Up @@ -246,6 +369,55 @@ describe("MessagesTimeline", () => {
}
});

it("starts changed-files collapsed when the default is disabled", async () => {
const props = {
...buildProps(),
defaultOpenChangedFiles: false,
};
const assistantMessageId = MessageId.make("assistant-default-closed");
const turnId = TurnId.make("turn-default-closed");
const screen = await render(
<MessagesTimeline
{...props}
timelineEntries={[
{
id: "assistant-entry-1",
kind: "message" as const,
createdAt: "2026-04-13T12:00:00.000Z",
message: {
id: assistantMessageId,
role: "assistant" as const,
text: "Updated files",
turnId,
createdAt: "2026-04-13T12:00:00.000Z",
completedAt: "2026-04-13T12:00:05.000Z",
streaming: false,
},
},
]}
turnDiffSummaryByAssistantMessageId={
new Map([
[
assistantMessageId,
{
turnId,
completedAt: "2026-04-13T12:00:05.000Z",
files: [{ path: "src/default-closed.ts", additions: 1, deletions: 0 }],
assistantMessageId,
},
],
])
}
/>,
);

try {
await expect.element(page.getByRole("button", { name: "Expand all" })).toBeVisible();
} finally {
await screen.unmount();
}
});

it("starts the newest long user prompt collapsed", async () => {
const screen = await render(
<MessagesTimeline
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/MessagesTimeline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ function buildProps() {
markdownCwd: undefined,
resolvedTheme: "light" as const,
timestampFormat: "locale" as const,
defaultOpenChangedFiles: true,
workspaceRoot: undefined,
onIsAtEndChange: () => {},
};
Expand Down
31 changes: 27 additions & 4 deletions apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ import {
type ParsedTerminalContextEntry,
} from "~/lib/terminalContext";
import { cn } from "~/lib/utils";
import { useUiStateStore } from "~/uiStateStore";
import { getThreadChangedFilesExpanded, useUiStateStore } from "~/uiStateStore";
import { type TimestampFormat } from "@t3tools/contracts/settings";
import { formatTimestamp } from "../../timestampFormat";

Expand All @@ -78,6 +78,7 @@ import { formatWorkspaceRelativePath } from "../../filePathDisplay";
interface TimelineRowSharedState {
timestampFormat: TimestampFormat;
routeThreadKey: string;
defaultOpenChangedFiles: boolean;
markdownCwd: string | undefined;
resolvedTheme: "light" | "dark";
workspaceRoot: string | undefined;
Expand Down Expand Up @@ -126,6 +127,7 @@ interface MessagesTimelineProps {
markdownCwd: string | undefined;
resolvedTheme: "light" | "dark";
timestampFormat: TimestampFormat;
defaultOpenChangedFiles: boolean;
workspaceRoot: string | undefined;
skills?: ReadonlyArray<Pick<ServerProviderSkill, "name" | "displayName">>;
onIsAtEndChange: (isAtEnd: boolean) => void;
Expand Down Expand Up @@ -155,6 +157,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
markdownCwd,
resolvedTheme,
timestampFormat,
defaultOpenChangedFiles,
workspaceRoot,
skills = EMPTY_TIMELINE_SKILLS,
onIsAtEndChange,
Expand Down Expand Up @@ -209,6 +212,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
() => ({
timestampFormat,
routeThreadKey,
defaultOpenChangedFiles,
markdownCwd,
resolvedTheme,
workspaceRoot,
Expand All @@ -221,6 +225,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
[
timestampFormat,
routeThreadKey,
defaultOpenChangedFiles,
markdownCwd,
resolvedTheme,
workspaceRoot,
Expand Down Expand Up @@ -423,6 +428,7 @@ function AssistantTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "mess
<AssistantChangedFilesSection
turnSummary={row.assistantTurnDiffSummary}
routeThreadKey={ctx.routeThreadKey}
defaultOpenChangedFiles={ctx.defaultOpenChangedFiles}
resolvedTheme={ctx.resolvedTheme}
onOpenTurnDiff={ctx.onOpenTurnDiff}
/>
Expand Down Expand Up @@ -651,11 +657,13 @@ const WorkGroupSection = memo(function WorkGroupSection({
const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection({
turnSummary,
routeThreadKey,
defaultOpenChangedFiles,
resolvedTheme,
onOpenTurnDiff,
}: {
turnSummary: TurnDiffSummary | undefined;
routeThreadKey: string;
defaultOpenChangedFiles: boolean;
resolvedTheme: "light" | "dark";
onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
}) {
Expand All @@ -668,6 +676,7 @@ const AssistantChangedFilesSection = memo(function AssistantChangedFilesSection(
turnSummary={turnSummary}
checkpointFiles={checkpointFiles}
routeThreadKey={routeThreadKey}
defaultOpenChangedFiles={defaultOpenChangedFiles}
resolvedTheme={resolvedTheme}
onOpenTurnDiff={onOpenTurnDiff}
/>
Expand All @@ -680,17 +689,24 @@ function AssistantChangedFilesSectionInner({
turnSummary,
checkpointFiles,
routeThreadKey,
defaultOpenChangedFiles,
resolvedTheme,
onOpenTurnDiff,
}: {
turnSummary: TurnDiffSummary;
checkpointFiles: TurnDiffSummary["files"];
routeThreadKey: string;
defaultOpenChangedFiles: boolean;
resolvedTheme: "light" | "dark";
onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
}) {
const allDirectoriesExpanded = useUiStateStore(
(store) => store.threadChangedFilesExpandedById[routeThreadKey]?.[turnSummary.turnId] ?? true,
const allDirectoriesExpanded = useUiStateStore((store) =>
getThreadChangedFilesExpanded(
store,
routeThreadKey,
turnSummary.turnId,
defaultOpenChangedFiles,
),
);
const setExpanded = useUiStateStore((store) => store.setThreadChangedFilesExpanded);
const summaryStat = summarizeTurnDiffStats(checkpointFiles);
Expand All @@ -714,7 +730,14 @@ function AssistantChangedFilesSectionInner({
size="xs"
variant="outline"
data-scroll-anchor-ignore
onClick={() => setExpanded(routeThreadKey, turnSummary.turnId, !allDirectoriesExpanded)}
onClick={() =>
setExpanded(
routeThreadKey,
turnSummary.turnId,
!allDirectoriesExpanded,
defaultOpenChangedFiles,
)
}
>
{allDirectoriesExpanded ? "Collapse all" : "Expand all"}
</Button>
Expand Down
Loading
Loading