diff --git a/packages/app/e2e/helpers/rename.ts b/packages/app/e2e/helpers/rename.ts new file mode 100644 index 000000000..f2b18949f --- /dev/null +++ b/packages/app/e2e/helpers/rename.ts @@ -0,0 +1,40 @@ +import { type Page } from "@playwright/test"; + +/** + * Listens for outbound WebSocket "session" frames of a given inner message type + * and accumulates them. The returned array is populated in-place as frames arrive. + */ +export function captureWsSessionFrames>( + page: Page, + messageType: string, + extract: (inner: Record) => T, +): T[] { + const captured: T[] = []; + page.on("websocket", (ws) => { + ws.on("framesent", (frame) => { + const raw = frame.payload; + const text = typeof raw === "string" ? raw : raw.toString("utf8"); + try { + const outer = JSON.parse(text) as { type?: string; message?: Record }; + if (outer.type === "session" && outer.message?.type === messageType) { + captured.push(extract(outer.message)); + } + } catch { + // Ignore non-JSON and binary frames. + } + }); + }); + return captured; +} + +export function renameModalInput(page: Page, testIdPrefix: string) { + return page.getByTestId(`${testIdPrefix}-input`); +} + +export function renameModalSubmit(page: Page, testIdPrefix: string) { + return page.getByTestId(`${testIdPrefix}-submit`); +} + +export function renameModalError(page: Page, testIdPrefix: string) { + return page.getByTestId(`${testIdPrefix}-error`); +} diff --git a/packages/app/e2e/helpers/settings.ts b/packages/app/e2e/helpers/settings.ts index aa16c1a25..7302cebbf 100644 --- a/packages/app/e2e/helpers/settings.ts +++ b/packages/app/e2e/helpers/settings.ts @@ -168,7 +168,7 @@ export async function expectGeneralContent(page: Page): Promise { export async function expectHostLabelDisplayed(page: Page): Promise { await expect(page.getByTestId("host-page-label-edit-button")).toBeVisible(); - await expect(page.getByTestId("host-page-label-input")).toHaveCount(0); + await expect(page.getByTestId("host-page-rename-modal-input")).toHaveCount(0); } export async function clickEditHostLabel(page: Page): Promise { @@ -176,9 +176,9 @@ export async function clickEditHostLabel(page: Page): Promise { } export async function expectHostLabelEditMode(page: Page, expectedLabel: string): Promise { - await expect(page.getByTestId("host-page-label-input")).toBeVisible(); - await expect(page.getByTestId("host-page-label-input")).toHaveValue(expectedLabel); - await expect(page.getByTestId("host-page-label-save")).toBeVisible(); + await expect(page.getByTestId("host-page-rename-modal-input")).toBeVisible(); + await expect(page.getByTestId("host-page-rename-modal-input")).toHaveValue(expectedLabel); + await expect(page.getByTestId("host-page-rename-modal-submit")).toBeVisible(); } export async function expectHostConnectionsCard(page: Page, port: string): Promise { diff --git a/packages/app/e2e/sidebar-workspace-rename.spec.ts b/packages/app/e2e/sidebar-workspace-rename.spec.ts new file mode 100644 index 000000000..b51d0f13c --- /dev/null +++ b/packages/app/e2e/sidebar-workspace-rename.spec.ts @@ -0,0 +1,138 @@ +import { execSync } from "node:child_process"; +import { test, expect, type Page } from "./fixtures"; +import { gotoAppShell } from "./helpers/app"; +import { createTempGitRepo } from "./helpers/workspace"; +import { connectWorkspaceSetupClient } from "./helpers/workspace-setup"; +import { captureWsSessionFrames } from "./helpers/rename"; + +function getServerId(): string { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); + } + return serverId; +} + +function workspaceRowTestId(workspaceId: string): string { + return `sidebar-workspace-row-${getServerId()}:${workspaceId}`; +} + +function workspaceRenameModalTestId(workspaceId: string, suffix: string): string { + return `sidebar-workspace-rename-modal-${getServerId()}:${workspaceId}-${suffix}`; +} + +async function openProjectViaDaemon( + client: Awaited>, + cwd: string, +): Promise<{ id: string; name: string; workspaceDirectory: string }> { + const result = await client.openProject(cwd); + if (!result.workspace || result.error) { + throw new Error(result.error ?? `Failed to open project ${cwd}`); + } + return { + id: String(result.workspace.id), + name: result.workspace.name, + workspaceDirectory: result.workspace.workspaceDirectory, + }; +} + +async function openRenameModal(page: Page, workspaceId: string) { + const serverId = getServerId(); + const row = page.getByTestId(`sidebar-workspace-row-${serverId}:${workspaceId}`); + await expect(row).toBeVisible({ timeout: 30_000 }); + await row.hover(); + + const kebab = page.getByTestId(`sidebar-workspace-kebab-${serverId}:${workspaceId}`); + await expect(kebab).toBeVisible({ timeout: 10_000 }); + await kebab.click(); + + const renameItem = page.getByTestId(`sidebar-workspace-menu-rename-${serverId}:${workspaceId}`); + await expect(renameItem).toBeVisible({ timeout: 10_000 }); + await renameItem.click(); + + const input = page.getByTestId(workspaceRenameModalTestId(workspaceId, "input")); + await expect(input).toBeVisible({ timeout: 10_000 }); + return input; +} + +test.describe("Sidebar workspace rename", () => { + test("renaming via kebab updates the branch name on disk and in the sidebar", async ({ + page, + }) => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("sidebar-rename-"); + + try { + const workspace = await openProjectViaDaemon(client, repo.path); + expect(workspace.name).toBe("main"); + + const renameRequests = captureWsSessionFrames( + page, + "checkout.rename_branch.request", + (inner) => ({ + branch: String(inner.branch ?? ""), + cwd: String(inner.cwd ?? ""), + }), + ); + + await gotoAppShell(page); + await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toBeVisible({ + timeout: 30_000, + }); + + const input = await openRenameModal(page, workspace.id); + await expect(input).toHaveValue("main"); + await input.fill("Feature Rename 2"); + + await page.getByTestId(workspaceRenameModalTestId(workspace.id, "submit")).click(); + + await expect(input).toHaveCount(0, { timeout: 15_000 }); + await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toContainText( + "feature-rename-2", + { timeout: 15_000 }, + ); + + expect(renameRequests.length).toBeGreaterThan(0); + expect(renameRequests.at(-1)).toEqual({ + branch: "feature-rename-2", + cwd: workspace.workspaceDirectory, + }); + + const currentBranchOnDisk = execSync("git branch --show-current", { + cwd: repo.path, + stdio: "pipe", + }) + .toString() + .trim(); + expect(currentBranchOnDisk).toBe("feature-rename-2"); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + + test("rename surfaces server errors inline and keeps the modal open", async ({ page }) => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("sidebar-rename-error-", { branches: ["taken"] }); + + try { + const workspace = await openProjectViaDaemon(client, repo.path); + + await gotoAppShell(page); + const input = await openRenameModal(page, workspace.id); + await expect(input).toHaveValue("main"); + + await input.fill("taken"); + await page.getByTestId(workspaceRenameModalTestId(workspace.id, "submit")).click(); + + const errorNode = page.getByTestId(workspaceRenameModalTestId(workspace.id, "error")); + await expect(errorNode).toBeVisible({ timeout: 15_000 }); + await expect(errorNode).toContainText(/already exists|branch/i); + await expect(input).toBeVisible(); + await expect(page.getByTestId(workspaceRowTestId(workspace.id))).toContainText("main"); + } finally { + await client.close(); + await repo.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/workspace-agent-tab-rename.spec.ts b/packages/app/e2e/workspace-agent-tab-rename.spec.ts new file mode 100644 index 000000000..91d04613d --- /dev/null +++ b/packages/app/e2e/workspace-agent-tab-rename.spec.ts @@ -0,0 +1,88 @@ +import { randomUUID } from "node:crypto"; +import { test, expect, type Page } from "./fixtures"; +import { createTempGitRepo } from "./helpers/workspace"; +import { + connectArchiveTabDaemonClient, + createIdleAgent, + expectWorkspaceTabVisible, +} from "./helpers/archive-tab"; +import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs"; +import { buildHostAgentDetailRoute } from "@/utils/host-routes"; +import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename"; + +function getServerId(): string { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set (expected from Playwright globalSetup)."); + } + return serverId; +} + +async function openAgentInWorkspace(page: Page, agent: { id: string; cwd: string }) { + await page.goto(buildHostAgentDetailRoute(getServerId(), agent.id, agent.cwd)); + await page.waitForURL( + (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), + { timeout: 60_000 }, + ); + await waitForWorkspaceTabsVisible(page); + await expectWorkspaceTabVisible(page, agent.id); +} + +test.describe("Workspace agent tab rename", () => { + test("right-click rename sends update_agent_request and updates the tab label", async ({ + page, + }) => { + test.setTimeout(120_000); + + const client = await connectArchiveTabDaemonClient(); + const repo = await createTempGitRepo("workspace-agent-rename-"); + + try { + const initialTitle = `agent-rename-${randomUUID().slice(0, 8)}`; + const agent = await createIdleAgent(client, { + cwd: repo.path, + title: initialTitle, + }); + + const updateFrames = captureWsSessionFrames(page, "update_agent_request", (inner) => ({ + agentId: String(inner.agentId ?? ""), + name: String(inner.name ?? ""), + requestId: String(inner.requestId ?? ""), + })); + + await openAgentInWorkspace(page, agent); + + const tab = page.getByTestId(`workspace-tab-agent_${agent.id}`).first(); + await expect(tab).toContainText(initialTitle, { timeout: 15_000 }); + + await tab.click({ button: "right" }); + await expect(page.getByTestId(`workspace-tab-context-agent_${agent.id}`)).toBeVisible({ + timeout: 10_000, + }); + const renameItem = page.getByTestId(`workspace-tab-context-agent_${agent.id}-rename`); + await expect(renameItem).toBeVisible({ timeout: 10_000 }); + await renameItem.click(); + + const modalPrefix = `workspace-tab-rename-modal-agent-${agent.id}`; + const input = renameModalInput(page, modalPrefix); + await expect(input).toBeVisible({ timeout: 10_000 }); + await expect(input).toHaveValue(initialTitle); + + const renamed = "My Renamed Agent"; + await input.fill(renamed); + await renameModalSubmit(page, modalPrefix).click(); + + await expect(input).toHaveCount(0, { timeout: 15_000 }); + await expect(tab).toContainText(renamed, { timeout: 15_000 }); + + expect(updateFrames.length).toBeGreaterThan(0); + const lastFrame = updateFrames.at(-1)!; + expect(lastFrame.agentId).toBe(agent.id); + expect(lastFrame.name).toBe(renamed); + expect(lastFrame.requestId.length).toBeGreaterThan(0); + } finally { + await client.close(); + await repo.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/workspace-terminal-tab-rename.spec.ts b/packages/app/e2e/workspace-terminal-tab-rename.spec.ts new file mode 100644 index 000000000..54a485fa8 --- /dev/null +++ b/packages/app/e2e/workspace-terminal-tab-rename.spec.ts @@ -0,0 +1,67 @@ +import { test, expect } from "./fixtures"; +import { createTempGitRepo } from "./helpers/workspace"; +import { connectTerminalClient, navigateToTerminal } from "./helpers/terminal-perf"; +import { captureWsSessionFrames, renameModalInput, renameModalSubmit } from "./helpers/rename"; + +test.describe("Workspace terminal tab rename", () => { + test("right-click rename sends terminal.rename.request and updates the tab label", async ({ + page, + }) => { + test.setTimeout(60_000); + + const client = await connectTerminalClient(); + const repo = await createTempGitRepo("workspace-terminal-rename-"); + + try { + const seeded = await client.openProject(repo.path); + if (!seeded.workspace) { + throw new Error(seeded.error ?? "Failed to seed workspace"); + } + const workspaceId = seeded.workspace.id; + + const created = await client.createTerminal(repo.path); + if (!created.terminal) { + throw new Error(created.error ?? "Failed to create terminal"); + } + const terminalId = created.terminal.id; + + const renameFrames = captureWsSessionFrames(page, "terminal.rename.request", (inner) => ({ + terminalId: String(inner.terminalId ?? ""), + title: String(inner.title ?? ""), + requestId: String(inner.requestId ?? ""), + })); + + await navigateToTerminal(page, { workspaceId, terminalId }); + + const tab = page.getByTestId(`workspace-tab-terminal_${terminalId}`).first(); + await expect(tab).toBeVisible({ timeout: 15_000 }); + + await tab.click({ button: "right" }); + await expect(page.getByTestId(`workspace-tab-context-terminal_${terminalId}`)).toBeVisible({ + timeout: 10_000, + }); + const renameItem = page.getByTestId(`workspace-tab-context-terminal_${terminalId}-rename`); + await expect(renameItem).toBeVisible({ timeout: 10_000 }); + await renameItem.click(); + + const modalPrefix = `workspace-tab-rename-modal-terminal-${terminalId}`; + const input = renameModalInput(page, modalPrefix); + await expect(input).toBeVisible({ timeout: 10_000 }); + + await input.fill("My Renamed Terminal"); + await renameModalSubmit(page, modalPrefix).click(); + + await expect(input).toHaveCount(0, { timeout: 15_000 }); + await expect(tab).toContainText("My Renamed Terminal", { timeout: 15_000 }); + + expect(renameFrames.length).toBeGreaterThan(0); + const lastFrame = renameFrames.at(-1)!; + expect(lastFrame.terminalId).toBe(terminalId); + expect(lastFrame.title).toBe("My Renamed Terminal"); + expect(lastFrame.requestId.length).toBeGreaterThan(0); + } finally { + await client.close(); + await repo.cleanup(); + } + }); +}); diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index d1ae7f5e7..054d13f24 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -202,6 +202,12 @@ const styles = StyleSheet.create((theme) => ({ padding: theme.spacing[SHEET_HORIZONTAL_PADDING_SCALE], gap: theme.spacing[4], }, + adaptiveInputOutline: { + outlineColor: theme.colors.accent, + }, + adaptiveInputPlaceholder: { + color: theme.colors.foregroundMuted, + }, })); const SEARCH_INPUT_STYLE = [styles.searchInput, isWeb && { outlineStyle: "none" }]; @@ -234,10 +240,23 @@ export type AdaptiveTextInputProps = TextInputProps & { export const AdaptiveTextInput = forwardRef( function AdaptiveTextInputInner(props, ref) { const isMobile = useIsCompactFormFactor(); - const { value: _value, initialValue, resetKey, defaultValue, ...inputProps } = props; + const { + value: _value, + initialValue, + resetKey, + defaultValue, + style, + placeholderTextColor, + ...inputProps + } = props; + // Recolor the browser's :focus-visible outline (defined in public/index.html) + // so it matches the active theme's accent instead of its hard-coded fallback. + // Consumer style wins if it sets outlineColor explicitly. const textInputProps = { ...inputProps, defaultValue: initialValue ?? defaultValue, + placeholderTextColor: placeholderTextColor ?? styles.adaptiveInputPlaceholder.color, + style: [styles.adaptiveInputOutline, style], }; if (isMobile && isNative) { diff --git a/packages/app/src/components/rename-modal.test.tsx b/packages/app/src/components/rename-modal.test.tsx new file mode 100644 index 000000000..f8d8ffbbe --- /dev/null +++ b/packages/app/src/components/rename-modal.test.tsx @@ -0,0 +1,368 @@ +import { JSDOM } from "jsdom"; +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AdaptiveRenameModal } from "./rename-modal"; + +const { theme, adaptiveInputState } = vi.hoisted(() => ({ + adaptiveInputState: { + latestProps: null as { + onChangeText?: (next: string) => void; + onSubmitEditing?: () => void; + } | null, + }, + theme: { + spacing: { 2: 8, 3: 12 }, + fontSize: { sm: 13, base: 15 }, + borderRadius: { md: 6 }, + colors: { + surface0: "#000", + foreground: "#fff", + foregroundMuted: "#aaa", + border: "#555", + palette: { red: { 300: "#f87171" } }, + }, + }, +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), + }, + useUnistyles: () => ({ theme }), +})); + +vi.mock("@/constants/platform", () => ({ + isWeb: true, + isNative: false, +})); + +vi.mock("@/components/adaptive-modal-sheet", async () => { + const ReactModule = await import("react"); + const AdaptiveModalSheet = ({ + visible, + title, + children, + onClose, + testID, + }: { + visible: boolean; + title: string; + children: React.ReactNode; + onClose: () => void; + testID?: string; + }) => { + if (!visible) return null; + return ReactModule.createElement( + "div", + { "data-testid": testID ?? "adaptive-modal-sheet", "data-modal-title": title }, + ReactModule.createElement( + "button", + { + type: "button", + "data-testid": "adaptive-modal-sheet-close", + onClick: onClose, + }, + "Close", + ), + children, + ); + }; + // Mirrors production AdaptiveTextInput: native-owned input seeded by + // initialValue, remounted (via key) when resetKey changes so the new + // initialValue takes effect. + const AdaptiveTextInput = ReactModule.forwardRef>( + (props, ref) => { + const p = props as { + initialValue?: string; + defaultValue?: string; + editable?: boolean; + maxLength?: number; + testID?: string; + onChangeText?: (next: string) => void; + onSubmitEditing?: () => void; + }; + adaptiveInputState.latestProps = { + onChangeText: p.onChangeText, + onSubmitEditing: p.onSubmitEditing, + }; + return ReactModule.createElement("input", { + ref, + defaultValue: p.initialValue ?? p.defaultValue ?? "", + disabled: p.editable === false, + maxLength: p.maxLength, + "data-testid": p.testID, + onChange: (e: { target: { value: string } }) => p.onChangeText?.(e.target.value), + onKeyDown: (e: { key: string; preventDefault: () => void }) => { + if (e.key === "Enter") { + e.preventDefault(); + p.onSubmitEditing?.(); + } + }, + }); + }, + ); + return { AdaptiveModalSheet, AdaptiveTextInput }; +}); + +vi.mock("@/components/ui/button", async () => { + const ReactModule = await import("react"); + return { + Button: ({ + children, + onPress, + disabled, + testID, + }: { + children?: React.ReactNode; + onPress?: () => void; + disabled?: boolean; + testID?: string; + }) => + ReactModule.createElement( + "button", + { + type: "button", + "data-testid": testID, + disabled: disabled || undefined, + onClick: () => !disabled && onPress?.(), + }, + children, + ), + }; +}); + +let root: Root | null = null; +let container: HTMLElement | null = null; + +beforeEach(() => { + const dom = new JSDOM(""); + vi.stubGlobal("React", React); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", dom.window); + vi.stubGlobal("document", dom.window.document); + vi.stubGlobal("HTMLElement", dom.window.HTMLElement); + vi.stubGlobal("HTMLInputElement", dom.window.HTMLInputElement); + vi.stubGlobal("KeyboardEvent", dom.window.KeyboardEvent); + vi.stubGlobal("Node", dom.window.Node); + vi.stubGlobal("navigator", dom.window.navigator); + + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + adaptiveInputState.latestProps = null; +}); + +afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container = null; + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +interface RenderOptions { + visible?: boolean; + initialValue?: string; + title?: string; + placeholder?: string; + submitLabel?: string; + onClose?: () => void; + onSubmit?: (value: string) => Promise | void; + validate?: (value: string) => string | null; + maxLength?: number; +} + +function renderModal(options: RenderOptions = {}): void { + const { + visible = true, + initialValue = "", + title = "Rename", + placeholder, + submitLabel, + onClose = vi.fn(), + onSubmit = vi.fn(), + validate, + maxLength, + } = options; + act(() => { + root?.render( + , + ); + }); +} + +function queryInput(): HTMLInputElement | null { + return document.querySelector('[data-testid="rename-modal-input"]'); +} + +function querySubmit(): HTMLButtonElement | null { + return document.querySelector('[data-testid="rename-modal-submit"]'); +} + +function queryCancel(): HTMLButtonElement | null { + return document.querySelector('[data-testid="rename-modal-cancel"]'); +} + +function queryError(): HTMLElement | null { + return document.querySelector('[data-testid="rename-modal-error"]'); +} + +function click(element: Element | null): void { + if (!element) throw new Error("Cannot click null element"); + act(() => { + element.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + }); +} + +function typeInto(value: string): void { + act(() => { + adaptiveInputState.latestProps?.onChangeText?.(value); + }); +} + +function pressEnter(): void { + act(() => { + adaptiveInputState.latestProps?.onSubmitEditing?.(); + }); +} + +async function flush(): Promise { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("RenameModal", () => { + it("renders with the initial value pre-filled and selects it after open", async () => { + vi.useFakeTimers(); + renderModal({ initialValue: "main" }); + const input = queryInput(); + expect(input).not.toBeNull(); + expect(input?.value).toBe("main"); + + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + + const focused = document.activeElement as HTMLInputElement | null; + expect(focused).toBe(input); + expect(focused?.selectionStart).toBe(0); + expect(focused?.selectionEnd).toBe("main".length); + }); + + it("submits on Enter keypress in the input when the value has changed", async () => { + const onSubmit = vi.fn(); + const onClose = vi.fn(); + renderModal({ initialValue: "feature", onSubmit, onClose }); + + typeInto("feature-2"); + pressEnter(); + await flush(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith("feature-2"); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("calls onClose when AdaptiveModalSheet's close prop fires (cancel button / backdrop delegated)", () => { + const onClose = vi.fn(); + const onSubmit = vi.fn(); + renderModal({ initialValue: "main", onClose, onSubmit }); + + click(document.querySelector('[data-testid="adaptive-modal-sheet-close"]')); + + expect(onClose).toHaveBeenCalledTimes(1); + expect(onSubmit).not.toHaveBeenCalled(); + }); + + it("disables submit when draft equals initialValue and re-enables after a change", async () => { + const onSubmit = vi.fn(); + renderModal({ initialValue: "main", onSubmit }); + + expect(querySubmit()?.disabled).toBe(true); + + pressEnter(); + await flush(); + expect(onSubmit).not.toHaveBeenCalled(); + + typeInto("main-v2"); + await flush(); + expect(querySubmit()?.disabled).toBe(false); + + typeInto("main"); + await flush(); + expect(querySubmit()?.disabled).toBe(true); + }); + + it("surfaces validate errors inline and blocks submission", async () => { + const onSubmit = vi.fn(); + const validate = vi.fn((value: string) => (value === "bad" ? "Invalid name" : null)); + renderModal({ initialValue: "ok", validate, onSubmit }); + + typeInto("bad"); + const submit = querySubmit()!; + expect(submit.disabled).toBe(true); + + pressEnter(); + await flush(); + + expect(onSubmit).not.toHaveBeenCalled(); + const errorNode = queryError(); + expect(errorNode?.textContent).toContain("Invalid name"); + }); + + it("disables the submit button while onSubmit is pending", async () => { + let resolve: () => void = () => {}; + const onSubmit = vi.fn( + () => + new Promise((r) => { + resolve = r; + }), + ); + renderModal({ initialValue: "main", onSubmit }); + + typeInto("main-renamed"); + click(querySubmit()); + await flush(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(querySubmit()?.disabled).toBe(true); + expect(queryCancel()?.disabled).toBe(true); + + await act(async () => { + resolve(); + await Promise.resolve(); + }); + }); + + it("keeps the modal open with an error when onSubmit rejects", async () => { + const onSubmit = vi.fn().mockRejectedValue(new Error("Server said no")); + const onClose = vi.fn(); + renderModal({ initialValue: "main", onSubmit, onClose }); + + typeInto("main-renamed"); + click(querySubmit()); + await flush(); + + expect(onClose).not.toHaveBeenCalled(); + expect(queryError()?.textContent).toContain("Server said no"); + expect(querySubmit()?.disabled).toBe(false); + }); +}); diff --git a/packages/app/src/components/rename-modal.tsx b/packages/app/src/components/rename-modal.tsx new file mode 100644 index 000000000..c0cfa7de1 --- /dev/null +++ b/packages/app/src/components/rename-modal.tsx @@ -0,0 +1,195 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Text, TextInput, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { + AdaptiveModalSheet, + AdaptiveTextInput, + type SheetHeader, +} from "@/components/adaptive-modal-sheet"; +import { Button } from "@/components/ui/button"; +import { isWeb } from "@/constants/platform"; + +export interface AdaptiveRenameModalProps { + visible: boolean; + title: string; + initialValue: string; + placeholder?: string; + submitLabel?: string; + onClose: () => void; + onSubmit: (value: string) => Promise | void; + validate?: (value: string) => string | null; + maxLength?: number; + testID?: string; +} + +export function AdaptiveRenameModal({ + visible, + title, + initialValue, + placeholder, + submitLabel = "Rename", + onClose, + onSubmit, + validate, + maxLength, + testID, +}: AdaptiveRenameModalProps) { + const [draft, setDraft] = useState(initialValue); + const [error, setError] = useState(null); + const [isPending, setIsPending] = useState(false); + const inputRef = useRef(null); + + useEffect(() => { + if (!visible) return; + setDraft(initialValue); + setError(null); + setIsPending(false); + }, [visible, initialValue]); + + useEffect(() => { + if (!visible) return; + const length = initialValue.length; + const timeout = setTimeout(() => { + const node = inputRef.current; + if (!node) return; + node.focus(); + if (isWeb && node instanceof HTMLInputElement) { + node.setSelectionRange(0, length); + } else if (!isWeb && length > 0) { + node.setNativeProps({ selection: { start: 0, end: length } }); + } + }, 50); + return () => clearTimeout(timeout); + }, [visible, initialValue]); + + const computeError = useCallback( + (value: string): string | null => { + if (!value.trim()) return "Name is required"; + return validate ? validate(value) : null; + }, + [validate], + ); + + const handleChange = useCallback((value: string) => { + setDraft(value); + setError(null); + }, []); + + const handleSubmit = useCallback(async () => { + if (isPending) return; + const value = draft; + if (value === initialValue) return; + const validationError = computeError(value); + if (validationError) { + setError(validationError); + return; + } + try { + setIsPending(true); + await onSubmit(value); + setIsPending(false); + onClose(); + } catch (err) { + setIsPending(false); + const message = err instanceof Error && err.message ? err.message : "Unable to save"; + setError(message); + } + }, [isPending, draft, initialValue, computeError, onSubmit, onClose]); + + const handleCancel = useCallback(() => { + if (isPending) return; + onClose(); + }, [isPending, onClose]); + + const handleSubmitVoid = useCallback(() => { + void handleSubmit(); + }, [handleSubmit]); + + const submitDisabled = isPending || draft === initialValue || computeError(draft) !== null; + const inputTestID = testID ? `${testID}-input` : undefined; + const errorTestID = testID ? `${testID}-error` : undefined; + const submitTestID = testID ? `${testID}-submit` : undefined; + const cancelTestID = testID ? `${testID}-cancel` : undefined; + const sheetHeader = useMemo(() => ({ title }), [title]); + + return ( + + + + {error ? ( + + {error} + + ) : null} + + + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + body: { + gap: theme.spacing[3], + paddingBottom: theme.spacing[2], + }, + input: { + backgroundColor: theme.colors.surface0, + color: theme.colors.foreground, + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + fontSize: theme.fontSize.base, + }, + errorText: { + color: theme.colors.palette.red[300], + fontSize: theme.fontSize.sm, + }, + actions: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + actionButton: { + flex: 1, + }, +})); diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index b386e9a4d..72e3330a6 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -12,7 +12,10 @@ import { type ViewStyle, } from "react-native"; import * as Haptics from "expo-haptics"; -import { useQueries } from "@tanstack/react-query"; +import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query"; +import { slugify, validateBranchSlug, MAX_SLUG_LENGTH } from "@server/utils/branch-slug"; +import { AdaptiveRenameModal } from "@/components/rename-modal"; +import { invalidateCheckoutGitQueriesForClient } from "@/git/query-keys"; import { useCallback, useMemo, @@ -45,6 +48,7 @@ import { SquareTerminal, Monitor, MoreVertical, + Pencil, Plus, Trash2, } from "lucide-react-native"; @@ -147,6 +151,7 @@ const ThemedTrash2 = withUnistyles(Trash2); const ThemedSettings = withUnistyles(Settings); const ThemedCopy = withUnistyles(Copy); const ThemedArchive = withUnistyles(Archive); +const ThemedPencil = withUnistyles(Pencil); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ @@ -232,6 +237,7 @@ interface WorkspaceRowInnerProps { onArchive?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; + onRename?: () => void; archiveShortcutKeys?: ShortcutKey[][] | null; } @@ -565,6 +571,7 @@ const trash2LeadingIcon = ; const copyLeadingIcon = ; const archiveLeadingIcon = ; +const renameLeadingIcon = ; function renderKebabTriggerIcon({ hovered }: { hovered?: boolean }) { return ( @@ -640,6 +647,7 @@ function WorkspaceRowRightGroup({ onArchive, onCopyBranchName, onCopyPath, + onRename, }: { workspace: SidebarWorkspaceEntry; isHovered: boolean; @@ -656,6 +664,7 @@ function WorkspaceRowRightGroup({ onArchive?: () => void; onCopyBranchName?: () => void; onCopyPath?: () => void; + onRename?: () => void; }) { const showKebab = Boolean(onArchive && (isHovered || isTouchPlatform)); return ( @@ -675,6 +684,7 @@ function WorkspaceRowRightGroup({ workspaceKey={workspace.workspaceKey} onCopyPath={onCopyPath} onCopyBranchName={onCopyBranchName} + onRename={onRename} onArchive={onArchive} archiveLabel={archiveLabel} archiveStatus={archiveStatus} @@ -701,6 +711,7 @@ function WorkspaceKebabMenu({ workspaceKey, onCopyPath, onCopyBranchName, + onRename, onArchive, archiveLabel, archiveStatus, @@ -710,6 +721,7 @@ function WorkspaceKebabMenu({ workspaceKey: string; onCopyPath?: () => void; onCopyBranchName?: () => void; + onRename?: () => void; onArchive: () => void; archiveLabel?: string; archiveStatus?: "idle" | "pending" | "success"; @@ -750,6 +762,15 @@ function WorkspaceKebabMenu({ Copy branch name ) : null} + {onRename ? ( + + Rename workspace + + ) : null} {prHint ? ( @@ -1469,7 +1492,9 @@ function WorkspaceRowWithMenu({ const toast = useToast(); const activeWorkspaceSelection = useActiveWorkspaceSelection(); const archiveWorktree = useCheckoutGitActionsStore((state) => state.archiveWorktree); + const queryClient = useQueryClient(); const [isArchivingWorkspace, setIsArchivingWorkspace] = useState(false); + const [isRenameOpen, setIsRenameOpen] = useState(false); const workspaceDirectory = resolveWorkspaceExecutionDirectory({ workspaceDirectory: workspace.workspaceDirectory, }); @@ -1599,6 +1624,51 @@ function WorkspaceRowWithMenu({ toast.copied("Branch name copied"); }, [toast, workspace.name]); + const renameMutation = useMutation({ + mutationFn: async (branch: string) => { + const client = getHostRuntimeStore().getClient(workspace.serverId); + if (!client) { + throw new Error("Host is not connected"); + } + const targetCwd = requireWorkspaceExecutionDirectory({ + workspaceId: workspace.workspaceId, + workspaceDirectory: workspace.workspaceDirectory, + }); + const payload = await client.renameBranch({ cwd: targetCwd, branch }); + if (!payload.success || payload.error) { + throw new Error(payload.error?.message ?? "Failed to rename branch"); + } + return { targetCwd }; + }, + onSuccess: async ({ targetCwd }) => { + await invalidateCheckoutGitQueriesForClient(queryClient, { + serverId: workspace.serverId, + cwd: targetCwd, + }); + }, + }); + + const handleOpenRename = useCallback(() => { + setIsRenameOpen(true); + }, []); + + const handleCloseRename = useCallback(() => { + setIsRenameOpen(false); + }, []); + + const handleSubmitRename = useCallback( + async (value: string) => { + await renameMutation.mutateAsync(slugify(value)); + }, + [renameMutation], + ); + + const validateRenameSlug = useCallback((value: string): string | null => { + const result = validateBranchSlug(slugify(value)); + if (result.valid) return null; + return result.error ?? "Invalid branch name"; + }, []); + const archiveShortcutKeys = useShortcutKeys("archive-worktree"); useKeyboardActionHandler({ @@ -1617,26 +1687,41 @@ function WorkspaceRowWithMenu({ }); return ( - + <> + + + ); } diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index ba6111bf8..9657c09e6 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -86,6 +86,7 @@ interface SplitContainerProps { onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; @@ -362,6 +363,7 @@ export function SplitContainer({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, @@ -577,6 +579,7 @@ export function SplitContainer({ onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} onReloadAgent={onReloadAgent} + onRenameTab={onRenameTab} onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} @@ -716,6 +719,7 @@ function SplitNodeView({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, @@ -768,6 +772,7 @@ function SplitNodeView({ onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} onReloadAgent={onReloadAgent} + onRenameTab={onRenameTab} onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} @@ -813,6 +818,7 @@ function SplitNodeView({ onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} onReloadAgent={onReloadAgent} + onRenameTab={onRenameTab} onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} @@ -864,6 +870,7 @@ function SplitPaneView({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, @@ -1004,6 +1011,7 @@ function SplitPaneView({ onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} onReloadAgent={onReloadAgent} + onRenameTab={onRenameTab} onCloseTabsToLeft={handleCloseTabsToLeft} onCloseTabsToRight={handleCloseTabsToRight} onCloseOtherTabs={handleCloseOtherTabs} diff --git a/packages/app/src/screens/settings/host-page.tsx b/packages/app/src/screens/settings/host-page.tsx index 1469c769c..f7894079d 100644 --- a/packages/app/src/screens/settings/host-page.tsx +++ b/packages/app/src/screens/settings/host-page.tsx @@ -1,30 +1,31 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Alert, Pressable, Text, TextInput, View } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ChevronRight, Globe, Monitor, Pencil, RotateCw, Trash2 } from "lucide-react-native"; -import type { HostConnection, HostProfile } from "@/types/host-connection"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Alert, Pressable, Text, View } from "react-native"; +import { StyleSheet, useUnistyles } from "react-native-unistyles"; +import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; +import { AdaptiveRenameModal } from "@/components/rename-modal"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; +import { PairDeviceModal } from "@/desktop/components/pair-device-modal"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; import { getHostRuntimeStore, isHostRuntimeConnected, + useHostMutations, useHostRuntimeClient, useHostRuntimeIsConnected, useHostRuntimeSnapshot, - useHostMutations, useHosts, } from "@/runtime/host-runtime"; -import { useSessionStore } from "@/stores/session-store"; -import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; -import { confirmDialog } from "@/utils/confirm-dialog"; -import { settingsStyles } from "@/styles/settings"; -import { Button } from "@/components/ui/button"; -import { Switch } from "@/components/ui/switch"; -import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; -import { useDaemonConfig } from "@/hooks/use-daemon-config"; -import { useIsLocalDaemon } from "@/hooks/use-is-local-daemon"; -import { SettingsSection } from "@/screens/settings/settings-section"; import { ProvidersSection } from "@/screens/settings/providers-section"; -import { PairDeviceModal } from "@/desktop/components/pair-device-modal"; -import { LocalDaemonSection } from "@/desktop/components/desktop-updates-section"; +import { SettingsSection } from "@/screens/settings/settings-section"; +import { useSessionStore } from "@/stores/session-store"; +import { settingsStyles } from "@/styles/settings"; +import type { HostConnection, HostProfile } from "@/types/host-connection"; +import { confirmDialog } from "@/utils/confirm-dialog"; +import { formatConnectionStatus, getConnectionStatusTone } from "@/utils/daemons"; const RESTART_CONFIRMATION_MESSAGE = "This will restart the daemon. Agents running on it will keep going; the app will reconnect automatically."; @@ -68,7 +69,6 @@ function formatDaemonVersionBadge(version: string | null): string | null { return trimmed.startsWith("v") ? trimmed : `v${trimmed}`; } -const RENAME_HOST_HEADER: SheetHeader = { title: "Rename host" }; const REMOVE_CONNECTION_HEADER: SheetHeader = { title: "Remove connection" }; const REMOVE_HOST_HEADER: SheetHeader = { title: "Remove host" }; @@ -178,64 +178,23 @@ export function HostRenameButton({ host }: { host: HostProfile }) { const { theme } = useUnistyles(); const { renameHost } = useHostMutations(); const [isEditing, setIsEditing] = useState(false); - const [draftLabel, setDraftLabel] = useState(host.label ?? ""); - const [isSaving, setIsSaving] = useState(false); - const inputRef = useRef(null); - useEffect(() => { - setDraftLabel(host.label ?? ""); - }, [host.serverId, host.label]); - - useEffect(() => { - if (isEditing) { - const timeout = setTimeout(() => inputRef.current?.focus(), 50); - return () => clearTimeout(timeout); - } - return undefined; - }, [isEditing]); - - const handleSave = useCallback(async () => { - const nextLabel = draftLabel.trim(); - if (!nextLabel) { - Alert.alert("Label required", "Enter a label for this host."); - return; - } - if (isSaving) return; - if (nextLabel === host.label.trim()) { - setIsEditing(false); - return; - } - try { - setIsSaving(true); + const handleSubmit = useCallback( + async (value: string) => { + const nextLabel = value.trim(); + if (nextLabel === host.label.trim()) return; await renameHost(host.serverId, nextLabel); - setIsEditing(false); - } catch (error) { - console.error("[HostPage] Failed to rename host", error); - Alert.alert("Error", "Unable to save host"); - } finally { - setIsSaving(false); - } - }, [draftLabel, host.label, host.serverId, isSaving, renameHost]); + }, + [host.label, host.serverId, renameHost], + ); - const handleCancel = useCallback(() => { - if (isSaving) return; - setDraftLabel(host.label ?? ""); - setIsEditing(false); - }, [host.label, isSaving]); - - const handleStartEdit = useCallback(() => { - setDraftLabel(host.label ?? ""); - setIsEditing(true); - }, [host.label]); - - const handleSavePress = useCallback(() => { - void handleSave(); - }, [handleSave]); + const openEditor = useCallback(() => setIsEditing(true), []); + const closeEditor = useCallback(() => setIsEditing(false), []); return ( <> - - - - - - - - - + /> ); } @@ -836,25 +763,6 @@ const styles = StyleSheet.create((theme) => ({ color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm, }, - renameBody: { - gap: theme.spacing[3], - paddingBottom: theme.spacing[2], - }, - renameInput: { - backgroundColor: theme.colors.surface0, - color: theme.colors.foreground, - paddingVertical: theme.spacing[3], - paddingHorizontal: theme.spacing[3], - borderRadius: theme.borderRadius.md, - borderWidth: 1, - borderColor: theme.colors.border, - fontSize: theme.fontSize.base, - }, - renameActions: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - }, })); const FLEX_1_STYLE = { flex: 1 }; diff --git a/packages/app/src/screens/workspace/use-workspace-tab-rename.tsx b/packages/app/src/screens/workspace/use-workspace-tab-rename.tsx new file mode 100644 index 000000000..958329cf7 --- /dev/null +++ b/packages/app/src/screens/workspace/use-workspace-tab-rename.tsx @@ -0,0 +1,125 @@ +import { useCallback, useState } from "react"; +import { type QueryClient } from "@tanstack/react-query"; +import type { DaemonClient } from "@server/client/daemon-client"; +import type { ListTerminalsResponse } from "@server/shared/messages"; +import { AdaptiveRenameModal } from "@/components/rename-modal"; +import { useSessionStore } from "@/stores/session-store"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; + +interface RenamingTabState { + kind: "terminal" | "agent"; + id: string; + currentTitle: string; +} + +interface UseWorkspaceTabRenameInput { + client: DaemonClient | null; + normalizedServerId: string; + queryClient: QueryClient; + terminalsData: ListTerminalsResponse["payload"] | undefined; + terminalsQueryKey: readonly unknown[]; +} + +interface UseWorkspaceTabRenameResult { + renamingTab: RenamingTabState | null; + handleRenameTab: (tab: WorkspaceTabDescriptor) => void; + handleRenameModalSubmit: (nextTitle: string) => Promise; + handleRenameModalClose: () => void; +} + +export function useWorkspaceTabRename( + input: UseWorkspaceTabRenameInput, +): UseWorkspaceTabRenameResult { + const { client, normalizedServerId, queryClient, terminalsData, terminalsQueryKey } = input; + const [renamingTab, setRenamingTab] = useState(null); + + const handleRenameTab = useCallback( + (tab: WorkspaceTabDescriptor) => { + if (tab.target.kind === "terminal") { + const { terminalId } = tab.target; + const terminal = terminalsData?.terminals.find((entry) => entry.id === terminalId) ?? null; + const currentTitle = terminal?.title ?? terminal?.name ?? ""; + setRenamingTab({ kind: "terminal", id: terminalId, currentTitle }); + return; + } + if (tab.target.kind === "agent") { + const { agentId } = tab.target; + const agent = + useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; + const currentTitle = agent?.title ?? ""; + setRenamingTab({ kind: "agent", id: agentId, currentTitle }); + } + }, + [normalizedServerId, terminalsData], + ); + + const handleRenameModalSubmit = useCallback( + async (nextTitle: string) => { + if (!renamingTab) return; + if (!client) { + throw new Error("Host is not connected"); + } + const trimmed = nextTitle.trim(); + if (renamingTab.kind === "terminal") { + const result = await client.renameTerminal({ + terminalId: renamingTab.id, + title: trimmed, + }); + if (!result.success) { + throw new Error(result.error ?? "Failed to rename terminal"); + } + void queryClient.invalidateQueries({ queryKey: terminalsQueryKey }); + return; + } + await client.updateAgent(renamingTab.id, { name: trimmed }); + void queryClient.invalidateQueries({ + queryKey: ["sidebarAgentsList", normalizedServerId], + }); + void queryClient.invalidateQueries({ + queryKey: ["allAgents", normalizedServerId], + }); + }, + [client, normalizedServerId, queryClient, renamingTab, terminalsQueryKey], + ); + + const handleRenameModalClose = useCallback(() => { + setRenamingTab(null); + }, []); + + return { + renamingTab, + handleRenameTab, + handleRenameModalSubmit, + handleRenameModalClose, + }; +} + +export interface WorkspaceTabRenameModalProps { + renamingTab: RenamingTabState | null; + onClose: () => void; + onSubmit: (nextTitle: string) => Promise; +} + +export function WorkspaceTabRenameModal({ + renamingTab, + onClose, + onSubmit, +}: WorkspaceTabRenameModalProps) { + const title = renamingTab?.kind === "terminal" ? "Rename terminal" : "Rename agent"; + const initialValue = renamingTab?.currentTitle ?? ""; + const testID = renamingTab + ? `workspace-tab-rename-modal-${renamingTab.kind}-${renamingTab.id}` + : undefined; + return ( + + ); +} diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 5b75de7a9..f55ea4b68 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -22,6 +22,7 @@ import { ArrowRightToLine, Columns2, Copy, + Pencil, RotateCw, Rows2, Globe, @@ -71,6 +72,7 @@ const ThemedRotateCw = withUnistyles(RotateCw); const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine); const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine); const ThemedCopyX = withUnistyles(CopyX); +const ThemedPencil = withUnistyles(Pencil); const ThemedSquarePen = withUnistyles(SquarePen); const ThemedSquareTerminal = withUnistyles(SquareTerminal); const ThemedGlobe = withUnistyles(Globe); @@ -101,6 +103,8 @@ function TabContextMenuItem({ return ; case "copy-x": return ; + case "pencil": + return ; case "x": return ; default: @@ -150,6 +154,7 @@ interface WorkspaceDesktopTabsRowProps { onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | void; @@ -467,6 +472,7 @@ export function WorkspaceDesktopTabsRow({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, @@ -599,6 +605,7 @@ export function WorkspaceDesktopTabsRow({ onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} onReloadAgent={onReloadAgent} + onRenameTab={onRenameTab} onCloseTabsToLeft={onCloseTabsToLeft} onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} @@ -630,6 +637,7 @@ export function WorkspaceDesktopTabsRow({ onCopyResumeCommand, onNavigateTab, onReloadAgent, + onRenameTab, setHoveredCloseTabKey, setHoveredTabKey, tabDropPreviewIndex, @@ -791,6 +799,7 @@ function ResolvedDesktopTabChip({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTabsToLeft, onCloseTabsToRight, onCloseOtherTabs, @@ -815,6 +824,7 @@ function ResolvedDesktopTabChip({ onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; onCloseOtherTabs: (tabId: string) => Promise | void; @@ -838,6 +848,7 @@ function ResolvedDesktopTabChip({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTab, onCloseTabsToLeft, onCloseTabsToRight, @@ -853,6 +864,7 @@ function ResolvedDesktopTabChip({ onCopyAgentId, onCopyResumeCommand, onReloadAgent, + onRenameTab, tabCount, ], ); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 25a61f089..33fe3b300 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -11,7 +11,7 @@ import { import { useStoreWithEqualityFn } from "zustand/traditional"; import { useIsFocused } from "@react-navigation/native"; import { ActivityIndicator, BackHandler, Keyboard, Pressable, Text, View } from "react-native"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useRouter, type Href } from "expo-router"; import * as Clipboard from "expo-clipboard"; import { DiffStat } from "@/components/diff-stat"; @@ -26,6 +26,7 @@ import { Globe, Import as ImportIcon, PanelRight, + Pencil, RotateCw, Settings, SquarePen, @@ -109,6 +110,10 @@ import { WorkspaceTabOptionRow, type WorkspaceTabPresentation, } from "@/screens/workspace/workspace-tab-presentation"; +import { + useWorkspaceTabRename, + WorkspaceTabRenameModal, +} from "@/screens/workspace/use-workspace-tab-rename"; import { WorkspaceDesktopTabsRow, type WorkspaceDesktopTabRowItem, @@ -181,6 +186,7 @@ const ThemedRotateCw = withUnistyles(RotateCw); const ThemedArrowLeftToLine = withUnistyles(ArrowLeftToLine); const ThemedArrowRightToLine = withUnistyles(ArrowRightToLine); const ThemedCopyX = withUnistyles(CopyX); +const ThemedPencil = withUnistyles(Pencil); const ThemedX = withUnistyles(X); const ThemedSquarePen = withUnistyles(SquarePen); const ThemedSquareTerminal = withUnistyles(SquareTerminal); @@ -297,6 +303,7 @@ interface MobileWorkspaceTabSwitcherProps { onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTab: (tabId: string) => Promise | void; onCloseTabsAbove: (tabId: string) => Promise | void; onCloseTabsBelow: (tabId: string) => Promise | void; @@ -436,6 +443,8 @@ function MobileTabDropdownMenuItem({ return ; case "copy-x": return ; + case "pencil": + return ; case "x": return ; default: @@ -473,6 +482,7 @@ function MobileWorkspaceTabOption({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTab, onCloseTabsAbove, onCloseTabsBelow, @@ -489,6 +499,7 @@ function MobileWorkspaceTabOption({ onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTab: (tabId: string) => Promise | void; onCloseTabsAbove: (tabId: string) => Promise | void; onCloseTabsBelow: (tabId: string) => Promise | void; @@ -504,6 +515,7 @@ function MobileWorkspaceTabOption({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTab, onCloseTabsBefore: onCloseTabsAbove, onCloseTabsAfter: onCloseTabsBelow, @@ -558,6 +570,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTab, onCloseTabsAbove, onCloseTabsBelow, @@ -611,6 +624,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ onCopyResumeCommand={onCopyResumeCommand} onCopyAgentId={onCopyAgentId} onReloadAgent={onReloadAgent} + onRenameTab={onRenameTab} onCloseTab={onCloseTab} onCloseTabsAbove={onCloseTabsAbove} onCloseTabsBelow={onCloseTabsBelow} @@ -627,6 +641,7 @@ const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTab, onCloseTabsAbove, onCloseTabsBelow, @@ -1532,6 +1547,7 @@ function WorkspaceScreenContent({ openWorkspaceTabFocused, toast, }); + const queryClient = useQueryClient(); const { createMutation: createTerminalMutation, createTerminal, @@ -1543,6 +1559,7 @@ function WorkspaceScreenContent({ liveTerminalIds, pendingCreateInput: pendingTerminalCreateInput, query: terminalsQuery, + queryKey: terminalsQueryKey, removeTerminalFromCache, standaloneTerminalIds, terminals, @@ -2125,6 +2142,14 @@ function WorkspaceScreenContent({ const [_hoveredTabKey, setHoveredTabKey] = useState(null); const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState(null); + const { handleRenameTab, renamingTab, handleRenameModalSubmit, handleRenameModalClose } = + useWorkspaceTabRename({ + client, + normalizedServerId, + queryClient, + terminalsData: terminalsQuery.data, + terminalsQueryKey, + }); const tabByKey = useMemo(() => { const map = new Map(); @@ -3152,6 +3177,7 @@ function WorkspaceScreenContent({ onCopyResumeCommand={handleCopyResumeCommand} onCopyAgentId={handleCopyAgentId} onReloadAgent={handleReloadAgent} + onRenameTab={handleRenameTab} onCloseTabsToLeft={handleCloseTabsToLeftInPane} onCloseTabsToRight={handleCloseTabsToRightInPane} onCloseOtherTabs={handleCloseOtherTabsInPane} @@ -3186,6 +3212,7 @@ function WorkspaceScreenContent({ handleCopyResumeCommand, handleCopyAgentId, handleReloadAgent, + handleRenameTab, handleCloseTabsToLeftInPane, handleCloseTabsToRightInPane, handleCloseOtherTabsInPane, @@ -3268,6 +3295,7 @@ function WorkspaceScreenContent({ onCopyResumeCommand={handleCopyResumeCommand} onCopyAgentId={handleCopyAgentId} onReloadAgent={handleReloadAgent} + onRenameTab={handleRenameTab} onCloseTab={handleCloseTabById} onCloseTabsAbove={handleCloseTabsToLeft} onCloseTabsBelow={handleCloseTabsToRight} @@ -3289,6 +3317,7 @@ function WorkspaceScreenContent({ onCopyResumeCommand={handleCopyResumeCommand} onCopyAgentId={handleCopyAgentId} onReloadAgent={handleReloadAgent} + onRenameTab={handleRenameTab} onCloseTabsToLeft={handleCloseTabsToLeft} onCloseTabsToRight={handleCloseTabsToRight} onCloseOtherTabs={handleCloseOtherTabs} @@ -3337,6 +3366,11 @@ function WorkspaceScreenContent({ onClose={closeImportSheet} onImportedAgent={handleImportedAgent} /> + ) diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.test.ts b/packages/app/src/screens/workspace/workspace-tab-menu.test.ts index 43bf39d3a..6c1c2e108 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.test.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.test.ts @@ -16,6 +16,7 @@ describe("buildWorkspaceTabMenuEntries", () => { const onCopyResumeCommand = vi.fn(); const onCopyAgentId = vi.fn(); const onReloadAgent = vi.fn(); + const onRenameTab = vi.fn(); const onCloseTab = vi.fn(); const onCloseTabsBefore = vi.fn(); const onCloseTabsAfter = vi.fn(); @@ -30,6 +31,7 @@ describe("buildWorkspaceTabMenuEntries", () => { onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTab, onCloseTabsBefore, onCloseTabsAfter, @@ -39,6 +41,7 @@ describe("buildWorkspaceTabMenuEntries", () => { expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([ "Copy resume command", "Copy agent id", + "Rename", "Close to the left", "Close to the right", "Close other tabs", @@ -57,6 +60,7 @@ describe("buildWorkspaceTabMenuEntries", () => { onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), onReloadAgent: vi.fn(), + onRenameTab: vi.fn(), onCloseTab: vi.fn(), onCloseTabsBefore: vi.fn(), onCloseTabsAfter: vi.fn(), @@ -66,6 +70,7 @@ describe("buildWorkspaceTabMenuEntries", () => { expect(entries.filter((entry) => entry.kind === "item").map((entry) => entry.label)).toEqual([ "Copy resume command", "Copy agent id", + "Rename", "Close tabs above", "Close tabs below", "Close other tabs", @@ -74,7 +79,7 @@ describe("buildWorkspaceTabMenuEntries", () => { ]); }); - it("omits agent copy actions for non-agent tabs", () => { + it("omits agent copy actions and rename for draft tabs", () => { const entries = buildWorkspaceTabMenuEntries({ surface: "mobile", tab: { @@ -89,6 +94,7 @@ describe("buildWorkspaceTabMenuEntries", () => { onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), onReloadAgent: vi.fn(), + onRenameTab: vi.fn(), onCloseTab: vi.fn(), onCloseTabsBefore: vi.fn(), onCloseTabsAfter: vi.fn(), @@ -101,6 +107,7 @@ describe("buildWorkspaceTabMenuEntries", () => { expect(entries.some((entry) => entry.kind === "item" && entry.label === "Reload agent")).toBe( false, ); + expect(entries.some((entry) => entry.kind === "item" && entry.label === "Rename")).toBe(false); expect(entries.some((entry) => entry.kind === "separator")).toBe(false); }); @@ -114,6 +121,7 @@ describe("buildWorkspaceTabMenuEntries", () => { onCopyResumeCommand: vi.fn(), onCopyAgentId: vi.fn(), onReloadAgent: vi.fn(), + onRenameTab: vi.fn(), onCloseTab: vi.fn(), onCloseTabsBefore: vi.fn(), onCloseTabsAfter: vi.fn(), @@ -128,4 +136,128 @@ describe("buildWorkspaceTabMenuEntries", () => { }), ); }); + + it("invokes onRenameTab when the rename entry is selected for agent tabs", () => { + const onRenameTab = vi.fn(); + const tab = createAgentTab(); + const entries = buildWorkspaceTabMenuEntries({ + surface: "desktop", + tab, + index: 0, + tabCount: 1, + menuTestIDBase: "workspace-tab-context-agent_123", + onCopyResumeCommand: vi.fn(), + onCopyAgentId: vi.fn(), + onReloadAgent: vi.fn(), + onRenameTab, + onCloseTab: vi.fn(), + onCloseTabsBefore: vi.fn(), + onCloseTabsAfter: vi.fn(), + onCloseOtherTabs: vi.fn(), + }); + + const renameEntry = entries.find((entry) => entry.kind === "item" && entry.label === "Rename"); + if (!renameEntry || renameEntry.kind !== "item") { + throw new Error("Rename entry missing"); + } + renameEntry.onSelect(); + + expect(onRenameTab).toHaveBeenCalledWith(tab); + }); + + it("includes rename as the first entry for terminal tabs", () => { + const onRenameTab = vi.fn(); + const terminalTab: WorkspaceTabDescriptor = { + key: "terminal_abc", + tabId: "terminal_abc", + kind: "terminal", + target: { kind: "terminal", terminalId: "terminal-abc" }, + }; + const entries = buildWorkspaceTabMenuEntries({ + surface: "desktop", + tab: terminalTab, + index: 0, + tabCount: 1, + menuTestIDBase: "workspace-tab-context-terminal_abc", + onCopyResumeCommand: vi.fn(), + onCopyAgentId: vi.fn(), + onReloadAgent: vi.fn(), + onRenameTab, + onCloseTab: vi.fn(), + onCloseTabsBefore: vi.fn(), + onCloseTabsAfter: vi.fn(), + onCloseOtherTabs: vi.fn(), + }); + + const labels = entries.filter((entry) => entry.kind === "item").map((entry) => entry.label); + expect(labels[0]).toBe("Rename"); + expect(labels).not.toContain("Copy resume command"); + expect(labels).not.toContain("Copy agent id"); + expect(labels).not.toContain("Reload agent"); + + const renameEntry = entries.find((entry) => entry.kind === "item" && entry.label === "Rename"); + if (!renameEntry || renameEntry.kind !== "item") { + throw new Error("Rename entry missing"); + } + renameEntry.onSelect(); + expect(onRenameTab).toHaveBeenCalledWith(terminalTab); + }); + + it("uses the same rename entry shape for agent and terminal tabs", () => { + const terminalTab: WorkspaceTabDescriptor = { + key: "terminal_abc", + tabId: "terminal_abc", + kind: "terminal", + target: { kind: "terminal", terminalId: "terminal-abc" }, + }; + const menuTestIDBase = "workspace-tab-context"; + const sharedInput = { + surface: "desktop" as const, + index: 0, + tabCount: 1, + menuTestIDBase, + onCopyResumeCommand: vi.fn(), + onCopyAgentId: vi.fn(), + onReloadAgent: vi.fn(), + onRenameTab: vi.fn(), + onCloseTab: vi.fn(), + onCloseTabsBefore: vi.fn(), + onCloseTabsAfter: vi.fn(), + onCloseOtherTabs: vi.fn(), + }; + + const agentEntries = buildWorkspaceTabMenuEntries({ ...sharedInput, tab: createAgentTab() }); + const terminalEntries = buildWorkspaceTabMenuEntries({ ...sharedInput, tab: terminalTab }); + + const agentRename = agentEntries.find( + (entry) => entry.kind === "item" && entry.key === "rename", + ); + const terminalRename = terminalEntries.find( + (entry) => entry.kind === "item" && entry.key === "rename", + ); + if (!agentRename || agentRename.kind !== "item") throw new Error("Agent rename missing"); + if (!terminalRename || terminalRename.kind !== "item") + throw new Error("Terminal rename missing"); + + expect({ + key: agentRename.key, + label: agentRename.label, + icon: agentRename.icon, + testID: agentRename.testID, + }).toEqual({ + key: terminalRename.key, + label: terminalRename.label, + icon: terminalRename.icon, + testID: terminalRename.testID, + }); + + const agentSeparator = agentEntries + .slice(agentEntries.indexOf(agentRename) + 1) + .find((entry) => entry.kind === "separator"); + const terminalSeparator = terminalEntries + .slice(terminalEntries.indexOf(terminalRename) + 1) + .find((entry) => entry.kind === "separator"); + expect(agentSeparator?.key).toBe("rename-separator"); + expect(terminalSeparator?.key).toBe("rename-separator"); + }); }); diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index da57221a5..123135763 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -8,7 +8,14 @@ export type WorkspaceTabMenuEntry = kind: "item"; key: string; label: string; - icon?: "copy" | "rotate-cw" | "arrow-left-to-line" | "arrow-right-to-line" | "copy-x" | "x"; + icon?: + | "copy" + | "rotate-cw" + | "arrow-left-to-line" + | "arrow-right-to-line" + | "copy-x" + | "pencil" + | "x"; hint?: string; tooltip?: string; disabled?: boolean; @@ -30,6 +37,7 @@ interface BuildWorkspaceTabMenuEntriesInput { onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTab: (tabId: string) => Promise | void; onCloseTabsBefore: (tabId: string) => Promise | void; onCloseTabsAfter: (tabId: string) => Promise | void; @@ -43,6 +51,7 @@ interface BuildWorkspaceDesktopTabActionsInput { onCopyResumeCommand: (agentId: string) => Promise | void; onCopyAgentId: (agentId: string) => Promise | void; onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; onCloseTab: (tabId: string) => Promise | void; onCloseTabsToLeft: (tabId: string) => Promise | void; onCloseTabsToRight: (tabId: string) => Promise | void; @@ -102,6 +111,7 @@ export function buildWorkspaceTabMenuEntries( onCopyResumeCommand, onCopyAgentId, onReloadAgent, + onRenameTab, onCloseTab, onCloseTabsBefore, onCloseTabsAfter, @@ -135,9 +145,22 @@ export function buildWorkspaceTabMenuEntries( void onCopyAgentId(agentId); }, }); + } + + if (tab.target.kind === "agent" || tab.target.kind === "terminal") { + entries.push({ + kind: "item", + key: "rename", + label: "Rename", + icon: "pencil", + testID: `${menuTestIDBase}-rename`, + onSelect: () => { + onRenameTab(tab); + }, + }); entries.push({ kind: "separator", - key: "copy-separator", + key: "rename-separator", }); } @@ -217,6 +240,7 @@ export function buildWorkspaceDesktopTabActions( onCopyResumeCommand: input.onCopyResumeCommand, onCopyAgentId: input.onCopyAgentId, onReloadAgent: input.onReloadAgent, + onRenameTab: input.onRenameTab, onCloseTab: input.onCloseTab, onCloseTabsBefore: input.onCloseTabsToLeft, onCloseTabsAfter: input.onCloseTabsToRight, diff --git a/packages/server/package.json b/packages/server/package.json index 542758ffa..d3832ba9a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -22,6 +22,11 @@ "types": "./dist/server/utils/tool-call-parsers.d.ts", "source": "./src/utils/tool-call-parsers.ts", "default": "./dist/server/utils/tool-call-parsers.js" + }, + "./utils/branch-slug": { + "types": "./dist/server/utils/branch-slug.d.ts", + "source": "./src/utils/branch-slug.ts", + "default": "./dist/server/utils/branch-slug.js" } }, "publishConfig": { diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index f79d7014f..6fb456990 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -1750,6 +1750,119 @@ test("requests checkout pull via RPC", async () => { }); }); +test("renames a branch via RPC", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.renameBranch({ + cwd: "/tmp/project", + branch: "feature/new-name", + requestId: "req-rename-branch", + }); + + expect(mock.sent).toHaveLength(1); + const request = JSON.parse(mock.sent[0]) as { + type: "session"; + message: { + type: "checkout.rename_branch.request"; + cwd: string; + branch: string; + requestId: string; + }; + }; + expect(request.message.type).toBe("checkout.rename_branch.request"); + expect(request.message.cwd).toBe("/tmp/project"); + expect(request.message.branch).toBe("feature/new-name"); + expect(request.message.requestId).toBe("req-rename-branch"); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "checkout.rename_branch.response", + payload: { + requestId: "req-rename-branch", + success: true, + cwd: "/tmp/project", + currentBranch: "feature/new-name", + error: null, + }, + }, + }), + ); + + await expect(promise).resolves.toEqual({ + requestId: "req-rename-branch", + success: true, + cwd: "/tmp/project", + currentBranch: "feature/new-name", + error: null, + }); +}); + +test("returns renameBranch business failures", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const promise = client.renameBranch({ + cwd: "/tmp/project", + branch: "already-exists", + requestId: "req-rename-branch-fail", + }); + + expect(mock.sent).toHaveLength(1); + + mock.triggerMessage( + JSON.stringify({ + type: "session", + message: { + type: "checkout.rename_branch.response", + payload: { + requestId: "req-rename-branch-fail", + success: false, + cwd: "/tmp/project", + currentBranch: null, + error: { code: "NOT_ALLOWED", message: "Branch already exists" }, + }, + }, + }), + ); + + await expect(promise).resolves.toEqual({ + requestId: "req-rename-branch-fail", + success: false, + cwd: "/tmp/project", + currentBranch: null, + error: { code: "NOT_ALLOWED", message: "Branch already exists" }, + }); +}); + test("resubscribes checkout diff streams after reconnect", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 9468c1134..9ce4a06ea 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -5,7 +5,9 @@ import { AgentCreatedStatusPayloadSchema, AgentRefreshedStatusPayloadSchema, AgentResumedStatusPayloadSchema, + CheckoutRenameBranchResponseSchema, parseServerInfoStatusPayload, + RenameTerminalResponseSchema, RestartRequestedStatusPayloadSchema, ShutdownRequestedStatusPayloadSchema, SessionInboundMessageSchema, @@ -287,6 +289,7 @@ type CheckoutGithubSetAutoMergePayload = CheckoutGithubSetAutoMergeResponse["pay type CheckoutPrStatusPayload = CheckoutPrStatusResponse["payload"]; type PullRequestTimelinePayload = PullRequestTimelineResponse["payload"]; type CheckoutSwitchBranchPayload = CheckoutSwitchBranchResponse["payload"]; +export type RenameBranchResult = z.infer["payload"]; type StashSavePayload = StashSaveResponse["payload"]; type StashPopPayload = StashPopResponse["payload"]; type StashListPayload = StashListResponse["payload"]; @@ -355,6 +358,7 @@ type DictationFinishAcceptedPayload = Extract< type AgentPermissionResolvedPayload = AgentPermissionResolvedMessage["payload"]; type ListTerminalsPayload = ListTerminalsResponse["payload"]; type CreateTerminalPayload = CreateTerminalResponse["payload"]; +export type RenameTerminalResult = z.infer["payload"]; type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"]; type CloseItemsPayload = CloseItemsResponse["payload"]; type KillTerminalPayload = KillTerminalResponse["payload"]; @@ -618,6 +622,16 @@ export interface UpdateScheduleOptions { expiresAt?: string | null; requestId?: string; } +export interface RenameBranchInput { + cwd: string; + branch: string; + requestId?: string; +} +export interface RenameTerminalInput { + terminalId: string; + title: string; + requestId?: string; +} type ListAvailableEditorsPayload = ListAvailableEditorsResponseMessage["payload"]; type OpenInEditorPayload = OpenInEditorResponseMessage["payload"]; type OpenProjectPayload = OpenProjectResponseMessage["payload"]; @@ -2933,6 +2947,19 @@ export class DaemonClient { }); } + async renameBranch(input: RenameBranchInput): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: input.requestId, + message: { + type: "checkout.rename_branch.request", + cwd: input.cwd, + branch: input.branch, + }, + responseType: "checkout.rename_branch.response", + timeout: 30000, + }); + } + async stashSave( cwd: string, options?: { branch?: string }, @@ -3661,6 +3688,19 @@ export class DaemonClient { }); } + async renameTerminal(input: RenameTerminalInput): Promise { + return this.sendCorrelatedSessionRequest({ + requestId: input.requestId, + message: { + type: "terminal.rename.request", + terminalId: input.terminalId, + title: input.title, + }, + responseType: "terminal.rename.response", + timeout: 10000, + }); + } + async subscribeTerminal( terminalId: string, requestId?: string, diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index c4e28641d..2c5a2aae8 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { randomUUID } from "node:crypto"; import { createTestLogger } from "../../test-utils/test-logger.js"; -import { AgentManager } from "./agent-manager.js"; +import { AgentManager, type ManagedAgent } from "./agent-manager.js"; import { AgentStorage } from "./agent-storage.js"; import { PARENT_AGENT_ID_LABEL } from "../../shared/agent-labels.js"; import type { StoredAgentRecord } from "./agent-storage.js"; @@ -1457,6 +1457,111 @@ test("setTitle bumps updatedAt and persists title in the same snapshot write", a expect(live!.updatedAt.getTime()).toBeGreaterThan(Date.parse(before!.updatedAt)); }); +test("setGeneratedTitleIfUnset preserves an existing user title", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-preserve-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000128", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + await manager.setTitle(snapshot.id, "User title"); + await manager.setGeneratedTitleIfUnset(snapshot.id, "Generated title"); + + const after = await storage.get(snapshot.id); + expect(after?.title).toBe("User title"); +}); + +test("setGeneratedTitleIfUnset persists generated title when no title exists", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-empty-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000129", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + + await manager.setGeneratedTitleIfUnset(snapshot.id, "Generated title"); + + const after = await storage.get(snapshot.id); + expect(after?.title).toBe("Generated title"); +}); + +test("setGeneratedTitleIfUnset ignores blank generated titles", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-blank-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000130", + }); + + const snapshot = await manager.createAgent({ + provider: "codex", + cwd: workdir, + }); + const before = await storage.get(snapshot.id); + expect(before).not.toBeNull(); + + const stateEvents: ManagedAgent[] = []; + manager.subscribe( + (event) => { + if (event.type === "agent_state") { + stateEvents.push(event.agent); + } + }, + { agentId: snapshot.id, replayState: false }, + ); + + await manager.setGeneratedTitleIfUnset(snapshot.id, " "); + + const after = await storage.get(snapshot.id); + expect(after?.title).toBeNull(); + expect(after?.updatedAt).toBe(before?.updatedAt); + expect(manager.getAgent(snapshot.id)?.updatedAt.toISOString()).toBe(before?.updatedAt); + expect(stateEvents).toEqual([]); +}); + +test("setGeneratedTitleIfUnset throws for an unknown agent", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-generated-title-unknown-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + }); + + await expect( + manager.setGeneratedTitleIfUnset("00000000-0000-4000-8000-000000000999", "Generated title"), + ).rejects.toThrow("Unknown agent '00000000-0000-4000-8000-000000000999'"); +}); + test("persists live mode, model, and thinking changes without an external snapshot subscriber", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-live-persist-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 9ab21b890..570ecbbab 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1228,6 +1228,23 @@ export class AgentManager { this.emitState(agent, { persist: false }); } + async setGeneratedTitleIfUnset(agentId: string, title: string): Promise { + const agent = this.requireAgent(agentId); + const normalizedTitle = title.trim(); + if (!normalizedTitle) { + return; + } + + const registry = this.requireRegistry(); + const persisted = await registry.setGeneratedTitleIfUnset(agent.id, normalizedTitle); + if (!persisted) { + return; + } + + agent.updatedAt = new Date(persisted.updatedAt); + this.emitState(agent, { persist: false }); + } + async setLabels(agentId: string, labels: Record): Promise { const agent = this.requireAgent(agentId); agent.labels = { ...agent.labels, ...labels }; diff --git a/packages/server/src/server/agent/agent-metadata-generator.ts b/packages/server/src/server/agent/agent-metadata-generator.ts index 016c2e821..f71da3348 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.ts @@ -158,7 +158,7 @@ export async function generateAndApplyAgentMetadata( if (needs.needsTitle && typeof result.title === "string") { const normalizedTitle = normalizeAutoTitle(result.title); if (normalizedTitle) { - await options.agentManager.setTitle(options.agentId, normalizedTitle); + await options.agentManager.setGeneratedTitleIfUnset(options.agentId, normalizedTitle); } } } diff --git a/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts b/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts index 1959510c3..2e075f675 100644 --- a/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts +++ b/packages/server/src/server/agent/agent-metadata-generator.unit.test.ts @@ -39,8 +39,8 @@ function createDeps( describe("agent metadata generator auto-title", () => { it("caps generated auto titles at 40 characters before persisting", async () => { - const setTitle = vi.fn().mockResolvedValue(undefined); - const manager = { setTitle } as unknown as AgentManager; + const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined); + const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager; const generatedTitle = "x".repeat(MAX_AUTO_AGENT_TITLE_CHARS + 25); const generateStructured = vi.fn().mockResolvedValue({ title: generatedTitle }) as NonNullable< AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"] @@ -56,13 +56,16 @@ describe("agent metadata generator auto-title", () => { deps: createDeps(generateStructured), }); - expect(setTitle).toHaveBeenCalledTimes(1); - expect(setTitle).toHaveBeenCalledWith("agent-1", "x".repeat(MAX_AUTO_AGENT_TITLE_CHARS)); + expect(setGeneratedTitleIfUnset).toHaveBeenCalledTimes(1); + expect(setGeneratedTitleIfUnset).toHaveBeenCalledWith( + "agent-1", + "x".repeat(MAX_AUTO_AGENT_TITLE_CHARS), + ); }); it("does not generate an auto title when an explicit title is provided", async () => { - const setTitle = vi.fn().mockResolvedValue(undefined); - const manager = { setTitle } as unknown as AgentManager; + const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined); + const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager; const generateStructured = vi.fn().mockResolvedValue({ title: "Generated" }) as NonNullable< AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"] >; @@ -78,12 +81,12 @@ describe("agent metadata generator auto-title", () => { }); expect(generateStructured).not.toHaveBeenCalled(); - expect(setTitle).not.toHaveBeenCalled(); + expect(setGeneratedTitleIfUnset).not.toHaveBeenCalled(); }); it("generates titles independently from workspace branch naming", async () => { - const setTitle = vi.fn().mockResolvedValue(undefined); - const manager = { setTitle } as unknown as AgentManager; + const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined); + const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager; const generateStructured = vi .fn() .mockResolvedValue({ title: "Generated title" }) as NonNullable< @@ -108,7 +111,10 @@ describe("agent metadata generator auto-title", () => { persistSession: false, }), ); - expect(setTitle).toHaveBeenCalledWith("agent-suppressed-branch", "Generated title"); + expect(setGeneratedTitleIfUnset).toHaveBeenCalledWith( + "agent-suppressed-branch", + "Generated title", + ); }); it.each([ @@ -170,8 +176,8 @@ async function generateTitlePromptWithConfig(config: unknown): Promise<{ prompt: writeFileSync(path.join(repoRoot, "paseo.json"), `${JSON.stringify(config)}\n`); } - const setTitle = vi.fn().mockResolvedValue(undefined); - const manager = { setTitle } as unknown as AgentManager; + const setGeneratedTitleIfUnset = vi.fn().mockResolvedValue(undefined); + const manager = { setGeneratedTitleIfUnset } as unknown as AgentManager; const generateStructured = vi.fn().mockResolvedValue({ title: "Generated title" }) as NonNullable< AgentMetadataGeneratorDeps["generateStructuredAgentResponseWithFallback"] >; diff --git a/packages/server/src/server/agent/agent-storage.test.ts b/packages/server/src/server/agent/agent-storage.test.ts index f1db9e77a..61e56e435 100644 --- a/packages/server/src/server/agent/agent-storage.test.ts +++ b/packages/server/src/server/agent/agent-storage.test.ts @@ -321,6 +321,42 @@ describe("AgentStorage", () => { ); }); + test("setGeneratedTitleIfUnset aborts when a user title is already set", async () => { + const agentId = "agent-generated-title-race"; + await storage.applySnapshot(createManagedAgent({ id: agentId })); + await storage.setTitle(agentId, "User title"); + + const result = await storage.setGeneratedTitleIfUnset(agentId, "Generated title"); + + expect(result).toBeNull(); + const record = await storage.get(agentId); + expect(record?.title).toBe("User title"); + }); + + test("setGeneratedTitleIfUnset with concurrent writes does not corrupt state", async () => { + const agentId = "agent-generated-title-concurrent"; + await storage.applySnapshot(createManagedAgent({ id: agentId })); + + await Promise.all([ + storage.setGeneratedTitleIfUnset(agentId, "Title A"), + storage.setGeneratedTitleIfUnset(agentId, "Title B"), + ]); + + const record = await storage.get(agentId); + expect(["Title A", "Title B"]).toContain(record?.title); + }); + + test("setGeneratedTitleIfUnset writes the generated title only when title is empty", async () => { + const agentId = "agent-generated-title-empty"; + await storage.applySnapshot(createManagedAgent({ id: agentId })); + + const written = await storage.setGeneratedTitleIfUnset(agentId, "Generated title"); + + expect(written?.title).toBe("Generated title"); + const record = await storage.get(agentId); + expect(record?.title).toBe("Generated title"); + }); + test("applySnapshot accepts explicit title overrides", async () => { const agentId = "agent-override"; await storage.applySnapshot(createManagedAgent({ id: agentId }), { title: "Provided Title" }); diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index 6c05f61a0..c747395e5 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -115,44 +115,51 @@ export class AgentStorage { async upsert(record: StoredAgentRecord): Promise { await this.load(); + await this.queueRecordWrite(record); + } + + private queueRecordWrite(record: StoredAgentRecord): Promise { const agentId = record.id; const prev = this.pendingWrites.get(agentId) ?? Promise.resolve(); const next = prev.then(async () => { if (this.deleting.has(agentId)) { - return; + return undefined; } - const nextPath = this.buildRecordPath(record); - const previousPath = this.pathById.get(agentId); - - await fs.mkdir(path.dirname(nextPath), { recursive: true }); - await writeFileAtomically(nextPath, JSON.stringify(record, null, 2)); - this.addIndexedPath(agentId, nextPath); - - if (previousPath && previousPath !== nextPath) { - try { - await fs.unlink(previousPath); - } catch { - // ignore cleanup errors - } - this.removeIndexedPath(agentId, previousPath); - } - - this.cache.set(agentId, record); - this.pathById.set(agentId, nextPath); - return; + await this.writeRecord(record); + return undefined; }); - this.pendingWrites.set( - agentId, - next.finally(() => { - if (this.pendingWrites.get(agentId) === next) { - this.pendingWrites.delete(agentId); - } - }), - ); + const tracked = next.finally(() => { + if (this.pendingWrites.get(agentId) === tracked) { + this.pendingWrites.delete(agentId); + } + }); - await next; + this.pendingWrites.set(agentId, tracked); + return tracked; + } + + private async writeRecord(record: StoredAgentRecord): Promise { + const agentId = record.id; + const nextPath = this.buildRecordPath(record); + const previousPath = this.pathById.get(agentId); + + await fs.mkdir(path.dirname(nextPath), { recursive: true }); + await writeFileAtomically(nextPath, JSON.stringify(record, null, 2)); + this.addIndexedPath(agentId, nextPath); + + if (previousPath && previousPath !== nextPath) { + try { + await fs.unlink(previousPath); + } catch { + // ignore cleanup errors + } + this.removeIndexedPath(agentId, previousPath); + } + + this.cache.set(agentId, record); + this.pathById.set(agentId, nextPath); } beginDelete(agentId: string): void { @@ -225,6 +232,40 @@ export class AgentStorage { await this.upsert({ ...record, title }); } + async setGeneratedTitleIfUnset( + agentId: string, + title: string, + ): Promise { + await this.load(); + await this.waitForPendingWrite(agentId); + const record = this.cache.get(agentId) ?? null; + if (!record) { + throw new Error(`Agent ${agentId} not found`); + } + if (record.title) { + return null; + } + + // Re-drain pending writes: a concurrent setTitle may have queued between the + // first drain and here. After waiting, re-read the cache before writing. + await this.waitForPendingWrite(agentId); + const latestRecord = this.cache.get(agentId) ?? null; + if (!latestRecord) { + throw new Error(`Agent ${agentId} not found`); + } + if (latestRecord.title) { + return null; + } + + const nextRecord = { + ...latestRecord, + title, + updatedAt: new Date().toISOString(), + }; + await this.queueRecordWrite(nextRecord); + return nextRecord; + } + async flush(): Promise { await this.load().catch(() => undefined); const writes = Array.from(this.pendingWrites.values()); diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 4f32ab8e1..82d7fd94d 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -112,6 +112,7 @@ const checkoutGitMocks = vi.hoisted(() => ({ mergeToBase: vi.fn(), pullCurrentBranch: vi.fn(), pushCurrentBranch: vi.fn(), + renameCurrentBranch: vi.fn(), resolveBranchCheckout: vi.fn(), warmCheckoutShortstatInBackground: vi.fn(), })); @@ -203,6 +204,7 @@ vi.mock("../utils/checkout-git.js", async (importOriginal) => { mergeToBase: checkoutGitMocks.mergeToBase, pullCurrentBranch: checkoutGitMocks.pullCurrentBranch, pushCurrentBranch: checkoutGitMocks.pushCurrentBranch, + renameCurrentBranch: checkoutGitMocks.renameCurrentBranch, resolveBranchCheckout: checkoutGitMocks.resolveBranchCheckout, warmCheckoutShortstatInBackground: checkoutGitMocks.warmCheckoutShortstatInBackground, }; @@ -927,6 +929,16 @@ function createWorkspaceGitSnapshot( }; } +function createTerminalManagerStub(options?: { setTerminalTitle?: ReturnType }): { + setTerminalTitle: ReturnType; + subscribeTerminalsChanged: ReturnType; +} { + return { + setTerminalTitle: options?.setTerminalTitle ?? vi.fn(), + subscribeTerminalsChanged: vi.fn(() => () => {}), + }; +} + afterEach(() => { vi.clearAllMocks(); }); @@ -3158,6 +3170,208 @@ describe("session checkout switch branch handling", () => { }); }); +describe("session checkout rename branch handling", () => { + test("rejects invalid branch slugs without renaming", async () => { + const messages: unknown[] = []; + const workspaceGitService = { + getSnapshot: vi.fn(), + peekSnapshot: vi.fn(), + }; + const session = createSessionForTest({ workspaceGitService, messages }); + + await session.handleMessage({ + type: "checkout.rename_branch.request", + cwd: "/tmp/repo", + branch: "Feature Name", + requestId: "request-rename-invalid", + }); + + expect(checkoutGitMocks.renameCurrentBranch).not.toHaveBeenCalled(); + expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); + expect(messages).toContainEqual({ + type: "checkout.rename_branch.response", + payload: { + cwd: "/tmp/repo", + success: false, + currentBranch: null, + error: { + code: "UNKNOWN", + message: + "Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes", + }, + requestId: "request-rename-invalid", + }, + }); + }); + + test("reports null current branch when branch rename fails", async () => { + const messages: unknown[] = []; + const workspaceGitService = { + getSnapshot: vi.fn(), + peekSnapshot: vi.fn(), + }; + const session = createSessionForTest({ workspaceGitService, messages }); + checkoutGitMocks.renameCurrentBranch.mockRejectedValue(new Error("branch already exists")); + + await session.handleMessage({ + type: "checkout.rename_branch.request", + cwd: "/tmp/repo", + branch: "feature/new-name", + requestId: "request-rename-failure", + }); + + expect(checkoutGitMocks.renameCurrentBranch).toHaveBeenCalledWith( + "/tmp/repo", + "feature/new-name", + ); + expect(workspaceGitService.peekSnapshot).not.toHaveBeenCalled(); + expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled(); + expect(messages).toContainEqual({ + type: "checkout.rename_branch.response", + payload: { + cwd: "/tmp/repo", + success: false, + currentBranch: null, + error: { + code: "UNKNOWN", + message: "branch already exists", + }, + requestId: "request-rename-failure", + }, + }); + }); + + test("forces workspace git refresh after renaming the current branch", async () => { + const messages: unknown[] = []; + const github = { invalidate: vi.fn() }; + const workspaceGitService = { + getSnapshot: vi.fn().mockResolvedValue( + createWorkspaceGitSnapshot("/tmp/repo", { + git: { + currentBranch: "feature/new-name", + isDirty: false, + }, + }), + ), + peekSnapshot: vi.fn(() => + createWorkspaceGitSnapshot("/tmp/repo", { + git: { currentBranch: "feature/old-name" }, + }), + ), + }; + const session = createSessionForTest({ github, workspaceGitService, messages }); + checkoutGitMocks.renameCurrentBranch.mockResolvedValue({ + previousBranch: "feature/old-name", + currentBranch: "feature/new-name", + }); + + await session.handleMessage({ + type: "checkout.rename_branch.request", + cwd: "/tmp/repo", + branch: "feature/new-name", + requestId: "request-rename-success", + }); + + expect(checkoutGitMocks.renameCurrentBranch).toHaveBeenCalledWith( + "/tmp/repo", + "feature/new-name", + ); + expect(workspaceGitService.getSnapshot).toHaveBeenCalledWith("/tmp/repo", { + force: true, + reason: "rename-branch", + }); + expect(github.invalidate).toHaveBeenCalledWith({ cwd: "/tmp/repo" }); + expect(messages).toContainEqual({ + type: "checkout.rename_branch.response", + payload: { + cwd: "/tmp/repo", + success: true, + currentBranch: "feature/new-name", + error: null, + requestId: "request-rename-success", + }, + }); + }); +}); + +describe("session terminal rename handling", () => { + test("rejects an empty terminal title without calling the terminal manager", async () => { + const messages: unknown[] = []; + const terminalManager = createTerminalManagerStub(); + const session = createSessionForTest({ terminalManager, messages }); + + await session.handleMessage({ + type: "terminal.rename.request", + terminalId: "terminal-1", + title: " ", + requestId: "request-empty-title", + }); + + expect(terminalManager.setTerminalTitle).not.toHaveBeenCalled(); + expect(messages).toContainEqual({ + type: "terminal.rename.response", + payload: { + requestId: "request-empty-title", + success: false, + error: "Title is required", + }, + }); + }); + + test("reports when the terminal manager cannot find the terminal", async () => { + const messages: unknown[] = []; + const terminalManager = createTerminalManagerStub({ + setTerminalTitle: vi.fn(() => false), + }); + const session = createSessionForTest({ terminalManager, messages }); + + await session.handleMessage({ + type: "terminal.rename.request", + terminalId: "missing-terminal", + title: "Renamed terminal", + requestId: "request-missing-terminal", + }); + + expect(terminalManager.setTerminalTitle).toHaveBeenCalledWith( + "missing-terminal", + "Renamed terminal", + ); + expect(messages).toContainEqual({ + type: "terminal.rename.response", + payload: { + requestId: "request-missing-terminal", + success: false, + error: "Terminal not found", + }, + }); + }); + + test("trims and sets a valid terminal title", async () => { + const messages: unknown[] = []; + const terminalManager = createTerminalManagerStub({ + setTerminalTitle: vi.fn(() => true), + }); + const session = createSessionForTest({ terminalManager, messages }); + + await session.handleMessage({ + type: "terminal.rename.request", + terminalId: "terminal-1", + title: " Renamed terminal ", + requestId: "request-title-success", + }); + + expect(terminalManager.setTerminalTitle).toHaveBeenCalledWith("terminal-1", "Renamed terminal"); + expect(messages).toContainEqual({ + type: "terminal.rename.response", + payload: { + requestId: "request-title-success", + success: true, + error: null, + }, + }); + }); +}); + describe("session branch suggestions handling", () => { test("lists branch suggestions through the workspace git service", async () => { const messages: unknown[] = []; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 04ee2264b..46a0978cc 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -20,6 +20,7 @@ import { type FileExplorerRequest, type FileDownloadTokenRequest, type GitSetupOptions, + type CheckoutRenameBranchRequest, type StartWorkspaceScriptRequest, type CloseItemsRequest, type SubscribeCheckoutDiffRequest, @@ -194,7 +195,9 @@ import { pullCurrentBranch, pushCurrentBranch, createPullRequest, + renameCurrentBranch, } from "../utils/checkout-git.js"; +import { validateBranchSlug } from "../utils/branch-slug.js"; import { getProjectIcon } from "../utils/project-icon.js"; import { expandTilde } from "../utils/path.js"; import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js"; @@ -308,8 +311,8 @@ type GitMutationRefreshReason = | "disable-pr-auto-merge" | "create-pr" | "switch-branch" - | "create-branch" | "rename-branch" + | "create-branch" | "stash-push" | "stash-pop" | "create-worktree"; @@ -431,6 +434,7 @@ type ProcessingPhase = "idle" | "transcribing"; interface WorkspaceGitWatchTarget { cwd: string; + workspaceId: string; watchers: FSWatcher[]; debounceTimer: ReturnType | null; refreshPromise: Promise | null; @@ -2046,12 +2050,8 @@ export class Session { return undefined; case "checkout_switch_branch_request": return this.handleCheckoutSwitchBranchRequest(msg); - case "stash_save_request": - return this.handleStashSaveRequest(msg); - case "stash_pop_request": - return this.handleStashPopRequest(msg); - case "stash_list_request": - return this.handleStashListRequest(msg); + case "checkout.rename_branch.request": + return this.handleCheckoutRenameBranchRequest(msg); case "checkout_commit_request": return this.handleCheckoutCommitRequest(msg); case "checkout_merge_request": @@ -2074,6 +2074,12 @@ export class Session { return this.handlePullRequestTimelineRequest(msg); case "github_search_request": return this.handleGitHubSearchRequest(msg); + case "stash_save_request": + return this.handleStashSaveRequest(msg); + case "stash_pop_request": + return this.handleStashPopRequest(msg); + case "stash_list_request": + return this.handleStashListRequest(msg); default: return undefined; } @@ -4933,16 +4939,35 @@ export class Session { target.lastBranchName = workspace?.name ?? null; } + private handleWorkspaceGitBranchSnapshot(cwd: string, branchName: string | null): void { + const target = this.workspaceGitWatchTargets.get(normalizePersistedWorkspaceId(cwd)); + if (!target) { + return; + } + + const previousBranchName = target.lastBranchName; + if (branchName === previousBranchName) { + return; + } + + target.lastBranchName = branchName; + this.onBranchChanged?.(target.workspaceId, previousBranchName, branchName); + } + private syncWorkspaceGitObservers(workspaces: Iterable): void { for (const workspace of workspaces) { this.syncWorkspaceGitObserver(workspace.workspaceDirectory, { isGit: workspace.projectKind === "git", + workspaceId: workspace.id, }); this.rememberWorkspaceGitDescriptorState(workspace.workspaceDirectory, workspace); } } - private syncWorkspaceGitObserver(cwd: string, options: { isGit: boolean }): void { + private syncWorkspaceGitObserver( + cwd: string, + options: { isGit: boolean; workspaceId: string }, + ): void { const normalizedCwd = normalizePersistedWorkspaceId(cwd); if (!options.isGit) { this.removeWorkspaceGitSubscription(normalizedCwd); @@ -4953,9 +4978,22 @@ export class Session { return; } + const target: WorkspaceGitWatchTarget = { + cwd: normalizedCwd, + workspaceId: options.workspaceId, + watchers: [], + debounceTimer: null, + refreshPromise: null, + refreshQueued: false, + latestDescriptorStateKey: null, + lastBranchName: null, + }; + this.workspaceGitWatchTargets.set(normalizedCwd, target); + const subscription = this.workspaceGitService.registerWorkspace( { cwd: normalizedCwd }, (snapshot) => { + this.handleWorkspaceGitBranchSnapshot(normalizedCwd, snapshot.git.currentBranch ?? null); void this.emitWorkspaceUpdateForCwd(normalizedCwd); this.emitCheckoutStatusUpdate(normalizedCwd, snapshot); }, @@ -5062,6 +5100,58 @@ export class Session { } } + private async handleCheckoutRenameBranchRequest(msg: CheckoutRenameBranchRequest): Promise { + const { cwd, branch, requestId } = msg; + const validation = validateBranchSlug(branch); + + if (!validation.valid) { + this.emit({ + type: "checkout.rename_branch.response", + payload: { + cwd, + success: false, + currentBranch: null, + error: toCheckoutError(new Error(validation.error ?? "Invalid branch name")), + requestId, + }, + }); + return; + } + + try { + const result = await renameCurrentBranch(cwd, branch); + await this.notifyGitMutation(cwd, "rename-branch", { invalidateGithub: true }); + this.checkoutDiffManager.scheduleRefreshForCwd(cwd); + this.handleWorkspaceGitBranchSnapshot(cwd, result.currentBranch); + + // Push a workspace_update immediately so the sidebar/header reflect + // the new branch name without waiting for the background git watcher. + await this.emitWorkspaceUpdateForCwd(cwd); + + this.emit({ + type: "checkout.rename_branch.response", + payload: { + cwd, + success: true, + currentBranch: result.currentBranch, + error: null, + requestId, + }, + }); + } catch (error) { + this.emit({ + type: "checkout.rename_branch.response", + payload: { + cwd, + success: false, + currentBranch: null, + error: toCheckoutError(error), + requestId, + }, + }); + } + } + // --------------------------------------------------------------------------- // Stash handlers // --------------------------------------------------------------------------- @@ -6779,7 +6869,10 @@ export class Session { private async emitWorkspaceUpdateForCwd( cwd: string, - options?: { skipReconcile?: boolean; dedupeGitState?: boolean }, + options?: { + skipReconcile?: boolean; + dedupeGitState?: boolean; + }, ): Promise { const workspaces = await this.workspaceRegistry.list(); const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces); diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 38e47afd0..be7cec138 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -1,8 +1,11 @@ import { describe, expect, test, vi } from "vitest"; import path from "node:path"; import type pino from "pino"; +import { createBranchChangeRouteHandler } from "./script-route-branch-handler.js"; +import { ScriptRouteStore } from "./script-proxy.js"; import { Session, type SessionOptions } from "./session.js"; import { asInternals, createStub } from "./test-utils/class-mocks.js"; +import { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-store.js"; import type { WorkspaceGitListener, WorkspaceGitRuntimeSnapshot, @@ -24,7 +27,7 @@ interface SessionInternals { lastEmittedByWorkspaceId: Map; }; buildWorkspaceDescriptorMap: () => Promise>; - syncWorkspaceGitObserver(cwd: string, details: { isGit: boolean }): void; + syncWorkspaceGitObserver(cwd: string, details: { isGit: boolean; workspaceId: string }): void; listAgentPayloads: () => Promise; } @@ -92,7 +95,15 @@ function createWorkspaceRuntimeSnapshot( }; } -function createSessionForWorkspaceGitWatchTests(): { +function createSessionForWorkspaceGitWatchTests(options?: { + onBranchChanged?: ( + workspaceId: string, + oldBranch: string | null, + newBranch: string | null, + ) => void; + scriptRouteStore?: ScriptRouteStore; + scriptRuntimeStore?: WorkspaceScriptRuntimeStore; +}): { session: Session; emitted: Array<{ type: string; payload: unknown }>; projects: Map>; @@ -221,6 +232,10 @@ function createSessionForWorkspaceGitWatchTests(): { stt: null, tts: null, terminalManager: null, + scriptRouteStore: options?.scriptRouteStore, + scriptRuntimeStore: options?.scriptRuntimeStore, + onBranchChanged: options?.onBranchChanged, + getDaemonTcpPort: () => 6767, }); asInternals(session).listAgentPayloads = async () => []; @@ -305,7 +320,7 @@ describe("workspace git watch targets", () => { sessionAny.buildWorkspaceDescriptorMap = async () => new Map([[descriptor.id, descriptor]]); - sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true }); + sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" }); expect(workspaceGitService.registerWorkspace).toHaveBeenCalledWith( { cwd: REPO_CWD }, @@ -364,7 +379,7 @@ describe("workspace git watch targets", () => { lastEmittedByWorkspaceId: new Map(), }; - sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true }); + sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" }); emitted.length = 0; subscriptions[0]?.listener( @@ -407,6 +422,74 @@ describe("workspace git watch targets", () => { await session.cleanup(); }); + test("updates running service script URLs when the git branch changes", async () => { + const routeStore = new ScriptRouteStore(); + routeStore.registerRoute({ + hostname: "app.old-branch.paseo.localhost", + port: 4321, + workspaceId: "ws-10", + projectSlug: "paseo", + scriptName: "app", + }); + const runtimeStore = new WorkspaceScriptRuntimeStore(); + runtimeStore.set({ + workspaceId: "ws-10", + scriptName: "app", + type: "service", + lifecycle: "running", + terminalId: "term-app", + exitCode: null, + }); + + const handleBranchChange = createBranchChangeRouteHandler({ + routeStore, + onRoutesChanged: vi.fn(), + }); + const { session, projects, workspaces, subscriptions } = createSessionForWorkspaceGitWatchTests( + { + scriptRouteStore: routeStore, + scriptRuntimeStore: runtimeStore, + onBranchChanged: handleBranchChange, + }, + ); + const sessionAny = session as unknown as SessionInternals; + seedGitWorkspace({ + projects, + workspaces, + projectId: "proj-1", + workspaceId: "ws-10", + cwd: "/tmp/repo", + name: "old-branch", + }); + + sessionAny.syncWorkspaceGitObserver("/tmp/repo", { isGit: true, workspaceId: "ws-10" }); + + subscriptions[0]?.listener( + createWorkspaceRuntimeSnapshot("/tmp/repo", { + git: { + currentBranch: "new-branch", + }, + }), + ); + + expect(routeStore.listRoutesForWorkspace("ws-10")).toEqual([ + expect.objectContaining({ + hostname: "app.new-branch.paseo.localhost", + projectSlug: "paseo", + scriptName: "app", + }), + ]); + expect(sessionAny.buildWorkspaceScriptPayloadSnapshot("ws-10", "/tmp/repo")).toEqual([ + expect.objectContaining({ + scriptName: "app", + hostname: "app.new-branch.paseo.localhost", + proxyUrl: "http://app.new-branch.paseo.localhost:6767", + }), + ]); + + await session.cleanup(); + }); + test("embeds PR status in checkout_status_update for GitHub-inclusive snapshot pushes", async () => { const { session, emitted, projects, workspaces, subscriptions } = createSessionForWorkspaceGitWatchTests(); @@ -427,7 +510,7 @@ describe("workspace git watch targets", () => { lastEmittedByWorkspaceId: new Map(), }; - sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true }); + sessionAny.syncWorkspaceGitObserver(REPO_CWD, { isGit: true, workspaceId: "ws-10" }); emitted.length = 0; subscriptions[0]?.listener( diff --git a/packages/server/src/shared/messages.rename-entities.test.ts b/packages/server/src/shared/messages.rename-entities.test.ts new file mode 100644 index 000000000..f032f553b --- /dev/null +++ b/packages/server/src/shared/messages.rename-entities.test.ts @@ -0,0 +1,140 @@ +import { z } from "zod"; +import { describe, expect, test } from "vitest"; +import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js"; + +type SessionMessageOption = z.ZodDiscriminatedUnionOption<"type">; + +function schemaWithoutMessageTypes( + schema: { options: SessionMessageOption[] }, + excludedTypes: string[], +) { + const excluded = new Set(excludedTypes); + const options = schema.options.filter((option) => !excluded.has(option.shape.type.value)); + + return z.discriminatedUnion("type", options as [SessionMessageOption, ...SessionMessageOption[]]); +} + +describe("rename entity message schemas", () => { + test("new client schema still parses old daemon checkout and terminal responses", () => { + const checkoutResponse = SessionOutboundMessageSchema.parse({ + type: "checkout_switch_branch_response", + payload: { + cwd: "/tmp/repo", + success: true, + branch: "main", + source: "local", + error: null, + requestId: "request-switch", + }, + }); + const terminalResponse = SessionOutboundMessageSchema.parse({ + type: "kill_terminal_response", + payload: { + terminalId: "terminal-1", + success: true, + requestId: "request-kill", + }, + }); + + expect(checkoutResponse).toEqual({ + type: "checkout_switch_branch_response", + payload: { + cwd: "/tmp/repo", + success: true, + branch: "main", + source: "local", + error: null, + requestId: "request-switch", + }, + }); + expect(terminalResponse).toEqual({ + type: "kill_terminal_response", + payload: { + terminalId: "terminal-1", + success: true, + requestId: "request-kill", + }, + }); + }); + + test("old unions without rename variants reject rename messages and still parse existing messages", () => { + const legacyInboundSchema = schemaWithoutMessageTypes(SessionInboundMessageSchema, [ + "terminal.rename.request", + "checkout.rename_branch.request", + ]); + const legacyOutboundSchema = schemaWithoutMessageTypes(SessionOutboundMessageSchema, [ + "terminal.rename.response", + "checkout.rename_branch.response", + ]); + + expect( + legacyInboundSchema.safeParse({ + type: "terminal.rename.request", + terminalId: "terminal-1", + title: "Server logs", + requestId: "request-terminal-rename", + }).success, + ).toBe(false); + expect( + legacyInboundSchema.safeParse({ + type: "checkout.rename_branch.request", + cwd: "/tmp/repo", + branch: "feature/new-name", + requestId: "request-branch-rename", + }).success, + ).toBe(false); + expect( + legacyOutboundSchema.safeParse({ + type: "terminal.rename.response", + payload: { + requestId: "request-terminal-rename", + success: true, + error: null, + }, + }).success, + ).toBe(false); + expect( + legacyOutboundSchema.safeParse({ + type: "checkout.rename_branch.response", + payload: { + requestId: "request-branch-rename", + success: true, + cwd: "/tmp/repo", + currentBranch: "feature/new-name", + error: null, + }, + }).success, + ).toBe(false); + + expect( + legacyInboundSchema.parse({ + type: "checkout_switch_branch_request", + cwd: "/tmp/repo", + branch: "main", + requestId: "request-switch", + }), + ).toEqual({ + type: "checkout_switch_branch_request", + cwd: "/tmp/repo", + branch: "main", + requestId: "request-switch", + }); + expect( + legacyOutboundSchema.parse({ + type: "kill_terminal_response", + payload: { + terminalId: "terminal-1", + success: true, + requestId: "request-kill", + }, + }), + ).toEqual({ + type: "kill_terminal_response", + payload: { + terminalId: "terminal-1", + success: true, + requestId: "request-kill", + }, + }); + }); +}); diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 8c18fe2ef..76207e2f4 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1389,6 +1389,13 @@ export const CheckoutSwitchBranchRequestSchema = z.object({ requestId: z.string(), }); +export const CheckoutRenameBranchRequestSchema = z.object({ + type: z.literal("checkout.rename_branch.request"), + cwd: z.string(), + branch: z.string(), + requestId: z.string(), +}); + export const StashSaveRequestSchema = z.object({ type: z.literal("stash_save_request"), cwd: z.string(), @@ -1706,6 +1713,13 @@ export const CreateTerminalRequestSchema = z.object({ requestId: z.string(), }); +export const RenameTerminalRequestSchema = z.object({ + type: z.literal("terminal.rename.request"), + terminalId: z.string(), + title: z.string(), + requestId: z.string(), +}); + export const StartWorkspaceScriptRequestSchema = z.object({ type: z.literal("start_workspace_script_request"), workspaceId: z.string(), @@ -1818,6 +1832,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPrStatusRequestSchema, PullRequestTimelineRequestSchema, CheckoutSwitchBranchRequestSchema, + CheckoutRenameBranchRequestSchema, StashSaveRequestSchema, StashPopRequestSchema, StashListRequestSchema, @@ -1845,6 +1860,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ SubscribeTerminalsRequestSchema, UnsubscribeTerminalsRequestSchema, CreateTerminalRequestSchema, + RenameTerminalRequestSchema, StartWorkspaceScriptRequestSchema, SubscribeTerminalRequestSchema, UnsubscribeTerminalRequestSchema, @@ -3105,6 +3121,17 @@ export const CheckoutSwitchBranchResponseSchema = z.object({ }), }); +export const CheckoutRenameBranchResponseSchema = z.object({ + type: z.literal("checkout.rename_branch.response"), + payload: z.object({ + requestId: z.string(), + success: z.boolean(), + cwd: z.string(), + currentBranch: z.string().nullable(), + error: CheckoutErrorSchema.nullable(), + }), +}); + const StashEntrySchema = z.object({ index: z.number().int().min(0), message: z.string(), @@ -3459,6 +3486,15 @@ export const CreateTerminalResponseSchema = z.object({ }), }); +export const RenameTerminalResponseSchema = z.object({ + type: z.literal("terminal.rename.response"), + payload: z.object({ + requestId: z.string(), + success: z.boolean(), + error: z.string().nullable(), + }), +}); + export const SubscribeTerminalResponseSchema = z.object({ type: z.literal("subscribe_terminal_response"), payload: z.union([ @@ -3572,6 +3608,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ CheckoutPrStatusResponseSchema, PullRequestTimelineResponseSchema, CheckoutSwitchBranchResponseSchema, + CheckoutRenameBranchResponseSchema, StashSaveResponseSchema, StashPopResponseSchema, StashListResponseSchema, @@ -3597,6 +3634,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ListTerminalsResponseSchema, TerminalsChangedSchema, CreateTerminalResponseSchema, + RenameTerminalResponseSchema, SubscribeTerminalResponseSchema, KillTerminalResponseSchema, CaptureTerminalResponseSchema, @@ -3844,6 +3882,8 @@ export type PullRequestTimelineItem = z.infer; export type CheckoutSwitchBranchRequest = z.infer; export type CheckoutSwitchBranchResponse = z.infer; +export type CheckoutRenameBranchRequest = z.infer; +export type CheckoutRenameBranchResponse = z.infer; export type StashSaveRequest = z.infer; export type StashSaveResponse = z.infer; export type StashPopRequest = z.infer; @@ -3897,6 +3937,8 @@ export type UnsubscribeTerminalsRequest = z.infer; export type CreateTerminalRequest = z.infer; export type CreateTerminalResponse = z.infer; +export type RenameTerminalRequest = z.infer; +export type RenameTerminalResponse = z.infer; export type StartWorkspaceScriptRequest = z.infer; export type StartWorkspaceScriptResponse = z.infer< typeof StartWorkspaceScriptResponseMessageSchema diff --git a/packages/server/src/terminal/terminal-manager.test.ts b/packages/server/src/terminal/terminal-manager.test.ts index 689340bd3..6104e3937 100644 --- a/packages/server/src/terminal/terminal-manager.test.ts +++ b/packages/server/src/terminal/terminal-manager.test.ts @@ -384,3 +384,35 @@ it("emits empty snapshot when last terminal is removed", async () => { unsubscribe(); }); + +it("setTerminalTitle returns false for unknown terminal ids without changing existing terminals", async () => { + manager = createTerminalManager(); + const session = await manager.createTerminal({ + cwd: realpathSync(tmpdir()), + title: "Existing title", + }); + const snapshots: Array> = []; + const unsubscribe = manager.subscribeTerminalsChanged((input) => { + snapshots.push( + input.terminals.map((terminal) => ({ + id: terminal.id, + ...(terminal.title ? { title: terminal.title } : {}), + })), + ); + }); + + expect(manager.setTerminalTitle("unknown-id", "x")).toBe(false); + expect(session.getTitle()).toBe("Existing title"); + expect(session.getState().title).toBe("Existing title"); + expect(snapshots).toEqual([]); + + unsubscribe(); +}); + +it("setTerminalTitle returns true and updates the terminal title for existing terminals", async () => { + manager = createTerminalManager(); + const session = await manager.createTerminal({ cwd: realpathSync(tmpdir()) }); + + expect(manager.setTerminalTitle(session.id, "x")).toBe(true); + expect(session.getTitle()).toBe("x"); +}); diff --git a/packages/server/src/terminal/terminal-manager.ts b/packages/server/src/terminal/terminal-manager.ts index 2a01b2252..dad8a1e4e 100644 --- a/packages/server/src/terminal/terminal-manager.ts +++ b/packages/server/src/terminal/terminal-manager.ts @@ -30,6 +30,7 @@ export interface TerminalManager { registerCwdEnv(options: { cwd: string; env: Record }): void; getTerminal(id: string): TerminalSession | undefined; getTerminalState(id: string): Promise; + setTerminalTitle(id: string, title: string): boolean; killTerminal(id: string): void; killTerminalAndWait( id: string, @@ -211,6 +212,16 @@ export function createTerminalManager(): TerminalManager { return terminalsById.get(id)?.getStateSnapshot() ?? null; }, + setTerminalTitle(id: string, title: string): boolean { + const session = terminalsById.get(id); + if (!session) { + return false; + } + + session.setTitle(title); + return true; + }, + killTerminal(id: string): void { removeSessionById(id, { kill: true }); }, diff --git a/packages/server/src/terminal/terminal-session-controller.ts b/packages/server/src/terminal/terminal-session-controller.ts index 103a6b4b3..c2e226b88 100644 --- a/packages/server/src/terminal/terminal-session-controller.ts +++ b/packages/server/src/terminal/terminal-session-controller.ts @@ -4,6 +4,7 @@ import type { CreateTerminalRequest, KillTerminalRequest, ListTerminalsRequest, + RenameTerminalRequest, SessionInboundMessage, SessionOutboundMessage, SubscribeTerminalRequest, @@ -64,7 +65,8 @@ type TerminalDispatchableMessage = | UnsubscribeTerminalRequest | TerminalInput | KillTerminalRequest - | CaptureTerminalRequest; + | CaptureTerminalRequest + | RenameTerminalRequest; const TERMINAL_MESSAGE_TYPES: ReadonlySet = new Set([ "subscribe_terminals_request", @@ -76,6 +78,7 @@ const TERMINAL_MESSAGE_TYPES: ReadonlySet = "terminal_input", "kill_terminal_request", "capture_terminal_request", + "terminal.rename.request", ]); export class TerminalSessionController { @@ -145,6 +148,8 @@ export class TerminalSessionController { return this.handleKillTerminalRequest(msg); case "capture_terminal_request": return this.handleCaptureTerminalRequest(msg); + case "terminal.rename.request": + return this.handleRenameTerminalRequest(msg); default: return undefined; } @@ -430,6 +435,32 @@ export class TerminalSessionController { } } + private async handleRenameTerminalRequest(msg: RenameTerminalRequest): Promise { + const respond = (success: boolean, error: string | null): void => { + this.emit({ + type: "terminal.rename.response", + payload: { requestId: msg.requestId, success, error }, + }); + }; + + const title = msg.title.trim(); + if (title.length === 0) { + respond(false, "Title is required"); + return; + } + if (title.length > 200) { + respond(false, "Title is too long"); + return; + } + if (!this.terminalManager) { + respond(false, "Terminal manager not available"); + return; + } + + const renamed = this.terminalManager.setTerminalTitle(msg.terminalId, title); + respond(renamed, renamed ? null : "Terminal not found"); + } + private async handleSubscribeTerminalRequest(msg: SubscribeTerminalRequest): Promise { if (!this.terminalManager) { this.emit({ diff --git a/packages/server/src/terminal/terminal.test.ts b/packages/server/src/terminal/terminal.test.ts index f20258ca1..b6b49c691 100644 --- a/packages/server/src/terminal/terminal.test.ts +++ b/packages/server/src/terminal/terminal.test.ts @@ -1,15 +1,19 @@ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, afterEach, vi } from "vitest"; import { isPlatform } from "../test-utils/platform.js"; import { + buildTerminalEnvironment, createTerminal, ensureNodePtySpawnHelperExecutableForCurrentPlatform, resolveDefaultTerminalShell, humanizeProcessTitle, normalizeProcessTitle, + resolveZshShellIntegrationDir, type TerminalSession, } from "./terminal.js"; import { chmodSync, + cpSync, + existsSync, mkdtempSync, mkdirSync, realpathSync, @@ -17,8 +21,92 @@ import { statSync, writeFileSync, } from "node:fs"; +import { spawnSync } from "node:child_process"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { setImmediate as waitForImmediate } from "node:timers/promises"; + +const hasZsh = existsSync("/bin/zsh"); + +type TerminalRow = ReturnType["grid"][number]; + +function rowToText(row: TerminalRow): string { + return row + .map((cell) => cell.char) + .join("") + .trimEnd(); +} + +// Extract text from a single row +function getRowText(state: ReturnType, rowIndex: number): string { + return rowToText(state.grid[rowIndex]); +} + +// Extract all visible lines as array (trimmed, empty lines included) +function getLines(state: ReturnType): string[] { + return state.grid.map(rowToText); +} + +// Wait for terminal state to match expected lines +async function waitForLines( + session: TerminalSession, + expectedLines: string[], + timeoutMs = 5000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const lines = getLines(session.getState()); + let matches = true; + for (let i = 0; i < expectedLines.length; i++) { + if (lines[i] !== expectedLines[i]) { + matches = false; + break; + } + } + if (matches) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + const actual = getLines(session.getState()).slice(0, expectedLines.length); + throw new Error( + `Timeout waiting for expected lines.\nExpected:\n${JSON.stringify(expectedLines, null, 2)}\nActual:\n${JSON.stringify(actual, null, 2)}`, + ); +} + +async function waitForState( + session: TerminalSession, + predicate: (state: ReturnType) => boolean, + timeoutMs = 5000, +): Promise> { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const state = session.getState(); + if (predicate(state)) { + return state; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + throw new Error("Timeout waiting for terminal state predicate to match"); +} + +async function waitForTitle( + session: TerminalSession, + predicate: (title: string | undefined) => boolean, + timeoutMs = 5000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const title = session.getTitle(); + if (predicate(title)) { + return title; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + + throw new Error("Timeout waiting for terminal title predicate to match"); +} if (isPlatform("win32") && !process.env.ComSpec && !process.env.COMSPEC) { process.env.ComSpec = "C:\\Windows\\System32\\cmd.exe"; @@ -28,6 +116,7 @@ const sessions: TerminalSession[] = []; const temporaryDirs: string[] = []; afterEach(async () => { + vi.useRealTimers(); for (const session of sessions) { session.kill(); } @@ -45,6 +134,17 @@ function trackSession(session: TerminalSession): TerminalSession { return session; } +async function waitForScheduledTimers(expectedTimerCount: number): Promise { + for (let attempt = 0; attempt < 100; attempt++) { + if (vi.getTimerCount() === expectedTimerCount) { + return; + } + await waitForImmediate(); + } + + throw new Error(`Expected ${expectedTimerCount} scheduled timers, got ${vi.getTimerCount()}`); +} + describe("createTerminal", () => { it("keeps full process titles while stripping path prefixes", () => { expect(normalizeProcessTitle(" /usr/local/bin/npm run dev ")).toBe("npm run dev"); @@ -205,6 +305,544 @@ describe("createTerminal", () => { }); }); +describe.skipIf(isPlatform("win32"))("send input", () => { + it("executes a simple echo command", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + // Wait for initial prompt, then send command + await waitForLines(session, ["$"]); + + session.send({ type: "input", data: "echo hello\r" }); + + // After running "echo hello", terminal should show: + // Line 0: "$ echo hello" + // Line 1: "hello" + // Line 2: "$" + await waitForLines(session, ["$ echo hello", "hello", "$"]); + + const state = session.getState(); + expect(getRowText(state, 0)).toBe("$ echo hello"); + expect(getRowText(state, 1)).toBe("hello"); + expect(getRowText(state, 2)).toBe("$"); + }); + + it("captures output from pwd in specified cwd", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + + await waitForLines(session, ["$"]); + + session.send({ type: "input", data: "pwd\r" }); + + await waitForLines(session, ["$ pwd", "/tmp", "$"]); + + const state = session.getState(); + expect(getRowText(state, 0)).toBe("$ pwd"); + expect(getRowText(state, 1)).toBe("/tmp"); + expect(getRowText(state, 2)).toBe("$"); + }); +}); + +describe.skipIf(isPlatform("win32"))("terminal title", () => { + it.skipIf(!hasZsh)("restores the user's ZDOTDIR through the zsh wrapper", async () => { + const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-home-")); + temporaryDirs.push(homeDir); + const realZdotdir = join(homeDir, ".config", "zsh"); + mkdirSync(realZdotdir, { recursive: true }); + writeFileSync(join(realZdotdir, ".zshenv"), "export PASEO_TEST_REAL_ZDOTDIR=1\n"); + + const session = trackSession( + await createTerminal({ + cwd: homeDir, + command: "/bin/zsh", + args: ["-c", 'printf \'%s\\n%s\\n\' "${ZDOTDIR-}" "${PASEO_TEST_REAL_ZDOTDIR-}"'], + env: { + HOME: homeDir, + ZDOTDIR: realZdotdir, + }, + }), + ); + + const exitInfo = await new Promise>>( + (resolve) => { + session.onExit((info) => resolve(info)); + }, + ); + + expect(exitInfo.lastOutputLines).toEqual([realZdotdir, "1"]); + }); + + it("emits the initial title from command args to title listeners", async () => { + const packageRoot = mkdtempSync(join(tmpdir(), "terminal-title-script-")); + temporaryDirs.push(packageRoot); + const scriptPath = join(packageRoot, "npm-cli.js"); + writeFileSync(scriptPath, "setTimeout(() => process.exit(0), 1000);\n"); + + const session = trackSession( + await createTerminal({ + cwd: packageRoot, + command: process.execPath, + args: [scriptPath, "run", "dev"], + }), + ); + const seenTitles: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + + await waitForTitle(session, (title) => title === "npm run dev"); + await waitForState(session, (state) => state.title === "npm run dev"); + + expect(seenTitles).toContain("npm run dev"); + expect(session.getTitle()).toBe("npm run dev"); + expect(session.getState().title).toBe("npm run dev"); + + unsubscribeTitle(); + }); + + it("emits OSC title updates to title listeners", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const seenTitles: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); + + await waitForTitle(session, (title) => title === "Build Log"); + + expect(seenTitles).toContain("Build Log"); + expect(session.getTitle()).toBe("Build Log"); + expect(session.getState().title).toBe("Build Log"); + + unsubscribeTitle(); + }); + + it("keeps preset titles instead of applying OSC title updates", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + title: "typecheck", + }), + ); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); + await new Promise((resolve) => setTimeout(resolve, 150)); + + expect(session.getTitle()).toBe("typecheck"); + expect(session.getState().title).toBe("typecheck"); + }); + + it("emits command completion from VS Code OSC 633 without visible output", async () => { + const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-")); + temporaryDirs.push(packageRoot); + const scriptPath = join(packageRoot, "emit-command-finished.sh"); + writeFileSync(scriptPath, "#!/bin/sh\nprintf '\\033]633;D;7\\007'\n"); + chmodSync(scriptPath, 0o755); + + const session = trackSession( + await createTerminal({ + cwd: packageRoot, + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const commandCompletions: Array = []; + const unsubscribeCommandFinished = session.onCommandFinished((info) => { + commandCompletions.push(info.exitCode); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "./emit-command-finished.sh\r" }); + + await waitForState(session, () => commandCompletions.length === 1); + + expect(commandCompletions).toEqual([7]); + expect(getLines(session.getState()).join("\n")).not.toContain("633;D;7"); + + unsubscribeCommandFinished(); + }); + + it("ignores malformed VS Code OSC 633 command completion payloads", async () => { + const packageRoot = mkdtempSync(join(tmpdir(), "terminal-command-finished-malformed-")); + temporaryDirs.push(packageRoot); + const scriptPath = join(packageRoot, "emit-malformed-command-finished.sh"); + writeFileSync( + scriptPath, + "#!/bin/sh\nprintf '\\033]633;D;garbage\\007\\033]633;D;8;extra\\007\\033]633;D;3\\007'\n", + ); + chmodSync(scriptPath, 0o755); + + const session = trackSession( + await createTerminal({ + cwd: packageRoot, + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const commandCompletions: Array = []; + const unsubscribeCommandFinished = session.onCommandFinished((info) => { + commandCompletions.push(info.exitCode); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "./emit-malformed-command-finished.sh\r" }); + + await waitForState(session, () => commandCompletions.length === 1); + + expect(commandCompletions).toEqual([3]); + expect(getLines(session.getState()).join("\n")).not.toContain("633;D;garbage"); + + unsubscribeCommandFinished(); + }); + + it("debounces rapid title changes and emits only the final title", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const seenTitles: Array = []; + const seenMessages: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + const unsubscribeMessages = session.subscribe((message) => { + if (message.type === "titleChange") { + seenMessages.push(message.title); + } + }); + + await waitForLines(session, ["$"]); + session.send({ + type: "input", + data: "printf '\\033]0;First\\007\\033]0;Second\\007\\033]0;Final\\007'\r", + }); + + await waitForTitle(session, (title) => title === "Final"); + + expect(seenTitles).toEqual(["Final"]); + expect(seenMessages).toEqual(["Final"]); + + unsubscribeMessages(); + unsubscribeTitle(); + }); + + it.skipIf(!hasZsh)("emits zsh shell integration titles for commands and prompts", async () => { + const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-integration-home-")); + temporaryDirs.push(homeDir); + const realZdotdir = join(homeDir, ".config", "zsh"); + const workingDir = join(homeDir, "dev", "faro"); + mkdirSync(realZdotdir, { recursive: true }); + mkdirSync(workingDir, { recursive: true }); + writeFileSync(join(realZdotdir, ".zshenv"), ""); + writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n"); + + const session = trackSession( + await createTerminal({ + cwd: workingDir, + shell: "/bin/zsh", + env: { + HOME: homeDir, + ZDOTDIR: realZdotdir, + }, + }), + ); + + await waitForLines(session, ["$"]); + await waitForTitle(session, (title) => title === "~/dev/faro"); + + session.send({ type: "input", data: "sleep 1\r" }); + + await waitForTitle(session, (title) => title === "sleep 1"); + await waitForTitle(session, (title) => title === "~/dev/faro", 4000); + }); + + it.skipIf(!hasZsh)("loads the user's zsh prompt when the integration dir is packaged", () => { + const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-packaged-home-")); + temporaryDirs.push(homeDir); + writeFileSync(join(homeDir, ".zshrc"), "PS1='PASEO_CUSTOM_PROMPT> '\n"); + + const fakeAppRoot = join(homeDir, "Paseo.app", "Contents", "Resources"); + const inaccessiblePackagedIntegrationDir = join( + fakeAppRoot, + "app.asar", + "node_modules", + "@getpaseo", + "server", + "dist", + "server", + "terminal", + "shell-integration", + "zsh", + ); + const unpackedIntegrationDir = join( + fakeAppRoot, + "app.asar.unpacked", + "node_modules", + "@getpaseo", + "server", + "dist", + "server", + "terminal", + "shell-integration", + "zsh", + ); + mkdirSync(unpackedIntegrationDir, { recursive: true }); + cpSync(resolveZshShellIntegrationDir(), unpackedIntegrationDir, { recursive: true }); + writeFileSync(join(fakeAppRoot, "app.asar"), "asar archive placeholder"); + + const env = buildTerminalEnvironment({ + shell: "/bin/zsh", + env: { + HOME: homeDir, + }, + zshShellIntegrationDir: inaccessiblePackagedIntegrationDir, + }); + + const result = spawnSync("/bin/zsh", ["-i", "-c", "print -r -- ${PROMPT}"], { + cwd: homeDir, + env, + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stdout.split(/\r?\n/)).toContain("PASEO_CUSTOM_PROMPT> "); + }); + + it.skipIf(!hasZsh)("emits zsh shell integration command completion", async () => { + const homeDir = mkdtempSync(join(tmpdir(), "terminal-zsh-command-finished-home-")); + temporaryDirs.push(homeDir); + const realZdotdir = join(homeDir, ".config", "zsh"); + const workingDir = join(homeDir, "dev", "faro"); + mkdirSync(realZdotdir, { recursive: true }); + mkdirSync(workingDir, { recursive: true }); + writeFileSync(join(realZdotdir, ".zshenv"), ""); + writeFileSync(join(realZdotdir, ".zshrc"), "PS1='$ '\n"); + + const session = trackSession( + await createTerminal({ + cwd: workingDir, + shell: "/bin/zsh", + env: { + HOME: homeDir, + ZDOTDIR: realZdotdir, + }, + }), + ); + const commandCompletions: Array = []; + const unsubscribeCommandFinished = session.onCommandFinished((info) => { + commandCompletions.push(info.exitCode); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "false\r" }); + + await waitForState(session, () => commandCompletions.includes(1)); + + expect(commandCompletions).toEqual([1]); + expect(getLines(session.getState()).join("\n")).not.toContain("633;D;1"); + + unsubscribeCommandFinished(); + }); + + it("clears already scheduled OSC title debounce timers when setting a user title", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const seenTitles: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); + + await waitForTitle(session, (title) => title === "Build Log"); + + vi.useFakeTimers(); + session.send({ type: "input", data: "printf '\\033]0;Pending Shell Title\\007'\r" }); + await waitForScheduledTimers(1); + + session.setTitle("User terminal"); + await vi.advanceTimersByTimeAsync(250); + vi.useRealTimers(); + + expect(seenTitles).toEqual(["Build Log", "User terminal"]); + expect(session.getTitle()).toBe("User terminal"); + expect(session.getState().title).toBe("User terminal"); + + unsubscribeTitle(); + }); + + it("ignores later OSC title updates after setting a user title", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const seenTitles: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + + await waitForLines(session, ["$"]); + session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); + + await waitForTitle(session, (title) => title === "Build Log"); + + session.setTitle("User terminal"); + session.send({ type: "input", data: "printf '\\033]0;Later Shell Title\\007'\r" }); + await new Promise((resolve) => setTimeout(resolve, 250)); + + expect(seenTitles).toEqual(["Build Log", "User terminal"]); + expect(session.getTitle()).toBe("User terminal"); + expect(session.getState().title).toBe("User terminal"); + + unsubscribeTitle(); + }); + + it("trims user-set titles and treats empty titles as no-ops", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ " }, + }), + ); + const seenTitles: Array = []; + const unsubscribeTitle = session.onTitleChange((title) => { + seenTitles.push(title); + }); + + await waitForLines(session, ["$"]); + + session.setTitle(" "); + session.send({ type: "input", data: "printf '\\033]0;Build Log\\007'\r" }); + await waitForTitle(session, (title) => title === "Build Log"); + + session.setTitle(" User terminal "); + + expect(seenTitles).toEqual(["Build Log", "User terminal"]); + expect(session.getTitle()).toBe("User terminal"); + expect(session.getState().title).toBe("User terminal"); + + unsubscribeTitle(); + }); +}); + +describe.skipIf(isPlatform("win32"))("colors", () => { + it("captures ANSI 16 color codes (mode 1)", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ ", TERM: "xterm-256color" }, + }), + ); + + await waitForLines(session, ["$"]); + + // \033[31m = ANSI red (color 1) + session.send({ type: "input", data: "printf '\\033[31mRED\\033[0m'\r" }); + + await waitForLines(session, ["$ printf '\\033[31mRED\\033[0m'", "RED$"]); + + const state = session.getState(); + const outputRow = state.grid[1]; + + expect(outputRow[0].char).toBe("R"); + expect(outputRow[0].fg).toBe(1); // ANSI red = 1 + expect(outputRow[0].fgMode).toBe(1); // Mode 1 = 16 ANSI colors + + // The "$" after RED should have default color + expect(outputRow[3].char).toBe("$"); + expect(outputRow[3].fg).toBe(undefined); + expect(outputRow[3].fgMode).toBe(undefined); + }); + + it("captures true color RGB (mode 3)", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ ", TERM: "xterm-256color" }, + }), + ); + + await waitForLines(session, ["$"]); + + // \033[38;2;255;128;64m = true color RGB(255, 128, 64) + session.send({ type: "input", data: "printf '\\033[38;2;255;128;64mRGB\\033[0m'\r" }); + + await waitForLines(session, ["$ printf '\\033[38;2;255;128;64mRGB\\033[0m'", "RGB$"]); + + const state = session.getState(); + const outputRow = state.grid[1]; + + // Check R cell + expect(outputRow[0].char).toBe("R"); + expect(outputRow[0].fgMode).toBe(3); // Mode 3 = true color + + // The color value should be packed RGB: (255 << 16) | (128 << 8) | 64 + const expectedPacked = (255 << 16) | (128 << 8) | 64; + expect(outputRow[0].fg).toBe(expectedPacked); + }); + + it("captures background colors", async () => { + const session = trackSession( + await createTerminal({ + cwd: "/tmp", + shell: "/bin/sh", + env: { PS1: "$ ", TERM: "xterm-256color" }, + }), + ); + + await waitForLines(session, ["$"]); + + // \033[41m = ANSI red background + session.send({ type: "input", data: "printf '\\033[41mBG\\033[0m'\r" }); + + await waitForLines(session, ["$ printf '\\033[41mBG\\033[0m'", "BG$"]); + + const state = session.getState(); + const outputRow = state.grid[1]; + + expect(outputRow[0].char).toBe("B"); + expect(outputRow[0].bg).toBe(1); // ANSI red = 1 + expect(outputRow[0].bgMode).toBe(1); // Mode 1 = 16 ANSI colors + }); +}); + describe("resize", () => { it("updates terminal dimensions on resize", async () => { const session = trackSession( diff --git a/packages/server/src/terminal/terminal.ts b/packages/server/src/terminal/terminal.ts index 56f54697c..4cd54e0cd 100644 --- a/packages/server/src/terminal/terminal.ts +++ b/packages/server/src/terminal/terminal.ts @@ -57,6 +57,7 @@ export interface TerminalSession { getStateSnapshot(): TerminalStateSnapshot; getReplayPreamble(): string; getTitle(): string | undefined; + setTitle(title: string): void; getExitInfo(): TerminalExitInfo | null; kill(): void; killAndWait(options?: { gracefulTimeoutMs?: number; forceTimeoutMs?: number }): Promise; @@ -549,12 +550,14 @@ export async function createTerminal(options: CreateTerminalOptions): Promise | null = null; let pendingInput = ""; let inputFlushImmediate: ReturnType | null = null; let stateRevision = 0; const inputModeTracker = new TerminalInputModeTracker(); + let titleChangeSubscription: { dispose(): void } | null = null; // Create xterm.js headless terminal const terminal = new Terminal({ @@ -598,14 +601,40 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { if (params.length === 0 || (params.length === 1 && params[0] === 0)) { ptyProcess.write("\x1b[?62;4;22c"); @@ -637,9 +666,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { + if (titleMode === "auto") { + titleChangeSubscription = terminal.onTitleChange((nextTitle) => { if (disposed || killed) { return; } @@ -650,6 +678,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { titleDebounceTimer = null; emitTitleChange(pendingTitle); + pendingTitle = undefined; }, TERMINAL_TITLE_DEBOUNCE_MS); }); } @@ -712,11 +741,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise { // no-op; kill is intentionally best-effort and synchronous in the public interface. diff --git a/packages/server/src/utils/branch-slug.test.ts b/packages/server/src/utils/branch-slug.test.ts new file mode 100644 index 000000000..343bbd16b --- /dev/null +++ b/packages/server/src/utils/branch-slug.test.ts @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { slugify, validateBranchSlug } from "./branch-slug.js"; + +describe("branch slug utilities", () => { + it("normalizes display names to lowercase branch slugs", () => { + expect(slugify("My Feature")).toBe("my-feature"); + }); + + it("collapses punctuation and whitespace to a single hyphen", () => { + expect(slugify("My___Feature! @#$ Next")).toBe("my-feature-next"); + }); + + it("trims leading and trailing hyphens", () => { + expect(slugify(" --- My Feature !!! ")).toBe("my-feature"); + }); + + it("enforces the 50 character slug limit", () => { + const slug = slugify("a".repeat(60)); + + expect(slug).toBe("a".repeat(50)); + }); + + it("validates branch slugs with clear messages", () => { + expect(validateBranchSlug("my-feature")).toEqual({ valid: true }); + expect(validateBranchSlug("")).toEqual({ + valid: false, + error: "Branch name cannot be empty", + }); + expect(validateBranchSlug("My Feature")).toEqual({ + valid: false, + error: + "Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes", + }); + expect(validateBranchSlug("-my-feature")).toEqual({ + valid: false, + error: "Branch name cannot start or end with a hyphen", + }); + }); + + it("is exported through the package subpath", () => { + const currentDir = dirname(fileURLToPath(import.meta.url)); + const packageJson = JSON.parse( + readFileSync(join(currentDir, "..", "..", "package.json"), "utf8"), + ) as { + exports?: Record; + }; + + expect(packageJson.exports?.["./utils/branch-slug"]).toEqual({ + types: "./dist/server/utils/branch-slug.d.ts", + source: "./src/utils/branch-slug.ts", + default: "./dist/server/utils/branch-slug.js", + }); + }); + + it("does not import server-only modules", () => { + const currentDir = dirname(fileURLToPath(import.meta.url)); + const source = readFileSync(join(currentDir, "branch-slug.ts"), "utf8"); + + expect(source).not.toMatch( + /from\s+["'](?:node:)?(?:fs|path|child_process)["']|from\s+["']node:/, + ); + }); +}); diff --git a/packages/server/src/utils/branch-slug.ts b/packages/server/src/utils/branch-slug.ts new file mode 100644 index 000000000..1713372fe --- /dev/null +++ b/packages/server/src/utils/branch-slug.ts @@ -0,0 +1,61 @@ +/** + * Validate that a string is a valid git branch name slug. + * Must be lowercase alphanumeric with hyphens and forward slashes only. + */ +export function validateBranchSlug(slug: string): { + valid: boolean; + error?: string; +} { + if (!slug || slug.length === 0) { + return { valid: false, error: "Branch name cannot be empty" }; + } + + if (slug.length > 100) { + return { valid: false, error: "Branch name too long (max 100 characters)" }; + } + + const validPattern = /^[a-z0-9-/]+$/; + if (!validPattern.test(slug)) { + return { + valid: false, + error: + "Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes", + }; + } + + if (slug.startsWith("-") || slug.endsWith("-")) { + return { + valid: false, + error: "Branch name cannot start or end with a hyphen", + }; + } + + if (slug.includes("--")) { + return { valid: false, error: "Branch name cannot have consecutive hyphens" }; + } + + return { valid: true }; +} + +export const MAX_SLUG_LENGTH = 50; + +/** + * Convert a string to kebab-case for branch names. + */ +export function slugify(input: string): string { + const slug = input + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + + if (slug.length <= MAX_SLUG_LENGTH) { + return slug; + } + + const truncated = slug.slice(0, MAX_SLUG_LENGTH); + const lastHyphen = truncated.lastIndexOf("-"); + if (lastHyphen > MAX_SLUG_LENGTH / 2) { + return truncated.slice(0, lastHyphen); + } + return truncated.replace(/-+$/, ""); +} diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 44c5d08cf..a5a8db146 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { execFileSync } from "child_process"; +import { execFileSync, execSync } from "child_process"; import { existsSync, mkdtempSync, @@ -34,6 +34,7 @@ import { resolveBranchCheckout, resolveRepositoryDefaultBranch, parseWorktreeList, + renameCurrentBranch, isPaseoWorktreePath, isDescendantPath, warmCheckoutShortstatInBackground, @@ -263,6 +264,48 @@ describe("checkout git utilities", () => { expect(branch).toBe("feature/rebase-test"); }); + it("renames the checked out branch and returns concrete branch names", async () => { + execSync("git checkout -b feature/old-name", { cwd: repoDir }); + + const result = await renameCurrentBranch(repoDir, "feature/new-name"); + + const currentBranch = execSync("git branch --show-current", { cwd: repoDir }).toString().trim(); + expect(currentBranch).toBe("feature/new-name"); + expect(result).toEqual({ + previousBranch: "feature/old-name", + currentBranch: "feature/new-name", + }); + expect(() => + execSync("git show-ref --verify refs/heads/feature/old-name", { cwd: repoDir }), + ).toThrow(); + expect( + execSync("git show-ref --verify refs/heads/feature/new-name", { cwd: repoDir }) + .toString() + .trim(), + ).toContain("refs/heads/feature/new-name"); + }); + + it("fails when renaming the checked out branch to an existing branch", async () => { + execSync("git branch feature/new-name", { cwd: repoDir }); + execSync("git checkout -b feature/old-name", { cwd: repoDir }); + + await expect(renameCurrentBranch(repoDir, "feature/new-name")).rejects.toThrow(); + + expect(execSync("git branch --show-current", { cwd: repoDir }).toString().trim()).toBe( + "feature/old-name", + ); + expect( + execSync("git show-ref --verify refs/heads/feature/old-name", { cwd: repoDir }) + .toString() + .trim(), + ).toContain("refs/heads/feature/old-name"); + expect( + execSync("git show-ref --verify refs/heads/feature/new-name", { cwd: repoDir }) + .toString() + .trim(), + ).toContain("refs/heads/feature/new-name"); + }); + it("handles status/diff/commit in a normal repo", async () => { writeFileSync(join(repoDir, "file.txt"), "updated\n"); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 80aefb2d0..24538a603 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -30,6 +30,9 @@ import { spawnProcess } from "./spawn.js"; import { resolvePaseoHome } from "../server/paseo-home.js"; import { createExternalProcessEnv } from "../server/paseo-env.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; +import { validateBranchSlug } from "./branch-slug.js"; + +export { slugify, validateBranchSlug } from "./branch-slug.js"; const execFileAsync = promisify(execFile); const READ_ONLY_GIT_ENV = { @@ -740,72 +743,6 @@ export async function getGitCommonDir(cwd: string): Promise { return commonDir; } -/** - * Validate that a string is a valid git branch name slug - * Must be lowercase, alphanumeric, hyphens only - */ -export function validateBranchSlug(slug: string): { - valid: boolean; - error?: string; -} { - if (!slug || slug.length === 0) { - return { valid: false, error: "Branch name cannot be empty" }; - } - - if (slug.length > 100) { - return { valid: false, error: "Branch name too long (max 100 characters)" }; - } - - // Check for valid characters: lowercase letters, numbers, hyphens, forward slashes - const validPattern = /^[a-z0-9-/]+$/; - if (!validPattern.test(slug)) { - return { - valid: false, - error: - "Branch name must contain only lowercase letters, numbers, hyphens, and forward slashes", - }; - } - - // Cannot start or end with hyphen - if (slug.startsWith("-") || slug.endsWith("-")) { - return { - valid: false, - error: "Branch name cannot start or end with a hyphen", - }; - } - - // Cannot have consecutive hyphens - if (slug.includes("--")) { - return { valid: false, error: "Branch name cannot have consecutive hyphens" }; - } - - return { valid: true }; -} - -const MAX_SLUG_LENGTH = 50; - -/** - * Convert string to kebab-case for branch names - */ -export function slugify(input: string): string { - const slug = input - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); - - if (slug.length <= MAX_SLUG_LENGTH) { - return slug; - } - - // Truncate at word boundary (hyphen) if possible - const truncated = slug.slice(0, MAX_SLUG_LENGTH); - const lastHyphen = truncated.lastIndexOf("-"); - if (lastHyphen > MAX_SLUG_LENGTH / 2) { - return truncated.slice(0, lastHyphen); - } - return truncated.replace(/-+$/, ""); -} - const WORKTREE_PROJECT_HASH_LENGTH = 8; function deriveShortAlphanumericHash(value: string): string {