feat(web): inline-edit run titles in the run header

Click the title (or its hover-revealed pencil) to swap it for an input;
Enter or blur saves via PATCH /runs/{id}, Esc reverts. Trims whitespace,
no-ops blank or unchanged values, and surfaces server validation errors
through the existing toast system. The board and breadcrumb refresh from
the same SWR cache after a successful save.
This commit is contained in:
Bryan Helmkamp 2026-05-09 11:54:41 -04:00
parent 48545f4a7d
commit 7b013cef7f
No known key found for this signature in database
5 changed files with 350 additions and 4 deletions

View file

@ -0,0 +1,192 @@
import { describe, expect, test } from "bun:test";
import TestRenderer, { act } from "react-test-renderer";
import { SWRConfig } from "swr";
import { EditableRunTitle } from "./editable-run-title";
import { ToastProvider } from "./toast";
import { generatedAxios } from "../lib/api-client";
function render(node: React.ReactNode): TestRenderer.ReactTestRenderer {
let tree: TestRenderer.ReactTestRenderer | undefined;
act(() => {
tree = TestRenderer.create(
<SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>
<ToastProvider>{node}</ToastProvider>
</SWRConfig>,
);
});
return tree!;
}
function instanceText(instance: TestRenderer.ReactTestInstance): string {
const parts: string[] = [];
for (const child of instance.children) {
if (typeof child === "string") parts.push(child);
else parts.push(instanceText(child));
}
return parts.join("");
}
function findEditButton(
tree: TestRenderer.ReactTestRenderer,
): TestRenderer.ReactTestInstance {
return tree.root.findByProps({ "aria-label": "Edit run title" });
}
function findInput(
tree: TestRenderer.ReactTestRenderer,
): TestRenderer.ReactTestInstance {
return tree.root.findByProps({ "aria-label": "Run title" });
}
describe("EditableRunTitle", () => {
test("renders the run title with an edit affordance", () => {
const tree = render(<EditableRunTitle runId="run-1" title="Initial title" />);
expect(instanceText(findEditButton(tree))).toContain("Initial title");
});
test("clicking the title swaps to an input pre-filled with the current value", () => {
const tree = render(<EditableRunTitle runId="run-1" title="Initial title" />);
act(() => {
findEditButton(tree).props.onClick();
});
expect(findInput(tree).props.value).toBe("Initial title");
});
test("Enter submits a PATCH and the input collapses back to the heading", async () => {
const submitted: unknown[] = [];
const originalAdapter = generatedAxios.defaults.adapter;
generatedAxios.defaults.adapter = async (config) => {
submitted.push({ url: config.url, method: config.method, body: JSON.parse(String(config.data)) });
return {
data: { id: "run-1", title: "Renamed title" },
status: 200,
statusText: "OK",
headers: {},
config,
};
};
try {
const tree = render(<EditableRunTitle runId="run-1" title="Initial title" />);
act(() => {
findEditButton(tree).props.onClick();
});
const input = findInput(tree);
act(() => {
input.props.onChange({ target: { value: "Renamed title" } });
});
await act(async () => {
input.props.onKeyDown({ key: "Enter", preventDefault: () => {} });
await Promise.resolve();
await Promise.resolve();
});
expect(submitted).toEqual([
{ url: "/api/v1/runs/run-1", method: "patch", body: { title: "Renamed title" } },
]);
} finally {
generatedAxios.defaults.adapter = originalAdapter;
}
});
test("Escape exits without sending a request", () => {
let calls = 0;
const originalAdapter = generatedAxios.defaults.adapter;
generatedAxios.defaults.adapter = async (config) => {
calls += 1;
return {
data: undefined,
status: 204,
statusText: "No Content",
headers: {},
config,
};
};
try {
const tree = render(<EditableRunTitle runId="run-1" title="Initial title" />);
act(() => {
findEditButton(tree).props.onClick();
});
const input = findInput(tree);
act(() => {
input.props.onChange({ target: { value: "Discarded" } });
});
act(() => {
input.props.onKeyDown({ key: "Escape", preventDefault: () => {} });
});
expect(calls).toBe(0);
expect(instanceText(findEditButton(tree))).toContain("Initial title");
} finally {
generatedAxios.defaults.adapter = originalAdapter;
}
});
test("submitting an empty title does not send a request", async () => {
let calls = 0;
const originalAdapter = generatedAxios.defaults.adapter;
generatedAxios.defaults.adapter = async (config) => {
calls += 1;
return {
data: undefined,
status: 200,
statusText: "OK",
headers: {},
config,
};
};
try {
const tree = render(<EditableRunTitle runId="run-1" title="Initial title" />);
act(() => {
findEditButton(tree).props.onClick();
});
const input = findInput(tree);
act(() => {
input.props.onChange({ target: { value: " " } });
});
await act(async () => {
input.props.onKeyDown({ key: "Enter", preventDefault: () => {} });
await Promise.resolve();
});
expect(calls).toBe(0);
} finally {
generatedAxios.defaults.adapter = originalAdapter;
}
});
test("unchanged title on Enter exits without a request", async () => {
let calls = 0;
const originalAdapter = generatedAxios.defaults.adapter;
generatedAxios.defaults.adapter = async (config) => {
calls += 1;
return {
data: undefined,
status: 200,
statusText: "OK",
headers: {},
config,
};
};
try {
const tree = render(<EditableRunTitle runId="run-1" title="Initial title" />);
act(() => {
findEditButton(tree).props.onClick();
});
const input = findInput(tree);
await act(async () => {
input.props.onKeyDown({ key: "Enter", preventDefault: () => {} });
await Promise.resolve();
});
expect(calls).toBe(0);
expect(instanceText(findEditButton(tree))).toContain("Initial title");
} finally {
generatedAxios.defaults.adapter = originalAdapter;
}
});
});

