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:
parent
60b651f20e
commit
d302414f52
|
|
@ -4,13 +4,16 @@ import { render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { Sidebar } from "./Sidebar";
|
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(),
|
navigateMock: vi.fn(),
|
||||||
mockParams: { projectId: undefined as string | undefined },
|
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) => {
|
vi.mock("@tanstack/react-router", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
|
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
|
||||||
return {
|
return {
|
||||||
|
|
@ -29,15 +32,30 @@ const workspace: WorkspaceSummary = {
|
||||||
sessions: [],
|
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 CreateProjectHandler = (input: { path: string; workerAgent: string; orchestratorAgent: string }) => Promise<void>;
|
||||||
type RemoveProjectHandler = (projectId: string) => Promise<void>;
|
type RemoveProjectHandler = (projectId: string) => Promise<void>;
|
||||||
|
|
||||||
function renderSidebar({
|
function renderSidebar({
|
||||||
onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler,
|
onCreateProject = vi.fn().mockResolvedValue(undefined) as CreateProjectHandler,
|
||||||
onRemoveProject = vi.fn().mockResolvedValue(undefined) as RemoveProjectHandler,
|
onRemoveProject = vi.fn().mockResolvedValue(undefined) as RemoveProjectHandler,
|
||||||
|
workspaces = [workspace],
|
||||||
}: {
|
}: {
|
||||||
onCreateProject?: CreateProjectHandler;
|
onCreateProject?: CreateProjectHandler;
|
||||||
onRemoveProject?: RemoveProjectHandler;
|
onRemoveProject?: RemoveProjectHandler;
|
||||||
|
workspaces?: WorkspaceSummary[];
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
|
@ -49,7 +67,7 @@ function renderSidebar({
|
||||||
daemonStatus={{ state: "running" }}
|
daemonStatus={{ state: "running" }}
|
||||||
onCreateProject={onCreateProject}
|
onCreateProject={onCreateProject}
|
||||||
onRemoveProject={onRemoveProject}
|
onRemoveProject={onRemoveProject}
|
||||||
workspaces={[workspace]}
|
workspaces={workspaces}
|
||||||
/>
|
/>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
|
|
@ -64,6 +82,7 @@ async function chooseOption(trigger: HTMLElement, optionName: string) {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
navigateMock.mockReset();
|
navigateMock.mockReset();
|
||||||
|
renameSessionMock.mockReset().mockResolvedValue(undefined);
|
||||||
mockParams.projectId = undefined;
|
mockParams.projectId = undefined;
|
||||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
vi.spyOn(window, "alert").mockImplementation(() => undefined);
|
vi.spyOn(window, "alert").mockImplementation(() => undefined);
|
||||||
|
|
@ -161,6 +180,42 @@ describe("Sidebar", () => {
|
||||||
expect(navigateMock).toHaveBeenCalledWith({ to: "/settings" });
|
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", () => {
|
it("always shows action icons and reserves padding for them", () => {
|
||||||
renderSidebar();
|
renderSidebar();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,14 @@ import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Moon,
|
Moon,
|
||||||
MoreVertical,
|
MoreVertical,
|
||||||
|
Pencil,
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Settings,
|
Settings,
|
||||||
Sun,
|
Sun,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useState, type ReactNode } from "react";
|
import { useRef, useState, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
attentionZone,
|
attentionZone,
|
||||||
isOrchestratorSession,
|
isOrchestratorSession,
|
||||||
|
|
@ -24,6 +25,7 @@ import {
|
||||||
import { aoBridge } from "../lib/bridge";
|
import { aoBridge } from "../lib/bridge";
|
||||||
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
|
import { workspaceQueryKey } from "../hooks/useWorkspaceQuery";
|
||||||
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
|
import { spawnOrchestrator } from "../lib/spawn-orchestrator";
|
||||||
|
import { renameSession } from "../lib/rename-session";
|
||||||
import { useEventsConnection } from "../hooks/useEventsConnection";
|
import { useEventsConnection } from "../hooks/useEventsConnection";
|
||||||
import { useResizable } from "../hooks/useResizable";
|
import { useResizable } from "../hooks/useResizable";
|
||||||
import {
|
import {
|
||||||
|
|
@ -70,6 +72,10 @@ const noDragStyle = isMac ? ({ WebkitAppRegion: "no-drag" } as React.CSSProperti
|
||||||
const HOVER_ACTION_CLASS =
|
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]";
|
"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 = {
|
type SidebarProps = {
|
||||||
daemonStatus: { state: string; message?: string };
|
daemonStatus: { state: string; message?: string };
|
||||||
underTopbar?: boolean;
|
underTopbar?: boolean;
|
||||||
|
|
@ -562,40 +568,122 @@ function ProjectItem({
|
||||||
sessions read as children without adding a persistent guide rail. */}
|
sessions read as children without adding a persistent guide rail. */}
|
||||||
{expanded && sessions.length > 0 && (
|
{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">
|
<SidebarMenuSub className="mx-0 ml-[18px] translate-x-0 gap-0 border-l-0 px-0 py-1 pl-2.5">
|
||||||
{sessions.map((session) => {
|
{sessions.map((session) => (
|
||||||
const active = selection.activeSessionId === session.id;
|
<SessionRow
|
||||||
return (
|
key={session.id}
|
||||||
<SidebarMenuSubItem key={session.id}>
|
session={session}
|
||||||
<button
|
active={selection.activeSessionId === session.id}
|
||||||
aria-current={active ? "page" : undefined}
|
onOpen={() => selection.goSession(workspace.id, session.id)}
|
||||||
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]",
|
|
||||||
"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)}
|
|
||||||
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")}
|
|
||||||
>
|
|
||||||
{session.title}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</SidebarMenuSubItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SidebarMenuSub>
|
</SidebarMenuSub>
|
||||||
)}
|
)}
|
||||||
</SidebarMenuItem>
|
</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>
|
||||||
|
<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-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={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")}>
|
||||||
|
{session.title}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/* 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",
|
||||||
|
)}
|
||||||
|
onClick={startEditing}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<Pencil aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</SidebarMenuSubItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CreateProjectButton({ onCreateProject }: Pick<SidebarProps, "onCreateProject">) {
|
function CreateProjectButton({ onCreateProject }: Pick<SidebarProps, "onCreateProject">) {
|
||||||
return (
|
return (
|
||||||
<CreateProjectFlow onCreateProject={onCreateProject}>
|
<CreateProjectFlow onCreateProject={onCreateProject}>
|
||||||
|
|
|
||||||
|
|
@ -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})`));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue