diff --git a/frontend/src/renderer/components/Sidebar.test.tsx b/frontend/src/renderer/components/Sidebar.test.tsx index 85cd3f99b..9e9215230 100644 --- a/frontend/src/renderer/components/Sidebar.test.tsx +++ b/frontend/src/renderer/components/Sidebar.test.tsx @@ -4,13 +4,16 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Sidebar } from "./Sidebar"; -import type { WorkspaceSummary } from "../types/workspace"; +import type { WorkspaceSession, WorkspaceSummary } from "../types/workspace"; -const { navigateMock, mockParams } = vi.hoisted(() => ({ +const { navigateMock, mockParams, renameSessionMock } = vi.hoisted(() => ({ navigateMock: vi.fn(), mockParams: { projectId: undefined as string | undefined }, + renameSessionMock: vi.fn().mockResolvedValue(undefined), })); +vi.mock("../lib/rename-session", () => ({ renameSession: renameSessionMock })); + vi.mock("@tanstack/react-router", async (importOriginal) => { const actual = await importOriginal(); return { @@ -29,15 +32,30 @@ const workspace: WorkspaceSummary = { sessions: [], }; +const session: WorkspaceSession = { + id: "proj-1-1", + workspaceId: "proj-1", + workspaceName: "Project One", + title: "fix login", + provider: "claude-code", + kind: "worker", + branch: "session/proj-1-1", + status: "working", + updatedAt: "2026-06-30T00:00:00Z", + prs: [], +}; + type CreateProjectHandler = (input: { path: string; workerAgent: string; orchestratorAgent: string }) => Promise; type RemoveProjectHandler = (projectId: string) => Promise; function renderSidebar({ onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler, onRemoveProject = vi.fn().mockResolvedValue(undefined) as RemoveProjectHandler, + workspaces = [workspace], }: { onCreateProject?: CreateProjectHandler; onRemoveProject?: RemoveProjectHandler; + workspaces?: WorkspaceSummary[]; } = {}) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, @@ -49,7 +67,7 @@ function renderSidebar({ daemonStatus={{ state: "running" }} onCreateProject={onCreateProject} onRemoveProject={onRemoveProject} - workspaces={[workspace]} + workspaces={workspaces} /> , @@ -64,6 +82,7 @@ async function chooseOption(trigger: HTMLElement, optionName: string) { beforeEach(() => { navigateMock.mockReset(); + renameSessionMock.mockReset().mockResolvedValue(undefined); mockParams.projectId = undefined; vi.spyOn(window, "confirm").mockReturnValue(true); vi.spyOn(window, "alert").mockImplementation(() => undefined); @@ -161,6 +180,42 @@ describe("Sidebar", () => { expect(navigateMock).toHaveBeenCalledWith({ to: "/settings" }); }); + it("renames a session inline and persists via the daemon", async () => { + const user = userEvent.setup(); + const workspaceWithSession = { ...workspace, sessions: [session] }; + renderSidebar({ workspaces: [workspaceWithSession] }); + + await user.click(screen.getByLabelText("Rename fix login")); + const input = screen.getByLabelText("Rename fix login"); + await user.clear(input); + await user.type(input, "polish login{Enter}"); + + await waitFor(() => expect(renameSessionMock).toHaveBeenCalledWith("proj-1-1", "polish login")); + }); + + it("caps the inline rename input at 20 characters", async () => { + const user = userEvent.setup(); + const workspaceWithSession = { ...workspace, sessions: [session] }; + renderSidebar({ workspaces: [workspaceWithSession] }); + + await user.click(screen.getByLabelText("Rename fix login")); + expect(screen.getByLabelText("Rename fix login")).toHaveAttribute("maxlength", "20"); + }); + + it("cancels the inline rename on Escape without calling the daemon", async () => { + const user = userEvent.setup(); + const workspaceWithSession = { ...workspace, sessions: [session] }; + renderSidebar({ workspaces: [workspaceWithSession] }); + + await user.click(screen.getByLabelText("Rename fix login")); + const input = screen.getByLabelText("Rename fix login"); + await user.clear(input); + await user.type(input, "discard me{Escape}"); + + expect(renameSessionMock).not.toHaveBeenCalled(); + expect(screen.getByLabelText("Open fix login")).toBeInTheDocument(); + }); + it("always shows action icons and reserves padding for them", () => { renderSidebar(); diff --git a/frontend/src/renderer/components/Sidebar.tsx b/frontend/src/renderer/components/Sidebar.tsx index d9620ff9c..c9567db77 100644 --- a/frontend/src/renderer/components/Sidebar.tsx +++ b/frontend/src/renderer/components/Sidebar.tsx @@ -6,13 +6,14 @@ import { LayoutDashboard, Moon, MoreVertical, + Pencil, Plus, Search, Settings, Sun, Trash2, } from "lucide-react"; -import { useState, type ReactNode } from "react"; +import { useRef, useState, type ReactNode } from "react"; import { attentionZone, isOrchestratorSession, @@ -24,6 +25,7 @@ import { import { aoBridge } from "../lib/bridge"; import { workspaceQueryKey } from "../hooks/useWorkspaceQuery"; import { spawnOrchestrator } from "../lib/spawn-orchestrator"; +import { renameSession } from "../lib/rename-session"; import { useEventsConnection } from "../hooks/useEventsConnection"; import { useResizable } from "../hooks/useResizable"; import { @@ -70,6 +72,10 @@ const noDragStyle = isMac ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperti const HOVER_ACTION_CLASS = "grid size-5 shrink-0 place-items-center rounded-md text-passive transition-colors hover:bg-interactive-hover hover:text-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-interactive-hover data-[state=open]:text-foreground [&_svg]:size-[15px]"; +// Mirrors the daemon's display-name cap (maxDisplayNameLen) and the spawn +// `--name` flag, so inline edits never round-trip a value the API would reject. +const MAX_DISPLAY_NAME_LEN = 20; + type SidebarProps = { daemonStatus: { state: string; message?: string }; underTopbar?: boolean; @@ -562,40 +568,122 @@ function ProjectItem({ sessions read as children without adding a persistent guide rail. */} {expanded && sessions.length > 0 && ( - {sessions.map((session) => { - const active = selection.activeSessionId === session.id; - return ( - - - - ); - })} + {sessions.map((session) => ( + selection.goSession(workspace.id, session.id)} + /> + ))} )} ); } +// One worker-session row. Reads as a link by default; a hover-revealed pencil +// flips the label into an inline input (Enter/blur saves, Escape cancels) that +// persists through the daemon rename endpoint, so the new name survives reload. +function SessionRow({ session, active, onOpen }: { session: WorkspaceSession; active: boolean; onOpen: () => void }) { + const queryClient = useQueryClient(); + const [isEditing, setIsEditing] = useState(false); + const [draft, setDraft] = useState(session.title); + // Escape must not be swallowed by the blur-to-save path: the keydown handler + // blurs the input, so it flags a cancel here for onBlur to honour. + const cancelledRef = useRef(false); + + const startEditing = () => { + setDraft(session.title); + setIsEditing(true); + }; + + const commit = async () => { + if (cancelledRef.current) { + cancelledRef.current = false; + setIsEditing(false); + return; + } + setIsEditing(false); + const name = draft.trim(); + if (!name || name === session.title) return; + try { + await renameSession(session.id, name); + await queryClient.invalidateQueries({ queryKey: workspaceQueryKey }); + } catch (err) { + console.error("Failed to rename session:", err); + } + }; + + if (isEditing) { + return ( + +
+ + void commit()} + onChange={(e) => setDraft(e.target.value)} + onFocus={(e) => e.currentTarget.select()} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + e.currentTarget.blur(); + } else if (e.key === "Escape") { + e.preventDefault(); + cancelledRef.current = true; + e.currentTarget.blur(); + } + }} + value={draft} + /> +
+
+ ); + } + + return ( + + + {/* Pencil reveals on row hover/focus (named group on SidebarMenuSubItem); + it sits beside the row button rather than nested inside it. */} + + + ); +} + function CreateProjectButton({ onCreateProject }: Pick) { return ( diff --git a/frontend/src/renderer/lib/rename-session.ts b/frontend/src/renderer/lib/rename-session.ts new file mode 100644 index 000000000..6d0ce5d6a --- /dev/null +++ b/frontend/src/renderer/lib/rename-session.ts @@ -0,0 +1,14 @@ +import { apiClient, apiErrorMessage } from "./api-client"; + +/** Update a session's display name via the daemon (PATCH /sessions/{id}). The + * daemon enforces the same 20-character limit as the spawn `--name` flag. */ +export async function renameSession(sessionId: string, displayName: string): Promise { + const { error, response } = await apiClient.PATCH("/api/v1/sessions/{sessionId}", { + params: { path: { sessionId } }, + body: { displayName }, + }); + + if (error) { + throw new Error(apiErrorMessage(error, `Failed to rename session (${response.status})`)); + } +}