feat(sidebar): inline-edit session display name via hover pencil (#2307)

* feat(sidebar): inline-edit session display name via hover pencil

Reuse the merged PATCH /sessions/{id} rename endpoint (#2302). A pencil
reveals on session-row hover/focus; clicking it swaps the label for an
inline input capped at 20 chars (same as spawn --name). Enter/blur saves
and invalidates the workspace query so the rename survives reload; Escape
cancels.

Refs #2301

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(sidebar): cover inline session rename save/cancel/cap

Refs #2301

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore: format with prettier [skip ci]

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Harshit Singh Bhandari 2026-07-01 03:19:27 +05:30 committed by GitHub
parent 60b651f20e
commit d302414f52
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 189 additions and 32 deletions

View File

@ -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<typeof import("@tanstack/react-router")>();
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<void>;
type RemoveProjectHandler = (projectId: string) => Promise<void>;
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}
/>
</SidebarProvider>
</QueryClientProvider>,
@ -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();

View File

@ -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,37 +568,119 @@ function ProjectItem({
sessions read as children without adding a persistent guide rail. */}
{expanded && sessions.length > 0 && (
<SidebarMenuSub className="mx-0 ml-[18px] translate-x-0 gap-0 border-l-0 px-0 py-1 pl-2.5">
{sessions.map((session) => {
const active = selection.activeSessionId === session.id;
{sessions.map((session) => (
<SessionRow
key={session.id}
session={session}
active={selection.activeSessionId === session.id}
onOpen={() => selection.goSession(workspace.id, session.id)}
/>
))}
</SidebarMenuSub>
)}
</SidebarMenuItem>
);
}
// 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 (
<SidebarMenuSubItem key={session.id}>
<SidebarMenuSubItem>
<div className="relative flex h-auto w-full items-center gap-[9px] rounded-[4px] py-[5px] pl-2.5 pr-1.5">
<SessionDot session={session} />
<input
aria-label={`Rename ${session.title}`}
autoFocus
className="min-w-0 flex-1 rounded-[3px] border border-accent bg-transparent px-1 py-px text-[12px] text-foreground outline-none focus-visible:ring-1 focus-visible:ring-accent"
maxLength={MAX_DISPLAY_NAME_LEN}
onBlur={() => 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}
/>
</div>
</SidebarMenuSubItem>
);
}
return (
<SidebarMenuSubItem>
<button
aria-current={active ? "page" : undefined}
aria-label={`Open ${session.title}`}
className={cn(
"relative flex h-auto w-full items-center gap-[9px] rounded-[4px] py-[5px] pl-2.5 pr-1.5 text-left outline-hidden transition-[color]",
"relative flex h-auto w-full items-center gap-[9px] rounded-[4px] py-[5px] pl-2.5 pr-7 text-left outline-hidden transition-[color]",
"before:absolute before:top-1.5 before:bottom-1.5 before:left-0 before:w-px before:rounded-full before:bg-transparent",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring",
active && "text-foreground before:bg-accent",
)}
onClick={() => selection.goSession(workspace.id, session.id)}
onClick={onOpen}
type="button"
>
<SessionDot session={session} />
<span className="min-w-0 flex-1">
<span
className={cn("block truncate text-[12px]", active ? "text-foreground" : "text-muted-foreground")}
>
<span className={cn("block truncate text-[12px]", active ? "text-foreground" : "text-muted-foreground")}>
{session.title}
</span>
</span>
</button>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
{/* Pencil reveals on row hover/focus (named group on SidebarMenuSubItem);
it sits beside the row button rather than nested inside it. */}
<button
aria-label={`Rename ${session.title}`}
className={cn(
HOVER_ACTION_CLASS,
"absolute top-1/2 right-1 -translate-y-1/2 opacity-0",
"group-focus-within/menu-sub-item:opacity-100 group-hover/menu-sub-item:opacity-100",
)}
</SidebarMenuItem>
onClick={startEditing}
type="button"
>
<Pencil aria-hidden="true" />
</button>
</SidebarMenuSubItem>
);
}

View File

@ -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<void> {
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})`));
}
}