Skip to content
Source
Sign inSign up

rightPanelStore.ts

1,293 lines50.8 KB

1import {
2 parseScopedThreadKey,
3 scopedThreadKey,
4 scopeThreadRef,
5} from "@t3tools/client-runtime/environment";
6import { EnvironmentId, RunId, ThreadId, type ScopedThreadRef } from "@t3tools/contracts";
7import { create } from "zustand";
8import { createJSONStorage, persist } from "zustand/middleware";
9​
10import { useClosedViewStore } from "./closedViewStore";
11import { resolveStorage } from "./lib/storage";
12import type { ThreadPanelPresentation } from "./rightPanelLayout";
13import type { ChatFileAttachment } from "./types";
14​
15const RIGHT_PANEL_KINDS = [
16 "diff",
17 "files",
18 "file",
19 "preview",
20 "device",
21 "terminal",
22 "pull-request",
23 "pull-requests",
24 "side-chat",
25] as const;
26export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];
27​
28export type PullRequestDetailTab = "summary" | "guide" | "timeline" | "code";
29​
30export interface DeviceTabTarget {
31 hostId: string;
32 deviceId: string;
33 platform: "ios" | "android";
34 name: string;
36​
37export type RightPanelSurface =
38 | { id: `browser:${string}`; kind: "preview"; resourceId: string }
39 | { id: "browser:new"; kind: "preview"; resourceId: null }
40 | { id: "device" | `device:${string}`; kind: "device"; target?: DeviceTabTarget; title?: string }
41 | {
42 id: `terminal:${string}`;
43 kind: "terminal";
44 resourceId: string;
45 terminalIds: string[];
46 activeTerminalId: string;
47 splitDirection?: "horizontal" | "vertical";
48 }
49 | { id: "diff"; kind: "diff" }
50 | {
51 id: "files";
52 kind: "files";
53 /** Set when breadcrumbs moved a folder browser up to the workspace root. */
54 folderTrail?: string;
55 }
56 | {
57 id: `file:${string}` | `attachment:${string}`;
58 kind: "file";
59 relativePath: string;
60 revealLine: number | null;
61 revealRequestId: number;
62 attachment?: ChatFileAttachment;
63 /** Deepest workspace path retained for navigating back down after moving up. */
64 folderTrail?: string;
65 }
66 | {
67 id: `pull-request:${string}`;
68 kind: "pull-request";
69 /** Cross-environment lists can repeat project ids; thread panels inherit their ref's environment. */
70 environmentId?: string;
71 projectId: string;
72 host?: string;
73 repository: string;
74 number: number;
75 url?: string;
76 /** A clicked link waiting for the panel to show where it points; cleared once shown. */
77 revealUrl?: string;
78 detailTab?: PullRequestDetailTab;
79 }
80 | { id: "pull-requests"; kind: "pull-requests" }
81 | { id: `side-chat:${string}`; kind: "side-chat"; threadId: ThreadId };
82​
83const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";
84const RIGHT_PANEL_STORAGE_VERSION = 14;
85​
86export const PULL_REQUESTS_PANEL_REF = scopeThreadRef(
87 EnvironmentId.make("pull-requests-panel"),
88 ThreadId.make("pull-requests-panel"),
89);
90​
91const isPullRequestsPanelKey = (threadKey: string) => threadKey.endsWith(":pull-requests-panel");
92​
93export interface ThreadRightPanelState {
94 isOpen: boolean;
95 activeSurfaceId: string | null;
96 surfaces: RightPanelSurface[];
97 dismissedDeviceSurfaceIds?: string[];
98 maximized?: true;
99 proactiveDiffTurnId?: RunId;
101​
102export interface ThreadPanelVisibility {
103 inlineOpen: boolean;
104 popoverOpen: boolean;
106​
107interface RightPanelStoreState {
108 byThreadKey: Record<string, ThreadRightPanelState>;
109 pendingMaximizeThreadKey: string | null;
110 requestMaximize: (ref: ScopedThreadRef) => void;
111 consumeMaximizeRequest: (ref: ScopedThreadRef) => boolean;
112 threadPanelVisibilityByThreadKey: Record<string, ThreadPanelVisibility>;
113 userActionRevisionByThreadKey: Record<string, number>;
114 closeRevisionByThreadKey: Record<string, number>;
115 getUserActionRevision: (ref: ScopedThreadRef) => number;
116 markUserAction: (ref: ScopedThreadRef) => number;
117 /** Refused if the user chose a panel after expectedUserActionRevision was read. */
118 openProactive: (
119 ref: ScopedThreadRef,
120 request:
121 | Extract<RightPanelSurface, { kind: "pull-request" | "pull-requests" }>
122 | { kind: "diff"; turnId: RunId },
123 expectedUserActionRevision: number,
124 activateExisting?: boolean,
125 ) => boolean;
126 open: (
127 ref: ScopedThreadRef,
128 kind: Exclude<RightPanelKind, "file" | "terminal" | "pull-request" | "side-chat">,
129 ) => void;
130 openSideChat: (ref: ScopedThreadRef, threadId: ThreadId) => void;
131 openDevice: (ref: ScopedThreadRef, target: DeviceTabTarget, automatic?: boolean) => void;
132 renameDevice: (ref: ScopedThreadRef, surfaceId: string, title: string) => void;
133 openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void;
134 openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void;
135 /** Moves in place; "" opens the root explorer. folderTrail keeps the return path; keepSource opens beside the source. */
136 navigateFolder: (
137 ref: ScopedThreadRef,
138 surfaceId: string,
139 relativePath: string,
140 folderTrail: string,
141 keepSource?: boolean,
142 ) => void;
143 openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void;
144 openPullRequest: (
145 ref: ScopedThreadRef,
146 target: {
147 environmentId?: string;
148 projectId: string;
149 host?: string;
150 repository: string;
151 number: number;
152 url?: string;
153 /** A clicked link to reveal; reopening a surface passes only its `url`. */
154 revealUrl?: string;
155 detailTab?: PullRequestDetailTab;
156 },
157 ) => void;
158 setPullRequestDetailTab: (
159 ref: ScopedThreadRef,
160 surfaceId: string,
161 detailTab: PullRequestDetailTab,
162 ) => void;
163 /** Clears the surface's pending link only while it is still `revealUrl`. */
164 settlePullRequestReveal: (ref: ScopedThreadRef, surfaceId: string, revealUrl: string) => void;
165 openTerminal: (ref: ScopedThreadRef, terminalId: string) => void;
166 splitTerminal: (
167 ref: ScopedThreadRef,
168 surfaceId: string,
169 terminalId: string,
170 direction?: "horizontal" | "vertical",
171 ) => void;
172 activateTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void;
173 closeTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void;
174 activateSurface: (ref: ScopedThreadRef, surfaceId: string) => void;
175 /** Agent-driven activation. Not a user action, so it does not move keyboard focus. */
176 revealSurface: (ref: ScopedThreadRef, surfaceId: string) => void;
177 closeSurface: (ref: ScopedThreadRef, surfaceId: string) => void;
178 closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void;
179 closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void;
180 closeAllSurfaces: (ref: ScopedThreadRef) => void;
181 moveSurface: (ref: ScopedThreadRef, surfaceId: string, toIndex: number) => void;
182 reconcileBrowserSurfaces: (
183 ref: ScopedThreadRef,
184 tabIds: readonly string[],
185 hiddenTabIds?: ReadonlySet<string>,
186 ) => void;
187 reconcileFileSurfaces: (ref: ScopedThreadRef, workspaceAvailable: boolean) => void;
188 show: (ref: ScopedThreadRef) => void;
189 close: (ref: ScopedThreadRef) => void;
190 setMaximized: (ref: ScopedThreadRef, maximized: boolean) => void;
191 toggleVisibility: (ref: ScopedThreadRef) => void;
192 toggle: (
193 ref: ScopedThreadRef,
194 kind: Exclude<RightPanelKind, "file" | "terminal" | "pull-request" | "side-chat">,
195 ) => void;
196 setThreadPanelOpen: (
197 ref: ScopedThreadRef,
198 presentation: ThreadPanelPresentation,
199 open: boolean,
200 ) => void;
201 toggleThreadPanel: (ref: ScopedThreadRef, presentation: ThreadPanelPresentation) => void;
202 removeThread: (ref: ScopedThreadRef) => void;
204​
205const EMPTY_THREAD_STATE: ThreadRightPanelState = {
206 isOpen: false,
207 activeSurfaceId: null,
208 surfaces: [],
209};
210​
211const DEFAULT_THREAD_PANEL_VISIBILITY: ThreadPanelVisibility = {
212 inlineOpen: true,
213 popoverOpen: false,
214};
215​
216const singletonSurface = (
217 kind: Exclude<RightPanelKind, "file" | "preview" | "terminal" | "pull-request" | "side-chat">,
218): RightPanelSurface => {
219 switch (kind) {
220 case "diff":
221 return { id: "diff", kind };
222 case "files":
223 return { id: "files", kind };
224 case "pull-requests":
225 return { id: "pull-requests", kind };
226 case "device":
227 return { id: "device", kind };
228 }
229};
230​
231const browserSurface = (tabId: string | null): RightPanelSurface =>
232 tabId
233 ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId }
234 : { id: "browser:new", kind: "preview", resourceId: null };
235​
236const fileSurface = (
237 relativePath: string,
238 revealLine: number | null,
239 revealRequestId: number,
240): RightPanelSurface => ({
241 id: `file:${relativePath}`,
242 kind: "file",
243 relativePath,
244 revealLine,
245 revealRequestId,
246});
247​
248const attachmentSurface = (attachment: ChatFileAttachment): RightPanelSurface => ({
249 id: `attachment:${attachment.id}`,
250 kind: "file",
251 relativePath: attachment.name,
252 revealLine: null,
253 revealRequestId: 0,
254 attachment,
255});
256​
257const terminalSurface = (terminalId: string): RightPanelSurface => ({
258 id: `terminal:${terminalId}`,
259 kind: "terminal",
260 resourceId: terminalId,
261 terminalIds: [terminalId],
262 activeTerminalId: terminalId,
263});
264​
265export type PullRequestSurface = Extract<RightPanelSurface, { kind: "pull-request" }>;
266​
267export function pullRequestSurfaceId(target: {
268 environmentId?: string;
269 projectId: string;
270 host?: string;
271 repository: string;
272 number: number;
273}): PullRequestSurface["id"] {
274 const scope =
275 target.environmentId === undefined ? "" : `${encodeURIComponent(target.environmentId)}:`;
276 const host = target.host === undefined ? "" : `${encodeURIComponent(target.host.toLowerCase())}:`;
277 return `pull-request:${scope}${encodeURIComponent(target.projectId)}:${host}${encodeURIComponent(target.repository)}:${target.number}`;
279​
280export function pullRequestSurface(target: {
281 environmentId?: string;
282 projectId: string;
283 host?: string;
284 repository: string;
285 number: number;
286 url?: string;
287 detailTab?: PullRequestDetailTab;
288}): PullRequestSurface {
289 return {
290 id: pullRequestSurfaceId(target),
291 kind: "pull-request",
292 ...(target.environmentId === undefined ? {} : { environmentId: target.environmentId }),
293 projectId: target.projectId,
294 ...(typeof target.host === "string" ? { host: target.host.toLowerCase() } : {}),
295 repository: target.repository,
296 number: target.number,
297 ...(typeof target.url === "string" ? { url: target.url } : {}),
298 ...(target.detailTab === undefined ? {} : { detailTab: target.detailTab }),
299 };
301​
302/** A pending link belongs to the click that made it, not to a reload or a reopen. */
303const withoutReveal = (surface: RightPanelSurface): RightPanelSurface => {
304 if (surface.kind !== "pull-request" || surface.revealUrl === undefined) return surface;
305 const { revealUrl: _pending, ...rest } = surface;
306 return rest;
307};
308​
309const upsertSurface = (
310 current: ThreadRightPanelState,
311 surface: RightPanelSurface,
312 activate = true,
313): ThreadRightPanelState => ({
314 ...current,
315 isOpen: true,
316 surfaces: current.surfaces.some((entry) => entry.id === surface.id)
317 ? current.surfaces
318 : [...current.surfaces, surface],
319 activeSurfaceId: activate ? surface.id : current.activeSurfaceId,
320});
321​
322const updateThreadStateMap = (
323 byThreadKey: Record<string, ThreadRightPanelState>,
324 threadKey: string,
325 updater: (current: ThreadRightPanelState) => ThreadRightPanelState,
326): Record<string, ThreadRightPanelState> => {
327 const current = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE;
328 const next = updater(current);
329 if (
330 !next.isOpen &&
331 next.activeSurfaceId === null &&
332 next.surfaces.length === 0 &&
333 !next.dismissedDeviceSurfaceIds?.length &&
334 next.proactiveDiffTurnId === undefined
335 ) {
336 if (!(threadKey in byThreadKey)) return byThreadKey;
337 const { [threadKey]: _removed, ...rest } = byThreadKey;
338 return rest;
339 }
340 if (next === current) return byThreadKey;
341 return { ...byThreadKey, [threadKey]: next };
342};
343​
344const updateThreadPanelVisibilityMap = (
345 byThreadKey: Record<string, ThreadPanelVisibility>,
346 threadKey: string,
347 updater: (current: ThreadPanelVisibility) => ThreadPanelVisibility,
348): Record<string, ThreadPanelVisibility> => {
349 const current = byThreadKey[threadKey] ?? DEFAULT_THREAD_PANEL_VISIBILITY;
350 const next = updater(current);
351 if (next.inlineOpen && !next.popoverOpen) {
352 if (!(threadKey in byThreadKey)) return byThreadKey;
353 const { [threadKey]: _removed, ...rest } = byThreadKey;
354 return rest;
355 }
356 if (next === current) return byThreadKey;
357 return { ...byThreadKey, [threadKey]: next };
358};
359​
360type RightPanelStoreData = Pick<
361 RightPanelStoreState,
362 "byThreadKey" | "threadPanelVisibilityByThreadKey"
363>;
364​
365const updateThread = (
366 state: RightPanelStoreData,
367 threadKey: string,
368 updater: (current: ThreadRightPanelState) => ThreadRightPanelState,
369): RightPanelStoreData => {
370 const current = state.byThreadKey[threadKey] ?? EMPTY_THREAD_STATE;
371 const byThreadKey = updateThreadStateMap(state.byThreadKey, threadKey, updater);
372 const next = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE;
373 let threadPanelVisibilityByThreadKey = state.threadPanelVisibilityByThreadKey;
374​
375 if (!current.isOpen && next.isOpen) {
376 threadPanelVisibilityByThreadKey = updateThreadPanelVisibilityMap(
377 threadPanelVisibilityByThreadKey,
378 threadKey,
379 (visibility) => (visibility.popoverOpen ? { ...visibility, popoverOpen: false } : visibility),
380 );
381 } else if (current.isOpen && !next.isOpen) {
382 threadPanelVisibilityByThreadKey = updateThreadPanelVisibilityMap(
383 threadPanelVisibilityByThreadKey,
384 threadKey,
385 (visibility) =>
386 visibility.popoverOpen && !visibility.inlineOpen
387 ? { ...visibility, inlineOpen: true }
388 : visibility,
389 );
390 }
391​
392 return { byThreadKey, threadPanelVisibilityByThreadKey };
393};
394​
395const automaticUpdate = (
396 state: RightPanelStoreState,
397 threadKey: string,
398 updater: (current: ThreadRightPanelState) => ThreadRightPanelState,
399): Partial<RightPanelStoreState> => ({
400 ...updateThread(state, threadKey, updater),
401});
402​
403// A side chat pane has no panel of its own, so its panel actions open in its parent's panel.
404const panelHosts = new Map<string, ScopedThreadRef>();
405​
406/** Routes `guest`'s panel actions to `host`'s panel until the returned cleanup runs. */
407export function hostRightPanel(guest: ScopedThreadRef, host: ScopedThreadRef): () => void {
408 const guestKey = scopedThreadKey(guest);
409 panelHosts.set(guestKey, host);
410 return () => {
411 if (panelHosts.get(guestKey) === host) panelHosts.delete(guestKey);
412 };
414​
415/** The thread whose panel shows `ref`'s panel actions, so it also owns their browser tabs. */
416export function rightPanelHostRef(ref: ScopedThreadRef): ScopedThreadRef {
417 return panelHosts.get(scopedThreadKey(ref)) ?? ref;
419​
420const userAction = (
421 state: RightPanelStoreState,
422 guestKey: string,
423 updater: (current: ThreadRightPanelState) => ThreadRightPanelState,
424 closesView = false,
425): Partial<RightPanelStoreState> => {
426 const host = panelHosts.get(guestKey);
427 const threadKey = host === undefined ? guestKey : scopedThreadKey(host);
428 return userActionOnThread(state, threadKey, updater, closesView);
429};
430​
431const userActionOnThread = (
432 state: RightPanelStoreState,
433 threadKey: string,
434 updater: (current: ThreadRightPanelState) => ThreadRightPanelState,
435 closesView: boolean,
436): Partial<RightPanelStoreState> => ({
437 ...updateThread(state, threadKey, (current) => {
438 const next = updater(current);
439 if (next === current) return current;
440 const removedDevices = current.surfaces.filter(
441 (surface) =>
442 surface.kind === "device" && !next.surfaces.some((entry) => entry.id === surface.id),
443 );
444 if (removedDevices.length === 0) return next;
445 return {
446 ...next,
447 dismissedDeviceSurfaceIds: [
448 ...new Set([
449 ...(next.dismissedDeviceSurfaceIds ?? []),
450 ...removedDevices.map((surface) => surface.id),
451 ]),
452 ],
453 };
454 }),
455 userActionRevisionByThreadKey: {
456 ...state.userActionRevisionByThreadKey,
457 [threadKey]: (state.userActionRevisionByThreadKey[threadKey] ?? 0) + 1,
458 },
459 ...(closesView
460 ? {
461 closeRevisionByThreadKey: {
462 ...state.closeRevisionByThreadKey,
463 [threadKey]: (state.closeRevisionByThreadKey[threadKey] ?? 0) + 1,
464 },
465 }
466 : {}),
467});
468​
469const activated = (current: ThreadRightPanelState, surfaceId: string): ThreadRightPanelState =>
470 current.surfaces.some((surface) => surface.id === surfaceId)
471 ? { ...current, isOpen: true, activeSurfaceId: surfaceId }
472 : current;
473​
474const closeAction = (
475 state: RightPanelStoreState,
476 threadKey: string,
477 updater: (current: ThreadRightPanelState) => ThreadRightPanelState,
478): Partial<RightPanelStoreState> => userAction(state, threadKey, updater, true);
479​
480function normalizeRevealLine(line: number | undefined): number | null {
481 if (line === undefined || !Number.isFinite(line)) return null;
482 return Math.max(1, Math.trunc(line));
484​
485export function migratePersistedRightPanelState(persistedState: unknown): {
486 byThreadKey: Record<string, ThreadRightPanelState>;
487 threadPanelVisibilityByThreadKey: Record<string, ThreadPanelVisibility>;
488} {
489 if (!persistedState || typeof persistedState !== "object") {
490 return { byThreadKey: {}, threadPanelVisibilityByThreadKey: {} };
491 }
492 const byThreadKey =
493 "byThreadKey" in persistedState &&
494 persistedState.byThreadKey &&
495 typeof persistedState.byThreadKey === "object"
496 ? Object.fromEntries(
497 Object.entries(persistedState.byThreadKey as Record<string, ThreadRightPanelState>)
498 .filter(([threadKey]) => !isPullRequestsPanelKey(threadKey))
499 .map(([threadKey, threadState]) => {
500 const validThreadState =
501 threadState && typeof threadState === "object" ? threadState : null;
502 const surfaces = Array.isArray(validThreadState?.surfaces)
503 ? validThreadState.surfaces.flatMap<RightPanelSurface>((surface) => {
504 const kind = (surface as { kind?: string }).kind;
505 if (kind === "plan" || kind === "agents") return [];
506 if (
507 (surface.kind === "file" || surface.kind === "files") &&
508 "folderTrail" in surface &&
509 typeof surface.folderTrail !== "string"
510 ) {
511 const { folderTrail: _invalid, ...rest } = surface;
512 surface = rest;
513 }
514 if (surface.kind === "file") {
515 const revealLine =
516 typeof surface.revealLine === "number" &&
517 Number.isFinite(surface.revealLine)
518 ? Math.max(1, Math.trunc(surface.revealLine))
519 : null;
520 const revealRequestId =
521 typeof surface.revealRequestId === "number" &&
522 Number.isSafeInteger(surface.revealRequestId) &&
523 surface.revealRequestId >= 0
524 ? surface.revealRequestId
525 : 0;
526 return [{ ...surface, revealLine, revealRequestId }];
527 }
528 if (surface.kind === "pull-request") {
529 if (
530 typeof surface.projectId !== "string" ||
531 typeof surface.repository !== "string" ||
532 typeof surface.number !== "number" ||
533 !Number.isSafeInteger(surface.number) ||
534 surface.number < 1
535 ) {
536 return [];
537 }
538 const { environmentId, ...rest } = surface;
539 return [
540 pullRequestSurface({
541 ...rest,
542 ...(typeof environmentId === "string" ? { environmentId } : {}),
543 }),
544 ];
545 }
546 if (surface.kind !== "terminal") return [surface];
547 if (
548 !("resourceId" in surface) ||
549 typeof surface.resourceId !== "string" ||
550 surface.id !== `terminal:${surface.resourceId}`
551 ) {
552 return [];
553 }
554 const terminalIds =
555 "terminalIds" in surface && Array.isArray(surface.terminalIds)
556 ? [
557 ...new Set(
558 surface.terminalIds.filter(
559 (terminalId): terminalId is string =>
560 typeof terminalId === "string",
561 ),
562 ),
563 ]
564 : [surface.resourceId];
565 const activeTerminalId =
566 "activeTerminalId" in surface &&
567 typeof surface.activeTerminalId === "string" &&
568 terminalIds.includes(surface.activeTerminalId)
569 ? surface.activeTerminalId
570 : (terminalIds[0] ?? surface.resourceId);
571 return [
572 {
573 ...surface,
574 terminalIds: terminalIds.length > 0 ? terminalIds : [surface.resourceId],
575 activeTerminalId,
576 },
577 ];
578 })
579 : [];
580 const rawActiveSurfaceId = validThreadState?.activeSurfaceId;
581 const persistedActiveSurfaceId = surfaces.some(
582 (surface) => surface.id === rawActiveSurfaceId,
583 )
584 ? (rawActiveSurfaceId ?? null)
585 : rawActiveSurfaceId === "pull-request"
586 ? (surfaces.find((surface) => surface.kind === "pull-request")?.id ?? null)
587 : null;
588 const isOpen =
589 surfaces.length > 0 &&
590 (typeof validThreadState?.isOpen === "boolean"
591 ? validThreadState.isOpen
592 : persistedActiveSurfaceId !== null);
593 const activeSurfaceId =
594 persistedActiveSurfaceId ??
595 (isOpen || rawActiveSurfaceId === "agents" ? (surfaces[0]?.id ?? null) : null);
596 return [
597 threadKey,
598 {
599 isOpen,
600 surfaces,
601 activeSurfaceId,
602 ...(typeof validThreadState?.proactiveDiffTurnId === "string"
603 ? { proactiveDiffTurnId: RunId.make(validThreadState.proactiveDiffTurnId) }
604 : {}),
605 ...(Array.isArray(validThreadState?.dismissedDeviceSurfaceIds)
606 ? {
607 dismissedDeviceSurfaceIds:
608 validThreadState.dismissedDeviceSurfaceIds.filter(
609 (id): id is string => typeof id === "string",
610 ),
611 }
612 : {}),
613 ...(validThreadState?.maximized === true ? { maximized: true as const } : {}),
614 },
615 ];
616 }),
617 )
618 : {};
619 const threadPanelVisibilityByThreadKey =
620 "threadPanelVisibilityByThreadKey" in persistedState &&
621 persistedState.threadPanelVisibilityByThreadKey &&
622 typeof persistedState.threadPanelVisibilityByThreadKey === "object"
623 ? Object.fromEntries(
624 Object.entries(
625 persistedState.threadPanelVisibilityByThreadKey as Record<string, unknown>,
626 ).flatMap(([threadKey, value]) => {
627 if (!value || typeof value !== "object" || !("inlineOpen" in value)) return [];
628 return value.inlineOpen === false
629 ? [[threadKey, { inlineOpen: false, popoverOpen: false }]]
630 : [];
631 }),
632 )
633 : {};
634 return { byThreadKey, threadPanelVisibilityByThreadKey };
636​
637export const useRightPanelStore = create<RightPanelStoreState>()(
638 persist(
639 (set, get) => ({
640 byThreadKey: {},
641 pendingMaximizeThreadKey: null,
642 requestMaximize: (ref) => set({ pendingMaximizeThreadKey: scopedThreadKey(ref) }),
643 consumeMaximizeRequest: (ref) => {
644 if (get().pendingMaximizeThreadKey !== scopedThreadKey(ref)) return false;
645 set({ pendingMaximizeThreadKey: null });
646 return true;
647 },
648 threadPanelVisibilityByThreadKey: {},
649 userActionRevisionByThreadKey: {},
650 closeRevisionByThreadKey: {},
651 getUserActionRevision: (ref) =>
652 get().userActionRevisionByThreadKey[scopedThreadKey(ref)] ?? 0,
653 markUserAction: (ref) => {
654 const revision = get().getUserActionRevision(ref) + 1;
655 set((state) => ({
656 userActionRevisionByThreadKey: {
657 ...state.userActionRevisionByThreadKey,
658 [scopedThreadKey(ref)]: revision,
659 },
660 }));
661 return revision;
662 },
663 openProactive: (ref, request, expectedUserActionRevision, activateExisting = false) => {
664 let opened = false;
665 set((state) => {
666 const threadKey = scopedThreadKey(ref);
667 if (request.kind === "diff") {
668 const current = selectThreadRightPanelState(state.byThreadKey, ref);
669 if (current.proactiveDiffTurnId === request.turnId) return state;
670 const activePanel = selectActiveRightPanel(state.byThreadKey, ref);
671 const pullRequestActive =
672 activePanel === "pull-request" || activePanel === "pull-requests";
673 opened =
674 (state.userActionRevisionByThreadKey[threadKey] ?? 0) ===
675 expectedUserActionRevision &&
676 activePanel !== "diff" &&
677 !pullRequestActive;
678 // Record the offer even when refused, so a revisit does not retry this turn.
679 return automaticUpdate(state, threadKey, (thread) => ({
680 ...(opened ? upsertSurface(thread, singletonSurface("diff")) : thread),
681 proactiveDiffTurnId: request.turnId,
682 }));
683 }
684 if (
685 (state.userActionRevisionByThreadKey[threadKey] ?? 0) !== expectedUserActionRevision
686 ) {
687 return state;
688 }
689 // Keep an existing PR tab where the user left it when revisiting a thread.
690 if (
691 !activateExisting &&
692 selectThreadRightPanelState(state.byThreadKey, ref).surfaces.some(
693 (entry) => entry.id === request.id,
694 )
695 ) {
696 return state;
697 }
698 opened = true;
699 return automaticUpdate(state, threadKey, (current) => upsertSurface(current, request));
700 });
701 return opened;
702 },
703 open: (ref, kind) =>
704 set((state) =>
705 userAction(state, scopedThreadKey(ref), (current) => {
706 if (kind === "preview") {
707 const existing = current.surfaces.find((surface) => surface.kind === "preview");
708 return upsertSurface(current, existing ?? browserSurface(null));
709 }
710 return upsertSurface(current, singletonSurface(kind));
711 }),
712 ),
713 openDevice: (ref, target, automatic = false) =>
714 set((state) =>
715 (automatic ? automaticUpdate : userAction)(state, scopedThreadKey(ref), (current) => {
716 const id =
717 `device:${encodeURIComponent(target.hostId)}:${encodeURIComponent(target.deviceId)}` as const;
718 if (automatic && current.dismissedDeviceSurfaceIds?.includes(id)) return current;
719 const surface: RightPanelSurface = { id, kind: "device", target };
720 const existing = current.surfaces.find((entry) => entry.id === id);
721 const surfaces = existing
722 ? current.surfaces.filter((entry) => entry.id !== "device")
723 : current.surfaces.map((entry) => (entry.id === "device" ? surface : entry));
724 return upsertSurface(
725 {
726 ...current,
727 surfaces,
728 dismissedDeviceSurfaceIds: (current.dismissedDeviceSurfaceIds ?? []).filter(
729 (entry) => entry !== id,
730 ),
731 },
732 existing ?? surface,
733 );
734 }),
735 ),
736 openSideChat: (ref, threadId) =>
737 set((state) =>
738 userAction(state, scopedThreadKey(ref), (current) =>
739 upsertSurface(current, { id: `side-chat:${threadId}`, kind: "side-chat", threadId }),
740 ),
741 ),
742 renameDevice: (ref, surfaceId, title) =>
743 set((state) =>
744 userAction(state, scopedThreadKey(ref), (current) => ({
745 ...current,
746 surfaces: current.surfaces.map((surface) =>
747 surface.id === surfaceId && surface.kind === "device"
748 ? { ...surface, title: title.trim() || surface.target?.name || "Device" }
749 : surface,
750 ),
751 })),
752 ),
753 openBrowser: (ref, tabId) =>
754 set((state) =>
755 userAction(state, scopedThreadKey(ref), (current) => {
756 const surface = browserSurface(tabId);
757 const withoutPlaceholder = tabId
758 ? current.surfaces.filter((entry) => entry.id !== "browser:new")
759 : current.surfaces;
760 return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface);
761 }),
762 ),
763 openPullRequest: (ref, target) =>
764 set((state) =>
765 userAction(state, scopedThreadKey(ref), (current) => {
766 const surface = pullRequestSurface(target);
767 const next = upsertSurface(current, surface);
768 const { url, revealUrl } = target;
769 if (!url && !revealUrl) return next;
770 return {
771 ...next,
772 surfaces: next.surfaces.map((entry) => {
773 if (entry.id !== surface.id || entry.kind !== "pull-request") return entry;
774 // A later link replaces one not yet shown.
775 const { revealUrl: _previous, ...rest } = entry;
776 return { ...rest, ...surface, ...(revealUrl ? { revealUrl } : {}) };
777 }),
778 };
779 }),
780 ),
781 setPullRequestDetailTab: (ref, surfaceId, detailTab) =>
782 set((state) => {
783 const threadKey = scopedThreadKey(ref);
784 const existing = state.byThreadKey[threadKey]?.surfaces.find(
785 (surface) => surface.id === surfaceId,
786 );
787 if (existing?.kind !== "pull-request" || (existing.detailTab ?? "summary") === detailTab)
788 return state;
789 return automaticUpdate(state, threadKey, (current) => ({
790 ...current,
791 surfaces: current.surfaces.map((surface) =>
792 surface.id === surfaceId && surface.kind === "pull-request"
793 ? { ...surface, detailTab }
794 : surface,
795 ),
796 }));
797 }),
798 settlePullRequestReveal: (ref, surfaceId, revealUrl) =>
799 set((state) => {
800 const threadKey = scopedThreadKey(ref);
801 const existing = state.byThreadKey[threadKey]?.surfaces.find(
802 (surface) => surface.id === surfaceId,
803 );
804 if (existing?.kind !== "pull-request" || existing.revealUrl !== revealUrl) return state;
805 return automaticUpdate(state, threadKey, (current) => ({
806 ...current,
807 surfaces: current.surfaces.map((surface) =>
808 surface.id === surfaceId ? withoutReveal(surface) : surface,
809 ),
810 }));
811 }),
812 openFile: (ref, requestedPath, line) =>
813 set((state) =>
814 userAction(state, scopedThreadKey(ref), (current) => {
815 if (requestedPath === ".") {
816 return upsertSurface(current, singletonSurface("files"));
817 }
818 // Workspace entry paths use '/', including on Windows.
819 const relativePath = /^[A-Za-z]:\/+$/.test(requestedPath)
820 ? requestedPath
821 : requestedPath.replace(/\/+$/, "") || requestedPath;
822 const withoutStandaloneExplorer = current.surfaces.filter(
823 (surface) => surface.kind !== "files",
824 );
825 const surfaceId = `file:${relativePath}` as const;
826 const existing = withoutStandaloneExplorer.find(
827 (surface): surface is Extract<RightPanelSurface, { kind: "file" }> =>
828 surface.id === surfaceId && surface.kind === "file",
829 );
830 const surface = fileSurface(
831 relativePath,
832 normalizeRevealLine(line),
833 (existing?.revealRequestId ?? 0) + 1,
834 );
835 return {
836 ...current,
837 isOpen: true,
838 activeSurfaceId: surface.id,
839 surfaces: existing
840 ? withoutStandaloneExplorer.map((entry) =>
841 entry.id === surface.id ? surface : entry,
842 )
843 : [...withoutStandaloneExplorer, surface],
844 };
845 }),
846 ),
847 navigateFolder: (ref, surfaceId, relativePath, folderTrail, keepSource = false) =>
848 set((state) =>
849 userAction(state, scopedThreadKey(ref), (current) => {
850 const index = current.surfaces.findIndex((surface) => surface.id === surfaceId);
851 if (index < 0) return current;
852 const trail = folderTrail === relativePath ? {} : { folderTrail };
853 const target: RightPanelSurface =
854 relativePath === ""
855 ? { ...singletonSurface("files"), ...trail }
856 : { ...fileSurface(relativePath, null, 1), ...trail };
857 const source = current.surfaces[index]!;
858 const existing = current.surfaces.some(
859 (surface, i) => i !== index && surface.id === target.id,
860 );
861 const surfaces = current.surfaces.flatMap((surface, i) => {
862 if (i === index) {
863 const kept = keepSource ? [source] : [];
864 return existing ? kept : [...kept, target];
865 }
866 return !existing && target.kind === "file" && surface.kind === "files"
867 ? []
868 : [surface];
869 });
870 return { ...current, isOpen: true, activeSurfaceId: target.id, surfaces };
871 }),
872 ),
873 openAttachment: (ref, attachment) =>
874 set((state) =>
875 userAction(state, scopedThreadKey(ref), (current) => {
876 const withoutStandaloneExplorer = current.surfaces.filter(
877 (surface) => surface.kind !== "files",
878 );
879 return upsertSurface(
880 { ...current, surfaces: withoutStandaloneExplorer },
881 attachmentSurface(attachment),
882 );
883 }),
884 ),
885 openTerminal: (ref, terminalId) =>
886 set((state) =>
887 userAction(state, scopedThreadKey(ref), (current) =>
888 upsertSurface(current, terminalSurface(terminalId)),
889 ),
890 ),
891 splitTerminal: (ref, surfaceId, terminalId, direction = "horizontal") =>
892 set((state) =>
893 userAction(state, scopedThreadKey(ref), (current) => ({
894 ...current,
895 isOpen: true,
896 activeSurfaceId: surfaceId,
897 surfaces: current.surfaces.map((surface) => {
898 if (surface.id !== surfaceId || surface.kind !== "terminal") return surface;
899 const { splitDirection: _splitDirection, ...baseSurface } = surface;
900 return {
901 ...baseSurface,
902 terminalIds: surface.terminalIds.includes(terminalId)
903 ? surface.terminalIds
904 : [...surface.terminalIds, terminalId],
905 activeTerminalId: terminalId,
906 ...(direction === "vertical" ? { splitDirection: "vertical" as const } : {}),
907 };
908 }),
909 })),
910 ),
911 activateTerminal: (ref, surfaceId, terminalId) =>
912 set((state) =>
913 userAction(state, scopedThreadKey(ref), (current) => ({
914 ...current,
915 activeSurfaceId: surfaceId,
916 surfaces: current.surfaces.map((surface) =>
917 surface.id === surfaceId &&
918 surface.kind === "terminal" &&
919 surface.terminalIds.includes(terminalId)
920 ? { ...surface, activeTerminalId: terminalId }
921 : surface,
922 ),
923 })),
924 ),
925 closeTerminal: (ref, surfaceId, terminalId) =>
926 set((state) =>
927 closeAction(state, scopedThreadKey(ref), (current) => {
928 const surface = current.surfaces.find(
929 (entry) => entry.id === surfaceId && entry.kind === "terminal",
930 );
931 if (!surface || surface.kind !== "terminal") return current;
932 const terminalIds = surface.terminalIds.filter((id) => id !== terminalId);
933 if (terminalIds.length === 0) {
934 const index = current.surfaces.findIndex((entry) => entry.id === surfaceId);
935 const surfaces = current.surfaces.filter((entry) => entry.id !== surfaceId);
936 const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null;
937 return {
938 ...current,
939 isOpen: surfaces.length > 0 && current.isOpen,
940 surfaces,
941 activeSurfaceId:
942 current.activeSurfaceId === surfaceId
943 ? (fallback?.id ?? null)
944 : current.activeSurfaceId,
945 };
946 }
947 return {
948 ...current,
949 surfaces: current.surfaces.map((entry) =>
950 entry.id === surfaceId && entry.kind === "terminal"
951 ? {
952 ...entry,
953 terminalIds,
954 activeTerminalId:
955 entry.activeTerminalId === terminalId
956 ? (terminalIds.at(-1) ?? terminalIds[0]!)
957 : entry.activeTerminalId,
958 }
959 : entry,
960 ),
961 };
962 }),
963 ),
964 activateSurface: (ref, surfaceId) =>
965 set((state) =>
966 userAction(state, scopedThreadKey(ref), (current) => activated(current, surfaceId)),
967 ),
968 revealSurface: (ref, surfaceId) =>
969 set((state) =>
970 automaticUpdate(state, scopedThreadKey(ref), (current) => activated(current, surfaceId)),
971 ),
972 closeSurface: (ref, surfaceId) =>
973 set((state) =>
974 closeAction(state, scopedThreadKey(ref), (current) => {
975 const index = current.surfaces.findIndex((surface) => surface.id === surfaceId);
976 if (index < 0) return current;
977 const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId);
978 const next = {
979 ...current,
980 isOpen: surfaces.length > 0 && current.isOpen,
981 surfaces,
982 };
983 if (current.activeSurfaceId !== surfaceId) {
984 return next;
985 }
986 const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null;
987 return {
988 ...next,
989 activeSurfaceId: fallback?.id ?? null,
990 };
991 }),
992 ),
993 closeOtherSurfaces: (ref, surfaceId) =>
994 set((state) =>
995 closeAction(state, scopedThreadKey(ref), (current) => {
996 const surface = current.surfaces.find((entry) => entry.id === surfaceId);
997 if (!surface || current.surfaces.length === 1) return current;
998 return {
999 ...current,
1000 isOpen: true,
1001 surfaces: [surface],
1002 activeSurfaceId: surface.id,
1003 };
1004 }),
1005 ),
1006 closeSurfacesToRight: (ref, surfaceId) =>
1007 set((state) =>
1008 closeAction(state, scopedThreadKey(ref), (current) => {
1009 const index = current.surfaces.findIndex((surface) => surface.id === surfaceId);
1010 if (index < 0 || index === current.surfaces.length - 1) return current;
1011 const surfaces = current.surfaces.slice(0, index + 1);
1012 const activeStillExists = surfaces.some(
1013 (surface) => surface.id === current.activeSurfaceId,
1014 );
1015 return {
1016 ...current,
1017 surfaces,
1018 activeSurfaceId: activeStillExists ? current.activeSurfaceId : surfaceId,
1019 };
1020 }),
1021 ),
1022 closeAllSurfaces: (ref) =>
1023 set((state) =>
1024 closeAction(state, scopedThreadKey(ref), (current) =>
1025 current.surfaces.length === 0
1026 ? current
1027 : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null },
1028 ),
1029 ),
1030 moveSurface: (ref, surfaceId, toIndex) =>
1031 set((state) =>
1032 updateThread(state, scopedThreadKey(ref), (current) => {
1033 const fromIndex = current.surfaces.findIndex((surface) => surface.id === surfaceId);
1034 if (fromIndex < 0 || fromIndex === toIndex) return current;
1035 const surfaces = [...current.surfaces];
1036 surfaces.splice(toIndex, 0, ...surfaces.splice(fromIndex, 1));
1037 return { ...current, surfaces };
1038 }),
1039 ),
1040 reconcileBrowserSurfaces: (ref, tabIds, hiddenTabIds) =>
1041 set((state) =>
1042 automaticUpdate(state, scopedThreadKey(ref), (current) => {
1043 const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`));
1044 const kept = current.surfaces.filter(
1045 (surface) =>
1046 surface.kind !== "preview" ||
1047 (surface.id !== "browser:new" && validIds.has(surface.id)),
1048 );
1049 const knownIds = new Set(kept.map((surface) => surface.id));
1050 const added = tabIds
1051 .filter((tabId) => !knownIds.has(`browser:${tabId}`) && !hiddenTabIds?.has(tabId))
1052 .map((tabId) => browserSurface(tabId));
1053 const surfaces = [...kept, ...added];
1054 const activeStillExists = surfaces.some(
1055 (surface) => surface.id === current.activeSurfaceId,
1056 );
1057 const fallbackBrowser = surfaces.find((surface) => surface.kind === "preview");
1058 // A closed session tab was the last surface: hide the panel rather than leave it on
1059 // the empty picker, as a user close would. A dropped `browser:new` placeholder does not count.
1060 const closedLastSessionTab =
1061 surfaces.length === 0 &&
1062 current.surfaces.some((surface) => surface.kind === "preview" && surface.resourceId);
1063 return {
1064 ...current,
1065 isOpen: closedLastSessionTab ? false : current.isOpen,
1066 surfaces,
1067 activeSurfaceId: activeStillExists
1068 ? current.activeSurfaceId
1069 : (fallbackBrowser?.id ?? surfaces[0]?.id ?? null),
1070 };
1071 }),
1072 ),
1073 reconcileFileSurfaces: (ref, workspaceAvailable) =>
1074 set((state) =>
1075 automaticUpdate(state, scopedThreadKey(ref), (current) => {
1076 if (workspaceAvailable) return current;
1077 const surfaces = current.surfaces.filter(
1078 (surface) =>
1079 surface.kind !== "files" &&
1080 (surface.kind !== "file" || surface.attachment !== undefined),
1081 );
1082 if (surfaces.length === current.surfaces.length) return current;
1083 const activeStillExists = surfaces.some(
1084 (surface) => surface.id === current.activeSurfaceId,
1085 );
1086 return {
1087 ...current,
1088 isOpen: surfaces.length > 0 ? current.isOpen : false,
1089 surfaces,
1090 activeSurfaceId: activeStillExists
1091 ? current.activeSurfaceId
1092 : (surfaces.at(-1)?.id ?? null),
1093 };
1094 }),
1095 ),
1096 show: (ref) =>
1097 set((state) =>
1098 userAction(state, scopedThreadKey(ref), (current) =>
1099 current.isOpen ? current : { ...current, isOpen: true },
1100 ),
1101 ),
1102 close: (ref) =>
1103 set((state) =>
1104 userAction(state, scopedThreadKey(ref), (current) =>
1105 current.isOpen ? { ...current, isOpen: false } : current,
1106 ),
1107 ),
1108 setMaximized: (ref, maximized) =>
1109 set((state) =>
1110 automaticUpdate(state, scopedThreadKey(ref), (current) => {
1111 if ((current.maximized ?? false) === maximized) return current;
1112 const { maximized: _maximized, ...rest } = current;
1113 return maximized ? { ...rest, maximized: true } : rest;
1114 }),
1115 ),
1116 toggleVisibility: (ref) =>
1117 set((state) =>
1118 userAction(state, scopedThreadKey(ref), (current) => ({
1119 ...current,
1120 isOpen: !current.isOpen,
1121 })),
1122 ),
1123 toggle: (ref, kind) =>
1124 set((state) =>
1125 userAction(state, scopedThreadKey(ref), (current) => {
1126 const active = current.surfaces.find(
1127 (surface) => surface.id === current.activeSurfaceId,
1128 );
1129 if (current.isOpen && active?.kind === kind) {
1130 return { ...current, isOpen: false };
1132 if (kind === "preview") {
1133 const existing = current.surfaces.find((surface) => surface.kind === "preview");
1134 return upsertSurface(current, existing ?? browserSurface(null));
1136 return upsertSurface(current, singletonSurface(kind));
1137 }),
1138 ),
1139 setThreadPanelOpen: (ref, presentation, open) =>
1140 set((state) => ({
1141 threadPanelVisibilityByThreadKey: updateThreadPanelVisibilityMap(
1142 state.threadPanelVisibilityByThreadKey,
1143 scopedThreadKey(ref),
1144 (visibility) => {
1145 const key = presentation === "inline" ? "inlineOpen" : "popoverOpen";
1146 return visibility[key] === open ? visibility : { ...visibility, [key]: open };
1147 },
1148 ),
1149 })),
1150 toggleThreadPanel: (ref, presentation) =>
1151 set((state) => ({
1152 threadPanelVisibilityByThreadKey: updateThreadPanelVisibilityMap(
1153 state.threadPanelVisibilityByThreadKey,
1154 scopedThreadKey(ref),
1155 (visibility) =>
1156 presentation === "inline"
1157 ? { ...visibility, inlineOpen: !visibility.inlineOpen }
1158 : { ...visibility, popoverOpen: !visibility.popoverOpen },
1159 ),
1160 })),
1161 removeThread: (ref) =>
1162 set((state) => {
1163 const threadKey = scopedThreadKey(ref);
1164 if (
1165 !(threadKey in state.byThreadKey) &&
1166 !(threadKey in state.threadPanelVisibilityByThreadKey) &&
1167 !(threadKey in state.userActionRevisionByThreadKey) &&
1168 !(threadKey in state.closeRevisionByThreadKey)
1169 ) {
1170 return state;
1172 const { [threadKey]: _removed, ...byThreadKey } = state.byThreadKey;
1173 const { [threadKey]: _visibility, ...threadPanelVisibilityByThreadKey } =
1174 state.threadPanelVisibilityByThreadKey;
1175 const { [threadKey]: _revision, ...userActionRevisionByThreadKey } =
1176 state.userActionRevisionByThreadKey;
1177 const { [threadKey]: _closeRevision, ...closeRevisionByThreadKey } =
1178 state.closeRevisionByThreadKey;
1179 return {
1180 byThreadKey,
1181 threadPanelVisibilityByThreadKey,
1182 userActionRevisionByThreadKey,
1183 closeRevisionByThreadKey,
1184 };
1185 }),
1186 }),
1188 name: RIGHT_PANEL_STORAGE_KEY,
1189 version: RIGHT_PANEL_STORAGE_VERSION,
1190 storage: createJSONStorage(() =>
1191 resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),
1192 ),
1193 partialize: (state) => ({
1194 byThreadKey: Object.fromEntries(
1195 Object.entries(state.byThreadKey).flatMap(([threadKey, threadState]) =>
1196 isPullRequestsPanelKey(threadKey)
1197 ? []
1198 : [
1200 threadKey,
1201 { ...threadState, surfaces: threadState.surfaces.map(withoutReveal) },
1202 ],
1203 ],
1204 ),
1205 ),
1206 threadPanelVisibilityByThreadKey: Object.fromEntries(
1207 Object.entries(state.threadPanelVisibilityByThreadKey).flatMap(
1208 ([threadKey, visibility]) =>
1209 visibility.inlineOpen ? [] : [[threadKey, { inlineOpen: false, popoverOpen: false }]],
1210 ),
1211 ),
1212 }),
1213 migrate: migratePersistedRightPanelState,
1214 },
1215 ),
1217​
1218useRightPanelStore.subscribe((next, previous) => {
1219 if (next.closeRevisionByThreadKey === previous.closeRevisionByThreadKey) return;
1220 for (const [threadKey, revision] of Object.entries(next.closeRevisionByThreadKey)) {
1221 if (revision === previous.closeRevisionByThreadKey[threadKey]) continue;
1222 const threadRef = parseScopedThreadKey(threadKey);
1223 if (!threadRef) continue;
1224 const before = previous.byThreadKey[threadKey] ?? EMPTY_THREAD_STATE;
1225 const after = next.byThreadKey[threadKey] ?? EMPTY_THREAD_STATE;
1226 const removed = before.surfaces.filter(
1227 (surface) => !after.surfaces.some((entry) => entry.id === surface.id),
1228 );
1229 const closedInOrder = [
1230 ...removed.filter((surface) => surface.id !== before.activeSurfaceId),
1231 ...removed.filter((surface) => surface.id === before.activeSurfaceId),
1232 ];
1233 for (const surface of closedInOrder.map(withoutReveal)) {
1234 if (
1235 surface.kind === "terminal" ||
1236 (surface.kind === "preview" && surface.resourceId !== null)
1238 continue;
1239 useClosedViewStore.getState().remember({ kind: "panel-tab", threadRef, surface });
1242});
1243​
1244export function selectThreadRightPanelState(
1245 byThreadKey: Record<string, ThreadRightPanelState>,
1246 ref: ScopedThreadRef | null | undefined,
1247): ThreadRightPanelState {
1248 if (!ref) return EMPTY_THREAD_STATE;
1249 return byThreadKey[scopedThreadKey(ref)] ?? EMPTY_THREAD_STATE;
1251​
1252export function selectThreadPanelVisibility(
1253 byThreadKey: Record<string, ThreadPanelVisibility>,
1254 ref: ScopedThreadRef | null | undefined,
1255): ThreadPanelVisibility {
1256 if (!ref) return DEFAULT_THREAD_PANEL_VISIBILITY;
1257 return byThreadKey[scopedThreadKey(ref)] ?? DEFAULT_THREAD_PANEL_VISIBILITY;
1259​
1260export function selectThreadPanelOpen(
1261 byThreadKey: Record<string, ThreadPanelVisibility>,
1262 ref: ScopedThreadRef | null | undefined,
1263 presentation: ThreadPanelPresentation,
1264): boolean {
1265 const visibility = selectThreadPanelVisibility(byThreadKey, ref);
1266 return presentation === "inline" ? visibility.inlineOpen : visibility.popoverOpen;
1268​
1269export function selectActiveRightPanel(
1270 byThreadKey: Record<string, ThreadRightPanelState>,
1271 ref: ScopedThreadRef | null | undefined,
1272): RightPanelKind | null {
1273 const state = selectThreadRightPanelState(byThreadKey, ref);
1274 if (!state.isOpen) return null;
1275 return state.surfaces.find((surface) => surface.id === state.activeSurfaceId)?.kind ?? null;
1277​
1278export function selectActiveRightPanelSurface(
1279 byThreadKey: Record<string, ThreadRightPanelState>,
1280 ref: ScopedThreadRef | null | undefined,
1281): RightPanelSurface | null {
1282 const state = selectThreadRightPanelState(byThreadKey, ref);
1283 if (!state.isOpen) return null;
1284 return selectSelectedRightPanelSurface(byThreadKey, ref);
1286​
1287export function selectSelectedRightPanelSurface(
1288 byThreadKey: Record<string, ThreadRightPanelState>,
1289 ref: ScopedThreadRef | null | undefined,
1290): RightPanelSurface | null {
1291 const state = selectThreadRightPanelState(byThreadKey, ref);
1292 return state.surfaces.find((surface) => surface.id === state.activeSurfaceId) ?? null;