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