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 =...@@ -172,6 +172,7 @@ const IMAGE_ONLY_BOOTSTRAP_PROMPT =
172const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];172const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = [];
173const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = [];173const EMPTY_PROPOSED_PLANS: Thread["proposedPlans"] = [];
174const EMPTY_PROVIDERS: ServerProvider[] = [];174const EMPTY_PROVIDERS: ServerProvider[] = [];
175const EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID: Record<string, boolean> = {};
175const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};176const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
176177
177type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;178type ThreadPlanCatalogEntry = Pick<Thread, "id" | "proposedPlans">;
...@@ -574,6 +575,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -574,6 +575,7 @@ export default function ChatView(props: ChatViewProps) {
574 () => scopeThreadRef(environmentId, threadId),575 () => scopeThreadRef(environmentId, threadId),
575 [environmentId, threadId],576 [environmentId, threadId],
576 );577 );
578 const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]);
577 const composerDraftTarget: ScopedThreadRef | DraftId =579 const composerDraftTarget: ScopedThreadRef | DraftId =
578 routeKind === "server" ? routeThreadRef : props.draftId;580 routeKind === "server" ? routeThreadRef : props.draftId;
579 const serverThread = useStore(581 const serverThread = useStore(
...@@ -584,10 +586,17 @@ export default function ChatView(props: ChatViewProps) {...@@ -584,10 +586,17 @@ export default function ChatView(props: ChatViewProps) {
584 );586 );
585 const setStoreThreadError = useStore((store) => store.setError);587 const setStoreThreadError = useStore((store) => store.setError);
586 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);588 const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
589 const setThreadChangedFilesExpanded = useUiStateStore(
590 (store) => store.setThreadChangedFilesExpanded,
591 );
587 const activeThreadLastVisitedAt = useUiStateStore((store) =>592 const activeThreadLastVisitedAt = useUiStateStore((store) =>
593 routeKind === "server" ? store.threadLastVisitedAtById[routeThreadKey] : undefined,
594 );
595 const changedFilesExpandedByTurnId = useUiStateStore((store) =>
588 routeKind === "server"596 routeKind === "server"
589 ? store.threadLastVisitedAtById[scopedThreadKey(scopeThreadRef(environmentId, threadId))]597 ? (store.threadChangedFilesExpandedById[routeThreadKey] ??
590 : undefined,598 EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID)
599 : EMPTY_CHANGED_FILES_EXPANDED_BY_TURN_ID,
591 );600 );
592 const settings = useSettings();601 const settings = useSettings();
593 const setStickyComposerModelSelection = useComposerDraftStore(602 const setStickyComposerModelSelection = useComposerDraftStore(
...@@ -981,6 +990,16 @@ export default function ChatView(props: ChatViewProps) {...@@ -981,6 +990,16 @@ export default function ChatView(props: ChatViewProps) {
981 [openOrReuseProjectDraftThread],990 [openOrReuseProjectDraftThread],
982 );991 );
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
984 useEffect(() => {1003 useEffect(() => {
985 if (!serverThread?.id) return;1004 if (!serverThread?.id) return;
986 if (!latestTurnSettled) return;1005 if (!latestTurnSettled) return;
...@@ -3337,6 +3356,8 @@ export default function ChatView(props: ChatViewProps) {...@@ -3337,6 +3356,8 @@ export default function ChatView(props: ChatViewProps) {
3337 activeThreadEnvironmentId={activeThread.environmentId}3356 activeThreadEnvironmentId={activeThread.environmentId}
3338 expandedWorkGroups={expandedWorkGroups}3357 expandedWorkGroups={expandedWorkGroups}
3339 onToggleWorkGroup={onToggleWorkGroup}3358 onToggleWorkGroup={onToggleWorkGroup}
3359 changedFilesExpandedByTurnId={changedFilesExpandedByTurnId}
3360 onSetChangedFilesExpanded={handleSetChangedFilesExpanded}
3340 onOpenTurnDiff={onOpenTurnDiff}3361 onOpenTurnDiff={onOpenTurnDiff}
3341 revertTurnCountByUserMessageId={revertTurnCountByUserMessageId}3362 revertTurnCountByUserMessageId={revertTurnCountByUserMessageId}
3342 onRevertUserMessage={onRevertUserMessage}3363 onRevertUserMessage={onRevertUserMessage}
apps/web/src/components/chat/MessagesTimeline.test.tsx+4
...@@ -82,6 +82,8 @@ describe("MessagesTimeline", () => {...@@ -82,6 +82,8 @@ describe("MessagesTimeline", () => {
82 nowIso="2026-03-17T19:12:30.000Z"82 nowIso="2026-03-17T19:12:30.000Z"
83 expandedWorkGroups={{}}83 expandedWorkGroups={{}}
84 onToggleWorkGroup={() => {}}84 onToggleWorkGroup={() => {}}
85 changedFilesExpandedByTurnId={{}}
86 onSetChangedFilesExpanded={() => {}}
85 onOpenTurnDiff={() => {}}87 onOpenTurnDiff={() => {}}
86 revertTurnCountByUserMessageId={new Map()}88 revertTurnCountByUserMessageId={new Map()}
87 onRevertUserMessage={() => {}}89 onRevertUserMessage={() => {}}
...@@ -128,6 +130,8 @@ describe("MessagesTimeline", () => {...@@ -128,6 +130,8 @@ describe("MessagesTimeline", () => {
128 nowIso="2026-03-17T19:12:30.000Z"130 nowIso="2026-03-17T19:12:30.000Z"
129 expandedWorkGroups={{}}131 expandedWorkGroups={{}}
130 onToggleWorkGroup={() => {}}132 onToggleWorkGroup={() => {}}
133 changedFilesExpandedByTurnId={{}}
134 onSetChangedFilesExpanded={() => {}}
131 onOpenTurnDiff={() => {}}135 onOpenTurnDiff={() => {}}
132 revertTurnCountByUserMessageId={new Map()}136 revertTurnCountByUserMessageId={new Map()}
133 onRevertUserMessage={() => {}}137 onRevertUserMessage={() => {}}
apps/web/src/components/chat/MessagesTimeline.tsx+8-11
...@@ -77,6 +77,8 @@ interface MessagesTimelineProps {...@@ -77,6 +77,8 @@ interface MessagesTimelineProps {
77 nowIso: string;77 nowIso: string;
78 expandedWorkGroups: Record<string, boolean>;78 expandedWorkGroups: Record<string, boolean>;
79 onToggleWorkGroup: (groupId: string) => void;79 onToggleWorkGroup: (groupId: string) => void;
80 changedFilesExpandedByTurnId: Record<string, boolean>;
81 onSetChangedFilesExpanded: (turnId: TurnId, expanded: boolean) => void;
80 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;82 onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void;
81 revertTurnCountByUserMessageId: Map<MessageId, number>;83 revertTurnCountByUserMessageId: Map<MessageId, number>;
82 onRevertUserMessage: (messageId: MessageId) => void;84 onRevertUserMessage: (messageId: MessageId) => void;
...@@ -113,6 +115,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -113,6 +115,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({
113 nowIso,115 nowIso,
114 expandedWorkGroups,116 expandedWorkGroups,
115 onToggleWorkGroup,117 onToggleWorkGroup,
118 changedFilesExpandedByTurnId,
119 onSetChangedFilesExpanded,
116 onOpenTurnDiff,120 onOpenTurnDiff,
117 revertTurnCountByUserMessageId,121 revertTurnCountByUserMessageId,
118 onRevertUserMessage,122 onRevertUserMessage,
...@@ -296,15 +300,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -296,15 +300,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({
296300
297 const virtualRows = rowVirtualizer.getVirtualItems();301 const virtualRows = rowVirtualizer.getVirtualItems();
298 const nonVirtualizedRows = rows.slice(virtualizedRowCount);302 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
309 const renderRowContent = (row: TimelineRow) => (304 const renderRowContent = (row: TimelineRow) => (
310 <div305 <div
...@@ -466,7 +461,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -466,7 +461,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
466 const summaryStat = summarizeTurnDiffStats(checkpointFiles);461 const summaryStat = summarizeTurnDiffStats(checkpointFiles);
467 const changedFileCountLabel = String(checkpointFiles.length);462 const changedFileCountLabel = String(checkpointFiles.length);
468 const allDirectoriesExpanded =463 const allDirectoriesExpanded =
469 allDirectoriesExpandedByTurnId[turnSummary.turnId] ?? true;464 changedFilesExpandedByTurnId[turnSummary.turnId] ?? true;
470 return (465 return (
471 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">466 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
472 <div className="mb-1.5 flex items-center justify-between gap-2">467 <div className="mb-1.5 flex items-center justify-between gap-2">
...@@ -488,7 +483,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({...@@ -488,7 +483,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({
488 size="xs"483 size="xs"
489 variant="outline"484 variant="outline"
490 data-scroll-anchor-ignore485 data-scroll-anchor-ignore
491 onClick={() => onToggleAllDirectories(turnSummary.turnId)}486 onClick={() =>
487 onSetChangedFilesExpanded(turnSummary.turnId, !allDirectoriesExpanded)
488 }
492 >489 >
493 {allDirectoriesExpanded ? "Collapse all" : "Expand all"}490 {allDirectoriesExpanded ? "Collapse all" : "Expand all"}
494 </Button>491 </Button>
apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx+17
...@@ -61,6 +61,9 @@ function MessagesTimelineBrowserHarness(...@@ -61,6 +61,9 @@ function MessagesTimelineBrowserHarness(
61 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>(61 const [expandedWorkGroups, setExpandedWorkGroups] = useState<Record<string, boolean>>(
62 () => props.expandedWorkGroups,62 () => props.expandedWorkGroups,
63 );63 );
64 const [changedFilesExpandedByTurnId, setChangedFilesExpandedByTurnId] = useState<
65 Record<string, boolean>
66 >(() => props.changedFilesExpandedByTurnId);
64 const handleToggleWorkGroup = useCallback(67 const handleToggleWorkGroup = useCallback(
65 (groupId: string) => {68 (groupId: string) => {
66 setExpandedWorkGroups((current) => ({69 setExpandedWorkGroups((current) => ({
...@@ -71,6 +74,16 @@ function MessagesTimelineBrowserHarness(...@@ -71,6 +74,16 @@ function MessagesTimelineBrowserHarness(
71 },74 },
72 [props],75 [props],
73 );76 );
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
75 return (88 return (
76 <div89 <div
...@@ -84,6 +97,8 @@ function MessagesTimelineBrowserHarness(...@@ -84,6 +97,8 @@ function MessagesTimelineBrowserHarness(
84 scrollContainer={scrollContainer}97 scrollContainer={scrollContainer}
85 expandedWorkGroups={expandedWorkGroups}98 expandedWorkGroups={expandedWorkGroups}
86 onToggleWorkGroup={handleToggleWorkGroup}99 onToggleWorkGroup={handleToggleWorkGroup}
100 changedFilesExpandedByTurnId={changedFilesExpandedByTurnId}
101 onSetChangedFilesExpanded={handleSetChangedFilesExpanded}
87 />102 />
88 </div>103 </div>
89 );104 );
...@@ -168,6 +183,8 @@ function createBaseTimelineProps(input: {...@@ -168,6 +183,8 @@ function createBaseTimelineProps(input: {
168 nowIso: isoAt(10_000),183 nowIso: isoAt(10_000),
169 expandedWorkGroups: input.expandedWorkGroups ?? {},184 expandedWorkGroups: input.expandedWorkGroups ?? {},
170 onToggleWorkGroup: () => {},185 onToggleWorkGroup: () => {},
186 changedFilesExpandedByTurnId: {},
187 onSetChangedFilesExpanded: () => {},
171 onOpenTurnDiff: () => {},188 onOpenTurnDiff: () => {},
172 revertTurnCountByUserMessageId: new Map(),189 revertTurnCountByUserMessageId: new Map(),
173 onRevertUserMessage: () => {},190 onRevertUserMessage: () => {},
apps/web/src/uiStateStore.test.ts+49
...@@ -6,6 +6,7 @@ import {...@@ -6,6 +6,7 @@ import {
6 markThreadUnread,6 markThreadUnread,
7 reorderProjects,7 reorderProjects,
8 setProjectExpanded,8 setProjectExpanded,
9 setThreadChangedFilesExpanded,
9 syncProjects,10 syncProjects,
10 syncThreads,11 syncThreads,
11 type UiState,12 type UiState,
...@@ -16,6 +17,7 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState {...@@ -16,6 +17,7 @@ function makeUiState(overrides: Partial<UiState> = {}): UiState {
16 projectExpandedById: {},17 projectExpandedById: {},
17 projectOrder: [],18 projectOrder: [],
18 threadLastVisitedAtById: {},19 threadLastVisitedAtById: {},
20 threadChangedFilesExpandedById: {},
19 ...overrides,21 ...overrides,
20 };22 };
21}23}
...@@ -137,6 +139,14 @@ describe("uiStateStore pure functions", () => {...@@ -137,6 +139,14 @@ describe("uiStateStore pure functions", () => {
137 [thread1]: "2026-02-25T12:35:00.000Z",139 [thread1]: "2026-02-25T12:35:00.000Z",
138 [thread2]: "2026-02-25T12:36:00.000Z",140 [thread2]: "2026-02-25T12:36:00.000Z",
139 },141 },
142 threadChangedFilesExpandedById: {
143 [thread1]: {
144 "turn-1": false,
145 },
146 [thread2]: {
147 "turn-2": false,
148 },
149 },
140 });150 });
141151
142 const next = syncThreads(initialState, [{ key: thread1 }]);152 const next = syncThreads(initialState, [{ key: thread1 }]);
...@@ -144,6 +154,11 @@ describe("uiStateStore pure functions", () => {...@@ -144,6 +154,11 @@ describe("uiStateStore pure functions", () => {
144 expect(next.threadLastVisitedAtById).toEqual({154 expect(next.threadLastVisitedAtById).toEqual({
145 [thread1]: "2026-02-25T12:35:00.000Z",155 [thread1]: "2026-02-25T12:35:00.000Z",
146 });156 });
157 expect(next.threadChangedFilesExpandedById).toEqual({
158 [thread1]: {
159 "turn-1": false,
160 },
161 });
147 });162 });
148163
149 it("syncThreads seeds visit state for unseen snapshot threads", () => {164 it("syncThreads seeds visit state for unseen snapshot threads", () => {
...@@ -183,10 +198,44 @@ describe("uiStateStore pure functions", () => {...@@ -183,10 +198,44 @@ describe("uiStateStore pure functions", () => {
183 threadLastVisitedAtById: {198 threadLastVisitedAtById: {
184 [thread1]: "2026-02-25T12:35:00.000Z",199 [thread1]: "2026-02-25T12:35:00.000Z",
185 },200 },
201 threadChangedFilesExpandedById: {
202 [thread1]: {
203 "turn-1": false,
204 },
205 },
186 });206 });
187207
188 const next = clearThreadUi(initialState, thread1);208 const next = clearThreadUi(initialState, thread1);
189209
190 expect(next.threadLastVisitedAtById).toEqual({});210 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({});
191 });240 });
192});241});
apps/web/src/uiStateStore.ts+136-4
...@@ -18,6 +18,7 @@ const LEGACY_PERSISTED_STATE_KEYS = [...@@ -18,6 +18,7 @@ const LEGACY_PERSISTED_STATE_KEYS = [
18interface PersistedUiState {18interface PersistedUiState {
19 expandedProjectCwds?: string[];19 expandedProjectCwds?: string[];
20 projectOrderCwds?: string[];20 projectOrderCwds?: string[];
21 threadChangedFilesExpandedById?: Record<string, Record<string, boolean>>;
21}22}
2223
23export interface UiProjectState {24export interface UiProjectState {
...@@ -27,6 +28,7 @@ export interface UiProjectState {...@@ -27,6 +28,7 @@ export interface UiProjectState {
2728
28export interface UiThreadState {29export interface UiThreadState {
29 threadLastVisitedAtById: Record<string, string>;30 threadLastVisitedAtById: Record<string, string>;
31 threadChangedFilesExpandedById: Record<string, Record<string, boolean>>;
30}32}
3133
32export interface UiState extends UiProjectState, UiThreadState {}34export interface UiState extends UiProjectState, UiThreadState {}
...@@ -45,6 +47,7 @@ const initialState: UiState = {...@@ -45,6 +47,7 @@ const initialState: UiState = {
45 projectExpandedById: {},47 projectExpandedById: {},
46 projectOrder: [],48 projectOrder: [],
47 threadLastVisitedAtById: {},49 threadLastVisitedAtById: {},
50 threadChangedFilesExpandedById: {},
48};51};
4952
50const persistedExpandedProjectCwds = new Set<string>();53const persistedExpandedProjectCwds = new Set<string>();
...@@ -69,13 +72,47 @@ function readPersistedState(): UiState {...@@ -69,13 +72,47 @@ function readPersistedState(): UiState {
69 }72 }
70 return initialState;73 return initialState;
71 }74 }
72 hydratePersistedProjectState(JSON.parse(raw) as PersistedUiState);75 const parsed = JSON.parse(raw) as PersistedUiState;
73 return initialState;76 hydratePersistedProjectState(parsed);
77 return {
78 ...initialState,
79 threadChangedFilesExpandedById: sanitizePersistedThreadChangedFilesExpanded(
80 parsed.threadChangedFilesExpandedById,
81 ),
82 };
74 } catch {83 } catch {
75 return initialState;84 return initialState;
76 }85 }
77}86}
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
79function hydratePersistedProjectState(parsed: PersistedUiState): void {116function hydratePersistedProjectState(parsed: PersistedUiState): void {
80 persistedExpandedProjectCwds.clear();117 persistedExpandedProjectCwds.clear();
81 persistedProjectOrderCwds.length = 0;118 persistedProjectOrderCwds.length = 0;
...@@ -106,11 +143,20 @@ function persistState(state: UiState): void {...@@ -106,11 +143,20 @@ function persistState(state: UiState): void {
106 const cwd = currentProjectCwdById.get(projectId);143 const cwd = currentProjectCwdById.get(projectId);
107 return cwd ? [cwd] : [];144 return cwd ? [cwd] : [];
108 });145 });
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 );
109 window.localStorage.setItem(154 window.localStorage.setItem(
110 PERSISTED_STATE_KEY,155 PERSISTED_STATE_KEY,
111 JSON.stringify({156 JSON.stringify({
112 expandedProjectCwds,157 expandedProjectCwds,
113 projectOrderCwds,158 projectOrderCwds,
159 threadChangedFilesExpandedById,
114 } satisfies PersistedUiState),160 } satisfies PersistedUiState),
115 );161 );
116 if (!legacyKeysCleanedUp) {162 if (!legacyKeysCleanedUp) {
...@@ -146,6 +192,23 @@ function projectOrdersEqual(left: readonly string[], right: readonly string[]):...@@ -146,6 +192,23 @@ function projectOrdersEqual(left: readonly string[], right: readonly string[]):
146 );192 );
147}193}
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
149export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState {212export function syncProjects(state: UiState, projects: readonly SyncProjectInput[]): UiState {
150 const previousProjectCwdById = new Map(currentProjectCwdById);213 const previousProjectCwdById = new Map(currentProjectCwdById);
151 const previousProjectIdByCwd = new Map(214 const previousProjectIdByCwd = new Map(
...@@ -260,12 +323,24 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[])...@@ -260,12 +323,24 @@ export function syncThreads(state: UiState, threads: readonly SyncThreadInput[])
260 nextThreadLastVisitedAtById[thread.key] = thread.seedVisitedAt;323 nextThreadLastVisitedAtById[thread.key] = thread.seedVisitedAt;
261 }324 }
262 }325 }
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 ) {
264 return state;338 return state;
265 }339 }
266 return {340 return {
267 ...state,341 ...state,
268 threadLastVisitedAtById: nextThreadLastVisitedAtById,342 threadLastVisitedAtById: nextThreadLastVisitedAtById,
343 threadChangedFilesExpandedById: nextThreadChangedFilesExpandedById,
269 };344 };
270}345}
271346
...@@ -316,14 +391,68 @@ export function markThreadUnread(...@@ -316,14 +391,68 @@ export function markThreadUnread(
316}391}
317392
318export function clearThreadUi(state: UiState, threadId: string): UiState {393export 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) {
320 return state;397 return state;
321 }398 }
322 const nextThreadLastVisitedAtById = { ...state.threadLastVisitedAtById };399 const nextThreadLastVisitedAtById = { ...state.threadLastVisitedAtById };
400 const nextThreadChangedFilesExpandedById = { ...state.threadChangedFilesExpandedById };
323 delete nextThreadLastVisitedAtById[threadId];401 delete nextThreadLastVisitedAtById[threadId];
402 delete nextThreadChangedFilesExpandedById[threadId];
324 return {403 return {
325 ...state,404 ...state,
326 threadLastVisitedAtById: nextThreadLastVisitedAtById,405 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 },
327 };456 };
328}457}
329458
...@@ -382,6 +511,7 @@ interface UiStateStore extends UiState {...@@ -382,6 +511,7 @@ interface UiStateStore extends UiState {
382 markThreadVisited: (threadId: string, visitedAt?: string) => void;511 markThreadVisited: (threadId: string, visitedAt?: string) => void;
383 markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void;512 markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void;
384 clearThreadUi: (threadId: string) => void;513 clearThreadUi: (threadId: string) => void;
514 setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void;
385 toggleProject: (projectId: string) => void;515 toggleProject: (projectId: string) => void;
386 setProjectExpanded: (projectId: string, expanded: boolean) => void;516 setProjectExpanded: (projectId: string, expanded: boolean) => void;
387 reorderProjects: (draggedProjectId: string, targetProjectId: string) => void;517 reorderProjects: (draggedProjectId: string, targetProjectId: string) => void;
...@@ -396,6 +526,8 @@ export const useUiStateStore = create<UiStateStore>((set) => ({...@@ -396,6 +526,8 @@ export const useUiStateStore = create<UiStateStore>((set) => ({
396 markThreadUnread: (threadId, latestTurnCompletedAt) =>526 markThreadUnread: (threadId, latestTurnCompletedAt) =>
397 set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)),527 set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)),
398 clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)),528 clearThreadUi: (threadId) => set((state) => clearThreadUi(state, threadId)),
529 setThreadChangedFilesExpanded: (threadId, turnId, expanded) =>
530 set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)),
399 toggleProject: (projectId) => set((state) => toggleProject(state, projectId)),531 toggleProject: (projectId) => set((state) => toggleProject(state, projectId)),
400 setProjectExpanded: (projectId, expanded) =>532 setProjectExpanded: (projectId, expanded) =>
401 set((state) => setProjectExpanded(state, projectId, expanded)),533 set((state) => setProjectExpanded(state, projectId, expanded)),