From c8f302abf2964618fd0a43e44ffe2e177eed92f7 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Mon, 20 Apr 2026 10:14:56 +0300 Subject: [PATCH 1/2] persist changed-files expansion per thread --- apps/web/src/components/ChatView.tsx | 20 ++++ .../components/chat/ChatTranscriptPane.tsx | 9 ++ .../components/chat/MessagesTimeline.test.tsx | 68 +++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 26 ++--- apps/web/src/store.test.ts | 92 +++++++++++++++++ apps/web/src/store.ts | 99 ++++++++++++++++++- 6 files changed, 301 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index bc72eb9d..ce33fcbf 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -318,6 +318,7 @@ const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_PROVIDER_NATIVE_COMMANDS: ProviderNativeCommandDescriptor[] = []; const EMPTY_PROVIDER_SKILLS: ProviderSkillDescriptor[] = []; +const EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID: Record = {}; function eventTargetsComposer( event: globalThis.KeyboardEvent, composerForm: HTMLFormElement | null, @@ -636,6 +637,9 @@ export default function ChatView({ const syncServerReadModel = useStore((store) => store.syncServerReadModel); const setStoreThreadError = useStore((store) => store.setError); const setStoreThreadWorkspace = useStore((store) => store.setThreadWorkspace); + const setStoreThreadChangedFilesExpanded = useStore( + (store) => store.setThreadChangedFilesExpanded, + ); const { settings } = useAppSettings(); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, @@ -739,6 +743,14 @@ export default function ChatView({ const fallbackDraftProject = useStore( useMemo(() => createProjectSelector(fallbackDraftProjectId), [fallbackDraftProjectId]), ); + const changedFilesExpandedByTurnId = useStore( + useMemo( + () => (store) => + store.threadChangedFilesExpandedByTurnId?.[threadId] ?? + EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID, + [threadId], + ), + ); const promptRef = useRef(prompt); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); @@ -5981,6 +5993,12 @@ export default function ChatView({ [groupId]: !existing[groupId], })); }, []); + const onSetChangedFilesExpanded = useCallback( + (turnId: TurnId, expanded: boolean) => { + setStoreThreadChangedFilesExpanded(threadId, turnId, expanded); + }, + [setStoreThreadChangedFilesExpanded, threadId], + ); const onExpandTimelineImage = useCallback((preview: ExpandedImagePreview) => { setExpandedImage(preview); }, []); @@ -6813,7 +6831,9 @@ export default function ChatView({ completionDividerBeforeEntryId={completionDividerBeforeEntryId} completionSummary={completionSummary} turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} + changedFilesExpandedByTurnId={changedFilesExpandedByTurnId} expandedWorkGroups={expandedWorkGroups} + onSetChangedFilesExpanded={onSetChangedFilesExpanded} onToggleWorkGroup={onToggleWorkGroup} onOpenTurnDiff={onOpenTurnDiff} onOpenThread={onNavigateToThread} diff --git a/apps/web/src/components/chat/ChatTranscriptPane.tsx b/apps/web/src/components/chat/ChatTranscriptPane.tsx index fd46f63c..339ab2db 100644 --- a/apps/web/src/components/chat/ChatTranscriptPane.tsx +++ b/apps/web/src/components/chat/ChatTranscriptPane.tsx @@ -20,11 +20,15 @@ import { type ExpandedImagePreview } from "./ExpandedImagePreview"; import { ChatEmptyStateHero } from "./ChatEmptyStateHero"; import { MessagesTimeline } from "./MessagesTimeline"; +const EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID: Record = {}; +const NOOP_SET_CHANGED_FILES_EXPANDED = () => {}; + interface ChatTranscriptPaneProps { activeThreadId: string; activeTurnInProgress: boolean; activeTurnStartedAt: string | null; chatFontSizePx: number; + changedFilesExpandedByTurnId?: Record; completionDividerBeforeEntryId: string | null; completionSummary: string | null; emptyStateProjectName: string | undefined; @@ -49,6 +53,7 @@ interface ChatTranscriptPaneProps { onOpenThread: (threadId: ThreadId) => void; onRevertUserMessage: (messageId: MessageId) => void; onScrollToBottom: () => void; + onSetChangedFilesExpanded?: (turnId: TurnId, expanded: boolean) => void; onTimelineHeightChange: () => void; onToggleWorkGroup: (groupId: string) => void; resolvedTheme: "light" | "dark"; @@ -68,6 +73,7 @@ export const ChatTranscriptPane = memo(function ChatTranscriptPane({ activeTurnInProgress, activeTurnStartedAt, chatFontSizePx, + changedFilesExpandedByTurnId = EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID, completionDividerBeforeEntryId, completionSummary, emptyStateProjectName, @@ -92,6 +98,7 @@ export const ChatTranscriptPane = memo(function ChatTranscriptPane({ onOpenThread, onRevertUserMessage, onScrollToBottom, + onSetChangedFilesExpanded = NOOP_SET_CHANGED_FILES_EXPANDED, onTimelineHeightChange, onToggleWorkGroup, resolvedTheme, @@ -140,7 +147,9 @@ export const ChatTranscriptPane = memo(function ChatTranscriptPane({ completionDividerBeforeEntryId={completionDividerBeforeEntryId} completionSummary={completionSummary} turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} + changedFilesExpandedByTurnId={changedFilesExpandedByTurnId} expandedWorkGroups={expandedWorkGroups} + onSetChangedFilesExpanded={onSetChangedFilesExpanded} onToggleWorkGroup={onToggleWorkGroup} onOpenTurnDiff={onOpenTurnDiff} onOpenThread={onOpenThread} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 1a24052a..1c919175 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1369,6 +1369,74 @@ describe("MessagesTimeline", () => { expect(markup).toContain("+2"); }); + it("renders changed-files cards from controlled thread-scoped expansion state", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const assistantMessageId = MessageId.makeUnsafe("message-assistant-collapsed-files"); + const turnId = TurnId.makeUnsafe("turn-collapsed-files"); + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="dark" + timestampFormat="locale" + workspaceRoot={undefined} + />, + ); + + expect(markup).toContain('aria-expanded="false"'); + expect(markup).toContain("Expand changed files list"); + expect(markup).toContain("grid-rows-[0fr] opacity-0"); + }); + it("renders inline edited rows from the turn summary when the file-change tool call has no filenames", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const assistantMessageId = MessageId.makeUnsafe("message-assistant-inline-summary-fallback"); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 1b446614..cc298ce9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -176,6 +176,7 @@ interface MessagesTimelineProps { activeTurnInProgress: boolean; activeTurnStartedAt: string | null; emptyStateContent?: ReactNode; + changedFilesExpandedByTurnId?: Record; scrollContainer: HTMLDivElement | null; timelineEntries: ReturnType; completionDividerBeforeEntryId: string | null; @@ -183,6 +184,7 @@ interface MessagesTimelineProps { turnDiffSummaryByAssistantMessageId: Map; nowIso?: string; expandedWorkGroups: Record; + onSetChangedFilesExpanded?: (turnId: TurnId, expanded: boolean) => void; onToggleWorkGroup: (groupId: string) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onOpenThread?: (threadId: ThreadId) => void; @@ -203,6 +205,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ isWorking, activeTurnInProgress, activeTurnStartedAt, + changedFilesExpandedByTurnId = {}, scrollContainer, timelineEntries, completionDividerBeforeEntryId, @@ -210,6 +213,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ turnDiffSummaryByAssistantMessageId, nowIso, expandedWorkGroups, + onSetChangedFilesExpanded, onToggleWorkGroup, onOpenTurnDiff, onOpenThread, @@ -236,9 +240,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); const timelineRootRef = useRef(null); const [timelineWidthPx, setTimelineWidthPx] = useState(null); - const [expandedFileChangesByMessageId, setExpandedFileChangesByMessageId] = useState< - Record - >({}); const [expandedUserMessagesById, setExpandedUserMessagesById] = useState>( {}, ); @@ -465,7 +466,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ scheduleVirtualizerMeasure(); }, [ expandedWorkGroups, - expandedFileChangesByMessageId, + changedFilesExpandedByTurnId, allDirectoriesExpandedByTurnId, normalizedChatFontSizePx, scheduleVirtualizerMeasure, @@ -499,12 +500,13 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const virtualRows = rowVirtualizer.getVirtualItems(); const nonVirtualizedRows = rows.slice(virtualizedRowCount); - const toggleFileChangesExpanded = useCallback((messageId: MessageId) => { - setExpandedFileChangesByMessageId((current) => ({ - ...current, - [messageId]: !(current[messageId] ?? true), - })); - }, []); + const toggleFileChangesExpanded = useCallback( + (turnId: TurnId) => { + const currentExpanded = changedFilesExpandedByTurnId[turnId] ?? true; + onSetChangedFilesExpanded?.(turnId, !currentExpanded); + }, + [changedFilesExpandedByTurnId, onSetChangedFilesExpanded], + ); const renderRowContent = (row: MessagesTimelineRow) => (
toggleFileChangesExpanded(row.message.id)} + onClick={() => toggleFileChangesExpanded(turnSummary.turnId)} > { } }); + it("stores only collapsed changed-files overrides and clears them when re-expanded", () => { + const threadId = ThreadId.makeUnsafe("thread-1"); + const turnId = TurnId.makeUnsafe("turn-1"); + const collapsed = setThreadChangedFilesExpanded( + makeState(makeThread()), + threadId, + turnId, + false, + ); + + expect(collapsed.threadChangedFilesExpandedByTurnId).toEqual({ + [threadId]: { + [turnId]: false, + }, + }); + + const expanded = setThreadChangedFilesExpanded(collapsed, threadId, turnId, true); + + expect(expanded.threadChangedFilesExpandedByTurnId).toEqual({}); + }); + + it("prunes persisted changed-files overrides when a thread disappears from the read model", () => { + const threadId = ThreadId.makeUnsafe("thread-1"); + const turnId = TurnId.makeUnsafe("turn-1"); + const otherThreadId = ThreadId.makeUnsafe("thread-2"); + const otherTurnId = TurnId.makeUnsafe("turn-2"); + const initialState = { + ...makeState(makeThread({ id: threadId })), + threads: [makeThread({ id: threadId }), makeThread({ id: otherThreadId })], + threadChangedFilesExpandedByTurnId: { + [threadId]: { [turnId]: false }, + [otherThreadId]: { [otherTurnId]: false }, + }, + } satisfies AppState; + + const next = syncServerReadModel( + initialState, + makeReadModel(makeReadModelThread({ id: threadId, updatedAt: "2026-02-28T00:00:00.000Z" })), + ); + + expect(next.threadChangedFilesExpandedByTurnId).toEqual({ + [threadId]: { [turnId]: false }, + }); + }); + + it("hydrates persisted changed-files overrides and ignores non-collapsed values", async () => { + const storage = new Map(); + const fakeWindow = { + localStorage: { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + removeItem: (key: string) => { + storage.delete(key); + }, + clear: () => { + storage.clear(); + }, + }, + addEventListener: vi.fn(), + }; + storage.set( + "t3code:renderer-state:v8", + JSON.stringify({ + threadChangedFilesExpandedByTurnId: { + "thread-1": { + "turn-collapsed": false, + "turn-expanded": true, + }, + "thread-empty": {}, + }, + }), + ); + + vi.stubGlobal("window", fakeWindow); + try { + vi.resetModules(); + + const freshStore = await import("./store"); + + expect(freshStore.useStore.getState().threadChangedFilesExpandedByTurnId).toEqual({ + [ThreadId.makeUnsafe("thread-1")]: { + [TurnId.makeUnsafe("turn-collapsed")]: false, + }, + }); + } finally { + vi.unstubAllGlobals(); + } + }); + it("reuses normalized thread objects when the incoming snapshot is unchanged", () => { const readModel = { snapshotSequence: 1, diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index c1cd7b3f..f06428aa 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -53,6 +53,7 @@ export interface AppState { proposedPlanByThreadId?: Record>; turnDiffIdsByThreadId?: Record; turnDiffSummaryByThreadId?: Record>; + threadChangedFilesExpandedByTurnId?: Record>; } type ReadModelProject = OrchestrationReadModel["projects"][number]; @@ -93,6 +94,8 @@ const EMPTY_TURN_DIFF_BY_THREAD: Record< ThreadId, Record > = {}; +const EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID: Record> = {}; +const EMPTY_CHANGED_FILES_EXPANDED_BY_TURN: Record = {}; const initialState: AppState = { projects: [], @@ -111,6 +114,7 @@ const initialState: AppState = { proposedPlanByThreadId: {}, turnDiffIdsByThreadId: {}, turnDiffSummaryByThreadId: {}, + threadChangedFilesExpandedByTurnId: {}, }; const persistedExpandedProjectCwds = new Set(); const persistedProjectOrderCwds: string[] = []; @@ -164,6 +168,7 @@ function readPersistedState(): AppState { expandedProjectCwds?: string[]; projectOrderCwds?: string[]; projectNamesByCwd?: Record; + threadChangedFilesExpandedByTurnId?: Record>; }; persistedExpandedProjectCwds.clear(); persistedProjectOrderCwds.length = 0; @@ -186,7 +191,12 @@ function readPersistedState(): AppState { if (trimmedName.length === 0) continue; persistedProjectNamesByCwd.set(projectCwdKey(cwd), trimmedName); } - return { ...initialState }; + return { + ...initialState, + threadChangedFilesExpandedByTurnId: sanitizeThreadChangedFilesExpandedByTurnId( + parsed.threadChangedFilesExpandedByTurnId, + ), + }; } catch { return initialState; } @@ -207,6 +217,7 @@ function persistState(state: AppState): void { .map((project) => project.cwd), projectOrderCwds: state.projects.map((project) => project.cwd), projectNamesByCwd: Object.fromEntries(persistedProjectNamesByCwd), + threadChangedFilesExpandedByTurnId: state.threadChangedFilesExpandedByTurnId ?? {}, }), ); if (!legacyKeysCleanedUp) { @@ -1785,6 +1796,35 @@ function retainThreadScopedRecord( return changed ? nextRecord : record; } +function sanitizeThreadChangedFilesExpandedByTurnId( + input: unknown, +): Record> { + if (!input || typeof input !== "object") { + return {}; + } + + const nextRecord: Record> = {}; + for (const [threadId, value] of Object.entries(input)) { + if (!value || typeof value !== "object") { + continue; + } + + const nextTurns: Record = {}; + for (const [turnId, expanded] of Object.entries(value)) { + if (turnId.length === 0 || expanded !== false) { + continue; + } + nextTurns[turnId as TurnId] = false; + } + + if (Object.keys(nextTurns).length > 0) { + nextRecord[threadId as ThreadId] = nextTurns; + } + } + + return nextRecord; +} + function writeThreadShellProjection( state: AppState, nextThread: { @@ -1963,6 +2003,8 @@ function removeThreadState(state: AppState, threadId: ThreadId): AppState { state.turnDiffIdsByThreadId ?? EMPTY_TURN_DIFF_IDS_BY_THREAD; const { [threadId]: _removedDiffs, ...turnDiffSummaryByThreadId } = state.turnDiffSummaryByThreadId ?? EMPTY_TURN_DIFF_BY_THREAD; + const { [threadId]: _removedChangedFilesExpanded, ...threadChangedFilesExpandedByTurnId } = + state.threadChangedFilesExpandedByTurnId ?? EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID; const { [threadId]: _removedSummary, ...sidebarThreadSummaryById } = state.sidebarThreadSummaryById; const nextThreadIds = (state.threadIds ?? EMPTY_THREAD_IDS).filter((id) => id !== threadId); @@ -1990,6 +2032,7 @@ function removeThreadState(state: AppState, threadId: ThreadId): AppState { proposedPlanByThreadId, turnDiffIdsByThreadId, turnDiffSummaryByThreadId, + threadChangedFilesExpandedByTurnId, sidebarThreadSummaryById, threads: nextThreads, }; @@ -3182,6 +3225,10 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea state.turnDiffSummaryByThreadId, nextThreadIds, ), + threadChangedFilesExpandedByTurnId: retainThreadScopedRecord( + state.threadChangedFilesExpandedByTurnId, + nextThreadIds, + ), }; for (const thread of nextThreads) { normalizedState = writeThreadState(normalizedState, thread); @@ -3218,6 +3265,8 @@ export function syncServerReadModel(state: AppState, readModel: OrchestrationRea normalizedState.proposedPlanByThreadId === state.proposedPlanByThreadId && normalizedState.turnDiffIdsByThreadId === state.turnDiffIdsByThreadId && normalizedState.turnDiffSummaryByThreadId === state.turnDiffSummaryByThreadId && + normalizedState.threadChangedFilesExpandedByTurnId === + state.threadChangedFilesExpandedByTurnId && state.threadsHydrated ) { return state; @@ -3405,6 +3454,51 @@ export function setThreadWorkspace( }); } +export function setThreadChangedFilesExpanded( + state: AppState, + threadId: ThreadId, + turnId: TurnId, + expanded: boolean, +): AppState { + const currentByThread = + state.threadChangedFilesExpandedByTurnId ?? EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID; + const currentByTurn = currentByThread[threadId] ?? EMPTY_CHANGED_FILES_EXPANDED_BY_TURN; + const currentExpanded = currentByTurn[turnId] ?? true; + if (currentExpanded === expanded) { + return state; + } + + if (expanded) { + const { [turnId]: _removedTurnId, ...remainingTurns } = currentByTurn; + if (Object.keys(remainingTurns).length === 0) { + const { [threadId]: _removedThreadId, ...remainingThreads } = currentByThread; + return { + ...state, + threadChangedFilesExpandedByTurnId: remainingThreads, + }; + } + + return { + ...state, + threadChangedFilesExpandedByTurnId: { + ...currentByThread, + [threadId]: remainingTurns, + }, + }; + } + + return { + ...state, + threadChangedFilesExpandedByTurnId: { + ...currentByThread, + [threadId]: { + ...currentByTurn, + [turnId]: false, + }, + }, + }; +} + // ── Zustand store ──────────────────────────────────────────────────── interface AppStore extends AppState { @@ -3425,6 +3519,7 @@ interface AppStore extends AppState { renameProjectLocally: (projectId: Project["id"], name: string | null) => void; setError: (threadId: ThreadId, error: string | null) => void; setThreadWorkspace: (threadId: ThreadId, patch: ThreadWorkspacePatch) => void; + setThreadChangedFilesExpanded: (threadId: ThreadId, turnId: TurnId, expanded: boolean) => void; } export const useStore = create((set) => ({ @@ -3456,6 +3551,8 @@ export const useStore = create((set) => ({ setError: (threadId, error) => set((state) => setError(state, threadId, error)), setThreadWorkspace: (threadId, patch) => set((state) => setThreadWorkspace(state, threadId, patch)), + setThreadChangedFilesExpanded: (threadId, turnId, expanded) => + set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), })); // Persist state changes with debouncing to avoid localStorage thrashing From a0aa9d3232e30edf1608b0026795b767b3099a84 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 23 Apr 2026 03:45:20 +0300 Subject: [PATCH 2/2] ci: retrigger runners