authorgravatar for 126423069+Marve10s@users.noreply.github.comIbrahim Elkamali <126423069+Marve10s@users.noreply.github.com> 2026-04-10 20:41:41+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-04-10 10:41:41-07:00
log9385314d26aba97d1fed1e05066e4562dbdf56bc
tree35c11e286f0f97eca263b6be4e8e3ba082be7c4d
parenta3f292770ed66b6521e7b3d92de92b1be0c463d7
signature Signed by PGP key B5690EEEBB952194

Persist changed-files expansion state per thread (#1858)


6 files changed, 237 insertions(+), 17 deletions(-)

apps/web/src/components/ChatView.tsx+23-2
......@@ -172,6 +172,7 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT =
172172const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
173173const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = [];
174174const EMPTY_PROVIDERS: ServerProvider[] = [];
175const EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID: Record<string, boolean> = {};
175176const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
176177
177178type 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+4
......@@ -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+8-11
......@@ -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+17
......@@ -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+49
......@@ -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,6 +139,14 @@ 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 }]);
......@@ -144,6 +154,11 @@ describe("uiStateStore pure functions", () => {
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});
apps/web/src/uiStateStore.ts+136-4
......@@ -18,6 +18,7 @@ const LEGACY_PERSISTED_STATE_KEYS = [
1818interface PersistedUiState {
1919 expandedProjectCwds?: string[];
2020 projectOrderCwds?: string[];
21 threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;
2122}
2223
2324export interface UiProjectState {
......@@ -27,6 +28,7 @@ export interface UiProjectState {
2728
2829export interface UiThreadState {
2930 threadLastVisitedAtById: Record<string, string>;
31 threadChangedFilesExpandedById: Record<string, Record<string, boolean>>;
3032}
3133
3234export interface UiState extends UiProjectState, UiThreadState {}
......@@ -45,6 +47,7 @@ const initialState: UiState = {
4547 projectExpandedById: {},
4648 projectOrder: [],
4749 threadLastVisitedAtById: {},
50 threadChangedFilesExpandedById: {},
4851};
4952
5053const persistedExpandedProjectCwds = new Set<string>();
......@@ -69,13 +72,47 @@ function readPersistedState(): UiState {
6972 }
7073 return initialState;
7174 }
72 hydratePersistedProjectState(JSON.parse(raw) as PersistedUiState);
73 return initialState;
75 const parsed = JSON.parse(raw) as PersistedUiState;
76 hydratePersistedProjectState(parsed);
77 return {
78 ...initialState,
79 threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded(
80 parsed.threadChangedFilesExpandedById,
81 ),
82 };
7483 } catch {
7584 return initialState;
7685 }
7786}
7887
88function sanitizePersistedThreadChangedFilesExpanded(
89 value: PersistedUiState["threadChangedFilesExpandedById"],
90): Record<string, Record<string, boolean>> {
91 if (!value || typeof value !== "object") {
92 return {};
93 }
94
95 const nextState: Record<string, Record<string, boolean>> = {};
96 for (const [threadId, turns] of Object.entries(value)) {
97 if (!threadId || !turns || typeof turns !== "object") {
98 continue;
99 }
100
101 const nextTurns: Record<string, boolean> = {};
102 for (const [turnId, expanded] of Object.entries(turns)) {
103 if (turnId && typeof expanded === "boolean" && expanded === false) {
104 nextTurns[turnId] = false;
105 }
106 }
107
108 if (Object.keys(nextTurns).length > 0) {
109 nextState[threadId] = nextTurns;
110 }
111 }
112
113 return nextState;
114}
115
79116function hydratePersistedProjectState(parsed: PersistedUiState): void {
80117 persistedExpandedProjectCwds.clear();
81118 persistedProjectOrderCwds.length = 0;
......@@ -106,11 +143,20 @@ function persistState(state: UiState): void {
106143 const cwd = currentProjectCwdById.get(projectId);
107144 return cwd ? [cwd] : [];
108145 });
146 const threadChangedFilesExpandedById = Object.fromEntries(
147 Object.entries(state.threadChangedFilesExpandedById).flatMap(([threadId, turns]) => {
148 const nextTurns = Object.fromEntries(
149 Object.entries(turns).filter(([, expanded]) => expanded === false),
150 );
151 return Object.keys(nextTurns).length > 0 ? [[threadId, nextTurns]] : [];
152 }),
153 );
109154 window.localStorage.setItem(
110155 PERSISTED_STATE_KEY,
111156 JSON.stringify({
112157 expandedProjectCwds,
113158 projectOrderCwds,
159 threadChangedFilesExpandedById,
114160 } satisfies PersistedUiState),
115161 );
116162 if (!legacyKeysCleanedUp) {
......@@ -146,6 +192,23 @@ function projectOrdersEqual(left: readonly string[], right: readonly string[]):
146192 );
147193}
148194
195function nestedBooleanRecordsEqual(
196 left: Record<string, Record<string, boolean>>,
197 right: Record<string, Record<string, boolean>>,
198): boolean {
199 const leftEntries = Object.entries(left);
200 const rightEntries = Object.entries(right);
201 if (leftEntries.length !== rightEntries.length) {
202 return false;
203 }
204 for (const [key, value] of leftEntries) {
205 if (!(key in right) || !recordsEqual(value, right[key]!)) {
206 return false;
207 }
208 }
209 return true;
210}
211
149212export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState {
150213 const previousProjectCwdById = new Map(currentProjectCwdById);
151214 const previousProjectIdByCwd = new Map(
......@@ -260,12 +323,24 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[])
260323 nextThreadLastVisitedAtById[thread.key] = thread.seedVisitedAt;
261324 }
262325 }
263 if (recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById)) {
326 const nextThreadChangedFilesExpandedById = Object.fromEntries(
327 Object.entries(state.threadChangedFilesExpandedById).filter(([threadId]) =>
328 retainedThreadIds.has(threadId),
329 ),
330 );
331 if (
332 recordsEqual(state.threadLastVisitedAtById, nextThreadLastVisitedAtById) &&
333 nestedBooleanRecordsEqual(
334 state.threadChangedFilesExpandedById,
335 nextThreadChangedFilesExpandedById,
336 )
337 ) {
264338 return state;
265339 }
266340 return {
267341 ...state,
268342 threadLastVisitedAtById: nextThreadLastVisitedAtById,
343 threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById,
269344 };
270345}
271346
......@@ -316,14 +391,68 @@ export function markThreadUnread(
316391}
317392
318393export function clearThreadUi(state: UiState, threadId: string): UiState {
319 if (!(threadId in state.threadLastVisitedAtById)) {
394 const hasVisitedState = threadId in state.threadLastVisitedAtById;
395 const hasChangedFilesState = threadId in state.threadChangedFilesExpandedById;
396 if (!hasVisitedState && !hasChangedFilesState) {
320397 return state;
321398 }
322399 const nextThreadLastVisitedAtById = { ...state.threadLastVisitedAtById };
400 const nextThreadChangedFilesExpandedById = { ...state.threadChangedFilesExpandedById };
323401 delete nextThreadLastVisitedAtById[threadId];
402 delete nextThreadChangedFilesExpandedById[threadId];
324403 return {
325404 ...state,
326405 threadLastVisitedAtById: nextThreadLastVisitedAtById,
406 threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById,
407 };
408}
409
410export function setThreadChangedFilesExpanded(
411 state: UiState,
412 threadId: string,
413 turnId: string,
414 expanded: boolean,
415): UiState {
416 const currentThreadState = state.threadChangedFilesExpandedById[threadId] ?? {};
417 const currentExpanded = currentThreadState[turnId] ?? true;
418 if (currentExpanded === expanded) {
419 return state;
420 }
421
422 if (expanded) {
423 if (!(turnId in currentThreadState)) {
424 return state;
425 }
426
427 const nextThreadState = { ...currentThreadState };
428 delete nextThreadState[turnId];
429 if (Object.keys(nextThreadState).length === 0) {
430 const nextState = { ...state.threadChangedFilesExpandedById };
431 delete nextState[threadId];
432 return {
433 ...state,
434 threadChangedFilesExpandedById: nextState,
435 };
436 }
437
438 return {
439 ...state,
440 threadChangedFilesExpandedById: {
441 ...state.threadChangedFilesExpandedById,
442 [threadId]: nextThreadState,
443 },
444 };
445 }
446
447 return {
448 ...state,
449 threadChangedFilesExpandedById: {
450 ...state.threadChangedFilesExpandedById,
451 [threadId]: {
452 ...currentThreadState,
453 [turnId]: false,
454 },
455 },
327456 };
328457}
329458
......@@ -382,6 +511,7 @@ interface UiStateStore extends UiState {
382511 markThreadVisited: (threadId: string, visitedAt?: string) => void;
383512 markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void;
384513 clearThreadUi: (threadId: string) => void;
514 setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void;
385515 toggleProject: (projectId: string) => void;
386516 setProjectExpanded: (projectId: string, expanded: boolean) => void;
387517 reorderProjects: (draggedProjectId: string, targetProjectId: string) => void;
......@@ -396,6 +526,8 @@ export const useUiStateStore = create<UiStateStore>((set) => ({
396526 markThreadUnread: (threadId, latestTurnCompletedAt) =>
397527 set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)),
398528 clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)),
529 setThreadChangedFilesExpanded: (threadId, turnId, expanded) =>
530 set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)),
399531 toggleProject: (projectId) => set((state) => toggleProject(state, projectId)),
400532 setProjectExpanded: (projectId, expanded) =>
401533 set((state) => setProjectExpanded(state, projectId, expanded)),