/** * Thread-scoped right-panel surface state. * * This is intentionally a shallow workspace model: it owns an ordered set of * surface descriptors and the active surface, while each feature continues to * own its durable resource state. Browser surfaces point at preview tab ids, * terminal surfaces point at terminal session ids, file surfaces point at * workspace paths, and diff/files remain singleton surfaces. */import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";import { EnvironmentId, ThreadId, type ChatFileAttachment, type ScopedThreadRef,} from "@t3tools/contracts";import { create } from "zustand";import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; const RIGHT_PANEL_KINDS = [ "diff", "files", "file", "preview", "device", "terminal", "pull-request", "pull-requests", "agents",] as const;export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; export interface DeviceTabTarget { hostId: string; deviceId: string; platform: "ios" | "android"; name: string;} export type RightPanelSurface = | { id: `browser:${string}`; kind: "preview"; resourceId: string } | { id: "browser:new"; kind: "preview"; resourceId: null } | { id: "device" | `device:${string}`; kind: "device"; target?: DeviceTabTarget; title?: string } | { id: `terminal:${string}`; kind: "terminal"; resourceId: string; terminalIds: string[]; activeTerminalId: string; splitDirection?: "horizontal" | "vertical"; } | { id: "diff"; kind: "diff" } | { id: "files"; kind: "files" } | { id: `file:${string}` | `attachment:${string}`; kind: "file"; /** Workspace-relative, or absolute for a host file outside the workspace. */ relativePath: string; revealLine: number | null; revealRequestId: number; /** Present when the file lives in the thread's attachment store rather than at a workspace or host path. */ attachment?: ChatFileAttachment; } | { /** * A change request opened beside a thread or in the pull-request list's shared panel. * The reference lives in the id so several pull requests can remain open as peer tabs. */ id: `pull-request:${string}`; kind: "pull-request"; /** * Which server the change request was read from. The list spans every connected one, so * two of them can hold the same project id; a panel beside a thread leaves this out and * takes the environment from its own ref. */ environmentId?: string; projectId: string; host?: string; repository: string; number: number; url?: string; } /** The thread's linked pull requests, one singleton tab beside any number of `pull-request` tabs. */ | { id: "pull-requests"; kind: "pull-requests" } | { id: "agents"; kind: "agents" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";// v9 removed the "plan" surface kind (plans render inline in the transcript).// v10 keys pull-request surfaces by reference instead of a singleton tab.// v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh.// v12 adds the device surface.const RIGHT_PANEL_STORAGE_VERSION = 13; /** A fixed workspace-level ref: each PR surface carries its own real environment. */export const PULL_REQUESTS_PANEL_REF = scopeThreadRef( EnvironmentId.make("pull-requests-panel"), ThreadId.make("pull-requests-panel"),); /** * The pull-request list's shared panel is session * state: reopening the app should show the list, not last session's tabs and detail fetches. */const isPullRequestsPanelKey = (threadKey: string) => threadKey.endsWith(":pull-requests-panel"); export interface ThreadRightPanelState { isOpen: boolean; activeSurfaceId: string | null; surfaces: RightPanelSurface[]; dismissedDeviceSurfaceIds?: string[];} interface RightPanelStoreState { byThreadKey: Record<string, ThreadRightPanelState>; /** Session-only count of user panel choices per thread. Automatic updates do not advance it. */ userActionRevisionByThreadKey: Record<string, number>; getUserActionRevision: (ref: ScopedThreadRef) => number; /** * Open a surface on behalf of the app, not the user. Refused when the user * made a panel choice after `expectedUserActionRevision` was read. */ openProactive: ( ref: ScopedThreadRef, surface: Extract<RightPanelSurface, { kind: "diff" | "pull-request" }>, expectedUserActionRevision: number, ) => boolean; open: ( ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "file" | "terminal" | "pull-request">, ) => void; openDevice: (ref: ScopedThreadRef, target: DeviceTabTarget, automatic?: boolean) => void; renameDevice: (ref: ScopedThreadRef, surfaceId: string, title: string) => void; openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void; openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void; openPullRequest: ( ref: ScopedThreadRef, target: { environmentId?: string; projectId: string; host?: string; repository: string; number: number; url?: string; }, ) => void; openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; splitTerminal: ( ref: ScopedThreadRef, surfaceId: string, terminalId: string, direction?: "horizontal" | "vertical", ) => void; activateTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void; closeTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void; activateSurface: (ref: ScopedThreadRef, surfaceId: string) => void; closeSurface: (ref: ScopedThreadRef, surfaceId: string) => void; closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void; closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void; closeAllSurfaces: (ref: ScopedThreadRef) => void; reconcileBrowserSurfaces: (ref: ScopedThreadRef, tabIds: readonly string[]) => void; reconcileFileSurfaces: (ref: ScopedThreadRef, workspaceAvailable: boolean) => void; show: (ref: ScopedThreadRef) => void; close: (ref: ScopedThreadRef) => void; toggleVisibility: (ref: ScopedThreadRef) => void; toggle: ( ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "file" | "terminal" | "pull-request">, ) => void; removeThread: (ref: ScopedThreadRef) => void;} const EMPTY_THREAD_STATE: ThreadRightPanelState = { isOpen: false, activeSurfaceId: null, surfaces: [],}; const singletonSurface = ( kind: Exclude<RightPanelKind, "file" | "preview" | "terminal" | "pull-request">,): RightPanelSurface => { switch (kind) { case "diff": return { id: "diff", kind }; case "files": return { id: "files", kind }; case "pull-requests": return { id: "pull-requests", kind }; case "agents": return { id: "agents", kind }; case "device": return { id: "device", kind }; }}; const browserSurface = (tabId: string | null): RightPanelSurface => tabId ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId } : { id: "browser:new", kind: "preview", resourceId: null }; const fileSurface = ( relativePath: string, revealLine: number | null, revealRequestId: number,): RightPanelSurface => ({ id: `file:${relativePath}`, kind: "file", relativePath, revealLine, revealRequestId,}); const attachmentSurface = (attachment: ChatFileAttachment): RightPanelSurface => ({ id: `attachment:${attachment.id}`, kind: "file", relativePath: attachment.name, revealLine: null, revealRequestId: 0, attachment,}); const terminalSurface = (terminalId: string): RightPanelSurface => ({ id: `terminal:${terminalId}`, kind: "terminal", resourceId: terminalId, terminalIds: [terminalId], activeTerminalId: terminalId,}); export type PullRequestSurface = Extract<RightPanelSurface, { kind: "pull-request" }>; export function pullRequestSurfaceId(target: { environmentId?: string; projectId: string; host?: string; repository: string; number: number;}): PullRequestSurface["id"] { // The environment leads the id where there is one, so the same change request read from two // servers is two tabs rather than one tab that changes its mind about which server it is on. const scope = target.environmentId === undefined ? "" : `${encodeURIComponent(target.environmentId)}:`; const host = target.host === undefined ? "" : `${encodeURIComponent(target.host.toLowerCase())}:`; return `pull-request:${scope}${encodeURIComponent(target.projectId)}:${host}${encodeURIComponent(target.repository)}:${target.number}`;} export function pullRequestSurface(target: { environmentId?: string; projectId: string; host?: string; repository: string; number: number; url?: string;}): PullRequestSurface { return { id: pullRequestSurfaceId(target), kind: "pull-request", ...(target.environmentId === undefined ? {} : { environmentId: target.environmentId }), projectId: target.projectId, ...(typeof target.host === "string" ? { host: target.host.toLowerCase() } : {}), repository: target.repository, number: target.number, ...(typeof target.url === "string" ? { url: target.url } : {}), };} const upsertSurface = ( current: ThreadRightPanelState, surface: RightPanelSurface, activate = true,): ThreadRightPanelState => ({ isOpen: true, surfaces: current.surfaces.some((entry) => entry.id === surface.id) ? current.surfaces : [...current.surfaces, surface], activeSurfaceId: activate ? surface.id : current.activeSurfaceId,}); const updateThread = ( byThreadKey: Record<string, ThreadRightPanelState>, threadKey: string, updater: (current: ThreadRightPanelState) => ThreadRightPanelState,): Record<string, ThreadRightPanelState> => { const current = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE; const next = updater(current); if ( !next.isOpen && next.activeSurfaceId === null && next.surfaces.length === 0 && !next.dismissedDeviceSurfaceIds?.length ) { if (!(threadKey in byThreadKey)) return byThreadKey; const { [threadKey]: _removed, ...rest } = byThreadKey; return rest; } if (next === current) return byThreadKey; return { ...byThreadKey, [threadKey]: next };}; // Every store action is a user choice unless it goes through `automaticUpdate`.// Only `openProactive` and resource reconciliation are automatic, so a new// action counts as a user choice by default.const automaticUpdate = ( state: RightPanelStoreState, threadKey: string, updater: (current: ThreadRightPanelState) => ThreadRightPanelState,): Partial<RightPanelStoreState> => ({ byThreadKey: updateThread(state.byThreadKey, threadKey, updater),}); const userAction = ( state: RightPanelStoreState, threadKey: string, updater: (current: ThreadRightPanelState) => ThreadRightPanelState,): Partial<RightPanelStoreState> => ({ byThreadKey: updateThread(state.byThreadKey, threadKey, (current) => { const next = updater(current); const removed = current.surfaces.filter( (surface) => surface.kind === "device" && surface.target && !next.surfaces.some((entry) => entry.id === surface.id), ); if (removed.length === 0) return next; return { ...next, dismissedDeviceSurfaceIds: [ ...new Set([ ...(next.dismissedDeviceSurfaceIds ?? []), ...removed.map((surface) => surface.id), ]), ], }; }), userActionRevisionByThreadKey: { ...state.userActionRevisionByThreadKey, [threadKey]: (state.userActionRevisionByThreadKey[threadKey] ?? 0) + 1, },}); function normalizeRevealLine(line: number | undefined): number | null { if (line === undefined || !Number.isFinite(line)) return null; return Math.max(1, Math.trunc(line));} export function migratePersistedRightPanelState(persistedState: unknown): { byThreadKey: Record<string, ThreadRightPanelState>;} { if (!persistedState || typeof persistedState !== "object") { return { byThreadKey: {} }; } const byThreadKey = "byThreadKey" in persistedState && persistedState.byThreadKey && typeof persistedState.byThreadKey === "object" ? Object.fromEntries( Object.entries(persistedState.byThreadKey as Record<string, ThreadRightPanelState>) .filter(([threadKey]) => !isPullRequestsPanelKey(threadKey)) .map(([threadKey, threadState]) => { const validThreadState = threadState && typeof threadState === "object" ? threadState : null; const surfaces = Array.isArray(validThreadState?.surfaces) ? validThreadState.surfaces.flatMap<RightPanelSurface>((surface) => { // Dropped surface kind: plans now render inline in the // transcript (v9). if ((surface as { kind?: string }).kind === "plan") return []; if (surface.kind === "file") { const revealLine = typeof surface.revealLine === "number" && Number.isFinite(surface.revealLine) ? Math.max(1, Math.trunc(surface.revealLine)) : null; const revealRequestId = typeof surface.revealRequestId === "number" && Number.isSafeInteger(surface.revealRequestId) && surface.revealRequestId >= 0 ? surface.revealRequestId : 0; return [{ ...surface, revealLine, revealRequestId }]; } if (surface.kind === "pull-request") { if ( typeof surface.projectId !== "string" || typeof surface.repository !== "string" || typeof surface.number !== "number" || !Number.isSafeInteger(surface.number) || surface.number < 1 ) { return []; } const { environmentId, ...rest } = surface; // Anything else stored under that name is not an environment. return [ pullRequestSurface({ ...rest, ...(typeof environmentId === "string" ? { environmentId } : {}), }), ]; } if (surface.kind !== "terminal") return [surface]; if ( !("resourceId" in surface) || typeof surface.resourceId !== "string" || surface.id !== `terminal:${surface.resourceId}` ) { return []; } const terminalIds = "terminalIds" in surface && Array.isArray(surface.terminalIds) ? [ ...new Set( surface.terminalIds.filter( (terminalId): terminalId is string => typeof terminalId === "string", ), ), ] : [surface.resourceId]; const activeTerminalId = "activeTerminalId" in surface && typeof surface.activeTerminalId === "string" && terminalIds.includes(surface.activeTerminalId) ? surface.activeTerminalId : (terminalIds[0] ?? surface.resourceId); return [ { ...surface, terminalIds: terminalIds.length > 0 ? terminalIds : [surface.resourceId], activeTerminalId, }, ]; }) : []; const rawActiveSurfaceId = validThreadState?.activeSurfaceId; const persistedActiveSurfaceId = surfaces.some( (surface) => surface.id === rawActiveSurfaceId, ) ? (rawActiveSurfaceId ?? null) : rawActiveSurfaceId === "pull-request" ? (surfaces.find((surface) => surface.kind === "pull-request")?.id ?? null) : null; // A migration that dropped every surface (e.g. plan-only panels // in v9) must not reopen an empty panel. const isOpen = surfaces.length > 0 && (typeof validThreadState?.isOpen === "boolean" ? validThreadState.isOpen : persistedActiveSurfaceId !== null); // An open panel needs an active surface: if migration dropped // the persisted one (e.g. plan was active), fall back to the // first survivor instead of rendering an open empty panel. const activeSurfaceId = persistedActiveSurfaceId ?? (isOpen ? (surfaces[0]?.id ?? null) : null); return [ threadKey, { isOpen, surfaces, activeSurfaceId, ...(Array.isArray(validThreadState?.dismissedDeviceSurfaceIds) ? { dismissedDeviceSurfaceIds: validThreadState.dismissedDeviceSurfaceIds.filter( (id): id is string => typeof id === "string", ), } : {}), }, ]; }), ) : {}; return { byThreadKey };} export const useRightPanelStore = create<RightPanelStoreState>()( persist( (set, get) => ({ byThreadKey: {}, userActionRevisionByThreadKey: {}, getUserActionRevision: (ref) => get().userActionRevisionByThreadKey[scopedThreadKey(ref)] ?? 0, openProactive: (ref, surface, expectedUserActionRevision) => { let opened = false; set((state) => { const threadKey = scopedThreadKey(ref); if ( (state.userActionRevisionByThreadKey[threadKey] ?? 0) !== expectedUserActionRevision ) { return state; } // A linked PR takes priority over a completed-turn diff. Manual actions // always apply, and later user choices reject both proactive requests. if ( surface.kind === "diff" && selectActiveRightPanel(state.byThreadKey, ref) === "pull-request" ) {Lines 1–500 of 911. Load more
/** * Thread-scoped right-panel surface state. * * This is intentionally a shallow workspace model: it owns an ordered set of * surface descriptors and the active surface, while each feature continues to * own its durable resource state. Browser surfaces point at preview tab ids, * terminal surfaces point at terminal session ids, file surfaces point at * workspace paths, and diff/files remain singleton surfaces. */ import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { EnvironmentId, ThreadId, type ChatFileAttachment, type ScopedThreadRef, } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; const RIGHT_PANEL_KINDS = [ "diff", "files", "file", "preview", "device", "terminal", "pull-request", "pull-requests", "agents", ] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; export interface DeviceTabTarget { hostId: string; deviceId: string; platform: "ios" | "android"; name: string; } export type RightPanelSurface = | { id: `browser:${string}`; kind: "preview"; resourceId: string } | { id: "browser:new"; kind: "preview"; resourceId: null } | { id: "device" | `device:${string}`; kind: "device"; target?: DeviceTabTarget; title?: string } | { id: `terminal:${string}`; kind: "terminal"; resourceId: string; terminalIds: string[]; activeTerminalId: string; splitDirection?: "horizontal" | "vertical"; } | { id: "diff"; kind: "diff" } | { id: "files"; kind: "files" } | { id: `file:${string}` | `attachment:${string}`; kind: "file"; /** Workspace-relative, or absolute for a host file outside the workspace. */ relativePath: string; revealLine: number | null; revealRequestId: number; /** Present when the file lives in the thread's attachment store rather than at a workspace or host path. */ attachment?: ChatFileAttachment; } | { /** * A change request opened beside a thread or in the pull-request list's shared panel. * The reference lives in the id so several pull requests can remain open as peer tabs. */ id: `pull-request:${string}`; kind: "pull-request"; /** * Which server the change request was read from. The list spans every connected one, so * two of them can hold the same project id; a panel beside a thread leaves this out and * takes the environment from its own ref. */ environmentId?: string; projectId: string; host?: string; repository: string; number: number; url?: string; } /** The thread's linked pull requests, one singleton tab beside any number of `pull-request` tabs. */ | { id: "pull-requests"; kind: "pull-requests" } | { id: "agents"; kind: "agents" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; // v9 removed the "plan" surface kind (plans render inline in the transcript). // v10 keys pull-request surfaces by reference instead of a singleton tab. // v11 stops persisting the pull-request list's shared panel, so a restart opens the page fresh. // v12 adds the device surface. const RIGHT_PANEL_STORAGE_VERSION = 13; /** A fixed workspace-level ref: each PR surface carries its own real environment. */ export const PULL_REQUESTS_PANEL_REF = scopeThreadRef( EnvironmentId.make("pull-requests-panel"), ThreadId.make("pull-requests-panel"), ); /** * The pull-request list's shared panel is session * state: reopening the app should show the list, not last session's tabs and detail fetches. */ const isPullRequestsPanelKey = (threadKey: string) => threadKey.endsWith(":pull-requests-panel"); export interface ThreadRightPanelState { isOpen: boolean; activeSurfaceId: string | null; surfaces: RightPanelSurface[]; dismissedDeviceSurfaceIds?: string[]; } interface RightPanelStoreState { byThreadKey: Record<string, ThreadRightPanelState>; /** Session-only count of user panel choices per thread. Automatic updates do not advance it. */ userActionRevisionByThreadKey: Record<string, number>; getUserActionRevision: (ref: ScopedThreadRef) => number; /** * Open a surface on behalf of the app, not the user. Refused when the user * made a panel choice after `expectedUserActionRevision` was read. */ openProactive: ( ref: ScopedThreadRef, surface: Extract<RightPanelSurface, { kind: "diff" | "pull-request" }>, expectedUserActionRevision: number, ) => boolean; open: ( ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "file" | "terminal" | "pull-request">, ) => void; openDevice: (ref: ScopedThreadRef, target: DeviceTabTarget, automatic?: boolean) => void; renameDevice: (ref: ScopedThreadRef, surfaceId: string, title: string) => void; openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void; openAttachment: (ref: ScopedThreadRef, attachment: ChatFileAttachment) => void; openPullRequest: ( ref: ScopedThreadRef, target: { environmentId?: string; projectId: string; host?: string; repository: string; number: number; url?: string; }, ) => void; openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; splitTerminal: ( ref: ScopedThreadRef, surfaceId: string, terminalId: string, direction?: "horizontal" | "vertical", ) => void; activateTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void; closeTerminal: (ref: ScopedThreadRef, surfaceId: string, terminalId: string) => void; activateSurface: (ref: ScopedThreadRef, surfaceId: string) => void; closeSurface: (ref: ScopedThreadRef, surfaceId: string) => void; closeOtherSurfaces: (ref: ScopedThreadRef, surfaceId: string) => void; closeSurfacesToRight: (ref: ScopedThreadRef, surfaceId: string) => void; closeAllSurfaces: (ref: ScopedThreadRef) => void; reconcileBrowserSurfaces: (ref: ScopedThreadRef, tabIds: readonly string[]) => void; reconcileFileSurfaces: (ref: ScopedThreadRef, workspaceAvailable: boolean) => void; show: (ref: ScopedThreadRef) => void; close: (ref: ScopedThreadRef) => void; toggleVisibility: (ref: ScopedThreadRef) => void; toggle: ( ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "file" | "terminal" | "pull-request">, ) => void; removeThread: (ref: ScopedThreadRef) => void; } const EMPTY_THREAD_STATE: ThreadRightPanelState = { isOpen: false, activeSurfaceId: null, surfaces: [], }; const singletonSurface = ( kind: Exclude<RightPanelKind, "file" | "preview" | "terminal" | "pull-request">, ): RightPanelSurface => { switch (kind) { case "diff": return { id: "diff", kind }; case "files": return { id: "files", kind }; case "pull-requests": return { id: "pull-requests", kind }; case "agents": return { id: "agents", kind }; case "device": return { id: "device", kind }; } }; const browserSurface = (tabId: string | null): RightPanelSurface => tabId ? { id: `browser:${tabId}`, kind: "preview", resourceId: tabId } : { id: "browser:new", kind: "preview", resourceId: null }; const fileSurface = ( relativePath: string, revealLine: number | null, revealRequestId: number, ): RightPanelSurface => ({ id: `file:${relativePath}`, kind: "file", relativePath, revealLine, revealRequestId, }); const attachmentSurface = (attachment: ChatFileAttachment): RightPanelSurface => ({ id: `attachment:${attachment.id}`, kind: "file", relativePath: attachment.name, revealLine: null, revealRequestId: 0, attachment, }); const terminalSurface = (terminalId: string): RightPanelSurface => ({ id: `terminal:${terminalId}`, kind: "terminal", resourceId: terminalId, terminalIds: [terminalId], activeTerminalId: terminalId, }); export type PullRequestSurface = Extract<RightPanelSurface, { kind: "pull-request" }>; export function pullRequestSurfaceId(target: { environmentId?: string; projectId: string; host?: string; repository: string; number: number; }): PullRequestSurface["id"] { // The environment leads the id where there is one, so the same change request read from two // servers is two tabs rather than one tab that changes its mind about which server it is on. const scope = target.environmentId === undefined ? "" : `${encodeURIComponent(target.environmentId)}:`; const host = target.host === undefined ? "" : `${encodeURIComponent(target.host.toLowerCase())}:`; return `pull-request:${scope}${encodeURIComponent(target.projectId)}:${host}${encodeURIComponent(target.repository)}:${target.number}`; } export function pullRequestSurface(target: { environmentId?: string; projectId: string; host?: string; repository: string; number: number; url?: string; }): PullRequestSurface { return { id: pullRequestSurfaceId(target), kind: "pull-request", ...(target.environmentId === undefined ? {} : { environmentId: target.environmentId }), projectId: target.projectId, ...(typeof target.host === "string" ? { host: target.host.toLowerCase() } : {}), repository: target.repository, number: target.number, ...(typeof target.url === "string" ? { url: target.url } : {}), }; } const upsertSurface = ( current: ThreadRightPanelState, surface: RightPanelSurface, activate = true, ): ThreadRightPanelState => ({ isOpen: true, surfaces: current.surfaces.some((entry) => entry.id === surface.id) ? current.surfaces : [...current.surfaces, surface], activeSurfaceId: activate ? surface.id : current.activeSurfaceId, }); const updateThread = ( byThreadKey: Record<string, ThreadRightPanelState>, threadKey: string, updater: (current: ThreadRightPanelState) => ThreadRightPanelState, ): Record<string, ThreadRightPanelState> => { const current = byThreadKey[threadKey] ?? EMPTY_THREAD_STATE; const next = updater(current); if ( !next.isOpen && next.activeSurfaceId === null && next.surfaces.length === 0 && !next.dismissedDeviceSurfaceIds?.length ) { if (!(threadKey in byThreadKey)) return byThreadKey; const { [threadKey]: _removed, ...rest } = byThreadKey; return rest; } if (next === current) return byThreadKey; return { ...byThreadKey, [threadKey]: next }; }; // Every store action is a user choice unless it goes through `automaticUpdate`. // Only `openProactive` and resource reconciliation are automatic, so a new // action counts as a user choice by default. const automaticUpdate = ( state: RightPanelStoreState, threadKey: string, updater: (current: ThreadRightPanelState) => ThreadRightPanelState, ): Partial<RightPanelStoreState> => ({ byThreadKey: updateThread(state.byThreadKey, threadKey, updater), }); const userAction = ( state: RightPanelStoreState, threadKey: string, updater: (current: ThreadRightPanelState) => ThreadRightPanelState, ): Partial<RightPanelStoreState> => ({ byThreadKey: updateThread(state.byThreadKey, threadKey, (current) => { const next = updater(current); const removed = current.surfaces.filter( (surface) => surface.kind === "device" && surface.target && !next.surfaces.some((entry) => entry.id === surface.id), ); if (removed.length === 0) return next; return { ...next, dismissedDeviceSurfaceIds: [ ...new Set([ ...(next.dismissedDeviceSurfaceIds ?? []), ...removed.map((surface) => surface.id), ]), ], }; }), userActionRevisionByThreadKey: { ...state.userActionRevisionByThreadKey, [threadKey]: (state.userActionRevisionByThreadKey[threadKey] ?? 0) + 1, }, }); function normalizeRevealLine(line: number | undefined): number | null { if (line === undefined || !Number.isFinite(line)) return null; return Math.max(1, Math.trunc(line)); } export function migratePersistedRightPanelState(persistedState: unknown): { byThreadKey: Record<string, ThreadRightPanelState>; } { if (!persistedState || typeof persistedState !== "object") { return { byThreadKey: {} }; } const byThreadKey = "byThreadKey" in persistedState && persistedState.byThreadKey && typeof persistedState.byThreadKey === "object" ? Object.fromEntries( Object.entries(persistedState.byThreadKey as Record<string, ThreadRightPanelState>) .filter(([threadKey]) => !isPullRequestsPanelKey(threadKey)) .map(([threadKey, threadState]) => { const validThreadState = threadState && typeof threadState === "object" ? threadState : null; const surfaces = Array.isArray(validThreadState?.surfaces) ? validThreadState.surfaces.flatMap<RightPanelSurface>((surface) => { // Dropped surface kind: plans now render inline in the // transcript (v9). if ((surface as { kind?: string }).kind === "plan") return []; if (surface.kind === "file") { const revealLine = typeof surface.revealLine === "number" && Number.isFinite(surface.revealLine) ? Math.max(1, Math.trunc(surface.revealLine)) : null; const revealRequestId = typeof surface.revealRequestId === "number" && Number.isSafeInteger(surface.revealRequestId) && surface.revealRequestId >= 0 ? surface.revealRequestId : 0; return [{ ...surface, revealLine, revealRequestId }]; } if (surface.kind === "pull-request") { if ( typeof surface.projectId !== "string" || typeof surface.repository !== "string" || typeof surface.number !== "number" || !Number.isSafeInteger(surface.number) || surface.number < 1 ) { return []; } const { environmentId, ...rest } = surface; // Anything else stored under that name is not an environment. return [ pullRequestSurface({ ...rest, ...(typeof environmentId === "string" ? { environmentId } : {}), }), ]; } if (surface.kind !== "terminal") return [surface]; if ( !("resourceId" in surface) || typeof surface.resourceId !== "string" || surface.id !== `terminal:${surface.resourceId}` ) { return []; } const terminalIds = "terminalIds" in surface && Array.isArray(surface.terminalIds) ? [ ...new Set( surface.terminalIds.filter( (terminalId): terminalId is string => typeof terminalId === "string", ), ), ] : [surface.resourceId]; const activeTerminalId = "activeTerminalId" in surface && typeof surface.activeTerminalId === "string" && terminalIds.includes(surface.activeTerminalId) ? surface.activeTerminalId : (terminalIds[0] ?? surface.resourceId); return [ { ...surface, terminalIds: terminalIds.length > 0 ? terminalIds : [surface.resourceId], activeTerminalId, }, ]; }) : []; const rawActiveSurfaceId = validThreadState?.activeSurfaceId; const persistedActiveSurfaceId = surfaces.some( (surface) => surface.id === rawActiveSurfaceId, ) ? (rawActiveSurfaceId ?? null) : rawActiveSurfaceId === "pull-request" ? (surfaces.find((surface) => surface.kind === "pull-request")?.id ?? null) : null; // A migration that dropped every surface (e.g. plan-only panels // in v9) must not reopen an empty panel. const isOpen = surfaces.length > 0 && (typeof validThreadState?.isOpen === "boolean" ? validThreadState.isOpen : persistedActiveSurfaceId !== null); // An open panel needs an active surface: if migration dropped // the persisted one (e.g. plan was active), fall back to the // first survivor instead of rendering an open empty panel. const activeSurfaceId = persistedActiveSurfaceId ?? (isOpen ? (surfaces[0]?.id ?? null) : null); return [ threadKey, { isOpen, surfaces, activeSurfaceId, ...(Array.isArray(validThreadState?.dismissedDeviceSurfaceIds) ? { dismissedDeviceSurfaceIds: validThreadState.dismissedDeviceSurfaceIds.filter( (id): id is string => typeof id === "string", ), } : {}), }, ]; }), ) : {}; return { byThreadKey }; } export const useRightPanelStore = create<RightPanelStoreState>()( persist( (set, get) => ({ byThreadKey: {}, userActionRevisionByThreadKey: {}, getUserActionRevision: (ref) => get().userActionRevisionByThreadKey[scopedThreadKey(ref)] ?? 0, openProactive: (ref, surface, expectedUserActionRevision) => { let opened = false; set((state) => { const threadKey = scopedThreadKey(ref); if ( (state.userActionRevisionByThreadKey[threadKey] ?? 0) !== expectedUserActionRevision ) { return state; } // A linked PR takes priority over a completed-turn diff. Manual actions // always apply, and later user choices reject both proactive requests. if ( surface.kind === "diff" && selectActiveRightPanel(state.byThreadKey, ref) === "pull-request" ) {