// Shared session-rename logic, reused by the three rename surfaces (the session // header title editor, the rail row context menu, and the rail row hover // affordance). The pure helpers here are unit-tested; the `useInlineRename` // hook wraps them with the small bit of editing state every surface needs so // the Enter-save / Esc-cancel / blur-save / empty-or-unchanged-no-op behaviour // lives in exactly one place. import { useCallback, useEffect, useRef, useState } from "react"; import type { Session } from "../types"; import type { RailSession } from "./session-list-entry"; import { resolveRenameSubmission, SESSION_TITLE_MAX_LENGTH, sessionDisplayTitle, sessionRenameSeed, } from "../../session-list-model"; /** * The title shown for a session: durable agent/user metadata once available, * otherwise a bounded opening-prompt preview while automatic naming is still * pending. Mirrors the rail list and the header so every surface reads * identically. */ export { sessionDisplayTitle }; /** * The value the editor seeds from when entering edit mode; see the shared * sessionRenameSeed. Rail entries carry a precomputed seed. */ export function renameSeedValue(session: RailSession): string { return sessionRenameSeed(session); } export { resolveRenameSubmission, SESSION_TITLE_MAX_LENGTH }; type RenameFn = (workspaceId: string, sessionId: string, title: string) => Promise; /** * Persist a submitted rename draft for a session. Resolves the draft against the * current display title and, only when it is a real change, calls `onRename` * with the session's workspace/id and the trimmed title. Returns the resulting * session, or `null` when the submission was an empty/unchanged no-op (so the * caller simply cancels without a network call). This is the exact effect the * `useInlineRename` commit runs; extracted so the three rename surfaces share — * and tests can assert — the persist semantics without a DOM. */ export async function performRename( session: RailSession, draft: string, onRename: RenameFn, ): Promise { const next = resolveRenameSubmission(draft, sessionDisplayTitle(session)); if (next === null) { return null; } return onRename(session.workspaceId, session.id, next); } export interface InlineRename { /** Whether the inline editor is currently open. */ editing: boolean; /** The current draft value (controlled input value). */ draft: string; /** Whether a save is in flight (commit is a no-op while saving). */ saving: boolean; /** Set the draft from the input's onChange. */ setDraft: (value: string) => void; /** Ref to attach to the input so it focuses + selects on open. */ inputRef: React.RefObject; /** Open the editor, seeding from the session's current title. */ startEditing: () => void; /** Persist the draft (or cancel if it is an empty/unchanged no-op). */ commit: () => Promise; /** Close the editor and discard the draft. */ cancel: () => void; } /** * The shared inline-rename interaction. Owns the editing/draft/saving state and * the commit/cancel logic; the host renders the input (and whatever trigger * opens it) and wires the handlers. Used by the header editor and the rail row. */ export function useInlineRename(session: RailSession, onRename: RenameFn): InlineRename { const display = sessionDisplayTitle(session); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(display); const [saving, setSaving] = useState(false); // Enter and blur can fire back-to-back while the first commit is still // awaiting the PATCH response. A ref closes that window synchronously; // `saving` is deliberately retained as render state for consumers but is // not authoritative for event handlers from the previous render. const commitInFlightRef = useRef(false); // A semantic/cross-client title can arrive while this editor remains open. // Retain the exact seed so an untouched provisional draft stays a no-op // instead of overwriting the newer durable title on blur. const editSeedRef = useRef(display); const inputRef = useRef(null); // Reseed the draft whenever the displayed title changes while not editing // (e.g. an agent or cross-client rename arrives), so opening the editor always // starts from the current title. useEffect(() => { if (!editing) { setDraft(display); } }, [display, editing]); const startEditing = useCallback(() => { const seed = renameSeedValue(session); editSeedRef.current = seed; setDraft(seed); setEditing(true); // Focus + select once the input mounts. requestAnimationFrame(() => { inputRef.current?.focus(); inputRef.current?.select(); }); }, [session]); const cancel = useCallback(() => { setEditing(false); setDraft(display); }, [display]); const commit = useCallback(async () => { if (commitInFlightRef.current || saving) { return; } const next = resolveRenameSubmission(draft, display, editSeedRef.current); setEditing(false); if (next === null) { return; } commitInFlightRef.current = true; setSaving(true); try { // Same resolve-and-persist as performRename; the draft is already // resolved here so we skip straight to the call. await onRename(session.workspaceId, session.id, next); } finally { commitInFlightRef.current = false; setSaving(false); } }, [saving, draft, display, onRename, session.workspaceId, session.id]); return { editing, draft, saving, setDraft, inputRef, startEditing, commit, cancel }; }