diff --git a/apps/web/components/task/dockview-desktop-layout.tsx b/apps/web/components/task/dockview-desktop-layout.tsx index 2e7da6343c..bedd63324c 100644 --- a/apps/web/components/task/dockview-desktop-layout.tsx +++ b/apps/web/components/task/dockview-desktop-layout.tsx @@ -47,8 +47,8 @@ import { PreviewFileTab, PreviewDiffTab, PreviewCommitTab, PinnedDefaultTab } fr import { SessionTab } from "./session-tab"; import { TerminalTab } from "./terminal-tab"; import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize"; +import { setupSessionTabSync } from "./dockview-session-tab-sync"; import { - setupSessionTabSync, setupChatPanelSafetyNet, useAutoSessionTab, useAutoPRPanel, @@ -583,8 +583,10 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({ useDockviewStore.setState({ currentLayoutEnvId: currentEnvId }); readyDisposersRef.current.push(setupGroupTracking(api)); - setupSessionTabSync(api, appStore); - setupChatPanelSafetyNet(api, appStore); + const sessionTabSyncDisposable = setupSessionTabSync(api, appStore); + readyDisposersRef.current.push(() => sessionTabSyncDisposable.dispose()); + const chatPanelSafetyNetDisposable = setupChatPanelSafetyNet(api, appStore); + readyDisposersRef.current.push(() => chatPanelSafetyNetDisposable.dispose()); readyDisposersRef.current.push(setupLayoutPersistence(api, saveTimerRef, envIdRef)); setupPortalCleanup(api, appStore); readyDisposersRef.current.push(setupContainerResizeSync(api)); diff --git a/apps/web/components/task/dockview-session-tab-sync.test.ts b/apps/web/components/task/dockview-session-tab-sync.test.ts new file mode 100644 index 0000000000..7f8044ba10 --- /dev/null +++ b/apps/web/components/task/dockview-session-tab-sync.test.ts @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { DockviewReadyEvent } from "dockview-react"; +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; +import { useDockviewStore } from "@/lib/state/dockview-store"; +import { + clearSessionTabUserActivationIntentsForTest, + markSessionTabUserActivationIntent, +} from "./session-tab-activation-intent"; +import { setupSessionTabSync } from "./dockview-session-tab-sync"; + +const TASK_ID = "task-A"; +const ACTIVE_SESSION_ID = "s-active"; +const OTHER_SESSION_ID = "s-other"; +const OTHER_SESSION_PANEL_ID = `session:${OTHER_SESSION_ID}`; + +type SessionTabSyncPanel = { + id: string; + api: { setActive: ReturnType> }; +}; + +type SessionTabSyncApi = { + panels: SessionTabSyncPanel[]; + getPanel: (id: string) => SessionTabSyncPanel | null; + onDidActivePanelChange: (callback: (panel: { id: string } | null) => void) => { + dispose: ReturnType>; + }; +}; + +type SessionTabSyncStore = { + getState: () => { + tasks: { activeTaskId: string; activeSessionId: string }; + taskSessions: { + items: Record; + }; + environmentIdBySessionId: Record; + setActiveSession: ReturnType>; + }; +}; + +type SessionTabSyncHarness = ReturnType; + +function makeSessionTabSyncHarness(args: { + activeTaskId: string; + activeSessionId: string; + otherSessionId: string; + includeOtherEnv?: boolean; +}) { + let activePanelChange: ((panel: { id: string } | null) => void) | null = null; + const activePanelSetActive = vi.fn(() => { + activePanelChange?.({ id: `session:${args.activeSessionId}` }); + }); + const otherPanelSetActive = vi.fn(); + const panels: SessionTabSyncPanel[] = [ + { id: `session:${args.activeSessionId}`, api: { setActive: activePanelSetActive } }, + { id: `session:${args.otherSessionId}`, api: { setActive: otherPanelSetActive } }, + ]; + const api: SessionTabSyncApi = { + panels, + getPanel: (id: string) => panels.find((panel) => panel.id === id) ?? null, + onDidActivePanelChange: (callback: (panel: { id: string } | null) => void) => { + activePanelChange = callback; + return { dispose: vi.fn() }; + }, + }; + const setActiveSession = vi.fn(); + const environmentIdBySessionId = { + [args.activeSessionId]: "env-A", + ...(args.includeOtherEnv === false ? {} : { [args.otherSessionId]: "env-A" }), + }; + const appStore: SessionTabSyncStore = { + getState: () => ({ + tasks: { + activeTaskId: args.activeTaskId, + activeSessionId: args.activeSessionId, + }, + taskSessions: { + items: { + [args.activeSessionId]: { id: args.activeSessionId, task_id: args.activeTaskId }, + [args.otherSessionId]: { id: args.otherSessionId, task_id: args.activeTaskId }, + }, + }, + environmentIdBySessionId, + setActiveSession, + }), + }; + + return { + api, + appStore, + setActiveSession, + activePanelSetActive, + otherPanelSetActive, + fireActivePanelChange: (panelId: string | null) => { + activePanelChange?.(panelId ? { id: panelId } : null); + }, + }; +} + +function makeDefaultSessionTabSyncHarness(args?: { includeOtherEnv?: boolean }) { + return makeSessionTabSyncHarness({ + activeTaskId: TASK_ID, + activeSessionId: ACTIVE_SESSION_ID, + otherSessionId: OTHER_SESSION_ID, + includeOtherEnv: args?.includeOtherEnv, + }); +} + +function startSessionTabSync(harness: SessionTabSyncHarness) { + setupSessionTabSync( + harness.api as unknown as DockviewReadyEvent["api"], + harness.appStore as unknown as StoreApi, + ); +} + +describe("setupSessionTabSync", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + clearSessionTabUserActivationIntentsForTest(); + useDockviewStore.setState({ isRestoringLayout: false }); + }); + + afterEach(() => { + clearSessionTabUserActivationIntentsForTest(); + useDockviewStore.setState({ isRestoringLayout: false }); + vi.useRealTimers(); + }); + + it("does not pin a session when Dockview activates another session panel without user intent", () => { + const harness = makeDefaultSessionTabSyncHarness(); + + startSessionTabSync(harness); + harness.fireActivePanelChange(OTHER_SESSION_PANEL_ID); + + expect(harness.setActiveSession).not.toHaveBeenCalled(); + expect(harness.activePanelSetActive).toHaveBeenCalledTimes(1); + }); + + it("pins the session when the active panel change follows explicit session-tab user intent", () => { + const harness = makeDefaultSessionTabSyncHarness(); + + startSessionTabSync(harness); + markSessionTabUserActivationIntent(OTHER_SESSION_ID); + harness.fireActivePanelChange(OTHER_SESSION_PANEL_ID); + + expect(harness.setActiveSession).toHaveBeenCalledWith(TASK_ID, OTHER_SESSION_ID); + expect(harness.activePanelSetActive).not.toHaveBeenCalled(); + }); + + it("ignores active panel changes while Dockview is restoring layout", () => { + const harness = makeDefaultSessionTabSyncHarness(); + + useDockviewStore.setState({ isRestoringLayout: true }); + startSessionTabSync(harness); + markSessionTabUserActivationIntent(OTHER_SESSION_ID); + harness.fireActivePanelChange(OTHER_SESSION_PANEL_ID); + + expect(harness.setActiveSession).not.toHaveBeenCalled(); + expect(harness.activePanelSetActive).not.toHaveBeenCalled(); + }); + + it("restores the active panel when intent exists for a different session", () => { + const harness = makeDefaultSessionTabSyncHarness(); + + startSessionTabSync(harness); + markSessionTabUserActivationIntent(ACTIVE_SESSION_ID); + harness.fireActivePanelChange(OTHER_SESSION_PANEL_ID); + + expect(harness.setActiveSession).not.toHaveBeenCalled(); + expect(harness.activePanelSetActive).toHaveBeenCalledTimes(1); + }); + + it("ignores null and non-session panel changes", () => { + const harness = makeDefaultSessionTabSyncHarness(); + + startSessionTabSync(harness); + harness.fireActivePanelChange(null); + harness.fireActivePanelChange("files"); + + expect(harness.setActiveSession).not.toHaveBeenCalled(); + expect(harness.activePanelSetActive).not.toHaveBeenCalled(); + expect(harness.otherPanelSetActive).not.toHaveBeenCalled(); + }); + + it("restores active panel for stale session panels without an environment mapping", () => { + const harness = makeDefaultSessionTabSyncHarness({ includeOtherEnv: false }); + + startSessionTabSync(harness); + markSessionTabUserActivationIntent(OTHER_SESSION_ID); + harness.fireActivePanelChange(OTHER_SESSION_PANEL_ID); + + expect(harness.setActiveSession).not.toHaveBeenCalled(); + expect(harness.activePanelSetActive).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/components/task/dockview-session-tab-sync.ts b/apps/web/components/task/dockview-session-tab-sync.ts new file mode 100644 index 0000000000..3610098d40 --- /dev/null +++ b/apps/web/components/task/dockview-session-tab-sync.ts @@ -0,0 +1,83 @@ +import type { DockviewReadyEvent } from "dockview-react"; +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; +import { useDockviewStore } from "@/lib/state/dockview-store"; +import { createDebugLogger, isDebug } from "@/lib/debug/log"; +import { consumeSessionTabUserActivationIntent } from "./session-tab-activation-intent"; +import { resolveSessionTabSyncTarget } from "./dockview-session-tabs"; + +const debug = createDebugLogger("dockview:session-tabs"); + +function restoreActiveSessionPanel( + api: DockviewReadyEvent["api"], + activeSessionId: string | null, +): void { + if (!activeSessionId) return; + api.getPanel(`session:${activeSessionId}`)?.api.setActive(); +} + +function isDifferentSessionPanel(panelId: string, activeSessionId: string | null): boolean { + return panelId.startsWith("session:") && panelId !== `session:${activeSessionId}`; +} + +/** + * Sync `activeSessionId` in the store when the user explicitly activates a + * session tab. Dockview can also activate panels internally while restoring + * layout or reconciling tabs; those activations must not pin a different + * session or they create an app-level feedback loop. + */ +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) { + const shouldRestoreActiveSession = isDifferentSessionPanel( + panel.id, + state.tasks.activeSessionId, + ); + if (isDebug() && panel.id.startsWith("session:")) { + debug("setupSessionTabSync: skip (stale or cross-task panel)", { + panelId: panel.id, + activeTaskId: state.tasks.activeTaskId, + }); + } + if (shouldRestoreActiveSession) restoreActiveSessionPanel(api, state.tasks.activeSessionId); + return; + } + if (!consumeSessionTabUserActivationIntent(target.sessionId)) { + if (isDebug()) { + debug("setupSessionTabSync: skip (no user activation intent)", { + panelId: panel.id, + activeSessionId: state.tasks.activeSessionId, + }); + } + restoreActiveSessionPanel(api, state.tasks.activeSessionId); + return; + } + if (isDebug()) { + debug("setupSessionTabSync: setActiveSession", { + taskId: target.taskId, + newSessionId: target.sessionId, + }); + } + state.setActiveSession(target.taskId, target.sessionId); + }); +} diff --git a/apps/web/components/task/dockview-session-tabs.ts b/apps/web/components/task/dockview-session-tabs.ts index 4aea62f212..644f67b1d7 100644 --- a/apps/web/components/task/dockview-session-tabs.ts +++ b/apps/web/components/task/dockview-session-tabs.ts @@ -58,53 +58,6 @@ export function resolveSessionTabSyncTarget(args: { 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. diff --git a/apps/web/components/task/session-reopen-menu.tsx b/apps/web/components/task/session-reopen-menu.tsx index e2211c5381..462077d245 100644 --- a/apps/web/components/task/session-reopen-menu.tsx +++ b/apps/web/components/task/session-reopen-menu.tsx @@ -13,6 +13,7 @@ import { useTaskSessions } from "@/hooks/use-task-sessions"; import { addSessionPanel } from "@/lib/state/dockview-panel-actions"; import { getSessionStateIcon } from "@/lib/ui/state-icons"; import { AgentLogo } from "@/components/agent-logo"; +import { markSessionTabUserActivationIntent } from "@/components/task/session-tab-activation-intent"; import type { TaskSession } from "@/lib/types/http"; import type { AgentProfileOption } from "@/lib/state/slices"; @@ -73,6 +74,7 @@ export function SessionReopenMenuItems({ if (!api) return; // Reopening a session within the same task = same env, so the env switch // action no-ops naturally. We just create the chat panel. + markSessionTabUserActivationIntent(sessionId); addSessionPanel(api, groupId ?? centerGroupId, sessionId, label); }, [api, centerGroupId], diff --git a/apps/web/components/task/session-tab-activation-intent.test.ts b/apps/web/components/task/session-tab-activation-intent.test.ts new file mode 100644 index 0000000000..283327aee1 --- /dev/null +++ b/apps/web/components/task/session-tab-activation-intent.test.ts @@ -0,0 +1,172 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearSessionTabUserActivationIntentsForTest, + consumeSessionTabUserActivationIntent, + markSessionPanelUserActivationIntent, + markSessionTabUserActivationIntent, + shouldMarkSessionTabUserActivationIntent, +} from "./session-tab-activation-intent"; + +const INACTIVE_SESSION_ID = "s-inactive"; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + clearSessionTabUserActivationIntentsForTest(); +}); + +afterEach(() => { + clearSessionTabUserActivationIntentsForTest(); + vi.useRealTimers(); +}); + +describe("session tab activation intent lifecycle", () => { + it("ignores null and undefined session ids", () => { + markSessionTabUserActivationIntent(null); + markSessionTabUserActivationIntent(undefined); + + expect(consumeSessionTabUserActivationIntent("s-active")).toBe(false); + }); + + it("expires marked intent after the TTL", () => { + markSessionTabUserActivationIntent("s-active"); + + vi.advanceTimersByTime(1501); + + expect(consumeSessionTabUserActivationIntent("s-active")).toBe(false); + }); + + it("preserves one session intent when another session is consumed", () => { + markSessionTabUserActivationIntent("s-active"); + + expect(consumeSessionTabUserActivationIntent("s-other")).toBe(false); + expect(consumeSessionTabUserActivationIntent("s-active")).toBe(true); + }); + + it("consumes a matching intent once", () => { + markSessionTabUserActivationIntent("s-active"); + + expect(consumeSessionTabUserActivationIntent("s-active")).toBe(true); + expect(consumeSessionTabUserActivationIntent("s-active")).toBe(false); + }); + + it("keeps independent intents for fast successive tab activations", () => { + markSessionTabUserActivationIntent("s-first"); + markSessionTabUserActivationIntent("s-second"); + + expect(consumeSessionTabUserActivationIntent("s-first")).toBe(true); + expect(consumeSessionTabUserActivationIntent("s-second")).toBe(true); + }); + + it("marks session intent from a session panel id", () => { + markSessionPanelUserActivationIntent("session:s-active"); + markSessionPanelUserActivationIntent("files"); + + expect(consumeSessionTabUserActivationIntent("s-active")).toBe(true); + expect(consumeSessionTabUserActivationIntent("files")).toBe(false); + }); +}); + +describe("session tab activation intent guard", () => { + it("does not mark intent for already-active tabs", () => { + expect( + shouldMarkSessionTabUserActivationIntent({ + sessionId: "s-active", + activeSessionId: "s-other", + isActive: true, + target: document.createElement("span"), + }), + ).toBe(false); + }); + + it("does not mark intent for the current active session when another panel is active", () => { + expect( + shouldMarkSessionTabUserActivationIntent({ + sessionId: "s-active", + activeSessionId: "s-active", + isActive: false, + target: document.createElement("span"), + }), + ).toBe(false); + }); + + it("does not mark intent without a session id", () => { + expect( + shouldMarkSessionTabUserActivationIntent({ + sessionId: null, + activeSessionId: "s-active", + isActive: false, + target: document.createElement("span"), + }), + ).toBe(false); + }); + + it("does not mark intent from nested interactive controls", () => { + const button = document.createElement("button"); + const label = document.createElement("span"); + button.append(label); + + expect( + shouldMarkSessionTabUserActivationIntent({ + sessionId: INACTIVE_SESSION_ID, + activeSessionId: "s-active", + isActive: false, + target: label, + }), + ).toBe(false); + }); + + it("does not mark intent from role-based nested interactive controls", () => { + const button = document.createElement("span"); + button.setAttribute("role", "button"); + const label = document.createElement("span"); + button.append(label); + + expect( + shouldMarkSessionTabUserActivationIntent({ + sessionId: INACTIVE_SESSION_ID, + activeSessionId: "s-active", + isActive: false, + target: label, + }), + ).toBe(false); + }); + + it("does not mark intent from Dockview close actions", () => { + const closeAction = document.createElement("div"); + closeAction.className = "dv-default-tab-action"; + const label = document.createElement("span"); + closeAction.append(label); + + expect( + shouldMarkSessionTabUserActivationIntent({ + sessionId: INACTIVE_SESSION_ID, + activeSessionId: "s-active", + isActive: false, + target: label, + }), + ).toBe(false); + }); + + it("marks intent for non-element targets on inactive tabs", () => { + expect( + shouldMarkSessionTabUserActivationIntent({ + sessionId: INACTIVE_SESSION_ID, + activeSessionId: "s-active", + isActive: false, + target: null, + }), + ).toBe(true); + }); + + it("marks intent for inactive tab activation surfaces", () => { + expect( + shouldMarkSessionTabUserActivationIntent({ + sessionId: INACTIVE_SESSION_ID, + activeSessionId: "s-active", + isActive: false, + target: document.createElement("span"), + }), + ).toBe(true); + }); +}); diff --git a/apps/web/components/task/session-tab-activation-intent.ts b/apps/web/components/task/session-tab-activation-intent.ts new file mode 100644 index 0000000000..4264597d65 --- /dev/null +++ b/apps/web/components/task/session-tab-activation-intent.ts @@ -0,0 +1,48 @@ +const SESSION_TAB_USER_ACTIVATION_TTL_MS = 1500; +const NESTED_INTERACTIVE_SELECTOR = + ".dv-default-tab-action, button, a, input, select, textarea, [role='button'], [role='menuitem']"; + +type SessionTabActivationIntent = { + expiresAt: number; +}; + +const sessionTabActivationIntents = new Map(); + +export function shouldMarkSessionTabUserActivationIntent(args: { + sessionId: string | null | undefined; + activeSessionId: string | null | undefined; + isActive: boolean; + target: EventTarget | null; +}): boolean { + if (!args.sessionId || args.isActive || args.sessionId === args.activeSessionId) return false; + if (typeof Element === "undefined" || !(args.target instanceof Element)) return true; + return !args.target.closest(NESTED_INTERACTIVE_SELECTOR); +} + +export function markSessionTabUserActivationIntent(sessionId: string | null | undefined): void { + if (!sessionId) return; + sessionTabActivationIntents.set(sessionId, { + expiresAt: Date.now() + SESSION_TAB_USER_ACTIVATION_TTL_MS, + }); +} + +export function markSessionPanelUserActivationIntent(panelId: string | null | undefined): void { + if (!panelId?.startsWith("session:")) return; + markSessionTabUserActivationIntent(panelId.slice("session:".length)); +} + +export function consumeSessionTabUserActivationIntent(sessionId: string): boolean { + const intent = sessionTabActivationIntents.get(sessionId); + if (!intent) return false; + const now = Date.now(); + if (intent.expiresAt < now) { + sessionTabActivationIntents.delete(sessionId); + return false; + } + sessionTabActivationIntents.delete(sessionId); + return true; +} + +export function clearSessionTabUserActivationIntentsForTest(): void { + sessionTabActivationIntents.clear(); +} diff --git a/apps/web/components/task/session-tab.tsx b/apps/web/components/task/session-tab.tsx index af800f2428..469195583e 100644 --- a/apps/web/components/task/session-tab.tsx +++ b/apps/web/components/task/session-tab.tsx @@ -1,6 +1,13 @@ "use client"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent, +} from "react"; import { DockviewDefaultTab, type IDockviewPanelHeaderProps } from "dockview-react"; import { IconStar } from "@tabler/icons-react"; import { AgentLogo } from "@/components/agent-logo"; @@ -35,6 +42,10 @@ import { HandoffContextMenuSub } from "@/components/task/handoff-profile-menu-it import { NewSessionDialog, type HandoffPreset } from "@/components/task/new-session-dialog"; import { usableConfigOptions } from "@/components/model-config-selector"; import type { TaskSessionState } from "@/lib/types/http"; +import { + markSessionTabUserActivationIntent, + shouldMarkSessionTabUserActivationIntent, +} from "./session-tab-activation-intent"; import { isSessionActive } from "./session-sort"; import { resolveSessionTabTitle } from "./session-tab-title"; import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize"; @@ -139,6 +150,45 @@ function useSessionTabActions( return { handleSetPrimary, handleStop, handleResume, handleDelete, handleCloseOthers }; } +function useSessionTabUserActivationIntent( + sessionId: string | undefined, + activeSessionId: string | null, + isActive: boolean, +) { + const markUserActivationIntent = useCallback( + (target: EventTarget | null) => { + if ( + !shouldMarkSessionTabUserActivationIntent({ sessionId, activeSessionId, isActive, target }) + ) + return; + markSessionTabUserActivationIntent(sessionId); + }, + [activeSessionId, isActive, sessionId], + ); + const handlePointerDownCapture = useCallback( + (event: ReactPointerEvent) => { + if (event.button === 0) markUserActivationIntent(event.target); + }, + [markUserActivationIntent], + ); + const handleKeyDownCapture = useCallback( + (event: ReactKeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") markUserActivationIntent(event.target); + }, + [markUserActivationIntent], + ); + return { handlePointerDownCapture, handleKeyDownCapture }; +} + +function useDockviewTabActiveState(api: IDockviewPanelHeaderProps["api"]) { + const [isActive, setIsActive] = useState(api.isActive); + useEffect(() => { + const disposable = api.onDidActiveChange((e) => setIsActive(e.isActive)); + return () => disposable.dispose(); + }, [api]); + return isActive; +} + function DeleteSessionDialog({ open, onOpenChange, @@ -335,7 +385,8 @@ export function SessionTab(props: IDockviewPanelHeaderProps) { const [shareOpen, setShareOpen] = useState(false); const [handoffOpen, setHandoffOpen] = useState(false); const [handoffPreset, setHandoffPreset] = useState(null); - const [isActive, setIsActive] = useState(api.isActive); + const isActive = useDockviewTabActiveState(api); + const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const canShare = !!taskId && !!sessionId && shareableSessionStateClient(sessionState); const handleHandoffProfile = useCallback( (profileId: string) => { @@ -346,11 +397,6 @@ export function SessionTab(props: IDockviewPanelHeaderProps) { [sessionId], ); - useEffect(() => { - const disposable = api.onDidActiveChange((e) => setIsActive(e.isActive)); - return () => disposable.dispose(); - }, [api]); - useEffect(() => { if (tabTitle && api.title !== tabTitle) api.setTitle(tabTitle); }, [tabTitle, api]); @@ -362,6 +408,11 @@ export function SessionTab(props: IDockviewPanelHeaderProps) { const handleCloseTab = useCallback(() => { setConfirmDelete(true); }, []); + const { handlePointerDownCapture, handleKeyDownCapture } = useSessionTabUserActivationIntent( + sessionId, + activeSessionId, + isActive, + ); return ( <> @@ -369,6 +420,8 @@ export function SessionTab(props: IDockviewPanelHeaderProps) {