Skip to content

Commit 9385314

Browse files
authored
Persist changed-files expansion state per thread (#1858)
1 parent a3f2927 commit 9385314

File tree

6 files changed

+237
-17
lines changed

6 files changed

+237
-17
lines changed

apps/web/src/components/ChatView.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT =
172172
const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
173173
const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = [];
174174
const EMPTY_PROVIDERS: ServerProvider[] = [];
175+
const EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID: Record<string, boolean> = {};
175176
const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
176177

177178
type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;
@@ -574,6 +575,7 @@ export default function ChatView(props: ChatViewProps) {
574575
() => scopeThreadRef(environmentId, threadId),
575576
[environmentId, threadId],
576577
);
578+
const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]);
577579
const composerDraftTarget: ScopedThreadRef | DraftId =
578580
routeKind === "server" ? routeThreadRef : props.draftId;
579581
const serverThread = useStore(
@@ -584,10 +586,17 @@ export default function ChatView(props: ChatViewProps) {
584586
);
585587
const setStoreThreadError = useStore((store) => store.setError);
586588
const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
589+
const setThreadChangedFilesExpanded = useUiStateStore(
590+
(store) => store.setThreadChangedFilesExpanded,
591+
);
587592
const activeThreadLastVisitedAt = useUiStateStore((store) =>
593+
routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined,
594+
);
595+
const changedFilesExpandedByTurnId = useUiStateStore((store) =>
588596
routeKind === "server"
589-
? store.threadLastVisitedAtById[scopedThreadKey(scopeThreadRef(environmentId, threadId))]
590-
: undefined,
597+
? (store.threadChangedFilesExpandedById[routeThreadKey] ??
598+
EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID)
599+
: EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID,
591600
);
592601
const settings = useSettings();
593602
const setStickyComposerModelSelection = useComposerDraftStore(
@@ -981,6 +990,16 @@ export default function ChatView(props: ChatViewProps) {
981990
[openOrReuseProjectDraftThread],
982991
);
983992

993+
const handleSetChangedFilesExpanded = useCallback(
994+
(turnId: TurnId, expanded: boolean) => {
995+
if (routeKind !== "server") {
996+
return;
997+
}
998+
setThreadChangedFilesExpanded(routeThreadKey, turnId, expanded);
999+
},
1000+
[routeKind, routeThreadKey, setThreadChangedFilesExpanded],
1001+
);
1002+
9841003
useEffect(() => {
9851004
if (!serverThread?.id) return;
9861005
if (!latestTurnSettled) return;
@@ -3337,6 +3356,8 @@ export default function ChatView(props: ChatViewProps) {
33373356
activeThreadEnvironmentId={activeThread.environmentId}
33383357
expandedWorkGroups={expandedWorkGroups}
33393358
onToggleWorkGroup={onToggleWorkGroup}
3359+
changedFilesExpandedByTurnId={changedFilesExpandedByTurnId}
3360+
onSetChangedFilesExpanded={handleSetChangedFilesExpanded}
33403361
onOpenTurnDiff={onOpenTurnDiff}
33413362
revertTurnCountByUserMessageId={revertTurnCountByUserMessageId}
33423363
onRevertUserMessage={onRevertUserMessage}

apps/web/src/components/chat/MessagesTimeline.test.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ describe("MessagesTimeline", () => {
8282
nowIso="2026-03-17T19:12:30.000Z"
8383
expandedWorkGroups={{}}
8484
onToggleWorkGroup={() => {}}
85+
changedFilesExpandedByTurnId={{}}
86+
onSetChangedFilesExpanded={() => {}}
8587
onOpenTurnDiff={() => {}}
8688
revertTurnCountByUserMessageId={new Map()}
8789
onRevertUserMessage={() => {}}
@@ -128,6 +130,8 @@ describe("MessagesTimeline", () => {
128130
nowIso="2026-03-17T19:12:30.000Z"
129131
expandedWorkGroups={{}}
130132
onToggleWorkGroup={() => {}}
133+
changedFilesExpandedByTurnId={{}}
134+
onSetChangedFilesExpanded={() => {}}
131135
onOpenTurnDiff={() => {}}
132136
revertTurnCountByUserMessageId={new Map()}
133137
onRevertUserMessage={() => {}}

apps/web/src/components/chat/MessagesTimeline.tsx

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ interface MessagesTimelineProps {
7777
nowIso: string;
7878
expandedWorkGroups: Record<string, boolean>;
7979
onToggleWorkGroup: (groupId: string) => void;
80+
changedFilesExpandedByTurnId: Record<string, boolean>;
81+
onSetChangedFilesExpanded: (turnId: TurnId, expanded: boolean) => void;
8082
onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
8183
revertTurnCountByUserMessageId: Map<MessageId, number>;
8284
onRevertUserMessage: (messageId: MessageId) => void;
@@ -113,6 +115,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
113115
nowIso,
114116
expandedWorkGroups,
115117
onToggleWorkGroup,
118+
changedFilesExpandedByTurnId,
119+
onSetChangedFilesExpanded,
116120
onOpenTurnDiff,
117121
revertTurnCountByUserMessageId,
118122
onRevertUserMessage,
@@ -296,15 +300,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
296300

297301
const virtualRows = rowVirtualizer.getVirtualItems();
298302
const nonVirtualizedRows = rows.slice(virtualizedRowCount);
299-
const [allDirectoriesExpandedByTurnId, setAllDirectoriesExpandedByTurnId] = useState<
300-
Record<string, boolean>
301-
>({});
302-
const onToggleAllDirectories = useCallback((turnId: TurnId) => {
303-
setAllDirectoriesExpandedByTurnId((current) => ({
304-
...current,
305-
[turnId]: !(current[turnId] ?? true),
306-
}));
307-
}, []);
308303

309304
const renderRowContent = (row: TimelineRow) => (
310305
<div
@@ -466,7 +461,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
466461
const summaryStat = summarizeTurnDiffStats(checkpointFiles);
467462
const changedFileCountLabel = String(checkpointFiles.length);
468463
const allDirectoriesExpanded =
469-
allDirectoriesExpandedByTurnId[turnSummary.turnId] ?? true;
464+
changedFilesExpandedByTurnId[turnSummary.turnId] ?? true;
470465
return (
471466
<div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
472467
<div className="mb-1.5 flex items-center justify-between gap-2">
@@ -488,7 +483,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
488483
size="xs"
489484
variant="outline"
490485
data-scroll-anchor-ignore
491-
onClick={() => onToggleAllDirectories(turnSummary.turnId)}
486+
onClick={() =>
487+
onSetChangedFilesExpanded(turnSummary.turnId, !allDirectoriesExpanded)
488+
}
492489
>
493490
{allDirectoriesExpanded ? "Collapse all" : "Expand all"}
494491
</Button>

apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ function MessagesTimelineBrowserHarness(
6161
const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>(
6262
() => props.expandedWorkGroups,
6363
);
64+
const [changedFilesExpandedByTurnId, setChangedFilesExpandedByTurnId] = useState<
65+
Record<string, boolean>
66+
>(() => props.changedFilesExpandedByTurnId);
6467
const handleToggleWorkGroup = useCallback(
6568
(groupId: string) => {
6669
setExpandedWorkGroups((current) => ({
@@ -71,6 +74,16 @@ function MessagesTimelineBrowserHarness(
7174
},
7275
[props],
7376
);
77+
const handleSetChangedFilesExpanded = useCallback(
78+
(turnId: TurnId, expanded: boolean) => {
79+
setChangedFilesExpandedByTurnId((current) => ({
80+
...current,
81+
[turnId]: expanded,
82+
}));
83+
props.onSetChangedFilesExpanded(turnId, expanded);
84+
},
85+
[props],
86+
);
7487

7588
return (
7689
<div
@@ -84,6 +97,8 @@ function MessagesTimelineBrowserHarness(
8497
scrollContainer={scrollContainer}
8598
expandedWorkGroups={expandedWorkGroups}
8699
onToggleWorkGroup={handleToggleWorkGroup}
100+
changedFilesExpandedByTurnId={changedFilesExpandedByTurnId}
101+
onSetChangedFilesExpanded={handleSetChangedFilesExpanded}
87102
/>
88103
</div>
89104
);
@@ -168,6 +183,8 @@ function createBaseTimelineProps(input: {
168183
nowIso: isoAt(10_000),
169184
expandedWorkGroups: input.expandedWorkGroups ?? {},
170185
onToggleWorkGroup: () => {},
186+
changedFilesExpandedByTurnId: {},
187+
onSetChangedFilesExpanded: () => {},
171188
onOpenTurnDiff: () => {},
172189
revertTurnCountByUserMessageId: new Map(),
173190
onRevertUserMessage: () => {},

apps/web/src/uiStateStore.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
markThreadUnread,
77
reorderProjects,
88
setProjectExpanded,
9+
setThreadChangedFilesExpanded,
910
syncProjects,
1011
syncThreads,
1112
type UiState,
@@ -16,6 +17,7 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState {
1617
projectExpandedById: {},
1718
projectOrder: [],
1819
threadLastVisitedAtById: {},
20+
threadChangedFilesExpandedById: {},
1921
...overrides,
2022
};
2123
}
@@ -137,13 +139,26 @@ describe("uiStateStore pure functions", () => {
137139
[thread1]: "2026-02-25T12:35:00.000Z",
138140
[thread2]: "2026-02-25T12:36:00.000Z",
139141
},
142+
threadChangedFilesExpandedById: {
143+
[thread1]: {
144+
"turn-1": false,
145+
},
146+
[thread2]: {
147+
"turn-2": false,
148+
},
149+
},
140150
});
141151

142152
const next = syncThreads(initialState, [{ key: thread1 }]);
143153

144154
expect(next.threadLastVisitedAtById).toEqual({
145155
[thread1]: "2026-02-25T12:35:00.000Z",
146156
});
157+
expect(next.threadChangedFilesExpandedById).toEqual({
158+
[thread1]: {
159+
"turn-1": false,
160+
},
161+
});
147162
});
148163

149164
it("syncThreads seeds visit state for unseen snapshot threads", () => {
@@ -183,10 +198,44 @@ describe("uiStateStore pure functions", () => {
183198
threadLastVisitedAtById: {
184199
[thread1]: "2026-02-25T12:35:00.000Z",
185200
},
201+
threadChangedFilesExpandedById: {
202+
[thread1]: {
203+
"turn-1": false,
204+
},
205+
},
186206
});
187207

188208
const next = clearThreadUi(initialState, thread1);
189209

190210
expect(next.threadLastVisitedAtById).toEqual({});
211+
expect(next.threadChangedFilesExpandedById).toEqual({});
212+
});
213+
214+
it("setThreadChangedFilesExpanded stores collapsed turns per thread", () => {
215+
const thread1 = ThreadId.make("thread-1");
216+
const initialState = makeUiState();
217+
218+
const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", false);
219+
220+
expect(next.threadChangedFilesExpandedById).toEqual({
221+
[thread1]: {
222+
"turn-1": false,
223+
},
224+
});
225+
});
226+
227+
it("setThreadChangedFilesExpanded removes thread overrides when expanded again", () => {
228+
const thread1 = ThreadId.make("thread-1");
229+
const initialState = makeUiState({
230+
threadChangedFilesExpandedById: {
231+
[thread1]: {
232+
"turn-1": false,
233+
},
234+
},
235+
});
236+
237+
const next = setThreadChangedFilesExpanded(initialState, thread1, "turn-1", true);
238+
239+
expect(next.threadChangedFilesExpandedById).toEqual({});
191240
});
192241
});

0 commit comments

Comments
 (0)