fix: prevent automatic session tab pinning (#1538)
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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<typeof vi.fn<[], void>> };
|
||||
};
|
||||
|
||||
type SessionTabSyncApi = {
|
||||
panels: SessionTabSyncPanel[];
|
||||
getPanel: (id: string) => SessionTabSyncPanel | null;
|
||||
onDidActivePanelChange: (callback: (panel: { id: string } | null) => void) => {
|
||||
dispose: ReturnType<typeof vi.fn<[], void>>;
|
||||
};
|
||||
};
|
||||
|
||||
type SessionTabSyncStore = {
|
||||
getState: () => {
|
||||
tasks: { activeTaskId: string; activeSessionId: string };
|
||||
taskSessions: {
|
||||
items: Record<string, { id: string; task_id: string }>;
|
||||
};
|
||||
environmentIdBySessionId: Record<string, string>;
|
||||
setActiveSession: ReturnType<typeof vi.fn<[string, string], void>>;
|
||||
};
|
||||
};
|
||||
|
||||
type SessionTabSyncHarness = ReturnType<typeof makeSessionTabSyncHarness>;
|
||||
|
||||
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<AppState>,
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<AppState>) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
@@ -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<AppState>) {
|
||||
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.
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<string, SessionTabActivationIntent>();
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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<HandoffPreset | null>(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) {
|
||||
<ContextMenuTrigger
|
||||
className="flex h-full items-center cursor-pointer select-none"
|
||||
data-testid={sessionId ? `session-tab-${sessionId}` : undefined}
|
||||
onPointerDownCapture={handlePointerDownCapture}
|
||||
onKeyDownCapture={handleKeyDownCapture}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
<SessionTabTriggerContent
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useDockviewStore } from "@/lib/state/dockview-store";
|
||||
import { useAppStoreApi } from "@/components/state-provider";
|
||||
import { markSessionPanelUserActivationIntent } from "@/components/task/session-tab-activation-intent";
|
||||
import { createUserShell } from "@/lib/api/domains/user-shell-api";
|
||||
import { matchesShortcut } from "@/lib/keyboard/utils";
|
||||
import { getShortcut, type StoredShortcutOverrides } from "@/lib/keyboard/shortcut-overrides";
|
||||
@@ -23,7 +24,9 @@ function handleTabNavigation(e: KeyboardEvent, api: DockviewApi) {
|
||||
|
||||
const direction = e.code === "BracketLeft" ? -1 : 1;
|
||||
const nextIndex = (currentIndex + direction + panels.length) % panels.length;
|
||||
panels[nextIndex].api.setActive();
|
||||
const nextPanel = panels[nextIndex];
|
||||
markSessionPanelUserActivationIntent(nextPanel.id);
|
||||
nextPanel.api.setActive();
|
||||
}
|
||||
|
||||
function handleTerminalToggle(
|
||||
|
||||
Reference in New Issue
Block a user