View file

@ -0,0 +1,133 @@
import { useEffect, useRef, useState } from "react";
import { PencilIcon } from "@heroicons/react/16/solid";
import { ApiError } from "../lib/api-client";
import { useUpdateRunTitle } from "../lib/mutations";
import { InlineMarkdown } from "./inline-markdown";
import { useToast } from "./toast";
const TITLE_MAX_LENGTH = 100;
function focusInputNextFrame(callback: () => void): void {
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(callback);
} else {
setTimeout(callback, 0);
}
}
export function EditableRunTitle({ runId, title }: { runId: string; title: string }) {
const [isEditing, setIsEditing] = useState(false);
const [draft, setDraft] = useState(title);
const submittedRef = useRef(false);
const inputRef = useRef<HTMLInputElement>(null);
const updateMutation = useUpdateRunTitle(runId);
const { push } = useToast();
const isSaving = updateMutation.isMutating;
useEffect(() => {
if (!isEditing) setDraft(title);
}, [title, isEditing]);
const enterEdit = () => {
setDraft(title);
submittedRef.current = false;
setIsEditing(true);
focusInputNextFrame(() => {
inputRef.current?.focus();
inputRef.current?.select();
});
};
const exitEdit = () => {
setIsEditing(false);
setDraft(title);
};
const submit = async () => {
if (submittedRef.current) return;
const trimmed = draft.trim();
if (trimmed === title.trim()) {
exitEdit();
return;
}
if (trimmed.length === 0) {
push({ message: "Run title can't be blank.", tone: "error" });
inputRef.current?.focus();
return;
}
submittedRef.current = true;
try {
await updateMutation.trigger({ title: trimmed });
setIsEditing(false);
push({ message: "Run title updated." });
} catch (error) {
submittedRef.current = false;
const message = error instanceof ApiError && error.message
? error.message
: "Could not update run title.";
push({ message, tone: "error" });
focusInputNextFrame(() => inputRef.current?.focus());
}
};
if (isEditing) {
const remaining = TITLE_MAX_LENGTH - draft.length;
const showCount = remaining <= 20;
return (
<div className="min-w-0">
<input
ref={inputRef}
name="run-title"
aria-label="Run title"
type="text"
value={draft}
maxLength={TITLE_MAX_LENGTH}
disabled={isSaving}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => void submit()}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
void submit();
} else if (e.key === "Escape") {
e.preventDefault();
submittedRef.current = true;
exitEdit();
}
}}
className="-mx-2 block w-full rounded-md bg-panel-alt px-2 py-0.5 text-xl font-semibold text-fg outline-1 -outline-offset-1 outline-line-strong focus:outline-2 focus:-outline-offset-1 focus:outline-teal-500 disabled:opacity-60"
/>
<p className="mt-1.5 flex items-center gap-2 text-xs text-fg-muted">
<span>
{isSaving ? "Saving…" : "Press Enter to save · Esc to cancel"}
</span>
{showCount && !isSaving && (
<span className={remaining < 0 ? "text-coral" : "tabular-nums"}>
{remaining} left
</span>
)}
</p>
</div>
);
}
return (
<h2 className="text-xl font-semibold text-fg">
<button
type="button"
onClick={enterEdit}
aria-label="Edit run title"
className="group/title -mx-2 flex min-w-0 max-w-full items-center gap-1.5 rounded-md px-2 py-0.5 text-left text-fg transition-colors hover:bg-overlay focus-visible:bg-overlay focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500"
>
<span className="min-w-0 truncate">
<InlineMarkdown content={title} />
</span>
<PencilIcon
aria-hidden="true"
className="size-3.5 shrink-0 text-fg-muted opacity-0 transition-opacity group-hover/title:opacity-100 group-focus-visible/title:opacity-100"
/>
</button>
</h2>
);
}

