name a chat from its own pane, and name the page after it
Two things, one title. The pane's header is now editable and calls the same `renameSession` the list's pencil does, so both surfaces write the transcript's `summary` line and the invalidation that follows refreshes the row. The id comes from the URL rather than `resumeSessionId`: they agree for an ordinary conversation and not for a merged `/clear` chain, where the resume target is the tail while the list and the server address the chain by its head — renaming the tail would have written a title nothing displays. `/chat/new` has no transcript yet, so there the title is read-only. And on `/chat/<id>` the conversation names the page, sitting between a typed tab name and the route default: `label ?? override ?? titleForPath()`. Naming a window is deliberate and must still win. Not gated on full screen, though that is where it earns its keep — the nav header is hidden there, so the browser tab strip is the only thing telling two side-by-side windows apart. Tiled, the same value fills the header's centre. The edit interaction is now one `EditableTitle` shared with the nav header instead of a second copy of it. `allowEmpty` is what keeps the header's "clear it to hand the tab back to the route name" working; everywhere else empty means keep, since the rename endpoint 400s on it. `SessionList`'s row rename is deliberately NOT folded in — it opens from a pencil and confirms with a check, so it is a different interaction wearing the same styling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
type EditableTitleProps = {
|
||||
/** What to show, and what the field opens on. */
|
||||
value: string;
|
||||
/** Called with the trimmed draft on Enter or blur, only when it actually differs from `value`. */
|
||||
onCommit: (next: string) => void;
|
||||
/**
|
||||
* Let an emptied field commit as `''`.
|
||||
*
|
||||
* Off by default, because for most titles empty is not a value — the chat rename endpoint 400s on it,
|
||||
* and there is nothing sensible to show. The nav header is the exception: clearing the field is how you
|
||||
* hand the tab back to the route's own name, and it is documented there as the only way out.
|
||||
*/
|
||||
allowEmpty?: boolean;
|
||||
/** No edit affordance — renders as plain text. For a title with nothing behind it yet, like a new chat. */
|
||||
readOnly?: boolean;
|
||||
ariaLabel: string;
|
||||
/** Tooltip on the resting state. The edit affordance is invisible otherwise, so this is how it is found. */
|
||||
hint?: string;
|
||||
className?: string;
|
||||
inputClassName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Click a title, type a new one.
|
||||
*
|
||||
* The edit is a **draft committed on blur/Enter**, not a live write. Writing every keystroke is fine
|
||||
* while a title is a plain string and stops being fine the moment empty means something — here it means
|
||||
* "keep what you had", so deleting the last character would otherwise snap the field to the old value
|
||||
* underneath the cursor. Escape discards.
|
||||
*
|
||||
* Extracted from the nav header's tab rename, which is now one of two callers. Deliberately not a third
|
||||
* caller: `SessionList`'s row rename is opened by a pencil button and confirmed by a check button, so it
|
||||
* is a different interaction wearing the same styling, and folding it in would mean an `isEditing` prop
|
||||
* that only one caller passes.
|
||||
*/
|
||||
export function EditableTitle({
|
||||
value,
|
||||
onCommit,
|
||||
allowEmpty,
|
||||
readOnly,
|
||||
ariaLabel,
|
||||
hint,
|
||||
className = '',
|
||||
inputClassName = '',
|
||||
}: EditableTitleProps) {
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
|
||||
if (readOnly) return <span className={className}>{value}</span>;
|
||||
|
||||
if (draft !== null) {
|
||||
return (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
onChange={(ev) => setDraft(ev.target.value)}
|
||||
onBlur={() => {
|
||||
const next = draft.trim();
|
||||
if ((next || allowEmpty) && next !== value) onCommit(next);
|
||||
setDraft(null);
|
||||
}}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Enter') ev.currentTarget.blur();
|
||||
if (ev.key === 'Escape') setDraft(null); // discard, keeping whatever it was called
|
||||
}}
|
||||
aria-label={ariaLabel}
|
||||
placeholder={value}
|
||||
className={inputClassName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={() => setDraft(value)} title={hint} className={className}>
|
||||
{value}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user