import { useEffect, useRef, type MutableRefObject } from "react"; import type { DockviewApi, DockviewReadyEvent, AddPanelOptions } from "dockview-react"; import type { StoreApi } from "zustand"; import type { AppState } from "@/lib/state/store"; import { useDockviewStore } from "@/lib/state/dockview-store"; import { focusOrAddPanel } from "@/lib/state/dockview-layout-builders"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { wasPRPanelOffered, markPRPanelOffered } from "@/lib/local-storage"; import { sessionId as toSessionId } from "@/lib/types/ids"; import { createDebugLogger, isDebug } from "@/lib/debug/log"; import type { TaskSession } from "@/lib/types/http"; const debug = createDebugLogger("dockview:session-tabs"); /** * Decide whether `onDidActivePanelChange` should write `setActiveSession`. * * The activated session must belong to the currently-active task. During a * task switch dockview can briefly fire activation for a stale `session:` * panel that still belongs to the previous task (panels are torn down async). * Writing it would poison `lastSessionByTaskId[newTaskId]` with a session from * a different task, which the next task-re-entry then prefers over the * primary session, restoring the wrong layout. * * Two ownership gates: * 1. Reject when the session is hydrated and known to belong to a different * task (the primary leak path). * 2. Reject when the session has no `environmentIdBySessionId` entry. The * session slice clears `taskSessions.items[sid]` and * `environmentIdBySessionId[sid]` together on `removeTaskSession`, so a * missing env mapping means the session has been deleted or never existed. * Writing `activeSessionId = sid` in that window briefly points every * activeSessionId-consumer (chat / file editor / shell / pr-detail / ...) * at a dead session. */ export function resolveSessionTabSyncTarget(args: { panelId: string; activeTaskId: string | null; activeSessionId: string | null; taskSessionsById: Record; environmentIdBySessionId: Record; }): { taskId: string; sessionId: string } | null { const { panelId, activeTaskId, activeSessionId, taskSessionsById, environmentIdBySessionId } = args; if (!panelId.startsWith("session:")) return null; const sid = panelId.slice("session:".length); if (!sid) return null; if (sid === activeSessionId) return null; if (!activeTaskId) return null; if (!environmentIdBySessionId[sid]) return null; const sessionTaskId = taskSessionsById[sid]?.task_id; if (sessionTaskId && sessionTaskId !== activeTaskId) return null; return { taskId: activeTaskId, sessionId: sid }; } /** * Sync `activeSessionId` in the store when the user clicks a session tab. * Layouts are env-keyed, so switching between sessions of the same task is * a no-op at the layout level — the env switch action short-circuits when * old==new env. No manual skip flag needed. */ export function setupSessionTabSync(api: DockviewReadyEvent["api"], appStore: StoreApi) { return api.onDidActivePanelChange((panel) => { if (!panel) return; const isRestoring = useDockviewStore.getState().isRestoringLayout; if (isDebug()) { debug("setupSessionTabSync: onDidActivePanelChange", { panelId: panel.id, isRestoring, currentActiveSessionId: appStore.getState().tasks.activeSessionId, currentActiveTaskId: appStore.getState().tasks.activeTaskId, livePanelIds: api.panels.map((p) => p.id), }); } if (isRestoring) return; const state = appStore.getState(); const target = resolveSessionTabSyncTarget({ panelId: panel.id, activeTaskId: state.tasks.activeTaskId, activeSessionId: state.tasks.activeSessionId, taskSessionsById: state.taskSessions.items, environmentIdBySessionId: state.environmentIdBySessionId, }); if (!target) { if (isDebug() && panel.id.startsWith("session:")) { debug("setupSessionTabSync: skip (stale or cross-task panel)", { panelId: panel.id, activeTaskId: state.tasks.activeTaskId, }); } return; } if (isDebug()) { debug("setupSessionTabSync: setActiveSession", { taskId: target.taskId, newSessionId: target.sessionId, }); } state.setActiveSession(target.taskId, target.sessionId); }); } /** * Re-create a chat or session panel if the last one is removed. * Prevents the user from ending up with no chat panel at all. * * Uses a delayed check to avoid racing with dockview drag-to-split * operations, which temporarily remove and re-add panels. */ export function setupChatPanelSafetyNet( api: DockviewReadyEvent["api"], appStore: StoreApi, ) { return api.onDidRemovePanel((panel) => { if (useDockviewStore.getState().isRestoringLayout) return; const isChatPanel = panel.id === "chat" || panel.id.startsWith("session:"); if (!isChatPanel) return; if (isDebug()) { debug("setupChatPanelSafetyNet: chat panel removed", { removedPanelId: panel.id, livePanelIds: api.panels.map((p) => p.id), }); } // Double rAF gives dockview time to finish internal operations like // drag-to-split moves (remove from old group → add to new group). requestAnimationFrame(() => { requestAnimationFrame(() => { if (useDockviewStore.getState().isRestoringLayout) return; const hasChatPanel = api.panels.some((p) => p.id === "chat" || p.id.startsWith("session:")); if (hasChatPanel) return; const activeSessionId = appStore.getState().tasks.activeSessionId; const position = undefined; // Only recreate a panel if there's still an active session. // If all sessions were deleted, leave the layout empty — the user // can create a new session via the "+" menu. if (!activeSessionId) { if (isDebug()) debug("setupChatPanelSafetyNet: skip recreate (no active session)"); return; } // Don't recreate a panel for a session that no longer exists in the // store — this guards against handleDelete racing with the safety net. const activeTaskId = appStore.getState().tasks.activeTaskId; const knownSessions = activeTaskId ? (appStore.getState().taskSessionsByTask.itemsByTaskId[activeTaskId] ?? []) : []; if (!knownSessions.some((s) => s.id === activeSessionId)) { if (isDebug()) { debug("setupChatPanelSafetyNet: skip recreate (session not in store)", { activeSessionId, activeTaskId, knownSessionIds: knownSessions.map((s) => s.id), }); } return; } if (isDebug()) { debug("setupChatPanelSafetyNet: recreating session panel", { activeSessionId, activeTaskId, anchor: "auto", }); } api.addPanel({ id: `session:${activeSessionId}`, component: "chat", tabComponent: "sessionTab", title: "Agent", params: { sessionId: activeSessionId }, position, }); const nc = api.getPanel(`session:${activeSessionId}`); if (nc) useDockviewStore.setState({ centerGroupId: nc.group.id }); }); }); }); } // --------------------------------------------------------------------------- // Auto-show PR detail panel // --------------------------------------------------------------------------- /** Pure decision function for whether the PR panel should be auto-added or removed. */ export function shouldAutoAddPRPanel(params: { hasPR: boolean; panelExists: boolean; isRestoringLayout: boolean; isMaximized: boolean; wasOffered: boolean; }): "add" | "remove" | "none" { if (!params.hasPR && params.panelExists) return "remove"; if (!params.hasPR) return "none"; if (params.panelExists) return "none"; if (params.isRestoringLayout) return "none"; if (params.isMaximized) return "none"; if (params.wasOffered) return "none"; return "add"; } /** * Resolve the group ID to anchor the PR detail panel to. * * Preference: the live session chat panel's group. It's the group the user is * actively looking at, and reading it directly avoids the stale-id window the * store's centerGroupId has across layout transitions (which caused the PR * panel to land in a split instead of as a tab next to the session). */ export function resolvePRPanelTargetGroup( api: DockviewApi, sessionId: string, centerGroupId: string, ): string { const sessionPanel = api.getPanel(`session:${sessionId}`); const resolved = sessionPanel?.group?.id ?? centerGroupId; return resolved; } /** * Auto-add the PR detail panel to the center group when the active task * has an associated pull request. The panel is added as a background tab * (the session/agent tab stays focused). * * Dismissal is persisted to sessionStorage: if the user closes the PR panel, * it won't be re-added for that session — even after a page refresh. */ export function useAutoPRPanel() { const taskId = useAppStore((s) => s.tasks.activeTaskId); const sessionId = useAppStore((s) => s.tasks.activeSessionId); const hasPR = useAppStore((s) => { const tid = s.tasks.activeTaskId; return tid ? (s.taskPRs.byTaskId[tid]?.length ?? 0) > 0 : false; }); const hasApi = useDockviewStore((s) => !!s.api); useEffect(() => { if (!taskId || !hasApi || !sessionId) return; requestAnimationFrame(() => { requestAnimationFrame(() => { const api = useDockviewStore.getState().api; if (!api) return; const decisionParams = { hasPR, panelExists: !!api.getPanel("pr-detail"), isRestoringLayout: useDockviewStore.getState().isRestoringLayout, isMaximized: useDockviewStore.getState().preMaximizeLayout !== null, wasOffered: wasPRPanelOffered(sessionId), }; const decision = shouldAutoAddPRPanel(decisionParams); if (decision === "remove") { api.getPanel("pr-detail")?.api.close(); return; } if (decision === "add") { const targetGroupId = resolvePRPanelTargetGroup( api, sessionId, useDockviewStore.getState().centerGroupId, ); focusOrAddPanel(api, { id: "pr-detail", component: "pr-detail", title: "Pull Request", position: { referenceGroup: targetGroupId }, inactive: true, }); markPRPanelOffered(sessionId); return; } // "none" — panel already present or conditions not met. // Mark as offered if the panel exists (e.g. restored from saved layout). if (hasPR && api.getPanel("pr-detail")) { markPRPanelOffered(sessionId); } }); }); }, [taskId, hasPR, hasApi, sessionId]); } /** * Panels that are added co-tabbed with a session panel (see `useAutoPRPanel`). * When a saved layout's session was stripped (phantom-session sanitize on * page load, or stale removal during env switch), these siblings end up alone * in a group with no session. Prefer joining that group when adding the new * active session — without this fallback we'd add the session as a fresh * split next to the sidebar, breaking the user's grouping. * * Each entry matches either the bare id (e.g. `pr-detail`) or a keyed * variant `|` (multi-repo PR panels use `pr-detail|owner/repo/N`, * see `addPRPanel` in dockview-panel-actions.ts). */ const SESSION_ANCHOR_PANEL_IDS = ["pr-detail"]; export function findSessionAnchorGroupId(api: DockviewApi): string | null { for (const id of SESSION_ANCHOR_PANEL_IDS) { const exact = api.getPanel(id); if (exact) return exact.group.id; const keyedPrefix = `${id}|`; const keyed = api.panels.find((p) => p.id.startsWith(keyedPrefix)); if (keyed) return keyed.group.id; } return null; } function resolveInitialPosition(api: DockviewApi): AddPanelOptions["position"] { // Prefer the live "chat" placeholder's group. The session panel must be added // INTO that group (so it's a tab beside chat) BEFORE chat is removed — // otherwise removing chat empties and destroys the center group, the stored // centerGroupId goes stale, and the session panel gets appended as a new row, // collapsing the horizontal default layout into a vertical stack. const chatGroupId = api.getPanel("chat")?.group?.id; if (chatGroupId) return { referenceGroup: chatGroupId }; const { centerGroupId } = useDockviewStore.getState(); const centerGroupExists = centerGroupId && api.groups.some((g) => g.id === centerGroupId); if (centerGroupExists) return { referenceGroup: centerGroupId }; const anchorGroupId = findSessionAnchorGroupId(api); // index:0 matches the project's session-on-the-left convention (agent tab // first, pr-detail/etc. to the right). Worth noting: if a user had // rearranged a previous layout to put pr-detail first, that ordering is // lost here — but the alternative (appending) would put the agent tab // to the right of pr-detail, which contradicts the default placement // every other code path produces. Pick the consistent default. if (anchorGroupId) return { referenceGroup: anchorGroupId, index: 0 }; return undefined; } function ensureSessionPanel( api: DockviewApi, sessionId: string, position: AddPanelOptions["position"], inactive: boolean, createdSet: Set, ): void { if (api.getPanel(`session:${sessionId}`)) { createdSet.add(sessionId); return; } api.addPanel({ id: `session:${sessionId}`, component: "chat", tabComponent: "sessionTab", title: "Agent", params: { sessionId }, position, inactive, }); createdSet.add(sessionId); } /** * Close session panels that no longer belong to the active task. * * Iterates `api.panels` (not `createdSet`) as the source of truth — `createdSet` * is unreliable because session panels can enter dockview via `tryRestoreLayout` * /`fromJSON` (never going through `ensureSessionPanel`) and can leave via * external removals like the right-click delete handler. Trusting it caused * tabs from a previous task to leak into the current task's view. */ export function reconcileRemovedSessionPanels( api: DockviewApi, createdSet: Set, currentSessionIds: string[], keepSessionId: string, ): void { const currentIds = new Set(currentSessionIds); const removed: string[] = []; // Snapshot before iterating: closing a panel can mutate `api.panels` // synchronously, which would skip elements in a `for...of` over the live // array. Matches the pattern in `removeEphemeralPanels`. for (const panel of [...api.panels]) { if (!panel.id.startsWith("session:")) continue; const sid = panel.id.slice("session:".length); if (sid === keepSessionId) continue; if (currentIds.has(sid)) continue; try { panel.api.close(); removed.push(panel.id); } catch { /* already gone */ } createdSet.delete(sid); } if (isDebug()) { const sessionPanels = api.panels.filter((p) => p.id.startsWith("session:")); debug("reconcileRemovedSessionPanels", { keepSessionId, currentSessionIds, liveSessionPanelIds: sessionPanels.map((p) => p.id), removed, createdSetAfter: Array.from(createdSet), }); } // Drop any remaining stale entries (panel already removed externally, e.g. // by the right-click delete handler) so the ref stays in sync with reality. for (const sid of [...createdSet]) { if (sid === keepSessionId) continue; if (currentIds.has(sid)) continue; createdSet.delete(sid); } } const EMPTY_SESSION_IDS_KEY = ""; /** * Whether the layout is maximized — session panels are intentionally absent * then (restored on exit-maximize). Returns true when callers should continue * ensuring panels, false to skip. */ function shouldEnsureSessionPanels(): boolean { return useDockviewStore.getState().preMaximizeLayout === null; } /** * Drop the generic "chat" placeholder once a real session panel exists. * * MUST run AFTER the session panel has been added to chat's group — removing * chat while it's the only panel in the center group destroys that group and * collapses the horizontal default layout into a vertical stack. */ function removeChatPlaceholder(api: DockviewApi): void { if (useDockviewStore.getState().preMaximizeLayout) return; const chatPanel = api.getPanel("chat"); if (chatPanel) api.removePanel(chatPanel); } /** * Decide whether to force-activate the session panel after it (and any * sibling tabs) have been ensured. * * - It was just created by `ensureSessionPanel` (no prior dockview state * to honor), or * - The hook is mounting for the first time (initial page load) — preserve * the long-standing behavior of focusing the agent tab so the chat is * visible immediately, even when a saved layout had a different center * tab active, or * - The user switched sessions within the same task (intra-task switch * where dockview hasn't re-activated the new session for us). * * After an env (task) switch the prev refs are populated and the task * changed — `restoreSavedActiveViews` has already applied the saved active * panel for the incoming task, so calling setActive here would override it * and force the agent tab on top of whatever the user had focused. */ function shouldActivateSessionPanel(args: { sessionPanelExistedBefore: boolean; prevTaskId: string | null; prevSessionId: string | null; currentTaskId: string | null; currentSessionId: string; }): boolean { const { sessionPanelExistedBefore, prevTaskId, prevSessionId, currentTaskId, currentSessionId } = args; if (!sessionPanelExistedBefore) return true; const isFirstMount = prevTaskId === null && prevSessionId === null; if (isFirstMount) return true; const taskChanged = prevTaskId !== currentTaskId; const sessionChanged = prevSessionId !== currentSessionId; return sessionChanged && !taskChanged; } type AutoSessionTabRefs = { sessionTabCreatedRef: MutableRefObject>; prevTaskIdRef: MutableRefObject; prevSessionIdRef: MutableRefObject; }; /** * Activate the newly-ensured session panel and update the center-group store * entry. Returns the resolved active panel for sibling anchoring. */ function activateSessionPanel( api: DockviewApi, effectiveSessionId: string, sessionPanelExistedBefore: boolean, refs: AutoSessionTabRefs, tid: string | null, ): ReturnType { const activePanel = api.getPanel(`session:${effectiveSessionId}`); if (!activePanel) return activePanel; const shouldActivate = shouldActivateSessionPanel({ sessionPanelExistedBefore, prevTaskId: refs.prevTaskIdRef.current, prevSessionId: refs.prevSessionIdRef.current, currentTaskId: tid, currentSessionId: effectiveSessionId, }); if (isDebug()) { debug("useAutoSessionTab: activation decision", { effectiveSessionId, shouldActivate, sessionPanelExistedBefore, prevTaskId: refs.prevTaskIdRef.current, prevSessionId: refs.prevSessionIdRef.current, currentTaskId: tid, activeGroupId: activePanel.group.id, }); } if (shouldActivate) activePanel.api.setActive(); useDockviewStore.setState({ centerGroupId: activePanel.group.id }); return activePanel; } /** * Add sibling session panels (inactive) into the same group as the active * panel so they appear as tabs. Returns the list of newly-created sibling IDs * for debug logging. */ function ensureSiblingPanels( api: DockviewApi, currentSessionIds: string[], effectiveSessionId: string, siblingAnchor: AddPanelOptions["position"], createdSet: Set, ): string[] { const created: string[] = []; for (const sid of currentSessionIds) { if (sid === effectiveSessionId) continue; if (isDebug() && !api.getPanel(`session:${sid}`)) created.push(sid); ensureSessionPanel(api, sid, siblingAnchor, true, createdSet); } return created; } /** Resolve the current session ID list from the store for the active task. */ function resolveCurrentSessionIds(appStore: ReturnType): { tid: string | null; currentSessionIds: string[]; } { const tid = appStore.getState().tasks.activeTaskId; const currentSessions = tid ? (appStore.getState().taskSessionsByTask.itemsByTaskId[tid] ?? []) : []; return { tid: tid ?? null, currentSessionIds: currentSessions.map((s) => s.id) }; } /** * Check early-exit conditions after reconciliation. Returns true when the * caller should bail out before ensuring panels. */ function shouldSkipPanelEnsure( api: DockviewApi, effectiveSessionId: string, currentSessionIds: string[], createdSet: Set, ): boolean { if (!currentSessionIds.includes(toSessionId(effectiveSessionId))) { if (isDebug()) { debug("useAutoSessionTab: skip (session not in store yet)", { effectiveSessionId, currentSessionIds, }); } return true; } if (!shouldEnsureSessionPanels()) { if (isDebug()) debug("useAutoSessionTab: skip body (maximized - panels suppressed)", { effectiveSessionId }); createdSet.add(effectiveSessionId); return true; } return false; } /** * Core effect body for useAutoSessionTab — extracted to reduce complexity of * the hook itself. */ function runAutoSessionTabEffect( effectiveSessionId: string | null, appStore: ReturnType, refs: AutoSessionTabRefs, ): void { const api = useDockviewStore.getState().api; if (!api) return; const { tid, currentSessionIds } = resolveCurrentSessionIds(appStore); if (isDebug()) { debug("useAutoSessionTab: effect entry", { effectiveSessionId, activeTaskId: tid, prevTaskId: refs.prevTaskIdRef.current, prevSessionId: refs.prevSessionIdRef.current, currentSessionIds, livePanelIdsBefore: api.panels.map((p) => p.id), createdSet: Array.from(refs.sessionTabCreatedRef.current), }); } reconcileRemovedSessionPanels( api, refs.sessionTabCreatedRef.current, currentSessionIds, effectiveSessionId ?? "", ); if (!effectiveSessionId) { if (isDebug()) debug("useAutoSessionTab: no effectiveSessionId, returning"); return; } if ( shouldSkipPanelEnsure( api, effectiveSessionId, currentSessionIds, refs.sessionTabCreatedRef.current, ) ) { return; } const initialPosition = resolveInitialPosition(api); const sessionPanelExistedBefore = !!api.getPanel(`session:${effectiveSessionId}`); if (isDebug()) { debug("useAutoSessionTab: ensuring active session panel", { effectiveSessionId, sessionPanelExistedBefore, initialPosition: JSON.stringify(initialPosition), }); } ensureSessionPanel( api, effectiveSessionId, initialPosition, false, refs.sessionTabCreatedRef.current, ); // Now that the session panel occupies the center group, drop the generic // "chat" placeholder. Order matters: removing chat first would empty and // destroy the center group, collapsing the horizontal layout. removeChatPlaceholder(api); const activePanel = activateSessionPanel( api, effectiveSessionId, sessionPanelExistedBefore, refs, tid, ); const siblingAnchor: AddPanelOptions["position"] = activePanel ? { referenceGroup: activePanel.group.id } : initialPosition; const siblingsCreated = ensureSiblingPanels( api, currentSessionIds, effectiveSessionId, siblingAnchor, refs.sessionTabCreatedRef.current, ); if (isDebug()) { debug("useAutoSessionTab: effect exit", { effectiveSessionId, siblingsCreated, livePanelIdsAfter: api.panels.map((p) => p.id), activeGroupId: activePanel?.group.id ?? null, liveActivePanelId: api.activePanel?.id ?? null, }); } refs.prevTaskIdRef.current = tid; refs.prevSessionIdRef.current = effectiveSessionId; } /** * Open a dockview tab for every session of the active task and keep them in sync * with the store. * * - On mount / session-list change: create a panel for each session if one does * not exist yet. Siblings are added adjacent to the active session's group so * they show up as tabs in the center area. * - The panel for `effectiveSessionId` is the active tab; the rest are added * inactive so switching the active session doesn't blow focus out of the * already-open layout. * - Deleted sessions have their panels closed. */ export function useAutoSessionTab(effectiveSessionId: string | null) { const sessionTabCreatedRef = useRef>(new Set()); const prevTaskIdRef = useRef(null); const prevSessionIdRef = useRef(null); const appStore = useAppStoreApi(); // Key-based dependency so the effect re-runs when the task's session list // changes (add/remove). Inside the effect we re-read the real array from // the store so we don't capture a stale reference. const sessionIdsKey = useAppStore((s) => { const tid = s.tasks.activeTaskId; if (!tid) return EMPTY_SESSION_IDS_KEY; const list = s.taskSessionsByTask.itemsByTaskId[tid]; if (!list || list.length === 0) return EMPTY_SESSION_IDS_KEY; return list.map((ss) => ss.id).join(","); }); useEffect(() => { runAutoSessionTabEffect(effectiveSessionId, appStore, { sessionTabCreatedRef, prevTaskIdRef, prevSessionIdRef, }); }, [effectiveSessionId, sessionIdsKey, appStore]); }