View file

@ -3,14 +3,17 @@ import { useSWRConfig } from "swr";
import type {
PreviewUrlResponse,
RunStatusResponse,
RunSummary,
SteerRunRequest,
SubmitAnswerRequest,
UpdateRunRequest,
} from "@qltysh/fabro-api-client";
import {
apiData,
authApi,
humanInTheLoopApi,
runsApi,
} from "./api-client";
import { queryKeys } from "./query-keys";
import type { LifecycleAction, LifecycleActionError } from "./run-actions";
@ -101,6 +104,25 @@ function useLifecycleMutation(
);
}
export function useUpdateRunTitle(id: string | undefined) {
const { mutate } = useSWRConfig();
return useSWRMutation(
id ? queryKeys.runs.updateTitle(id) : null,
async (_key, { arg }: { arg: UpdateRunRequest }): Promise<RunSummary> => {
if (!id) throw new Error("id is required");
return apiData(() => runsApi.updateRun(id, arg));
},
{
onSuccess: (run) => {
if (!id) return;
void mutate(queryKeys.runs.detail(id), run, { revalidate: false });
void mutate(queryKeys.boards.runs());
void mutate(queryKeys.boards.runs(true));
},
},
);
}
export type SubmitInterviewAnswerArg = {
questionId: string;
answer: SubmitAnswerRequest;

View file

@ -44,6 +44,7 @@ export const queryKeys = {
cancel: (id: string) => ["runs", "cancel", id] as const,
archive: (id: string) => ["runs", "archive", id] as const,
unarchive: (id: string) => ["runs", "unarchive", id] as const,
updateTitle: (id: string) => ["runs", "update-title", id] as const,
attachUrl: (id: string) => `/api/v1/runs/${pathSegment(id)}/attach`,
},
workflows: {

View file

@ -16,7 +16,7 @@ import {
import { Link, Outlet, useLocation, useMatches, useNavigate } from "react-router";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { InlineMarkdown } from "../components/inline-markdown";
import { EditableRunTitle } from "../components/editable-run-title";
import { InterviewDock } from "../components/interview-dock";
import { PullRequestChip } from "../components/pull-request-chip";
import { SteerBar, type SteerBarHandle } from "../components/steer-bar";
@ -297,9 +297,7 @@ export default function RunDetail({ params }: { params: { id: string } }) {
)}
>
<div className="min-w-0 flex-1">
<h2 className="text-xl font-semibold text-fg">
<InlineMarkdown content={run.title} />
</h2>
<EditableRunTitle runId={params.id} title={run.title} />
<div className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm">
<span className="flex items-center gap-1.5">
<span className={`size-2 rounded-full ${run.statusDot}`} />