Add rename for workspaces, terminals, and agent tabs (#531)

* Add rename for workspaces, terminals, and agent tabs

Surfaces rename via the sidebar workspace kebab (git branch rename with
client-side slugify), the terminal tab context menu (stops OSC 2 auto-title
overrides), and the agent tab context menu (locks the title against the
metadata generator). A shared RenameModal wraps AdaptiveModalSheet and
replaces the inline rename on the host page.

Auto-title races are closed from both sides: AgentManager gains
setGeneratedTitleIfUnset with an atomic per-agent write queue, and
TerminalSession replaces lockedTitle with a titleMode discriminated union
so user rename flips to manual and disposes the OSC subscription.

New WebSocket messages are additive: rename_terminal_request/response and
checkout_rename_branch_request/response (branch rename uses the
CheckoutError family). A new @getpaseo/server/utils/branch-slug subpath
export shares slugify + validateBranchSlug between server and app.

* Tighten rename feature and restore lost rebase wiring

Unslop pass on the rename commit plus two rebase artifacts:

- session.ts: collapse handleRenameTerminalRequest to a respond helper
  with early returns; restore workspaceGitWatchTargets.set() in
  syncWorkspaceGitObserver (lost during rebase onto main, which broke
  onBranchChanged firing on branch rename).
- terminal.ts: remove DA1 query handler accidentally re-introduced by the
  rebase (main intentionally removed it); restore conditional
  onTitleChange registration under titleMode === "auto".
- rename-modal.tsx: drop unconfident optional-call on setNativeProps and
  the unknown-cast HTMLInputElement narrowing dance.
- sidebar-workspace-list.tsx: remove unused branch field from rename
  result; flatten validateRenameSlug into early returns.
- host-page.tsx: drop ?? "" fallback on a typed-string field.

* Fix typecheck after rebase: pass parsed config to getScriptConfigs

main refactored getScriptConfigs to take the parsed paseo.json config
instead of a repo path. spawnWorktreeScripts (added in the rename
feature) was still passing repoRoot. Read and parse the config first,
matching how spawnWorkspaceScript already does it.

* Resolve lint regressions and restore lost rebase fixes

Post-rebase cleanup: drop the await on syncWorkspaceGitObservers so
fetch_workspaces emits the response before any cold registration-
triggered git work fires (workspace.id is already on the descriptor —
no registry lookup needed). Restore the DA1 CSI handler that answers
\x1b[?62;4;22c on the daemon-side xterm so foreground apps like nvim
get a reply on stdin. Extract WorkspaceTabRenameModal/useWorkspaceTabRename
to drop WorkspaceScreenContent below the cyclomatic-complexity ceiling.
Switch session test internals to handleMessage so we exercise the public
dispatcher and avoid casting through any. Convert literal 'type' aliases
to interfaces and stop spawning callbacks inline so the codebase passes
the post-rebase oxlint rules.

* Fix typecheck after rebase: wire workspaceGitService and worker setTitle

Bootstrap was missing workspaceGitService when constructing
CreatePaseoWorktreeWorkflowDependencies after main's worktree workflow
refactor. Worker terminal manager needed setTitle on the session and
setTerminalTitle on the manager to satisfy the rename additions to
TerminalSession/TerminalManager interfaces.

* Format session.test.ts after rebase

* Remove stray auto-spawn of workspace scripts after bootstrap

The rebase brought in a spawnWorktreeScripts helper and a call inside
runWorktreeSetupInBackground that auto-started every configured workspace
script after worktree setup completed. main never auto-started scripts —
this regressed the workspace-setup-streaming Playwright test, which
expects the "web" script to be idle so the user can click Run.

Drops the helper, the call, and the workspaceGitService dep that only
existed to feed it.

* Fix checkout branch rename tests

* Fix sidebar checkout action store import

* Skip POSIX terminal tests on Windows

* Unslop the rename-entities feature

Six audit findings closed and ~450 net lines trimmed from the branch:

- Remove the duplicate "rename-branch" union member in
  GitMutationRefreshReason.
- Fold dispatchStashMessage back into handleSessionMessage; the split was a
  feature-first artifact of adding checkout_rename_branch_request, with no
  documented rationale.
- Drop the protected beforeGeneratedTitleIfUnsetWrite test seam from
  AgentStorage; rewrite the race test to exercise real Promise.all
  concurrency against the existing per-agent write queue.
- Reshape useWorkspaceTabRename so the hook returns state and handlers
  only; promote WorkspaceTabRenameModal to an exported component the
  consumer renders directly.
- Rename RenameModal to AdaptiveRenameModal so it reads as a generic
  primitive next to AdaptiveModalSheet, and update host-page,
  sidebar-workspace-list, and the workspace tab rename hook to import it
  by its new name.
- Trim duplicate matrix coverage and Zod self-tests across
  rename-modal.test.tsx, terminal.test.ts, session.test.ts,
  agent-storage.test.ts, messages.rename-entities.test.ts and the three
  rename e2e specs; introduce packages/app/e2e/helpers/rename.ts to share
  setup across the e2e specs without merging coverage.

Behavior of the rename feature is unchanged; targeted vitest, branch-wide
typecheck, and lint all green.

* Restore agentMetadataMocks dropped during rebase

5e6aeb2d removed the agentMetadataMocks hoisted definition and its
vi.mock wiring when it deleted the import describe block. The block
was kept (it belongs to main's import feature) but the mock support
was left behind.

* Fix terminal-manager tests using hardcoded /tmp on Windows

setTerminalTitle tests used cwd: "/tmp" which is not a valid directory
on Windows, causing node-pty error code 267 (ERROR_DIRECTORY). Use
realpathSync(tmpdir()) like the rest of the test file.

* Fix typecheck and lint after rebase onto main

AdaptiveModalSheet moved from a `title` string prop to a structured
`header: SheetHeader`; update AdaptiveRenameModal to memoize and pass
a SheetHeader. invalidateCheckoutGitQueriesForClient moved from
git/actions-store to git/query-keys. useWorkspaceTerminals now owns
the terminal query, so workspace-screen pulls queryKey from the hook
and re-declares queryClient via useQueryClient.

* Update agent metadata test mock to match setGeneratedTitleIfUnset rename

The branch renamed AgentManager.setTitle to setGeneratedTitleIfUnset
for the rename feature; the generateTitlePromptWithConfig helper still
mocked the old method, so eight prompt-byte tests crashed with
"setGeneratedTitleIfUnset is not a function" on both ubuntu and
windows server-tests jobs. Sister mocks in the same file were already
on the new name.

* Fix rename modal showing empty input by using controlled TextInput

AdaptiveTextInput (introduced on main by 29ce6653f) is intentionally
uncontrolled: it drops the `value` prop and seeds the native input
once via `initialValue`. AdaptiveRenameModal still passed `value=
draft` from the pre-rebase shape, so every rename modal opened with
an empty textbox and a disabled Save button. Three playwright e2e
specs (settings-host-page, sidebar-workspace-rename, workspace-agent-
tab-rename) failed for this reason.

Switch the rename modal to a plain controlled TextInput so the input
seeds with the current label and the slug transform (used by sidebar
workspace rename) reflects live in the textbox as the user types.
The unit test's old AdaptiveTextInput mock is replaced with a
react-native mock providing a controlled TextInput shim that captures
the same onChangeText/onSubmitEditing handlers.

* Revert "Fix rename modal showing empty input by using controlled TextInput"

This reverts commit 6283deae842b8a5fed63cb8bf06c78029f98386e.

* Seed rename modal input via AdaptiveTextInput's initialValue + resetKey

AdaptiveTextInput is intentionally uncontrolled — it drops `value` and
seeds the native input once with `initialValue`. The rename modal was
passing `value={draft}` from the pre-rebase shape, so every rename
modal opened with an empty textbox (three playwright specs failing).

Pass `initialValue={draft}` and bump `resetKey` only when the
transform rewrote what the user typed. That seeds the native input
with the current label on open, lets the user keep typing inside
text without cursor jumps (no remount when transform is a no-op),
and remounts the native input with the slug when the transform
diverges so live slugification keeps working in sidebar workspace
rename. Keeping AdaptiveTextInput preserves the BottomSheetTextInput
swap on mobile so the keyboard stays above the sheet — using a plain
TextInput would break that.

The unit test mock is updated to read `initialValue`/`resetKey` so it
mirrors production behavior instead of pretending the input is
controlled.

* Slugify branch rename at submit only, drop live transform

The rename modal's transform prop remounted the native input every
time the slug diverged from what the user typed, which lost focus
mid-edit on every uppercase letter or space in the sidebar workspace
rename. Live-rewriting what the user typed was also surprising — the
expectation is that you type a name, the daemon stores a slug.

Drop the transform prop from AdaptiveRenameModal entirely. Sidebar
workspace rename now slugifies once in handleSubmitRename before
calling renameBranch, and validateRenameSlug runs validateBranchSlug
against the slugified value so the user sees inline errors. The
playwright spec drops the live-slug assertion; it still verifies the
post-submit branch on disk and the rename request payload.

* Rename new rename RPCs to dotted convention

docs/rpc-namespacing.md says new RPCs use dotted names with the
direction as the final segment, and explicitly bans new flat snake_
case names. This PR introduced two flat ones; rename them in place
before the protocol ships:

- checkout_rename_branch_request → checkout.rename_branch.request
- checkout_rename_branch_response → checkout.rename_branch.response
- rename_terminal_request → terminal.rename.request
- rename_terminal_response → terminal.rename.response

Touched: the Zod literals in messages.ts, the discriminated union
entries, the session dispatcher and its tests, the daemon client
wrappers and their tests, the terminal session controller, and the
message-parsing rename-entities test. No callers exist outside the
server package — app and CLI go through the daemon-client wrappers,
which now emit the dotted names.

* Match rename modal Save button and input focus to app conventions

Save uses Button variant=default so it gets the same accent
background + white text as every other primary action in the app
(open-project, settings host save, pair-link confirm, etc).

The input previously fell back to the browser's blue focus outline
on web because the rename modal never set outlineStyle: none — every
other AdaptiveTextInput call site that wants accent feedback already
does this. Kill the browser outline, track focus state, and switch
borderColor to accent while focused.

* Theme the focus-visible outline color via AdaptiveTextInput

public/index.html paints a 2px :focus-visible outline on every web
element with a hard-coded #20744a (Paseo green). On the Claude theme,
the rename modal's input got that green ring instead of the theme's
brown accent — visible mismatch against every other accent-colored
control (primary buttons, etc).

Add an outlineColor entry to AdaptiveTextInput's stylesheet sourced
from theme.colors.accent. Unistyles' Babel plugin tracks the read and
updates the native ShadowTree on theme switch — no React re-render,
no useUnistyles() call (forbidden on this hot path per docs/unistyles.md).
The inline outline-color on the rendered DOM input overrides the
selector-level color from index.html; outline-width/style/offset
still come from the global rule.

Consumer style merges in after, so existing callers that pass
outlineColor (message-input, review/surface, question-form-card)
still win.

Also drop the half-baked isFocused/focusedBorderStyle local state
I added to rename-modal earlier — the AdaptiveTextInput fix removes
the need for it.

* Drop useUnistyles() from rename modal

The rename modal only read theme.colors.foregroundMuted to pass as
the TextInput's placeholderTextColor prop. The rest already went
through StyleSheet.create((theme) => ...). Per docs/unistyles.md the
hook is forbidden when an alternative exists — and this is exactly
the alternative the rest of the codebase uses (project-settings-screen,
add-host-modal, pair-link-modal, command-center): put the muted color
in a tiny StyleSheet entry and read .color off it for the prop.

* Default placeholderTextColor in AdaptiveTextInput

placeholderTextColor was being duplicated at every AdaptiveTextInput
call site (add-host-modal, command-center, pair-link-modal,
project-picker-modal, provider-diagnostic-sheet, combobox, the
sheet's own search inputs, etc.) — all passing the same
theme.colors.foregroundMuted. The shared input should own this.

Move it into AdaptiveTextInput as a default sourced from the same
StyleSheet.create(theme => ...) block as the accent outline. Consumers
still override via the prop if they need a different color. Strip
the redundant placeholderTextColor and local placeholderColor style
entry from rename-modal; other call sites can be cleaned up in a
follow-up.

* Update rename e2e specs to use dotted RPC type strings

Earlier commit (5d5624943) renamed the new rename RPCs to the dotted
convention but missed the two e2e specs that capture the WebSocket
frames by type. captureWsSessionFrames matched the old flat names, so
renameRequests / renameFrames stayed empty even though the rename
went through end to end — the sidebar updated and the branch was
renamed on disk. The toBeGreaterThan(0) assertion fired and the test
failed in playwright CI.
This commit is contained in:
Mohamed Boudra
2026-05-19 18:05:45 +08:00
committed by GitHub
parent 0a2307d199
commit 3743df09e6
40 changed files with 3337 additions and 294 deletions
+40
View File
@@ -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<T extends Record<string, unknown>>(
page: Page,
messageType: string,
extract: (inner: Record<string, unknown>) => 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<string, unknown> };
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`);
}
+4 -4
View File
@@ -168,7 +168,7 @@ export async function expectGeneralContent(page: Page): Promise<void> {
export async function expectHostLabelDisplayed(page: Page): Promise<void> {
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<void> {
@@ -176,9 +176,9 @@ export async function clickEditHostLabel(page: Page): Promise<void> {
}
export async function expectHostLabelEditMode(page: Page, expectedLabel: string): Promise<void> {
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<void> {
@@ -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<ReturnType<typeof connectWorkspaceSetupClient>>,
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();
}
});
});
@@ -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();
}
});
});
@@ -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();
}
});
});
@@ -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<TextInput, AdaptiveTextInputProps>(
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) {
@@ -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<HTMLInputElement, Record<string, unknown>>(
(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("<!doctype html><html><body></body></html>");
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> | 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(
<AdaptiveRenameModal
visible={visible}
title={title}
initialValue={initialValue}
placeholder={placeholder}
submitLabel={submitLabel}
onClose={onClose}
onSubmit={onSubmit}
validate={validate}
maxLength={maxLength}
testID="rename-modal"
/>,
);
});
}
function queryInput(): HTMLInputElement | null {
return document.querySelector<HTMLInputElement>('[data-testid="rename-modal-input"]');
}
function querySubmit(): HTMLButtonElement | null {
return document.querySelector<HTMLButtonElement>('[data-testid="rename-modal-submit"]');
}
function queryCancel(): HTMLButtonElement | null {
return document.querySelector<HTMLButtonElement>('[data-testid="rename-modal-cancel"]');
}
function queryError(): HTMLElement | null {
return document.querySelector<HTMLElement>('[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<void> {
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<void>((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);
});
});
@@ -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> | 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<string | null>(null);
const [isPending, setIsPending] = useState(false);
const inputRef = useRef<TextInput>(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<SheetHeader>(() => ({ title }), [title]);
return (
<AdaptiveModalSheet
visible={visible}
onClose={handleCancel}
header={sheetHeader}
testID={testID}
>
<View style={styles.body}>
<AdaptiveTextInput
ref={inputRef}
initialValue={initialValue}
onChangeText={handleChange}
placeholder={placeholder}
autoCapitalize="none"
autoCorrect={false}
editable={!isPending}
maxLength={maxLength}
onSubmitEditing={handleSubmitVoid}
style={styles.input}
testID={inputTestID}
/>
{error ? (
<Text style={styles.errorText} testID={errorTestID}>
{error}
</Text>
) : null}
<View style={styles.actions}>
<Button
variant="secondary"
size="sm"
style={styles.actionButton}
onPress={handleCancel}
disabled={isPending}
testID={cancelTestID}
>
Cancel
</Button>
<Button
variant="default"
size="sm"
style={styles.actionButton}
onPress={handleSubmitVoid}
disabled={submitDisabled}
testID={submitTestID}
>
{isPending ? "Saving..." : submitLabel}
</Button>
</View>
</View>
</AdaptiveModalSheet>
);
}
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,
},
}));
@@ -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 = <ThemedTrash2 size={14} uniProps={foregroundMutedColor
const settingsLeadingIcon = <ThemedSettings size={14} uniProps={foregroundMutedColorMapping} />;
const copyLeadingIcon = <ThemedCopy size={14} uniProps={foregroundMutedColorMapping} />;
const archiveLeadingIcon = <ThemedArchive size={14} uniProps={foregroundMutedColorMapping} />;
const renameLeadingIcon = <ThemedPencil size={14} uniProps={foregroundMutedColorMapping} />;
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
</DropdownMenuItem>
) : null}
{onRename ? (
<DropdownMenuItem
testID={`sidebar-workspace-menu-rename-${workspaceKey}`}
leading={renameLeadingIcon}
onSelect={onRename}
>
Rename workspace
</DropdownMenuItem>
) : null}
<DropdownMenuItem
testID={`sidebar-workspace-menu-archive-${workspaceKey}`}
leading={archiveLeadingIcon}
@@ -1319,6 +1340,7 @@ function WorkspaceRowInner({
onArchive,
onCopyBranchName,
onCopyPath,
onRename,
archiveShortcutKeys,
}: WorkspaceRowInnerProps) {
const _isCompact = useIsCompactFormFactor();
@@ -1429,6 +1451,7 @@ function WorkspaceRowInner({
onArchive={onArchive}
onCopyBranchName={onCopyBranchName}
onCopyPath={onCopyPath}
onRename={onRename}
/>
</View>
{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 (
<WorkspaceRowInner
workspace={workspace}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={onPress}
drag={drag}
isDragging={isDragging}
isArchiving={isArchiving}
isCreating={isCreating}
dragHandleProps={dragHandleProps}
menuController={null}
archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"}
archiveStatus={getWorkspaceArchiveStatus(isWorktree, archiveStatus, isArchivingWorkspace)}
archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."}
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<>
<WorkspaceRowInner
workspace={workspace}
selected={selected}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
onPress={onPress}
drag={drag}
isDragging={isDragging}
isArchiving={isArchiving}
isCreating={isCreating}
dragHandleProps={dragHandleProps}
menuController={null}
archiveLabel={isWorktree ? "Archive worktree" : "Hide from sidebar"}
archiveStatus={getWorkspaceArchiveStatus(isWorktree, archiveStatus, isArchivingWorkspace)}
archivePendingLabel={isWorktree ? "Archiving..." : "Hiding..."}
onArchive={isWorktree ? handleArchiveWorktree : handleArchiveWorkspace}
onCopyBranchName={canCopyBranchName ? handleCopyBranchName : undefined}
onCopyPath={handleCopyPath}
onRename={canCopyBranchName ? handleOpenRename : undefined}
archiveShortcutKeys={selected ? archiveShortcutKeys : null}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
title="Rename workspace"
initialValue={workspace.name}
placeholder="branch-name"
submitLabel="Rename"
validate={validateRenameSlug}
maxLength={MAX_SLUG_LENGTH}
onClose={handleCloseRename}
onSubmit={handleSubmitRename}
testID={`sidebar-workspace-rename-modal-${workspace.workspaceKey}`}
/>
</>
);
}
@@ -86,6 +86,7 @@ interface SplitContainerProps {
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTabsToLeft: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | void;
onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise<void> | 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}
+36 -128
View File
@@ -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<TextInput>(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 (
<>
<Pressable
onPress={handleStartEdit}
onPress={openEditor}
hitSlop={8}
style={styles.identityEditButton}
accessibilityRole="button"
@@ -245,48 +204,16 @@ export function HostRenameButton({ host }: { host: HostProfile }) {
<Pencil size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<AdaptiveModalSheet
<AdaptiveRenameModal
visible={isEditing}
onClose={handleCancel}
header={RENAME_HOST_HEADER}
title="Rename host"
initialValue={host.label}
placeholder="My Host"
submitLabel="Save"
onClose={closeEditor}
onSubmit={handleSubmit}
testID="host-page-rename-modal"
>
<View style={styles.renameBody}>
<TextInput
ref={inputRef}
value={draftLabel}
onChangeText={setDraftLabel}
placeholder="My Host"
placeholderTextColor={theme.colors.foregroundMuted}
autoCapitalize="none"
autoCorrect={false}
editable={!isSaving}
onSubmitEditing={handleSavePress}
style={styles.renameInput}
testID="host-page-label-input"
/>
<View style={styles.renameActions}>
<Button
variant="secondary"
size="sm"
style={FLEX_1_STYLE}
onPress={handleCancel}
disabled={isSaving}
>
Cancel
</Button>
<Button
size="sm"
style={FLEX_1_STYLE}
onPress={handleSavePress}
disabled={isSaving}
testID="host-page-label-save"
>
{isSaving ? "Saving..." : "Save"}
</Button>
</View>
</View>
</AdaptiveModalSheet>
/>
</>
);
}
@@ -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 };
@@ -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<void>;
handleRenameModalClose: () => void;
}
export function useWorkspaceTabRename(
input: UseWorkspaceTabRenameInput,
): UseWorkspaceTabRenameResult {
const { client, normalizedServerId, queryClient, terminalsData, terminalsQueryKey } = input;
const [renamingTab, setRenamingTab] = useState<RenamingTabState | null>(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<void>;
}
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 (
<AdaptiveRenameModal
visible={renamingTab !== null}
title={title}
initialValue={initialValue}
submitLabel="Rename"
maxLength={200}
onClose={onClose}
onSubmit={onSubmit}
testID={testID}
/>
);
}
@@ -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 <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
case "copy-x":
return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
case "pencil":
return <ThemedPencil size={16} uniProps={mutedColorMapping} />;
case "x":
return <ThemedX size={16} uniProps={mutedColorMapping} />;
default:
@@ -150,6 +154,7 @@ interface WorkspaceDesktopTabsRowProps {
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | 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> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
onCloseTabsToRight: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | 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,
],
);
@@ -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> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
@@ -436,6 +443,8 @@ function MobileTabDropdownMenuItem({
return <ThemedArrowRightToLine size={16} uniProps={mutedColorMapping} />;
case "copy-x":
return <ThemedCopyX size={16} uniProps={mutedColorMapping} />;
case "pencil":
return <ThemedPencil size={16} uniProps={mutedColorMapping} />;
case "x":
return <ThemedX size={16} uniProps={mutedColorMapping} />;
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> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
onCloseTabsBelow: (tabId: string) => Promise<void> | 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<string | null>(null);
const [hoveredCloseTabKey, setHoveredCloseTabKey] = useState<string | null>(null);
const { handleRenameTab, renamingTab, handleRenameModalSubmit, handleRenameModalClose } =
useWorkspaceTabRename({
client,
normalizedServerId,
queryClient,
terminalsData: terminalsQuery.data,
terminalsQueryKey,
});
const tabByKey = useMemo(() => {
const map = new Map<string, WorkspaceTabDescriptor>();
@@ -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}
/>
<WorkspaceTabRenameModal
renamingTab={renamingTab}
onSubmit={handleRenameModalSubmit}
onClose={handleRenameModalClose}
/>
</View>
</WorkspaceFocusProvider>
)
@@ -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");
});
});
@@ -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> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsBefore: (tabId: string) => Promise<void> | void;
onCloseTabsAfter: (tabId: string) => Promise<void> | void;
@@ -43,6 +51,7 @@ interface BuildWorkspaceDesktopTabActionsInput {
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsToLeft: (tabId: string) => Promise<void> | void;
onCloseTabsToRight: (tabId: string) => Promise<void> | 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,
+5
View File
@@ -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": {
@@ -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();
@@ -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<typeof CheckoutRenameBranchResponseSchema>["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<typeof RenameTerminalResponseSchema>["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<RenameBranchResult> {
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<RenameTerminalResult> {
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,
@@ -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");
@@ -1228,6 +1228,23 @@ export class AgentManager {
this.emitState(agent, { persist: false });
}
async setGeneratedTitleIfUnset(agentId: string, title: string): Promise<void> {
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<string, string>): Promise<void> {
const agent = this.requireAgent(agentId);
agent.labels = { ...agent.labels, ...labels };
@@ -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);
}
}
}
@@ -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"]
>;
@@ -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" });
@@ -115,44 +115,51 @@ export class AgentStorage {
async upsert(record: StoredAgentRecord): Promise<void> {
await this.load();
await this.queueRecordWrite(record);
}
private queueRecordWrite(record: StoredAgentRecord): Promise<void> {
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<void> {
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<StoredAgentRecord | null> {
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<void> {
await this.load().catch(() => undefined);
const writes = Array.from(this.pendingWrites.values());
+214
View File
@@ -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<typeof vi.fn> }): {
setTerminalTitle: ReturnType<typeof vi.fn>;
subscribeTerminalsChanged: ReturnType<typeof vi.fn>;
} {
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[] = [];
+102 -9
View File
@@ -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<typeof setTimeout> | null;
refreshPromise: Promise<void> | 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<WorkspaceDescriptorPayload>): 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<void> {
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<void> {
const workspaces = await this.workspaceRegistry.list();
const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, workspaces);
@@ -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<string, unknown>;
};
buildWorkspaceDescriptorMap: () => Promise<Map<string, unknown>>;
syncWorkspaceGitObserver(cwd: string, details: { isGit: boolean }): void;
syncWorkspaceGitObserver(cwd: string, details: { isGit: boolean; workspaceId: string }): void;
listAgentPayloads: () => Promise<unknown[]>;
}
@@ -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<string, ReturnType<typeof createPersistedProjectRecord>>;
@@ -221,6 +232,10 @@ function createSessionForWorkspaceGitWatchTests(): {
stt: null,
tts: null,
terminalManager: null,
scriptRouteStore: options?.scriptRouteStore,
scriptRuntimeStore: options?.scriptRuntimeStore,
onBranchChanged: options?.onBranchChanged,
getDaemonTcpPort: () => 6767,
});
asInternals<SessionInternals>(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(
@@ -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",
},
});
});
});
+42
View File
@@ -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<typeof PullRequestTimelineItemSche
export type PullRequestTimelineResponse = z.infer<typeof PullRequestTimelineResponseSchema>;
export type CheckoutSwitchBranchRequest = z.infer<typeof CheckoutSwitchBranchRequestSchema>;
export type CheckoutSwitchBranchResponse = z.infer<typeof CheckoutSwitchBranchResponseSchema>;
export type CheckoutRenameBranchRequest = z.infer<typeof CheckoutRenameBranchRequestSchema>;
export type CheckoutRenameBranchResponse = z.infer<typeof CheckoutRenameBranchResponseSchema>;
export type StashSaveRequest = z.infer<typeof StashSaveRequestSchema>;
export type StashSaveResponse = z.infer<typeof StashSaveResponseSchema>;
export type StashPopRequest = z.infer<typeof StashPopRequestSchema>;
@@ -3897,6 +3937,8 @@ export type UnsubscribeTerminalsRequest = z.infer<typeof UnsubscribeTerminalsReq
export type TerminalsChanged = z.infer<typeof TerminalsChangedSchema>;
export type CreateTerminalRequest = z.infer<typeof CreateTerminalRequestSchema>;
export type CreateTerminalResponse = z.infer<typeof CreateTerminalResponseSchema>;
export type RenameTerminalRequest = z.infer<typeof RenameTerminalRequestSchema>;
export type RenameTerminalResponse = z.infer<typeof RenameTerminalResponseSchema>;
export type StartWorkspaceScriptRequest = z.infer<typeof StartWorkspaceScriptRequestSchema>;
export type StartWorkspaceScriptResponse = z.infer<
typeof StartWorkspaceScriptResponseMessageSchema
@@ -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<Array<{ id: string; title?: string }>> = [];
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");
});
@@ -30,6 +30,7 @@ export interface TerminalManager {
registerCwdEnv(options: { cwd: string; env: Record<string, string> }): void;
getTerminal(id: string): TerminalSession | undefined;
getTerminalState(id: string): Promise<TerminalStateSnapshot | null>;
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 });
},
@@ -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<TerminalDispatchableMessage["type"]> = new Set([
"subscribe_terminals_request",
@@ -76,6 +78,7 @@ const TERMINAL_MESSAGE_TYPES: ReadonlySet<TerminalDispatchableMessage["type"]> =
"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<void> {
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<void> {
if (!this.terminalManager) {
this.emit({
+639 -1
View File
@@ -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<TerminalSession["getState"]>["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<TerminalSession["getState"]>, rowIndex: number): string {
return rowToText(state.grid[rowIndex]);
}
// Extract all visible lines as array (trimmed, empty lines included)
function getLines(state: ReturnType<TerminalSession["getState"]>): string[] {
return state.grid.map(rowToText);
}
// Wait for terminal state to match expected lines
async function waitForLines(
session: TerminalSession,
expectedLines: string[],
timeoutMs = 5000,
): Promise<void> {
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<TerminalSession["getState"]>) => boolean,
timeoutMs = 5000,
): Promise<ReturnType<TerminalSession["getState"]>> {
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<string | undefined> {
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<void> {
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<NonNullable<ReturnType<TerminalSession["getExitInfo"]>>>(
(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<string | undefined> = [];
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<string | undefined> = [];
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<number | null> = [];
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<number | null> = [];
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<string | undefined> = [];
const seenMessages: Array<string | undefined> = [];
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<number | null> = [];
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<string | undefined> = [];
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<string | undefined> = [];
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<string | undefined> = [];
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(
+37 -10
View File
@@ -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<void>;
@@ -549,12 +550,14 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
let exitInfo: TerminalExitInfo | null = null;
let recentOutputText = "";
let title: string | undefined;
let titleMode: "auto" | "manual" = presetTitle?.trim() ? "manual" : "auto";
let pendingTitle: string | undefined;
let titleDebounceTimer: ReturnType<typeof setTimeout> | null = null;
let pendingInput = "";
let inputFlushImmediate: ReturnType<typeof setImmediate> | 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<Te
}
}
const lockedTitle = presetTitle?.trim() || undefined;
function clearPendingTitleChange(): void {
pendingTitle = undefined;
if (titleDebounceTimer) {
clearTimeout(titleDebounceTimer);
titleDebounceTimer = null;
}
}
function disposeTitleChangeSubscription(): void {
titleChangeSubscription?.dispose();
titleChangeSubscription = null;
}
function setTitle(nextTitle: string): void {
const manualTitle = nextTitle.trim();
if (!manualTitle) {
return;
}
titleMode = "manual";
disposeTitleChangeSubscription();
clearPendingTitleChange();
emitTitleChange(manualTitle);
}
const initialManualTitle = presetTitle?.trim() || undefined;
const processTitle = command ? [command, ...args].join(" ") : null;
let initialTitle = lockedTitle;
let initialTitle = initialManualTitle;
if (!initialTitle && processTitle) {
initialTitle = humanizeProcessTitle(processTitle) ?? normalizeProcessTitle(processTitle);
}
emitTitleChange(initialTitle);
// Respond to DA1 queries (CSI c or CSI 0 c) — apps like nvim query terminal capabilities
terminal.parser.registerCsiHandler({ final: "c" }, (params) => {
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<Te
return true;
});
let disposeTitleChangeSubscription: { dispose(): void } | null = null;
if (!lockedTitle) {
disposeTitleChangeSubscription = terminal.onTitleChange((nextTitle) => {
if (titleMode === "auto") {
titleChangeSubscription = terminal.onTitleChange((nextTitle) => {
if (disposed || killed) {
return;
}
@@ -650,6 +678,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
titleDebounceTimer = setTimeout(() => {
titleDebounceTimer = null;
emitTitleChange(pendingTitle);
pendingTitle = undefined;
}, TERMINAL_TITLE_DEBOUNCE_MS);
});
}
@@ -712,11 +741,8 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
clearImmediate(inputFlushImmediate);
inputFlushImmediate = null;
}
if (titleDebounceTimer) {
clearTimeout(titleDebounceTimer);
titleDebounceTimer = null;
}
disposeTitleChangeSubscription?.dispose();
clearPendingTitleChange();
disposeTitleChangeSubscription();
disposeCommandLifecycleSubscription.dispose();
terminal.dispose();
listeners.clear();
@@ -1049,6 +1075,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
getStateSnapshot,
getReplayPreamble,
getTitle,
setTitle,
getExitInfo,
kill,
killAndWait,
@@ -256,6 +256,16 @@ export function createWorkerTerminalManager(
getTitle(): string | undefined {
return record.info.title;
},
setTitle(nextTitle: string): void {
const manualTitle = nextTitle.trim();
if (!manualTitle) {
return;
}
record.info = { ...record.info, title: manualTitle };
for (const listener of Array.from(record.titleChangeListeners)) {
listener(manualTitle);
}
},
getExitInfo(): TerminalExitInfo | null {
return record.exitInfo;
},
@@ -536,6 +546,15 @@ export function createWorkerTerminalManager(
})) as TerminalWorkerStateResult;
},
setTerminalTitle(id: string, title: string): boolean {
const session = recordsById.get(id)?.session;
if (!session) {
return false;
}
session.setTitle(title);
return true;
},
killTerminal(id: string): void {
void sendRequest({ type: "killTerminal", terminalId: id }).catch(() => {
// no-op; kill is intentionally best-effort and synchronous in the public interface.
@@ -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<string, { types?: string; source?: string; default?: string }>;
};
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:/,
);
});
});
+61
View File
@@ -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(/-+$/, "");
}
+44 -1
View File
@@ -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");
+3 -66
View File
@@ -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<string> {
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 {