/** * 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/plan/files remain singleton surfaces. */import { scopedThreadKey } from "@t3tools/client-runtime";import type { ScopedThreadRef } from "@t3tools/contracts";import { create } from "zustand";import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; export const RIGHT_PANEL_KINDS = ["plan", "diff", "files", "file", "preview", "terminal"] as const;export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; export type RightPanelSurface = | { id: `browser:${string}`; kind: "preview"; resourceId: string } | { id: "browser:new"; kind: "preview"; resourceId: null } | { 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}`; kind: "file"; relativePath: string } | { id: "plan"; kind: "plan" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";const RIGHT_PANEL_STORAGE_VERSION = 6; export interface ThreadRightPanelState { isOpen: boolean; activeSurfaceId: string | null; surfaces: RightPanelSurface[];} interface RightPanelStoreState { byThreadKey: Record<string, ThreadRightPanelState>; open: (ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "file" | "terminal">) => void; openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; openFile: (ref: ScopedThreadRef, relativePath: 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; 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">) => void; removeThread: (ref: ScopedThreadRef) => void;} const EMPTY_THREAD_STATE: ThreadRightPanelState = { isOpen: false, activeSurfaceId: null, surfaces: [],}; const singletonSurface = ( kind: Exclude<RightPanelKind, "file" | "preview" | "terminal">,): RightPanelSurface => { switch (kind) { case "diff": return { id: "diff", kind }; case "files": return { id: "files", kind }; case "plan": return { id: "plan", 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): RightPanelSurface => ({ id: `file:${relativePath}`, kind: "file", relativePath,}); const terminalSurface = (terminalId: string): RightPanelSurface => ({ id: `terminal:${terminalId}`, kind: "terminal", resourceId: terminalId, terminalIds: [terminalId], activeTerminalId: terminalId,}); 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) { if (!(threadKey in byThreadKey)) return byThreadKey; const { [threadKey]: _removed, ...rest } = byThreadKey; return rest; } if (next === current) return byThreadKey; return { ...byThreadKey, [threadKey]: next };}; 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>).map( ([threadKey, threadState]) => { const validThreadState = threadState && typeof threadState === "object" ? threadState : null; const surfaces = Array.isArray(validThreadState?.surfaces) ? validThreadState.surfaces.flatMap<RightPanelSurface>((surface) => { 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 activeSurfaceId = surfaces.some( (surface) => surface.id === validThreadState?.activeSurfaceId, ) ? (validThreadState?.activeSurfaceId ?? null) : null; const isOpen = typeof validThreadState?.isOpen === "boolean" ? validThreadState.isOpen : activeSurfaceId !== null; return [threadKey, { isOpen, surfaces, activeSurfaceId }]; }, ), ) : {}; return { byThreadKey };} export const useRightPanelStore = create<RightPanelStoreState>()( persist( (set) => ({ byThreadKey: {}, open: (ref, kind) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { if (kind === "preview") { const existing = current.surfaces.find((surface) => surface.kind === "preview"); return upsertSurface(current, existing ?? browserSurface(null)); } return upsertSurface(current, singletonSurface(kind)); }), })), openBrowser: (ref, tabId) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { const surface = browserSurface(tabId); const withoutPlaceholder = tabId ? current.surfaces.filter((entry) => entry.id !== "browser:new") : current.surfaces; return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface); }), })), openFile: (ref, relativePath) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { const withoutStandaloneExplorer = current.surfaces.filter( (surface) => surface.kind !== "files", ); return upsertSurface( { ...current, surfaces: withoutStandaloneExplorer }, fileSurface(relativePath), ); }), })), openTerminal: (ref, terminalId) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => upsertSurface(current, terminalSurface(terminalId)), ), })), splitTerminal: (ref, surfaceId, terminalId, direction = "horizontal") => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ ...current, isOpen: true, activeSurfaceId: surfaceId, surfaces: current.surfaces.map((surface) => { if (surface.id !== surfaceId || surface.kind !== "terminal") return surface; const { splitDirection: _splitDirection, ...baseSurface } = surface; return { ...baseSurface, terminalIds: surface.terminalIds.includes(terminalId) ? surface.terminalIds : [...surface.terminalIds, terminalId], activeTerminalId: terminalId, ...(direction === "vertical" ? { splitDirection: "vertical" as const } : {}), }; }), })), })), activateTerminal: (ref, surfaceId, terminalId) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ ...current, activeSurfaceId: surfaceId, surfaces: current.surfaces.map((surface) => surface.id === surfaceId && surface.kind === "terminal" && surface.terminalIds.includes(terminalId) ? { ...surface, activeTerminalId: terminalId } : surface, ), })), })), closeTerminal: (ref, surfaceId, terminalId) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { const surface = current.surfaces.find( (entry) => entry.id === surfaceId && entry.kind === "terminal", ); if (!surface || surface.kind !== "terminal") return current; const terminalIds = surface.terminalIds.filter((id) => id !== terminalId); if (terminalIds.length === 0) { const index = current.surfaces.findIndex((entry) => entry.id === surfaceId); const surfaces = current.surfaces.filter((entry) => entry.id !== surfaceId); const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null; return { ...current, surfaces, activeSurfaceId: current.activeSurfaceId === surfaceId ? (fallback?.id ?? null) : current.activeSurfaceId, }; } return { ...current, surfaces: current.surfaces.map((entry) => entry.id === surfaceId && entry.kind === "terminal" ? { ...entry, terminalIds, activeTerminalId: entry.activeTerminalId === terminalId ? (terminalIds.at(-1) ?? terminalIds[0]!) : entry.activeTerminalId, } : entry, ), }; }), })), activateSurface: (ref, surfaceId) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => current.surfaces.some((surface) => surface.id === surfaceId) ? { ...current, isOpen: true, activeSurfaceId: surfaceId } : current, ), })), closeSurface: (ref, surfaceId) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { const index = current.surfaces.findIndex((surface) => surface.id === surfaceId); if (index < 0) return current; const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId); if (current.activeSurfaceId !== surfaceId) return { ...current, surfaces }; const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null; return { ...current, surfaces, activeSurfaceId: fallback?.id ?? null }; }), })), reconcileBrowserSurfaces: (ref, tabIds) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`)); const nonBrowser = current.surfaces.filter((surface) => surface.kind !== "preview"); const existingBrowser = current.surfaces.filter( (surface): surface is Extract<RightPanelSurface, { kind: "preview" }> => surface.kind === "preview" && surface.id !== "browser:new" && validIds.has(surface.id), ); const knownIds = new Set(existingBrowser.map((surface) => surface.id)); const added = tabIds .filter((tabId) => !knownIds.has(`browser:${tabId}`)) .map((tabId) => browserSurface(tabId)); const surfaces = [...nonBrowser, ...existingBrowser, ...added]; const activeStillExists = surfaces.some( (surface) => surface.id === current.activeSurfaceId, ); const fallbackBrowser = surfaces.find((surface) => surface.kind === "preview"); return { ...current, surfaces, activeSurfaceId: activeStillExists ? current.activeSurfaceId : (fallbackBrowser?.id ?? surfaces[0]?.id ?? null), }; }), })), reconcileFileSurfaces: (ref, workspaceAvailable) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { if (workspaceAvailable) return current; const surfaces = current.surfaces.filter( (surface) => surface.kind !== "files" && surface.kind !== "file", ); if (surfaces.length === current.surfaces.length) return current; const activeStillExists = surfaces.some( (surface) => surface.id === current.activeSurfaceId, ); return { ...current, isOpen: surfaces.length > 0 ? current.isOpen : false, surfaces, activeSurfaceId: activeStillExists ? current.activeSurfaceId : (surfaces.at(-1)?.id ?? null), }; }), })), show: (ref) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => current.isOpen ? current : { ...current, isOpen: true }, ), })), close: (ref) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => current.isOpen ? { ...current, isOpen: false } : current, ), })), toggleVisibility: (ref) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ ...current, isOpen: !current.isOpen, })), })), toggle: (ref, kind) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { const active = current.surfaces.find( (surface) => surface.id === current.activeSurfaceId, ); if (current.isOpen && active?.kind === kind) { return { ...current, isOpen: false }; } if (kind === "preview") { const existing = current.surfaces.find((surface) => surface.kind === "preview"); return upsertSurface(current, existing ?? browserSurface(null)); } return upsertSurface(current, singletonSurface(kind)); }), })), removeThread: (ref) => set((state) => { const threadKey = scopedThreadKey(ref); if (!(threadKey in state.byThreadKey)) return state; const { [threadKey]: _removed, ...rest } = state.byThreadKey; return { byThreadKey: rest }; }), }), { name: RIGHT_PANEL_STORAGE_KEY, version: RIGHT_PANEL_STORAGE_VERSION, storage: createJSONStorage(() => resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined), ), partialize: (state) => ({ byThreadKey: state.byThreadKey }), migrate: migratePersistedRightPanelState, }, ),); export function selectThreadRightPanelState( byThreadKey: Record<string, ThreadRightPanelState>, ref: ScopedThreadRef | null | undefined,): ThreadRightPanelState { if (!ref) return EMPTY_THREAD_STATE; return byThreadKey[scopedThreadKey(ref)] ?? EMPTY_THREAD_STATE;} export function selectActiveRightPanel( byThreadKey: Record<string, ThreadRightPanelState>, ref: ScopedThreadRef | null | undefined,): RightPanelKind | null { const state = selectThreadRightPanelState(byThreadKey, ref); if (!state.isOpen) return null; return state.surfaces.find((surface) => surface.id === state.activeSurfaceId)?.kind ?? null;} export function selectActiveRightPanelSurface( byThreadKey: Record<string, ThreadRightPanelState>, ref: ScopedThreadRef | null | undefined,): RightPanelSurface | null { const state = selectThreadRightPanelState(byThreadKey, ref); if (!state.isOpen) return null; return state.surfaces.find((surface) => surface.id === state.activeSurfaceId) ?? null;} export function selectActiveRightPanelKindWithUrl( byThreadKey: Record<string, ThreadRightPanelState>, ref: ScopedThreadRef | null | undefined, diffSearchActive: boolean,): RightPanelKind | null { if (!selectThreadRightPanelState(byThreadKey, ref).isOpen) return null; if (diffSearchActive) return "diff"; return selectActiveRightPanel(byThreadKey, ref);}
/**
* 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/plan/files remain singleton surfaces.
*/
import { scopedThreadKey } from "@t3tools/client-runtime";
import type { ScopedThreadRef } from "@t3tools/contracts";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { resolveStorage } from "./lib/storage";
export const RIGHT_PANEL_KINDS = ["plan", "diff", "files", "file", "preview", "terminal"] as const;
export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number];
export type RightPanelSurface =
| { id: `browser:${string}`; kind: "preview"; resourceId: string }
| { id: "browser:new"; kind: "preview"; resourceId: null }
| {
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}`; kind: "file"; relativePath: string }
| { id: "plan"; kind: "plan" };
const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2";
const RIGHT_PANEL_STORAGE_VERSION = 6;
export interface ThreadRightPanelState {
isOpen: boolean;
activeSurfaceId: string | null;
surfaces: RightPanelSurface[];
}
interface RightPanelStoreState {
byThreadKey: Record<string, ThreadRightPanelState>;
open: (ref: ScopedThreadRef, kind: Exclude<RightPanelKind, "file" | "terminal">) => void;
openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void;
openFile: (ref: ScopedThreadRef, relativePath: 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;
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">) => void;
removeThread: (ref: ScopedThreadRef) => void;
}
const EMPTY_THREAD_STATE: ThreadRightPanelState = {
isOpen: false,
activeSurfaceId: null,
surfaces: [],
};
const singletonSurface = (
kind: Exclude<RightPanelKind, "file" | "preview" | "terminal">,
): RightPanelSurface => {
switch (kind) {
case "diff":
return { id: "diff", kind };
case "files":
return { id: "files", kind };
case "plan":
return { id: "plan", 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): RightPanelSurface => ({
id: `file:${relativePath}`,
kind: "file",
relativePath,
});
const terminalSurface = (terminalId: string): RightPanelSurface => ({
id: `terminal:${terminalId}`,
kind: "terminal",
resourceId: terminalId,
terminalIds: [terminalId],
activeTerminalId: terminalId,
});
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) {
if (!(threadKey in byThreadKey)) return byThreadKey;
const { [threadKey]: _removed, ...rest } = byThreadKey;
return rest;
}
if (next === current) return byThreadKey;
return { ...byThreadKey, [threadKey]: next };
};
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>).map(
([threadKey, threadState]) => {
const validThreadState =
threadState && typeof threadState === "object" ? threadState : null;
const surfaces = Array.isArray(validThreadState?.surfaces)
? validThreadState.surfaces.flatMap<RightPanelSurface>((surface) => {
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 activeSurfaceId = surfaces.some(
(surface) => surface.id === validThreadState?.activeSurfaceId,
)
? (validThreadState?.activeSurfaceId ?? null)
: null;
const isOpen =
typeof validThreadState?.isOpen === "boolean"
? validThreadState.isOpen
: activeSurfaceId !== null;
return [threadKey, { isOpen, surfaces, activeSurfaceId }];
},
),
)
: {};
return { byThreadKey };
}
export const useRightPanelStore = create<RightPanelStoreState>()(
persist(
(set) => ({
byThreadKey: {},
open: (ref, kind) =>
set((state) => ({
byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
if (kind === "preview") {
const existing = current.surfaces.find((surface) => surface.kind === "preview");
return upsertSurface(current, existing ?? browserSurface(null));
}
return upsertSurface(current, singletonSurface(kind));
}),
})),
openBrowser: (ref, tabId) =>
set((state) => ({
byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
const surface = browserSurface(tabId);
const withoutPlaceholder = tabId
? current.surfaces.filter((entry) => entry.id !== "browser:new")
: current.surfaces;
return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface);
}),
})),
openFile: (ref, relativePath) =>
set((state) => ({
byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
const withoutStandaloneExplorer = current.surfaces.filter(
(surface) => surface.kind !== "files",
);
return upsertSurface(
{ ...current, surfaces: withoutStandaloneExplorer },
fileSurface(relativePath),
);
}),
})),
openTerminal: (ref, terminalId) =>
set((state) => ({
byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>
upsertSurface(current, terminalSurface(terminalId)),
),
})),
splitTerminal: (ref, surfaceId, terminalId, direction = "horizontal") =>
set((state) => ({
byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({
...current,
isOpen: true,
activeSurfaceId: surfaceId,
surfaces: current.surfaces.map((surface) => {
if (surface.id !== surfaceId || surface.kind !== "terminal") return surface;
const { splitDirection: _splitDirection, ...baseSurface } = surface;
return {
...baseSurface,
terminalIds: surface.terminalIds.includes(terminalId)
? surface.terminalIds
: [...surface.terminalIds, terminalId],
activeTerminalId: terminalId,
...(direction === "vertical" ? { splitDirection: "vertical" as const } : {}),
};
}),
})),
})),
activateTerminal: (ref, surfaceId, terminalId) =>
set((state) => ({
byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({
...current,
activeSurfaceId: surfaceId,
surfaces: current.surfaces.map((surface) =>
surface.id === surfaceId &&
surface.kind === "terminal" &&
surface.terminalIds.includes(terminalId)
? { ...surface, activeTerminalId: terminalId }
: surface,
),
})),
})),
closeTerminal: (ref, surfaceId, terminalId) =>
set((state) => ({
byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
const surface = current.surfaces.find(
(entry) => entry.id === surfaceId && entry.kind === "terminal",
);
if (!surface || surface.kind !== "terminal") return current;
const terminalIds = surface.terminalIds.filter((id) => id !== terminalId);
if (terminalIds.length === 0) {
const index = current.surfaces.findIndex((entry) => entry.id === surfaceId);
const surfaces = current.surfaces.filter((entry) => entry.id !== surfaceId);
const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null;
return {
...current,
surfaces,
activeSurfaceId:
current.activeSurfaceId === surfaceId
? (fallback?.id ?? null)
: current.activeSurfaceId,
};
}
return {
...current,
surfaces: current.surfaces.map((entry) =>
entry.id === surfaceId && entry.kind === "terminal"
? {
...entry,
terminalIds,
activeTerminalId:
entry.activeTerminalId === terminalId
? (terminalIds.at(-1) ?? terminalIds[0]!)
: entry.activeTerminalId,
}
: entry,
),
};
}),
})),
activateSurface: (ref, surfaceId) =>
set((state) => ({
byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>
current.surfaces.some((surface) => surface.id === surfaceId)
? { ...current, isOpen: true, activeSurfaceId: surfaceId }
: current,
),
})),
closeSurface: (ref, surfaceId) =>
set((state) => ({
byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
const index = current.surfaces.findIndex((surface) => surface.id === surfaceId);
if (index < 0) return current;
const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId);
if (current.activeSurfaceId !== surfaceId) return { ...current, surfaces };
const fallback = surfaces[Math.min(index, surfaces.length - 1)] ?? null;
return { ...current, surfaces, activeSurfaceId: fallback?.id ?? null };
}),
})),
reconcileBrowserSurfaces: (ref, tabIds) =>
set((state) => ({
byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`));
const nonBrowser = current.surfaces.filter((surface) => surface.kind !== "preview");
const existingBrowser = current.surfaces.filter(
(surface): surface is Extract<RightPanelSurface, { kind: "preview" }> =>
surface.kind === "preview" &&
surface.id !== "browser:new" &&
validIds.has(surface.id),
);
const knownIds = new Set(existingBrowser.map((surface) => surface.id));
const added = tabIds
.filter((tabId) => !knownIds.has(`browser:${tabId}`))
.map((tabId) => browserSurface(tabId));
const surfaces = [...nonBrowser, ...existingBrowser, ...added];
const activeStillExists = surfaces.some(
(surface) => surface.id === current.activeSurfaceId,
);
const fallbackBrowser = surfaces.find((surface) => surface.kind === "preview");
return {
...current,
surfaces,
activeSurfaceId: activeStillExists
? current.activeSurfaceId
: (fallbackBrowser?.id ?? surfaces[0]?.id ?? null),
};
}),
})),
reconcileFileSurfaces: (ref, workspaceAvailable) =>
set((state) => ({
byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
if (workspaceAvailable) return current;
const surfaces = current.surfaces.filter(
(surface) => surface.kind !== "files" && surface.kind !== "file",
);
if (surfaces.length === current.surfaces.length) return current;
const activeStillExists = surfaces.some(
(surface) => surface.id === current.activeSurfaceId,
);
return {
...current,
isOpen: surfaces.length > 0 ? current.isOpen : false,
surfaces,
activeSurfaceId: activeStillExists
? current.activeSurfaceId
: (surfaces.at(-1)?.id ?? null),
};
}),
})),
show: (ref) =>
set((state) => ({
byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>
current.isOpen ? current : { ...current, isOpen: true },
),
})),
close: (ref) =>
set((state) => ({
byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) =>
current.isOpen ? { ...current, isOpen: false } : current,
),
})),
toggleVisibility: (ref) =>
set((state) => ({
byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({
...current,
isOpen: !current.isOpen,
})),
})),
toggle: (ref, kind) =>
set((state) => ({
byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => {
const active = current.surfaces.find(
(surface) => surface.id === current.activeSurfaceId,
);
if (current.isOpen && active?.kind === kind) {
return { ...current, isOpen: false };
}
if (kind === "preview") {
const existing = current.surfaces.find((surface) => surface.kind === "preview");
return upsertSurface(current, existing ?? browserSurface(null));
}
return upsertSurface(current, singletonSurface(kind));
}),
})),
removeThread: (ref) =>
set((state) => {
const threadKey = scopedThreadKey(ref);
if (!(threadKey in state.byThreadKey)) return state;
const { [threadKey]: _removed, ...rest } = state.byThreadKey;
return { byThreadKey: rest };
}),
}),
{
name: RIGHT_PANEL_STORAGE_KEY,
version: RIGHT_PANEL_STORAGE_VERSION,
storage: createJSONStorage(() =>
resolveStorage(typeof window !== "undefined" ? window.localStorage : undefined),
),
partialize: (state) => ({ byThreadKey: state.byThreadKey }),
migrate: migratePersistedRightPanelState,
},
),
);
export function selectThreadRightPanelState(
byThreadKey: Record<string, ThreadRightPanelState>,
ref: ScopedThreadRef | null | undefined,
): ThreadRightPanelState {
if (!ref) return EMPTY_THREAD_STATE;
return byThreadKey[scopedThreadKey(ref)] ?? EMPTY_THREAD_STATE;
}
export function selectActiveRightPanel(
byThreadKey: Record<string, ThreadRightPanelState>,
ref: ScopedThreadRef | null | undefined,
): RightPanelKind | null {
const state = selectThreadRightPanelState(byThreadKey, ref);
if (!state.isOpen) return null;
return state.surfaces.find((surface) => surface.id === state.activeSurfaceId)?.kind ?? null;
}
export function selectActiveRightPanelSurface(
byThreadKey: Record<string, ThreadRightPanelState>,
ref: ScopedThreadRef | null | undefined,
): RightPanelSurface | null {
const state = selectThreadRightPanelState(byThreadKey, ref);
if (!state.isOpen) return null;
return state.surfaces.find((surface) => surface.id === state.activeSurfaceId) ?? null;
}
export function selectActiveRightPanelKindWithUrl(
byThreadKey: Record<string, ThreadRightPanelState>,
ref: ScopedThreadRef | null | undefined,
diffSearchActive: boolean,
): RightPanelKind | null {
if (!selectThreadRightPanelState(byThreadKey, ref).isOpen) return null;
if (diffSearchActive) return "diff";
return selectActiveRightPanel(byThreadKey, ref);
}