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
60 changes: 52 additions & 8 deletions src/renderer/components/thread/ThreadToolRail.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useLayoutEffect, useRef, useState } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { LucideIcon } from "lucide-react";
import { FileDiff, FolderOpen, NotebookPen, PanelRightOpen, TerminalSquare } from "lucide-react";
Expand Down Expand Up @@ -27,6 +27,7 @@ const railPillClass = "flex flex-col items-center gap-0.5 rounded-full p-1";
* each side — 920 + 2 × 44.
*/
const ALWAYS_OPEN_MIN_PANE_WIDTH = 1008;
const HEADER_MENU_CLOSE_DELAY_MS = 250;

interface RailTool {
id: string;
Expand Down Expand Up @@ -84,14 +85,32 @@ export function ThreadToolRail(props: {

const paneAnchorRef = useRef<HTMLSpanElement>(null);
const pillRef = useRef<HTMLDivElement>(null);
const headerMenuCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [paneElement, setPaneElement] = useState<HTMLElement | null>(null);
const [paneWidth, setPaneWidth] = useState<number | null>(null);
const [paneHeight, setPaneHeight] = useState<number | null>(null);
const [railHeight, setRailHeight] = useState<number | null>(null);
const [headerMenuPhase, setHeaderMenuPhase] = useState<HeaderMenuPhase>("ready");
const [headerMenuPointerOpen, setHeaderMenuPointerOpen] = useState(false);
const [headerMenuFocusOpen, setHeaderMenuFocusOpen] = useState(false);
const alwaysOpen =
paneCount === 1 && paneWidth !== null && paneWidth >= ALWAYS_OPEN_MIN_PANE_WIDTH;
const sideRailVisible = alwaysOpen && paneElement !== null && !sidePanelOpen;
const headerMenuOpen =
headerMenuPhase === "ready" && (headerMenuPointerOpen || headerMenuFocusOpen);

const cancelHeaderMenuClose = () => {
if (headerMenuCloseTimerRef.current === null) return;
clearTimeout(headerMenuCloseTimerRef.current);
headerMenuCloseTimerRef.current = null;
};

useEffect(
() => () => {
cancelHeaderMenuClose();
},
[],
);

useLayoutEffect(() => {
if (paneCount !== 1 || sidePanelOpen) return;
Expand Down Expand Up @@ -188,6 +207,9 @@ export function ThreadToolRail(props: {
if (!primaryTool) return null;

const suppressHeaderMenu = () => {
cancelHeaderMenuClose();
setHeaderMenuPointerOpen(false);
setHeaderMenuFocusOpen(false);
setHeaderMenuPhase("suppressed");
};

Expand Down Expand Up @@ -247,14 +269,38 @@ export function ThreadToolRail(props: {
<div
data-poracode-thread-tool-rail=""
data-placement="header"
className="poracode-overlay-header__controls group/thread-tools relative shrink-0"
className="poracode-overlay-header__controls relative shrink-0"
onPointerLeave={() => {
setHeaderMenuPhase((phase) => (phase === "suppressed" ? "awaiting-reentry" : phase));
if (headerMenuPhase === "suppressed") {
setHeaderMenuPhase("awaiting-reentry");
return;
}
cancelHeaderMenuClose();
headerMenuCloseTimerRef.current = setTimeout(() => {
headerMenuCloseTimerRef.current = null;
setHeaderMenuPointerOpen(false);
}, HEADER_MENU_CLOSE_DELAY_MS);
}}
onPointerEnter={() => {
setHeaderMenuPhase((phase) => (phase === "awaiting-reentry" ? "ready" : phase));
cancelHeaderMenuClose();
if (headerMenuPhase === "suppressed") return;
setHeaderMenuPointerOpen(true);
if (headerMenuPhase === "awaiting-reentry") setHeaderMenuPhase("ready");
}}
onFocusCapture={() => {
cancelHeaderMenuClose();
setHeaderMenuPhase("ready");
setHeaderMenuFocusOpen(true);
}}
onBlurCapture={(event) => {
if (
event.relatedTarget instanceof Node &&
event.currentTarget.contains(event.relatedTarget)
) {
return;
}
setHeaderMenuFocusOpen(false);
}}
onFocusCapture={() => setHeaderMenuPhase("ready")}
>
<button
type="button"
Expand All @@ -271,9 +317,7 @@ export function ThreadToolRail(props: {
<div
data-poracode-thread-tool-menu=""
className={`pointer-events-none invisible absolute left-1/2 top-full z-30 w-9 -translate-x-1/2 opacity-0 transition-opacity duration-150 ${
headerMenuPhase !== "ready"
? ""
: "group-hover/thread-tools:pointer-events-auto group-hover/thread-tools:visible group-hover/thread-tools:opacity-100 group-focus-within/thread-tools:pointer-events-auto group-focus-within/thread-tools:visible group-focus-within/thread-tools:opacity-100"
headerMenuOpen ? "pointer-events-auto visible opacity-100" : ""
}`}
>
<div className="pt-1">
Expand Down
120 changes: 69 additions & 51 deletions src/renderer/components/thread/ThreadView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1641,59 +1641,77 @@ describe("ThreadView", () => {
expect(hasAncestorWithClassFragment(terminalPane.parentElement, "max-w-[1040px]")).toBe(true);
});

it("moves thread tools into the header when the pane cannot clear a side rail", () => {
renderThreadView({
thread: {
id: "thread-split-tool-menu",
projectId: "project-1",
title: "Split pane thread",
agentKind: "codex",
config: {
model: "gpt-5.4",
it("keeps header thread tools open while the pointer crosses into the menu", async () => {
vi.useFakeTimers();
try {
renderThreadView({
thread: {
id: "thread-split-tool-menu",
projectId: "project-1",
title: "Split pane thread",
agentKind: "codex",
config: {
model: "gpt-5.4",
},
status: "idle",
attention: "none",
canResumeWithConfig: true,
archived: false,
done: false,
starred: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
agentStatus: undefined,
projectLocation: {
kind: "windows",
path: "C:\\repo",
},
status: "idle",
attention: "none",
canResumeWithConfig: true,
archived: false,
done: false,
starred: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
agentStatus: undefined,
projectLocation: {
kind: "windows",
path: "C:\\repo",
},
paneCount: 2,
onMarkDone: () => undefined,
});
paneCount: 2,
onMarkDone: () => undefined,
});

const headerTrigger = screen.getByRole("button", { name: "Show thread tools" });
const headerMenu = headerTrigger.closest("[data-poracode-thread-tool-rail]");
const toolMenu = headerMenu?.querySelector("[data-poracode-thread-tool-menu]");
const doneButton = screen.getByRole("button", { name: "Mark done" });

expect(headerMenu).toHaveAttribute("data-placement", "header");
expect(headerMenu).not.toHaveClass("invisible");
expect(toolMenu).toHaveClass("left-1/2", "w-9", "-translate-x-1/2");
expect(toolMenu).toHaveClass("transition-opacity");
expect(toolMenu).not.toHaveClass("grid-rows-[0fr]");
expect(toolMenu).toHaveClass("group-hover/thread-tools:visible");
expect(doneButton.nextElementSibling).toBe(headerMenu);
expect(hasAncestorWithClassFragment(headerMenu as HTMLElement, "@container")).toBe(true);
expect(headerMenu?.querySelector('[aria-label="Git"]')).not.toBeNull();
expect(headerMenu?.querySelector('[aria-label="Files"]')).not.toBeNull();
expect(headerMenu?.querySelector('[aria-label="Terminal"]')).not.toBeNull();
expect(headerMenu?.querySelector('[aria-label="Notes"]')).not.toBeNull();

fireEvent.click(headerTrigger);
expect(toolMenu).not.toHaveClass("group-hover/thread-tools:visible");
fireEvent.pointerLeave(headerMenu as HTMLElement);
expect(toolMenu).not.toHaveClass("group-hover/thread-tools:visible");
fireEvent.pointerEnter(headerMenu as HTMLElement);
expect(toolMenu).toHaveClass("group-hover/thread-tools:visible");
fireEvent.click(headerTrigger);
const headerTrigger = screen.getByRole("button", { name: "Show thread tools" });
const headerMenu = headerTrigger.closest("[data-poracode-thread-tool-rail]");
const toolMenu = headerMenu?.querySelector("[data-poracode-thread-tool-menu]");
const doneButton = screen.getByRole("button", { name: "Mark done" });

expect(headerMenu).toHaveAttribute("data-placement", "header");
expect(headerMenu).not.toHaveClass("invisible");
expect(toolMenu).toHaveClass("left-1/2", "w-9", "-translate-x-1/2");
expect(toolMenu).toHaveClass("transition-opacity");
expect(toolMenu).not.toHaveClass("grid-rows-[0fr]");
expect(toolMenu).toHaveClass("invisible");
expect(doneButton.nextElementSibling).toBe(headerMenu);
expect(hasAncestorWithClassFragment(headerMenu as HTMLElement, "@container")).toBe(true);
expect(headerMenu?.querySelector('[aria-label="Git"]')).not.toBeNull();
expect(headerMenu?.querySelector('[aria-label="Files"]')).not.toBeNull();
expect(headerMenu?.querySelector('[aria-label="Terminal"]')).not.toBeNull();
expect(headerMenu?.querySelector('[aria-label="Notes"]')).not.toBeNull();

fireEvent.pointerEnter(headerMenu as HTMLElement);
expect(toolMenu).toHaveClass("visible");

fireEvent.pointerLeave(headerMenu as HTMLElement);
expect(toolMenu).toHaveClass("visible");
fireEvent.pointerEnter(toolMenu as HTMLElement);
await act(() => vi.advanceTimersByTimeAsync(300));
expect(toolMenu).toHaveClass("visible");

fireEvent.pointerLeave(headerMenu as HTMLElement);
await act(() => vi.advanceTimersByTimeAsync(300));
expect(toolMenu).toHaveClass("invisible");

fireEvent.pointerEnter(headerMenu as HTMLElement);
fireEvent.click(headerTrigger);
expect(toolMenu).toHaveClass("invisible");
fireEvent.pointerLeave(headerMenu as HTMLElement);
fireEvent.pointerEnter(headerMenu as HTMLElement);
expect(toolMenu).toHaveClass("visible");
fireEvent.click(headerTrigger);
} finally {
vi.useRealTimers();
}
});

it("allows queued follow-ups and stop while a GUI ACP thread is running", async () => {
Expand Down