Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/desktop/src/renderer/components/app/TabNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
GearSix,
} from "@phosphor-icons/react";
import { cn } from "../ui/cn";
import { useClampedFixedPosition } from "../../hooks/useClampedFixedPosition";
import { useAppStore } from "../../state/appStore";
import { revealLabel } from "../../lib/platform";
import { openExternalUrl } from "../../lib/openExternal";
Expand Down Expand Up @@ -106,6 +107,7 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null })
projectBinding?.kind === "remote" ? projectBinding.rootPath : (project?.rootPath ?? null);
const hasActiveProject = Boolean(activeProjectRoot);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const { ref: sidebarMenuRef, position: sidebarMenuPosition } = useClampedFixedPosition(contextMenu);
const [avatarBroken, setAvatarBroken] = useState(false);
const [isPackaged, setIsPackaged] = useState(false);
const githubLogin = githubStatus?.userLogin || null;
Expand Down Expand Up @@ -329,8 +331,13 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null })
{/* Context menu */}
{contextMenu && activeProjectRoot ? (
<div
ref={sidebarMenuRef}
className="ade-shell-sidebar-menu fixed z-40 min-w-[170px] p-1 shadow-float"
style={{ left: contextMenu.x, top: contextMenu.y }}
style={{
left: sidebarMenuPosition?.left ?? contextMenu.x,
top: sidebarMenuPosition?.top ?? contextMenu.y,
visibility: sidebarMenuPosition ? "visible" : "hidden",
}}
onPointerDown={(e) => e.stopPropagation()}
>
<button
Expand Down
14 changes: 7 additions & 7 deletions apps/desktop/src/renderer/components/files/v2/ContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useEffect, useRef } from "react";
import React, { useEffect } from "react";
import { COLORS } from "../../lanes/laneDesignTokens";
import { useClampedFixedPosition } from "../../../hooks/useClampedFixedPosition";

export type ContextMenuItem =
| { type: "separator" }
Expand Down Expand Up @@ -27,7 +28,8 @@ export function ContextMenu({
items: ContextMenuItem[];
onClose: () => void;
}) {
const ref = useRef<HTMLDivElement | null>(null);
const itemsKey = items.map((item) => (item.type === "separator" ? "|" : `${item.label}:${item.disabled ? "0" : "1"}`)).join("\0");
const { ref, position } = useClampedFixedPosition({ x, y }, itemsKey);

useEffect(() => {
const onDocMouseDown = (e: MouseEvent) => {
Expand All @@ -47,18 +49,16 @@ export function ContextMenu({
}, [onClose]);

const width = 220;
const estHeight = items.reduce((h, it) => h + (it.type === "separator" ? 9 : ROW_H), 8);
const left = Math.max(6, Math.min(x, window.innerWidth - width - 6));
const top = Math.max(6, Math.min(y, window.innerHeight - estHeight - 6));

return (
<div
ref={ref}
className="fixed z-[200] py-1"
style={{
left,
top,
left: position?.left ?? x,
top: position?.top ?? y,
width,
visibility: position ? "visible" : "hidden",
background: COLORS.cardBgSolid,
border: `1px solid ${COLORS.outlineBorder}`,
borderRadius: 10,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { Button } from "../ui/Button";
import { Chip } from "../ui/Chip";
import { EmptyState } from "../ui/EmptyState";
import { cn } from "../ui/cn";
import { useClampedFixedPosition } from "../../hooks/useClampedFixedPosition";
import { useLaneAgents, type LaneAgent } from "../lanes/laneAgents";
import { openAgentInWorkTabPath } from "../../lib/laneNavigation";
import {
Expand Down Expand Up @@ -342,6 +343,10 @@ function GraphInner({ active = true }: { active?: boolean }) {
const [loadingRisk, setLoadingRisk] = React.useState(true);
const [errorBanner, setErrorBanner] = React.useState<string | null>(null);
const [contextMenu, setContextMenu] = React.useState<{ laneId: string; x: number; y: number } | null>(null);
const { ref: graphContextMenuRef, position: graphContextMenuPosition } = useClampedFixedPosition(
contextMenu ? { x: contextMenu.x, y: contextMenu.y } : null,
contextMenu?.laneId ?? null,
);
const [selectedLaneIds, setSelectedLaneIds] = React.useState<string[]>([]);
const [batchStatus, setBatchStatus] = React.useState<{
operation: string;
Expand Down Expand Up @@ -3531,8 +3536,13 @@ function GraphInner({ active = true }: { active?: boolean }) {

{contextMenu ? (
<div
ref={graphContextMenuRef}
className="fixed z-[90] min-w-[190px] rounded-xl border border-white/[0.06] bg-white/[0.03] backdrop-blur-xl p-1 shadow-float"
style={{ left: contextMenu.x, top: contextMenu.y }}
style={{
left: graphContextMenuPosition?.left ?? contextMenu.x,
top: graphContextMenuPosition?.top ?? contextMenu.y,
visibility: graphContextMenuPosition ? "visible" : "hidden",
}}
onMouseLeave={() => setContextMenu(null)}
>
{(() => {
Expand Down
30 changes: 27 additions & 3 deletions apps/desktop/src/renderer/components/lanes/LaneContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from "react";
import type { LaneSummary } from "../../../shared/types";
import { useClampedFixedPosition } from "../../hooks/useClampedFixedPosition";
import { revealLabel } from "../../lib/platform";
import { useAppStore } from "../../state/appStore";
import { COLORS, MONO_FONT } from "./laneDesignTokens";
Expand Down Expand Up @@ -83,6 +84,7 @@ export function LaneContextMenu({
onSelectAll,
onBatchManage,
onAppearanceChanged,
onStartChatInLane,
}: {
laneContextMenu: { laneId: string; x: number; y: number };
lanesById: Map<string, LaneSummary>;
Expand All @@ -97,6 +99,7 @@ export function LaneContextMenu({
onSelectAll: () => void;
onBatchManage: (laneIds: string[]) => void;
onAppearanceChanged?: () => void | Promise<void>;
onStartChatInLane?: (laneId: string) => void;
}) {
const isRemoteProject = useAppStore((s) => s.projectBinding?.kind === "remote");
const ctxLane = lanesById.get(laneContextMenu.laneId) ?? null;
Expand All @@ -108,7 +111,10 @@ export function LaneContextMenu({
return lane && lane.laneType !== "primary";
});

const menuRef = React.useRef<HTMLDivElement>(null);
const { ref: menuRef, position: menuPosition } = useClampedFixedPosition(
{ x: laneContextMenu.x, y: laneContextMenu.y },
`${laneContextMenu.laneId}:${ctxLane?.id ?? ""}`,
);

React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
Expand Down Expand Up @@ -139,8 +145,9 @@ export function LaneContextMenu({
overflowY: "auto",
border: `1px solid ${COLORS.outlineBorder}`,
padding: "4px 0",
left: laneContextMenu.x,
top: Math.min(laneContextMenu.y, window.innerHeight - 20),
left: menuPosition?.left ?? laneContextMenu.x,
top: menuPosition?.top ?? laneContextMenu.y,
visibility: menuPosition ? "visible" : "hidden",
}}
onPointerDown={(e) => e.stopPropagation()}
>
Expand Down Expand Up @@ -234,6 +241,23 @@ export function LaneContextMenu({
</>
) : null}

{ctxLane && onStartChatInLane ? (
<>
<div style={{ height: 1, background: COLORS.border, margin: "4px 0" }} />
<HoverButton
style={menuItemStyle}
dataTour="lanes.startChatInLane"
onClick={() => {
const ctxLaneId = laneContextMenu.laneId;
onClose();
onStartChatInLane(ctxLaneId);
}}
>
Start chat in lane
</HoverButton>
</>
) : null}

{ctxLane && !isPrimary ? (
<>
<div style={{ height: 1, background: COLORS.border, margin: "4px 0" }} />
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/renderer/components/lanes/LanesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { ResizeGutter } from "../ui/ResizeGutter";
import { LaneStackPane } from "./LaneStackPane";
import { useLaneAgents, type LaneAgent } from "./laneAgents";
import { openAgentInWorkTabPath } from "../../lib/laneNavigation";
import { useStartChatInLane } from "../../hooks/useStartChatInLane";
import {
consumeLaunchedLanesHighlight,
subscribeLaunchedLanesHighlight,
Expand Down Expand Up @@ -423,6 +424,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) {
const setLaneInspectorTab = useAppStore((s) => s.setLaneInspectorTab);
const clearLaneInspectorTab = useAppStore((s) => s.clearLaneInspectorTab);
const setLaneWorkViewState = useAppStore((s) => s.setLaneWorkViewState);
const setWorkViewState = useAppStore((s) => s.setWorkViewState);
const keybindings = useAppStore((s) => s.keybindings);
const activeProjectRoot = useAppStore(selectActiveProjectRoot);
const getActiveProjectRoot = useCallback(() => {
Expand Down Expand Up @@ -1277,6 +1279,13 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) {
return () => window.removeEventListener("pointerdown", onPointerDown);
}, [laneContextMenu]);

const startChatInLane = useStartChatInLane({
projectRoot: activeProjectRoot,
setWorkViewState,
selectLane,
navigate,
});

useEffect(() => {
if (!adoptTargetLaneId) return;
if (lanesById.has(adoptTargetLaneId)) return;
Expand Down Expand Up @@ -4211,6 +4220,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) {
}}
onBatchManage={openBatchManage}
onAppearanceChanged={() => refreshLanes({ includeStatus: false }).catch(() => {})}
onStartChatInLane={startChatInLane}
/>
) : null}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useRef, useEffect, useLayoutEffect } from "react";
import { useState, useRef, useEffect } from "react";
import type { TerminalSessionSummary } from "../../../shared/types";
import { useClampedFixedPosition } from "../../hooks/useClampedFixedPosition";
import { isChatToolType } from "../../lib/sessions";

export type SessionContextMenuState = {
Expand Down Expand Up @@ -47,14 +48,11 @@ export function SessionContextMenu({
const [renaming, setRenaming] = useState(false);
const [draft, setDraft] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const menuRef = useRef<HTMLDivElement>(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(() => {
Expand All @@ -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);

Expand All @@ -123,7 +95,10 @@ export function SessionContextMenu({
<div
ref={menuRef}
className="ade-liquid-glass-menu fixed z-50 min-w-[180px] py-1"
style={menuPosition}
style={{
...menuPosition,
visibility: clampedPosition ? "visible" : "hidden",
}}
onPointerDown={(e) => e.stopPropagation()}
>
{renaming && (
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null = null;

vi.mock("react-router-dom", async () => {
const actual = await vi.importActual<typeof import("react-router-dom")>("react-router-dom");
return {
...actual,
useNavigate: () => navigate,
};
});

vi.mock("../../state/appStore", async () => {
const actual = await vi.importActual<typeof import("../../state/appStore")>("../../state/appStore");
return {
...actual,
useAppStore: (selector: (state: Record<string, unknown>) => 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<string, unknown>) => {
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 }) => <MemoryRouter>{children}</MemoryRouter>,
});

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<string, unknown>) => Record<string, unknown>;
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");
});
});
Loading
Loading