(null);
+ const requestClose = useCallback(
+ (terminalId: string, anchor: HTMLElement | null) => {
+ const terminal = terminals.find((t) => t.id === terminalId);
+ if (!terminal || !anchor) return false;
+ closeAnchorRef.current = anchor;
+ setPendingClose(terminal);
+ return true;
+ },
+ [terminals],
+ );
+
const handleAskCloseTab = useCallback(
(event: MouseEvent, terminalId: string) => {
- const terminal = terminals.find((t) => t.id === terminalId);
- if (terminal) {
+ if (requestClose(terminalId, event.currentTarget as HTMLElement)) {
event.preventDefault();
event.stopPropagation();
- closeAnchorRef.current = event.currentTarget as HTMLElement;
- setPendingClose(terminal);
return;
}
handleCloseTab(event, terminalId);
},
- [terminals, handleCloseTab],
+ [requestClose, handleCloseTab],
);
const handleConfirmClose = useCallback(async () => {
@@ -233,6 +283,7 @@ function useConfirmableTerminalClose({
setPendingClose,
closeAnchorRef,
handleAskCloseTab,
+ requestClose,
handleConfirmClose,
};
}
@@ -447,7 +498,7 @@ function useTaskRightPanel({
handleCloseDevTab,
handleCloseTab: closeConfirm.handleAskCloseTab,
renameTerminal,
- destroyTerminal,
+ requestCloseFromContext: closeConfirm.requestClose,
sessionId,
setRightPanelActiveTab,
});
diff --git a/apps/web/components/task/terminal-tab.test.tsx b/apps/web/components/task/terminal-tab.test.tsx
index 5bcc43fa14..47b914162e 100644
--- a/apps/web/components/task/terminal-tab.test.tsx
+++ b/apps/web/components/task/terminal-tab.test.tsx
@@ -71,14 +71,30 @@ vi.mock("@kandev/ui/context-menu", () => ({
}: {
children: React.ReactNode;
[key: string]: unknown;
- }) => {children}
,
+ }) => (
+
+ {children}
+
+ ),
ContextMenuContent: ({
children,
onCloseAutoFocus,
}: {
children: React.ReactNode;
onCloseAutoFocus?: (event: { preventDefault: () => void }) => void;
- }) => onCloseAutoFocus?.({ preventDefault: vi.fn() })}>{children}
,
+ }) => (
+ {
+ const preventDefault = vi.fn();
+ onCloseAutoFocus?.({ preventDefault });
+ if (!preventDefault.mock.calls.length) {
+ document.querySelector('[data-testid="terminal-tab-shell-1"]')?.focus();
+ }
+ }}
+ >
+ {children}
+
+ ),
ContextMenuItem: ({
children,
onClick,
@@ -169,6 +185,16 @@ describe("TerminalTab", () => {
expect(mockDestroyUserShell).toHaveBeenCalledTimes(1);
});
+ it("focuses the inline rename input after the context menu closes", async () => {
+ render();
+
+ fireEvent.click(screen.getByRole("button", { name: /^Rename/ }));
+
+ await waitFor(() => {
+ expect(document.activeElement).toBe(screen.getByTestId("terminal-tab-rename-input"));
+ });
+ });
+
it("keeps the panel dismissed and reports an error if destroy fails", async () => {
mockDestroyUserShell.mockRejectedValueOnce(new Error("network down"));
render();
diff --git a/apps/web/components/task/terminal-tab.tsx b/apps/web/components/task/terminal-tab.tsx
index 32af0a4222..656d0afff7 100644
--- a/apps/web/components/task/terminal-tab.tsx
+++ b/apps/web/components/task/terminal-tab.tsx
@@ -342,7 +342,7 @@ function TerminalTabBody({
);
}
-function TerminalTabMenu({
+export function TerminalTabMenu({
canMutate,
onStartRename,
onClosePanel,
@@ -354,28 +354,37 @@ function TerminalTabMenu({
onTerminatePanel: () => void;
}) {
const { t } = useTranslation();
- const requestTerminateAfterCloseRef = useRef(false);
+ const pendingActionAfterCloseRef = useRef<"rename" | "terminate" | null>(null);
+
+ const handleRename = useCallback(() => {
+ pendingActionAfterCloseRef.current = "rename";
+ }, []);
const handleTerminate = useCallback(() => {
if (!canMutate) {
onClosePanel();
return;
}
- requestTerminateAfterCloseRef.current = true;
+ pendingActionAfterCloseRef.current = "terminate";
}, [canMutate, onClosePanel]);
return (
{
- if (!requestTerminateAfterCloseRef.current) return;
- requestTerminateAfterCloseRef.current = false;
+ const pendingAction = pendingActionAfterCloseRef.current;
+ if (!pendingAction) return;
+ pendingActionAfterCloseRef.current = null;
event.preventDefault();
+ if (pendingAction === "rename") {
+ queueMicrotask(onStartRename);
+ return;
+ }
onTerminatePanel();
}}
>
{canMutate && (
<>
- {t("task:rename2")}
+ {t("task:rename2")}
>
)}
diff --git a/apps/web/e2e/tests/terminal/terminal-dockview-ui.spec.ts b/apps/web/e2e/tests/terminal/terminal-dockview-ui.spec.ts
index 0bf33b726f..d790298893 100644
--- a/apps/web/e2e/tests/terminal/terminal-dockview-ui.spec.ts
+++ b/apps/web/e2e/tests/terminal/terminal-dockview-ui.spec.ts
@@ -58,6 +58,42 @@ async function openTask(page: Page, title: string): Promise {
return session;
}
+async function openTabletTask(page: Page, taskId: string): Promise {
+ await page.goto("/");
+ await page.evaluate(() => {
+ window.localStorage.setItem("task-right-panel-collapsed", "false");
+ });
+ await page.goto(`/t/${taskId}`);
+ const session = new SessionPage(page);
+ await session.waitForLoad();
+ await expect(page.getByTestId("tablet-task-layout")).toBeVisible({ timeout: 15_000 });
+ return session;
+}
+
+async function listTerminalIds(
+ apiClient: ApiClient,
+ taskId: string,
+ environmentId: string,
+): Promise {
+ const response = await apiClient.wsRequest<{
+ shells?: Array<{ id?: string; terminal_id?: string }>;
+ }>("user_shell.list", {
+ task_id: taskId,
+ task_environment_id: environmentId,
+ include_parked: true,
+ });
+ return (response.shells ?? [])
+ .map((shell) => shell.id ?? shell.terminal_id)
+ .filter((id): id is string => Boolean(id));
+}
+
+async function settleBrowserFrames(page: Page) {
+ await page.evaluate(
+ () =>
+ new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))),
+ );
+}
+
async function clickNewTerminalInPlusMenu(page: Page, session: SessionPage) {
await session.addPanelButton().click();
const menu = page
@@ -73,6 +109,96 @@ async function clickNewTerminalInPlusMenu(page: Page, session: SessionPage) {
}
test.describe("Terminals — dockview UI", () => {
+ test("right-panel terminal context menu supports inline rename and confirmed terminate", async ({
+ tabletTestPage,
+ apiClient,
+ seedData,
+ }) => {
+ test.setTimeout(120_000);
+ const task = await createTaskAndWait(apiClient, seedData, "Right Panel Context Menu");
+ const { sessions } = await apiClient.listTaskSessions(task.id);
+ const environmentId = sessions[0]?.task_environment_id;
+ expect(environmentId).toBeTruthy();
+ if (!environmentId) throw new Error("task session is missing an environment id");
+
+ const first = await apiClient.wsRequest<{ terminal_id: string }>("user_shell.create", {
+ task_id: task.id,
+ task_environment_id: environmentId,
+ });
+ const second = await apiClient.wsRequest<{ terminal_id: string }>("user_shell.create", {
+ task_id: task.id,
+ task_environment_id: environmentId,
+ });
+ await openTabletTask(tabletTestPage, task.id);
+ const firstTab = tabletTestPage.getByTestId(`terminal-tab-${first.terminal_id}`);
+ const secondTab = tabletTestPage.getByTestId(`terminal-tab-${second.terminal_id}`);
+ const firstRenameInput = tabletTestPage
+ .getByTestId(`terminal-tab-${first.terminal_id}`)
+ .locator("..")
+ .getByTestId(`terminal-tab-${first.terminal_id}-content`)
+ .getByTestId("terminal-tab-rename-input");
+ const secondRenameInput = tabletTestPage
+ .getByTestId(`terminal-tab-${second.terminal_id}`)
+ .locator("..")
+ .getByTestId(`terminal-tab-${second.terminal_id}-content`)
+ .getByTestId("terminal-tab-rename-input");
+ await expect(firstTab).toBeVisible({ timeout: 15_000 });
+ await expect(secondTab).toBeVisible({ timeout: 15_000 });
+
+ // Double-click keeps the existing inline rename entry point functional.
+ await firstTab.dblclick();
+ await expect(firstRenameInput).toBeVisible();
+ await expect(firstRenameInput).toBeFocused();
+ await firstRenameInput.fill("double click rename");
+ await tabletTestPage.keyboard.press("Enter");
+ await expect(firstTab).toContainText("double click rename", { timeout: 10_000 });
+
+ // The close button still opens the same local confirmation and returns
+ // focus to that control when cancelled.
+ await firstTab.hover();
+ const firstClose = tabletTestPage.getByTestId(`terminal-tab-close-${first.terminal_id}`);
+ await firstClose.click();
+ const closeConfirmation = tabletTestPage.getByTestId("terminal-close-confirm-popover");
+ await expect(closeConfirmation).toBeVisible();
+ await closeConfirmation.getByRole("button", { name: "Cancel", exact: true }).click();
+ await expect(firstClose).toBeFocused();
+ await expect(firstTab).toBeVisible();
+
+ // Context-menu actions are separate and do not invoke browser-native
+ // prompt() or destroy the shell before the anchored confirmation.
+ let nativeDialogSeen = false;
+ tabletTestPage.once("dialog", (dialog) => {
+ nativeDialogSeen = true;
+ void dialog.dismiss();
+ });
+ await secondTab.click({ button: "right" });
+ await tabletTestPage.getByRole("menuitem", { name: /^Rename/ }).click();
+ await expect(secondRenameInput).toBeVisible();
+ await expect(secondRenameInput).toBeFocused();
+ await secondRenameInput.fill("context menu rename");
+ await tabletTestPage.keyboard.press("Enter");
+ await expect(secondTab).toContainText("context menu rename", { timeout: 10_000 });
+
+ await secondTab.click({ button: "right" });
+ await tabletTestPage.getByRole("menuitem", { name: /^Terminate/ }).click();
+ await expect(closeConfirmation).toBeVisible();
+ expect(nativeDialogSeen).toBe(false);
+ await expect(secondTab).toBeVisible();
+ await settleBrowserFrames(tabletTestPage);
+ expect(
+ await listTerminalIds(apiClient, task.id, environmentId),
+ "context-menu terminate must wait for confirmation",
+ ).toContain(second.terminal_id);
+
+ await closeConfirmation.getByRole("button", { name: "Close terminal", exact: true }).click();
+ await expect(secondTab).toHaveCount(0, { timeout: 10_000 });
+ await expect
+ .poll(() => listTerminalIds(apiClient, task.id, environmentId), {
+ message: "confirmed context-menu terminate should destroy the shell",
+ })
+ .not.toContain(second.terminal_id);
+ });
+
/**
* Regression: the tab title for ordinary terminals should be the
* literal "Terminal" (no " N" suffix) with the sequence number in a