From 59011e942214860e98ec981787c43c2291a6e542 Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 9 Apr 2026 22:04:39 +0300 Subject: [PATCH 1/4] persist changed-files expansion per thread --- apps/web/src/components/ChatView.tsx | 25 +++- .../components/chat/MessagesTimeline.test.tsx | 4 + .../src/components/chat/MessagesTimeline.tsx | 19 +-- ...essagesTimeline.virtualization.browser.tsx | 2 + apps/web/src/uiStateStore.test.ts | 49 ++++++ apps/web/src/uiStateStore.ts | 140 +++++++++++++++++- 6 files changed, 222 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6c408ea7b0..7a90a6d06a 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -231,6 +231,7 @@ const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; +const EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID: Record = {}; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; type ThreadPlanCatalogEntry = Pick; @@ -685,6 +686,7 @@ export default function ChatView(props: ChatViewProps) { () => scopeThreadRef(environmentId, threadId), [environmentId, threadId], ); + const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); const composerDraftTarget: ScopedThreadRef | DraftId = routeKind === "server" ? routeThreadRef : props.draftId; const serverThread = useStore( @@ -695,10 +697,17 @@ export default function ChatView(props: ChatViewProps) { ); const setStoreThreadError = useStore((store) => store.setError); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); + const setThreadChangedFilesExpanded = useUiStateStore( + (store) => store.setThreadChangedFilesExpanded, + ); const activeThreadLastVisitedAt = useUiStateStore((store) => + routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined, + ); + const changedFilesExpandedByTurnId = useUiStateStore((store) => routeKind === "server" - ? store.threadLastVisitedAtById[scopedThreadKey(scopeThreadRef(environmentId, threadId))] - : undefined, + ? (store.threadChangedFilesExpandedById[routeThreadKey] ?? + EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID) + : EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID, ); const settings = useSettings(); const setStickyComposerModelSelection = useComposerDraftStore( @@ -1192,6 +1201,16 @@ export default function ChatView(props: ChatViewProps) { [openOrReuseProjectDraftThread], ); + const handleSetChangedFilesExpanded = useCallback( + (turnId: TurnId, expanded: boolean) => { + if (routeKind !== "server") { + return; + } + setThreadChangedFilesExpanded(routeThreadKey, turnId, expanded); + }, + [routeKind, routeThreadKey, setThreadChangedFilesExpanded], + ); + useEffect(() => { if (!serverThread?.id) return; if (!latestTurnSettled) return; @@ -4414,6 +4433,8 @@ export default function ChatView(props: ChatViewProps) { activeThreadEnvironmentId={activeThread.environmentId} expandedWorkGroups={expandedWorkGroups} onToggleWorkGroup={onToggleWorkGroup} + changedFilesExpandedByTurnId={changedFilesExpandedByTurnId} + onSetChangedFilesExpanded={handleSetChangedFilesExpanded} onOpenTurnDiff={onOpenTurnDiff} revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} onRevertUserMessage={onRevertUserMessage} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index c644867aac..d936c0ec83 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -82,6 +82,8 @@ describe("MessagesTimeline", () => { nowIso="2026-03-17T19:12:30.000Z" expandedWorkGroups={{}} onToggleWorkGroup={() => {}} + changedFilesExpandedByTurnId={{}} + onSetChangedFilesExpanded={() => {}} onOpenTurnDiff={() => {}} revertTurnCountByUserMessageId={new Map()} onRevertUserMessage={() => {}} @@ -128,6 +130,8 @@ describe("MessagesTimeline", () => { nowIso="2026-03-17T19:12:30.000Z" expandedWorkGroups={{}} onToggleWorkGroup={() => {}} + changedFilesExpandedByTurnId={{}} + onSetChangedFilesExpanded={() => {}} onOpenTurnDiff={() => {}} revertTurnCountByUserMessageId={new Map()} onRevertUserMessage={() => {}} diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 5100824328..bd0d1c76bc 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -77,6 +77,8 @@ interface MessagesTimelineProps { nowIso: string; expandedWorkGroups: Record; onToggleWorkGroup: (groupId: string) => void; + changedFilesExpandedByTurnId: Record; + onSetChangedFilesExpanded: (turnId: TurnId, expanded: boolean) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; revertTurnCountByUserMessageId: Map; onRevertUserMessage: (messageId: MessageId) => void; @@ -113,6 +115,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ nowIso, expandedWorkGroups, onToggleWorkGroup, + changedFilesExpandedByTurnId, + onSetChangedFilesExpanded, onOpenTurnDiff, revertTurnCountByUserMessageId, onRevertUserMessage, @@ -296,15 +300,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ const virtualRows = rowVirtualizer.getVirtualItems(); const nonVirtualizedRows = rows.slice(virtualizedRowCount); - const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState< - Record - >({}); - const onToggleAllDirectories = useCallback((turnId: TurnId) => { - setAllDirectoriesExpandedByTurnId((current) => ({ - ...current, - [turnId]: !(current[turnId] ?? true), - })); - }, []); const renderRowContent = (row: TimelineRow) => (
@@ -488,7 +483,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ size="xs" variant="outline" data-scroll-anchor-ignore - onClick={() => onToggleAllDirectories(turnSummary.turnId)} + onClick={() => + onSetChangedFilesExpanded(turnSummary.turnId, !allDirectoriesExpanded) + } > {allDirectoriesExpanded ? "Collapse all" : "Expand all"} diff --git a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx index 6a37c5b099..a7dc309cc2 100644 --- a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx @@ -168,6 +168,8 @@ function createBaseTimelineProps(input: { nowIso: isoAt(10_000), expandedWorkGroups: input.expandedWorkGroups ?? {}, onToggleWorkGroup: () => {}, + changedFilesExpandedByTurnId: {}, + onSetChangedFilesExpanded: () => {}, onOpenTurnDiff: () => {}, revertTurnCountByUserMessageId: new Map(), onRevertUserMessage: () => {}, diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 950a7e11ff..8cd8227967 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -6,6 +6,7 @@ import { markThreadUnread, reorderProjects, setProjectExpanded, + setThreadChangedFilesExpanded, syncProjects, syncThreads, type UiState, @@ -16,6 +17,7 @@ function makeUiState(overrides: Partial = {}): UiState { projectExpandedById: {}, projectOrder: [], threadLastVisitedAtById: {}, + threadChangedFilesExpandedById: {}, ...overrides, }; } @@ -137,6 +139,14 @@ describe("uiStateStore pure functions", () => { [thread1]: "2026-02-25T12:35:00.000Z", [thread2]: "2026-02-25T12:36:00.000Z", }, + threadChangedFilesExpandedById: { + [thread1]: { + "turn-1": false, + }, + [thread2]: { + "turn-2": false, + }, + }, }); const next = syncThreads(initialState, [{ key: thread1 }]); @@ -144,6 +154,11 @@ describe("uiStateStore pure functions", () => { expect(next.threadLastVisitedAtById).toEqual({ [thread1]: "2026-02-25T12:35:00.000Z", }); + expect(next.threadChangedFilesExpandedById).toEqual({ + [thread1]: { + "turn-1": false, + }, + }); }); it("syncThreads seeds visit state for unseen snapshot threads", () => { @@ -183,10 +198,44 @@ describe("uiStateStore pure functions", () => { threadLastVisitedAtById: { [thread1]: "2026-02-25T12:35:00.000Z", }, + threadChangedFilesExpandedById: { + [thread1]: { + "turn-1": false, + }, + }, }); const next = clearThreadUi(initialState, thread1); expect(next.threadLastVisitedAtById).toEqual({}); + expect(next.threadChangedFilesExpandedById).toEqual({}); + }); + + it("setThreadChangedFilesExpanded stores collapsed turns per thread", () => { + const thread1 = ThreadId.makeUnsafe("thread-1"); + const initialState = makeUiState(); + + const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", false); + + expect(next.threadChangedFilesExpandedById).toEqual({ + [thread1]: { + "turn-1": false, + }, + }); + }); + + it("setThreadChangedFilesExpanded removes thread overrides when expanded again", () => { + const thread1 = ThreadId.makeUnsafe("thread-1"); + const initialState = makeUiState({ + threadChangedFilesExpandedById: { + [thread1]: { + "turn-1": false, + }, + }, + }); + + const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", true); + + expect(next.threadChangedFilesExpandedById).toEqual({}); }); }); diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 5a7d53a028..61d1563097 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -18,6 +18,7 @@ const LEGACY_PERSISTED_STATE_KEYS = [ interface PersistedUiState { expandedProjectCwds?: string[]; projectOrderCwds?: string[]; + threadChangedFilesExpandedById?: Record>; } export interface UiProjectState { @@ -27,6 +28,7 @@ export interface UiProjectState { export interface UiThreadState { threadLastVisitedAtById: Record; + threadChangedFilesExpandedById: Record>; } export interface UiState extends UiProjectState, UiThreadState {} @@ -45,6 +47,7 @@ const initialState: UiState = { projectExpandedById: {}, projectOrder: [], threadLastVisitedAtById: {}, + threadChangedFilesExpandedById: {}, }; const persistedExpandedProjectCwds = new Set(); @@ -69,13 +72,47 @@ function readPersistedState(): UiState { } return initialState; } - hydratePersistedProjectState(JSON.parse(raw) as PersistedUiState); - return initialState; + const parsed = JSON.parse(raw) as PersistedUiState; + hydratePersistedProjectState(parsed); + return { + ...initialState, + threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded( + parsed.threadChangedFilesExpandedById, + ), + }; } catch { return initialState; } } +function sanitizePersistedThreadChangedFilesExpanded( + value: PersistedUiState["threadChangedFilesExpandedById"], +): Record> { + if (!value || typeof value !== "object") { + return {}; + } + + const nextState: Record> = {}; + for (const [threadId, turns] of Object.entries(value)) { + if (!threadId || !turns || typeof turns !== "object") { + continue; + } + + const nextTurns: Record = {}; + for (const [turnId, expanded] of Object.entries(turns)) { + if (turnId && typeof expanded === "boolean" && expanded === false) { + nextTurns[turnId] = false; + } + } + + if (Object.keys(nextTurns).length > 0) { + nextState[threadId] = nextTurns; + } + } + + return nextState; +} + function hydratePersistedProjectState(parsed: PersistedUiState): void { persistedExpandedProjectCwds.clear(); persistedProjectOrderCwds.length = 0; @@ -106,11 +143,20 @@ function persistState(state: UiState): void { const cwd = currentProjectCwdById.get(projectId); return cwd ? [cwd] : []; }); + const threadChangedFilesExpandedById = Object.fromEntries( + Object.entries(state.threadChangedFilesExpandedById).flatMap(([threadId, turns]) => { + const nextTurns = Object.fromEntries( + Object.entries(turns).filter(([, expanded]) => expanded === false), + ); + return Object.keys(nextTurns).length > 0 ? [[threadId, nextTurns]] : []; + }), + ); window.localStorage.setItem( PERSISTED_STATE_KEY, JSON.stringify({ expandedProjectCwds, projectOrderCwds, + threadChangedFilesExpandedById, } satisfies PersistedUiState), ); if (!legacyKeysCleanedUp) { @@ -146,6 +192,23 @@ function projectOrdersEqual(left: readonly string[], right: readonly string[]): ); } +function nestedBooleanRecordsEqual( + left: Record>, + right: Record>, +): boolean { + const leftEntries = Object.entries(left); + const rightEntries = Object.entries(right); + if (leftEntries.length !== rightEntries.length) { + return false; + } + for (const [key, value] of leftEntries) { + if (!recordsEqual(value, right[key] ?? {})) { + return false; + } + } + return true; +} + export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState { const previousProjectCwdById = new Map(currentProjectCwdById); const previousProjectIdByCwd = new Map( @@ -260,12 +323,24 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[]) nextThreadLastVisitedAtById[thread.key] = thread.seedVisitedAt; } } - if (recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById)) { + const nextThreadChangedFilesExpandedById = Object.fromEntries( + Object.entries(state.threadChangedFilesExpandedById).filter(([threadId]) => + retainedThreadIds.has(threadId), + ), + ); + if ( + recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById) && + nestedBooleanRecordsEqual( + state.threadChangedFilesExpandedById, + nextThreadChangedFilesExpandedById, + ) + ) { return state; } return { ...state, threadLastVisitedAtById: nextThreadLastVisitedAtById, + threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById, }; } @@ -316,14 +391,68 @@ export function markThreadUnread( } export function clearThreadUi(state: UiState, threadId: string): UiState { - if (!(threadId in state.threadLastVisitedAtById)) { + const hasVisitedState = threadId in state.threadLastVisitedAtById; + const hasChangedFilesState = threadId in state.threadChangedFilesExpandedById; + if (!hasVisitedState && !hasChangedFilesState) { return state; } const nextThreadLastVisitedAtById = { ...state.threadLastVisitedAtById }; + const nextThreadChangedFilesExpandedById = { ...state.threadChangedFilesExpandedById }; delete nextThreadLastVisitedAtById[threadId]; + delete nextThreadChangedFilesExpandedById[threadId]; return { ...state, threadLastVisitedAtById: nextThreadLastVisitedAtById, + threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById, + }; +} + +export function setThreadChangedFilesExpanded( + state: UiState, + threadId: string, + turnId: string, + expanded: boolean, +): UiState { + const currentThreadState = state.threadChangedFilesExpandedById[threadId] ?? {}; + const currentExpanded = currentThreadState[turnId] ?? true; + if (currentExpanded === expanded) { + return state; + } + + if (expanded) { + if (!(turnId in currentThreadState)) { + return state; + } + + const nextThreadState = { ...currentThreadState }; + delete nextThreadState[turnId]; + if (Object.keys(nextThreadState).length === 0) { + const nextState = { ...state.threadChangedFilesExpandedById }; + delete nextState[threadId]; + return { + ...state, + threadChangedFilesExpandedById: nextState, + }; + } + + return { + ...state, + threadChangedFilesExpandedById: { + ...state.threadChangedFilesExpandedById, + [threadId]: nextThreadState, + }, + }; + } + + return { + ...state, + threadChangedFilesExpandedById: { + ...state.threadChangedFilesExpandedById, + [threadId]: { + ...currentThreadState, + [turnId]: false, + }, + }, }; } @@ -382,6 +511,7 @@ interface UiStateStore extends UiState { markThreadVisited: (threadId: string, visitedAt?: string) => void; markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void; clearThreadUi: (threadId: string) => void; + setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void; toggleProject: (projectId: string) => void; setProjectExpanded: (projectId: string, expanded: boolean) => void; reorderProjects: (draggedProjectId: string, targetProjectId: string) => void; @@ -396,6 +526,8 @@ export const useUiStateStore = create((set) => ({ markThreadUnread: (threadId, latestTurnCompletedAt) => set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)), clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)), + setThreadChangedFilesExpanded: (threadId, turnId, expanded) => + set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)), toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), setProjectExpanded: (projectId, expanded) => set((state) => setProjectExpanded(state, projectId, expanded)), From cfe8b846842fe7ca65091c6c6d5fc554fc7759bd Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 9 Apr 2026 22:09:48 +0300 Subject: [PATCH 2/4] fix changed-files virtualization harness state --- .../MessagesTimeline.virtualization.browser.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx index a7dc309cc2..6d07b1d7bb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx @@ -61,6 +61,9 @@ function MessagesTimelineBrowserHarness( const [expandedWorkGroups, setExpandedWorkGroups] = useState>( () => props.expandedWorkGroups, ); + const [changedFilesExpandedByTurnId, setChangedFilesExpandedByTurnId] = useState< + Record + >(() => props.changedFilesExpandedByTurnId); const handleToggleWorkGroup = useCallback( (groupId: string) => { setExpandedWorkGroups((current) => ({ @@ -71,6 +74,16 @@ function MessagesTimelineBrowserHarness( }, [props], ); + const handleSetChangedFilesExpanded = useCallback( + (turnId: TurnId, expanded: boolean) => { + setChangedFilesExpandedByTurnId((current) => ({ + ...current, + [turnId]: expanded, + })); + props.onSetChangedFilesExpanded(turnId, expanded); + }, + [props], + ); return (
); From 9bf57fdfe581d8f3a0576d5491d04418f2dc53da Mon Sep 17 00:00:00 2001 From: Marve10s Date: Thu, 9 Apr 2026 22:28:51 +0300 Subject: [PATCH 3/4] fix nestedBooleanRecordsEqual false-positive on mismatched keys The `?? {}` fallback treated a missing key in `right` as an empty object, so two records with different outer keys but empty inner values were incorrectly considered equal. Check `key in right` before comparing inner records. --- apps/web/src/uiStateStore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 61d1563097..5f75b60281 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -202,7 +202,7 @@ function nestedBooleanRecordsEqual( return false; } for (const [key, value] of leftEntries) { - if (!recordsEqual(value, right[key] ?? {})) { + if (!(key in right) || !recordsEqual(value, right[key]!)) { return false; } } From b3979fc84ede04296496f837ffcc6b10eb786f5a Mon Sep 17 00:00:00 2001 From: Marve10s Date: Fri, 10 Apr 2026 19:11:27 +0300 Subject: [PATCH 4/4] fix(web): use ThreadId.make instead of non-existent makeUnsafe in test The branded schema API exposes `make`, not `makeUnsafe`. --- apps/web/src/uiStateStore.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 8a116981e6..b6d31b57e9 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -212,7 +212,7 @@ describe("uiStateStore pure functions", () => { }); it("setThreadChangedFilesExpanded stores collapsed turns per thread", () => { - const thread1 = ThreadId.makeUnsafe("thread-1"); + const thread1 = ThreadId.make("thread-1"); const initialState = makeUiState(); const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", false); @@ -225,7 +225,7 @@ describe("uiStateStore pure functions", () => { }); it("setThreadChangedFilesExpanded removes thread overrides when expanded again", () => { - const thread1 = ThreadId.makeUnsafe("thread-1"); + const thread1 = ThreadId.make("thread-1"); const initialState = makeUiState({ threadChangedFilesExpandedById: { [thread1]: {