(null);
const finalizedRef = useRef(false);
- const [clampedPosition, setClampedPosition] = useState<{
- sourceX: number;
- sourceY: number;
- left: number;
- top: number;
- } | null>(null);
+ const { ref: menuRef, position: clampedPosition } = useClampedFixedPosition(
+ menu ? { x: menu.x, y: menu.y } : null,
+ renaming,
+ );
// Reset rename state when menu changes
useEffect(() => {
@@ -71,36 +69,10 @@ export function SessionContextMenu({
}
}, [renaming]);
- useLayoutEffect(() => {
- if (!menu) return;
- if (!menuRef.current) return;
- const { x, y } = menu;
- const padding = 8;
- const rect = menuRef.current.getBoundingClientRect();
- const maxLeft = Math.max(padding, window.innerWidth - rect.width - padding);
- const maxTop = Math.max(padding, window.innerHeight - rect.height - padding);
- const left = Math.min(Math.max(padding, x), maxLeft);
- const top = Math.min(Math.max(padding, y), maxTop);
- setClampedPosition((prev) => {
- if (
- prev?.sourceX === x &&
- prev.sourceY === y &&
- Math.abs(prev.left - left) < 0.5 &&
- Math.abs(prev.top - top) < 0.5
- ) {
- return prev;
- }
- return { sourceX: x, sourceY: y, left, top };
- });
- }, [menu, renaming]);
-
if (!menu) return null;
const { session, x, y } = menu;
- const menuPosition =
- clampedPosition?.sourceX === x && clampedPosition.sourceY === y
- ? { left: clampedPosition.left, top: clampedPosition.top }
- : { left: x, top: y };
+ const menuPosition = clampedPosition ?? { left: x, top: y };
const isRunning = session.status === "running";
const isChat = isChatToolType(session.toolType);
@@ -123,7 +95,10 @@ export function SessionContextMenu({
e.stopPropagation()}
>
{renaming && (
diff --git a/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.test.tsx b/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.test.tsx
new file mode 100644
index 000000000..a1276cfdb
--- /dev/null
+++ b/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.test.tsx
@@ -0,0 +1,108 @@
+/* @vitest-environment jsdom */
+
+import { act, cleanup, render, renderHook } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import React from "react";
+import { MemoryRouter } from "react-router-dom";
+import type { LaneSummary } from "../../../shared/types";
+import { useWorkLaneContextMenu } from "./useWorkLaneContextMenu";
+
+const navigate = vi.fn();
+const selectLane = vi.fn();
+const setWorkViewState = vi.fn();
+
+let capturedLaneContextMenuProps: Record | null = null;
+
+vi.mock("react-router-dom", async () => {
+ const actual = await vi.importActual("react-router-dom");
+ return {
+ ...actual,
+ useNavigate: () => navigate,
+ };
+});
+
+vi.mock("../../state/appStore", async () => {
+ const actual = await vi.importActual("../../state/appStore");
+ return {
+ ...actual,
+ useAppStore: (selector: (state: Record) => unknown) =>
+ selector({
+ lanes: [
+ {
+ id: "lane-remote",
+ name: "Remote Lane",
+ laneType: "worktree",
+ baseRef: "main",
+ branchRef: "remote-lane",
+ worktreePath: "/tmp/remote-lane",
+ parentLaneId: null,
+ childCount: 0,
+ stackDepth: 0,
+ parentStatus: null,
+ isEditProtected: false,
+ status: { dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false },
+ color: null,
+ icon: null,
+ tags: [],
+ createdAt: "2026-04-22T10:00:00.000Z",
+ } satisfies LaneSummary,
+ ],
+ project: { rootPath: "/local/project" },
+ projectBinding: { kind: "remote", rootPath: "/remote/project" },
+ selectLane,
+ setWorkViewState,
+ }),
+ };
+});
+
+vi.mock("../lanes/LaneContextMenu", () => ({
+ LaneContextMenu: (props: Record) => {
+ capturedLaneContextMenuProps = props;
+ return null;
+ },
+}));
+
+afterEach(() => {
+ cleanup();
+ capturedLaneContextMenuProps = null;
+ navigate.mockReset();
+ selectLane.mockReset();
+ setWorkViewState.mockReset();
+});
+
+describe("useWorkLaneContextMenu", () => {
+ it("persists start-chat draft state under the active project root for remote projects", () => {
+ const { result } = renderHook(() => useWorkLaneContextMenu(), {
+ wrapper: ({ children }) => {children},
+ });
+
+ act(() => {
+ result.current.trigger("lane-remote", {
+ preventDefault: vi.fn(),
+ clientX: 12,
+ clientY: 34,
+ });
+ });
+
+ render(<>{result.current.menu}>);
+
+ expect(capturedLaneContextMenuProps).not.toBeNull();
+ const onStartChatInLane = capturedLaneContextMenuProps?.onStartChatInLane as (laneId: string) => void;
+
+ act(() => {
+ onStartChatInLane("lane-remote");
+ });
+
+ expect(setWorkViewState).toHaveBeenCalledWith("/remote/project", expect.any(Function));
+ const updater = setWorkViewState.mock.calls[0]?.[1] as (prev: Record) => Record;
+ expect(updater({ draftKind: "cli", orchestratorEnabled: true, activeItemId: "session-1" })).toMatchObject({
+ draftKind: "chat",
+ orchestratorEnabled: false,
+ draftLaneId: "lane-remote",
+ activeItemId: null,
+ selectedItemId: null,
+ });
+ expect(selectLane).toHaveBeenCalledWith("lane-remote");
+ expect(navigate).toHaveBeenCalledWith("/work");
+ });
+});
diff --git a/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx b/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx
index 66efdb610..aafa652f3 100644
--- a/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx
+++ b/apps/desktop/src/renderer/components/terminals/useWorkLaneContextMenu.tsx
@@ -1,7 +1,8 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { useNavigate } from "react-router-dom";
-import { useAppStore } from "../../state/appStore";
+import { useAppStore, selectActiveProjectRoot } from "../../state/appStore";
+import { useStartChatInLane } from "../../hooks/useStartChatInLane";
import { LaneContextMenu } from "../lanes/LaneContextMenu";
type MenuState = { laneId: string; x: number; y: number };
@@ -18,6 +19,8 @@ export function useWorkLaneContextMenu(): {
const navigate = useNavigate();
const lanes = useAppStore((s) => s.lanes);
const selectLane = useAppStore((s) => s.selectLane);
+ const projectRoot = useAppStore(selectActiveProjectRoot);
+ const setWorkViewState = useAppStore((s) => s.setWorkViewState);
const [menuState, setMenuState] = useState(null);
@@ -57,6 +60,13 @@ export function useWorkLaneContextMenu(): {
[navigate, selectLane],
);
+ const startChatInLane = useStartChatInLane({
+ projectRoot,
+ setWorkViewState,
+ selectLane,
+ navigate,
+ });
+
const menu = menuState
? createPortal(
,
document.body,
)
diff --git a/apps/desktop/src/renderer/hooks/useClampedFixedPosition.test.ts b/apps/desktop/src/renderer/hooks/useClampedFixedPosition.test.ts
new file mode 100644
index 000000000..39ece6553
--- /dev/null
+++ b/apps/desktop/src/renderer/hooks/useClampedFixedPosition.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+import { clampFixedPosition } from "./useClampedFixedPosition";
+
+describe("clampFixedPosition", () => {
+ it("keeps menus inside the viewport on both axes", () => {
+ const result = clampFixedPosition(
+ { x: 900, y: 700 },
+ { width: 220, height: 320 },
+ 8,
+ { width: 1024, height: 768 },
+ );
+ expect(result.left).toBe(796);
+ expect(result.top).toBe(440);
+ });
+
+ it("respects padding when the menu is larger than the viewport", () => {
+ const result = clampFixedPosition(
+ { x: 12, y: 18 },
+ { width: 1200, height: 900 },
+ 8,
+ { width: 800, height: 600 },
+ );
+ expect(result.left).toBe(8);
+ expect(result.top).toBe(8);
+ });
+});
diff --git a/apps/desktop/src/renderer/hooks/useClampedFixedPosition.ts b/apps/desktop/src/renderer/hooks/useClampedFixedPosition.ts
new file mode 100644
index 000000000..8595bbdbc
--- /dev/null
+++ b/apps/desktop/src/renderer/hooks/useClampedFixedPosition.ts
@@ -0,0 +1,50 @@
+import { useLayoutEffect, useRef, useState, type MutableRefObject } from "react";
+
+export type FixedAnchor = { x: number; y: number };
+
+export type ClampedFixedPosition = {
+ left: number;
+ top: number;
+};
+
+export function clampFixedPosition(
+ anchor: FixedAnchor,
+ size: { width: number; height: number },
+ padding = 8,
+ viewport?: { width: number; height: number },
+): ClampedFixedPosition {
+ const viewportWidth = viewport?.width ?? (typeof window !== "undefined" ? window.innerWidth : size.width + padding * 2);
+ const viewportHeight = viewport?.height ?? (typeof window !== "undefined" ? window.innerHeight : size.height + padding * 2);
+ const maxLeft = Math.max(padding, viewportWidth - size.width - padding);
+ const maxTop = Math.max(padding, viewportHeight - size.height - padding);
+ return {
+ left: Math.min(Math.max(padding, anchor.x), maxLeft),
+ top: Math.min(Math.max(padding, anchor.y), maxTop),
+ };
+}
+
+/**
+ * Keeps a fixed-position popover inside the viewport after layout, using the
+ * element's measured size (handles menus that open near window edges).
+ */
+export function useClampedFixedPosition(
+ anchor: FixedAnchor | null,
+ remeasureKey: unknown = null,
+): {
+ ref: MutableRefObject;
+ position: ClampedFixedPosition | null;
+} {
+ const ref = useRef(null);
+ const [position, setPosition] = useState(null);
+
+ useLayoutEffect(() => {
+ if (!anchor || !ref.current) {
+ setPosition(null);
+ return;
+ }
+ const rect = ref.current.getBoundingClientRect();
+ setPosition(clampFixedPosition(anchor, { width: rect.width, height: rect.height }));
+ }, [anchor?.x, anchor?.y, remeasureKey]);
+
+ return { ref, position };
+}
diff --git a/apps/desktop/src/renderer/hooks/useStartChatInLane.ts b/apps/desktop/src/renderer/hooks/useStartChatInLane.ts
new file mode 100644
index 000000000..541316ea3
--- /dev/null
+++ b/apps/desktop/src/renderer/hooks/useStartChatInLane.ts
@@ -0,0 +1,33 @@
+import { useCallback } from "react";
+import type { WorkProjectViewState } from "../state/appStore";
+import { startChatDraftPatch } from "../lib/workDraft";
+
+type WorkViewStateUpdater = (
+ projectRoot: string,
+ next: WorkProjectViewState | ((prev: WorkProjectViewState) => WorkProjectViewState),
+) => void;
+
+export function useStartChatInLane({
+ projectRoot,
+ setWorkViewState,
+ selectLane,
+ navigate,
+}: {
+ projectRoot: string | null;
+ setWorkViewState: WorkViewStateUpdater;
+ selectLane: (laneId: string) => void;
+ navigate: (path: string) => void | Promise;
+}) {
+ return useCallback(
+ (laneId: string) => {
+ if (!projectRoot) return;
+ setWorkViewState(projectRoot, (prev) => ({
+ ...prev,
+ ...startChatDraftPatch(laneId),
+ }));
+ selectLane(laneId);
+ void navigate("/work");
+ },
+ [navigate, projectRoot, selectLane, setWorkViewState],
+ );
+}
diff --git a/apps/desktop/src/renderer/lib/workDraft.test.ts b/apps/desktop/src/renderer/lib/workDraft.test.ts
new file mode 100644
index 000000000..34e3ecd81
--- /dev/null
+++ b/apps/desktop/src/renderer/lib/workDraft.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vitest";
+import { startChatDraftPatch } from "./workDraft";
+
+describe("startChatDraftPatch", () => {
+ it("opens a chat draft on the requested lane and clears active session selection", () => {
+ expect(startChatDraftPatch("lane-42")).toEqual({
+ draftKind: "chat",
+ orchestratorEnabled: false,
+ draftLaneId: "lane-42",
+ activeItemId: null,
+ selectedItemId: null,
+ });
+ });
+});
diff --git a/apps/desktop/src/renderer/lib/workDraft.ts b/apps/desktop/src/renderer/lib/workDraft.ts
new file mode 100644
index 000000000..6dd7da2d7
--- /dev/null
+++ b/apps/desktop/src/renderer/lib/workDraft.ts
@@ -0,0 +1,16 @@
+import type { WorkProjectViewState } from "../state/appStore";
+
+export type StartChatDraftPatch = Pick<
+ WorkProjectViewState,
+ "draftKind" | "orchestratorEnabled" | "draftLaneId" | "activeItemId" | "selectedItemId"
+>;
+
+export function startChatDraftPatch(laneId: string): StartChatDraftPatch {
+ return {
+ draftKind: "chat",
+ orchestratorEnabled: false,
+ draftLaneId: laneId,
+ activeItemId: null,
+ selectedItemId: null,
+ };
+}