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:
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, NavLink } from 'react-router';
|
import { Link, NavLink } from 'react-router';
|
||||||
import { Menu } from 'lucide-react';
|
import { Menu } from 'lucide-react';
|
||||||
|
import { EditableTitle } from '@/components/EditableTitle';
|
||||||
// import { Menu, Terminal } from 'lucide-react'; // Terminal used by the commented-out web inspector button
|
// import { Menu, Terminal } from 'lucide-react'; // Terminal used by the commented-out web inspector button
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
|
||||||
import type { DockItem } from '../Dock';
|
import type { DockItem } from '../Dock';
|
||||||
@@ -12,42 +13,21 @@ import { usePageTitle } from '@/state/usePageTitle';
|
|||||||
// import { BugReportButton } from '../BugReport/BugReportButton';
|
// import { BugReportButton } from '../BugReport/BugReportButton';
|
||||||
|
|
||||||
// Center title: click to rename this browser tab. The name is kept in sessionStorage, so it survives a
|
// Center title: click to rename this browser tab. The name is kept in sessionStorage, so it survives a
|
||||||
// refresh and navigation and dies with the tab — see usePageTitle.
|
// refresh and navigation and dies with the tab — see usePageTitle. `allowEmpty` is what makes clearing
|
||||||
|
// the field hand the tab back to the route's own name.
|
||||||
function EditablePageTitle() {
|
function EditablePageTitle() {
|
||||||
const [title, rename] = usePageTitle();
|
const [title, rename] = usePageTitle();
|
||||||
const [draft, setDraft] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// The edit is a draft committed on blur/Enter rather than a live write. Writing every keystroke was
|
|
||||||
// fine while the title was a plain string, but an empty field now means "use the route name", so
|
|
||||||
// deleting the last character would snap the input to "Chat" underneath the cursor.
|
|
||||||
if (draft !== null) {
|
|
||||||
return (
|
return (
|
||||||
<input
|
<EditableTitle
|
||||||
autoFocus
|
value={title}
|
||||||
value={draft}
|
onCommit={rename}
|
||||||
onChange={(ev) => setDraft(ev.target.value)}
|
allowEmpty
|
||||||
onBlur={() => {
|
ariaLabel="Tab name"
|
||||||
rename(draft);
|
hint="Click to rename this tab — clear it to go back to the page name"
|
||||||
setDraft(null);
|
|
||||||
}}
|
|
||||||
onKeyDown={(ev) => {
|
|
||||||
if (ev.key === 'Enter') ev.currentTarget.blur();
|
|
||||||
if (ev.key === 'Escape') setDraft(null); // discard, keeping whatever the tab was called
|
|
||||||
}}
|
|
||||||
aria-label="Tab name"
|
|
||||||
placeholder={title}
|
|
||||||
className="pointer-events-auto max-w-[50vw] border-b border-[#1d2724]/40 bg-transparent text-center text-base font-bold text-[#1d2724] outline-none md:text-xl"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={() => setDraft(title)}
|
|
||||||
title="Click to rename this tab — clear it to go back to the page name"
|
|
||||||
className="pointer-events-auto max-w-[50vw] cursor-text truncate text-base font-bold text-[#1d2724] transition-opacity hover:opacity-70 md:text-xl"
|
className="pointer-events-auto max-w-[50vw] cursor-text truncate text-base font-bold text-[#1d2724] transition-opacity hover:opacity-70 md:text-xl"
|
||||||
>
|
inputClassName="pointer-events-auto max-w-[50vw] border-b border-[#1d2724]/40 bg-transparent text-center text-base font-bold text-[#1d2724] outline-none md:text-xl"
|
||||||
{title}
|
/>
|
||||||
</button>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
|
import { usePageTitleOverride } from 'officerdev';
|
||||||
import { useSessionState, writeSessionValue } from 'hooks/useSessionState';
|
import { useSessionState, writeSessionValue } from 'hooks/useSessionState';
|
||||||
|
|
||||||
type TitleRule = { match: (p: string) => boolean; title: string };
|
type TitleRule = { match: (p: string) => boolean; title: string };
|
||||||
@@ -150,16 +151,21 @@ claimTabIdentity();
|
|||||||
* refresh: you named this window to find it again among a dozen others, and wiping it because you opened
|
* refresh: you named this window to find it again among a dozen others, and wiping it because you opened
|
||||||
* a different screen would defeat the point. Clear the field to hand the tab back to the route's own
|
* a different screen would defeat the point. Clear the field to hand the tab back to the route's own
|
||||||
* name — that is the only way out, and there is no third state to get stuck in.
|
* name — that is the only way out, and there is no third state to get stuck in.
|
||||||
|
*
|
||||||
|
* Between the two sits a screen's own name for itself, published by whatever is showing (see
|
||||||
|
* `usePublishPageTitle`): on `/chat/<id>` that is the conversation's title. It ranks under a typed name
|
||||||
|
* for the reason above, and over the route default because "Chat" says less than the chat's name does.
|
||||||
*/
|
*/
|
||||||
export function usePageTitle() {
|
export function usePageTitle() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
const override = usePageTitleOverride();
|
||||||
const [label, setLabel] = useSessionState<string | null>(TAB_LABEL_KEY, null);
|
const [label, setLabel] = useSessionState<string | null>(TAB_LABEL_KEY, null);
|
||||||
|
|
||||||
useEffect(() => onTabLabelDropped(() => setLabel(null)), [setLabel]);
|
useEffect(() => onTabLabelDropped(() => setLabel(null)), [setLabel]);
|
||||||
|
|
||||||
const rename = useCallback((next: string) => setLabel(next.trim() || null), [setLabel]);
|
const rename = useCallback((next: string) => setLabel(next.trim() || null), [setLabel]);
|
||||||
|
|
||||||
return [label ?? titleForPath(pathname), rename] as const;
|
return [label ?? override ?? titleForPath(pathname), rename] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation, useParams } from 'react-router';
|
||||||
import { Unplug } from 'lucide-react';
|
import { Unplug } from 'lucide-react';
|
||||||
|
import { toast } from '@/components/ui/sonner';
|
||||||
|
import { EditableTitle } from '@/components/EditableTitle';
|
||||||
import { useSelectedChatSession } from '../../channels';
|
import { useSelectedChatSession } from '../../channels';
|
||||||
|
import { usePublishPageTitle } from '../../page-title';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
|
import { errorText } from 'helpers/error-text';
|
||||||
import { useClaudeSessions } from 'state/useClaudeSessions';
|
import { useClaudeSessions } from 'state/useClaudeSessions';
|
||||||
import { useChat, EmbeddableChat } from '../Chat';
|
import { useChat, EmbeddableChat } from '../Chat';
|
||||||
import type { ChatMessage } from '../Chat/types';
|
import type { ChatMessage } from '../Chat/types';
|
||||||
@@ -47,10 +51,41 @@ type DetailBarProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function DetailBar({ sessionTitle, partCount, isConnected, isGenerating, onDisconnect }: DetailBarProps) {
|
function DetailBar({ sessionTitle, partCount, isConnected, isGenerating, onDisconnect }: DetailBarProps) {
|
||||||
|
// The id to rename comes from the URL, not from `resumeSessionId`. They are the same for an ordinary
|
||||||
|
// conversation and not for a merged `/clear` chain, where the resume target is the tail transcript
|
||||||
|
// while the list, the links and the server all address the chain by its head. Renaming the tail would
|
||||||
|
// have written a title nothing displays.
|
||||||
|
//
|
||||||
|
// Absent on `/chat/new`, which makes the title read-only — there is no transcript to name yet.
|
||||||
|
const { sessionId } = useParams<{ sessionId: string }>();
|
||||||
|
|
||||||
|
// The same rename the list's pencil calls, so both surfaces write one place and the invalidation that
|
||||||
|
// follows refreshes the row too. A failure toasts rather than reverting silently, matching `SessionList`.
|
||||||
|
const { renameSession } = useClaudeSessions();
|
||||||
|
const commitRename = useCallback(
|
||||||
|
async (next: string) => {
|
||||||
|
if (!sessionId) return;
|
||||||
|
try {
|
||||||
|
await renameSession(sessionId, next);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(errorText(err));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[renameSession, sessionId],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shrink-0 flex items-center px-4 py-2 border-b border-border bg-background/60">
|
<div className="shrink-0 flex items-center px-4 py-2 border-b border-border bg-background/60">
|
||||||
<div className="flex-1 min-w-0 px-3 text-center">
|
<div className="flex-1 min-w-0 px-3 text-center">
|
||||||
<div className="truncate text-sm font-medium text-foreground/80">{sessionTitle ?? 'New chat'}</div>
|
<EditableTitle
|
||||||
|
value={sessionTitle ?? 'New chat'}
|
||||||
|
onCommit={commitRename}
|
||||||
|
readOnly={!sessionId}
|
||||||
|
ariaLabel="Session title"
|
||||||
|
hint="Click to rename this chat"
|
||||||
|
className="block w-full cursor-text truncate text-sm font-medium text-foreground/80 transition-opacity hover:opacity-70"
|
||||||
|
inputClassName="w-full border-b border-primary/40 bg-transparent text-center text-sm font-medium text-foreground/80 outline-none"
|
||||||
|
/>
|
||||||
{/* This replaced a link back to the previous session. There is nowhere to go now — the previous
|
{/* This replaced a link back to the previous session. There is nowhere to go now — the previous
|
||||||
session is scrolled up above you, with a divider where the clear happened. */}
|
session is scrolled up above you, with a divider where the clear happened. */}
|
||||||
{partCount != null && partCount > 1 && (
|
{partCount != null && partCount > 1 && (
|
||||||
@@ -162,6 +197,16 @@ function NewChat(props: NewChatProps) {
|
|||||||
export const ChatDetailPanel = () => {
|
export const ChatDetailPanel = () => {
|
||||||
const [selected] = useSelectedChatSession();
|
const [selected] = useSelectedChatSession();
|
||||||
|
|
||||||
|
// Name the page after the conversation, whenever the URL names a real one. Gated on the route param
|
||||||
|
// rather than on `selected`, so `/chat` and `/chat/new` keep the plain "Chat" — the panel holds a
|
||||||
|
// selection in both, and only `/chat/<id>` means a conversation that exists.
|
||||||
|
//
|
||||||
|
// Not gated on full screen, though that is where it earns its keep: the nav header is hidden then, so
|
||||||
|
// the browser tab strip is the only place the name shows, which is exactly what tells two side-by-side
|
||||||
|
// windows apart. Tiled, the same value fills the header's centre. One rule, both states.
|
||||||
|
const { sessionId } = useParams<{ sessionId: string }>();
|
||||||
|
usePublishPageTitle(sessionId ? (selected?.title ?? null) : null);
|
||||||
|
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export * from './hooks';
|
export * from './hooks';
|
||||||
export * from './channels';
|
export * from './channels';
|
||||||
|
// For the shell: a screen naming itself better than its route can — see usePageTitle's precedence.
|
||||||
|
export { usePageTitleOverride, usePublishPageTitle } from './page-title';
|
||||||
export * from './AppRegistry';
|
export * from './AppRegistry';
|
||||||
export * from './WidgetRegistry';
|
export * from './WidgetRegistry';
|
||||||
export * from './MusicPlayer';
|
export * from './MusicPlayer';
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useGlobal } from 'hooks/useGlobal';
|
||||||
|
|
||||||
|
// ── "This screen has a better name for itself than its route does" ──
|
||||||
|
//
|
||||||
|
// A panel knows things the shell does not — which conversation is open, what it is called. The nav
|
||||||
|
// header is an ancestor of every screen, so nothing flows upward, and the title is wanted in two places
|
||||||
|
// at once (the header's centre, and the browser tab through `usePageTitleSync`). Hence a global, the
|
||||||
|
// same shape as `panel-fullscreen.ts`.
|
||||||
|
//
|
||||||
|
// It sits BELOW a typed tab name in `usePageTitle`'s precedence and above the route default:
|
||||||
|
// `label ?? override ?? titleForPath(pathname)`. Naming a window is a deliberate act meant to survive
|
||||||
|
// navigation — a chat title must not quietly take it back.
|
||||||
|
//
|
||||||
|
// In memory only. It is derived from data the panel already holds, so a remount republishes it, and a
|
||||||
|
// stale copy would be a header naming a conversation that is no longer open.
|
||||||
|
const PAGE_TITLE_OVERRIDE = 'PAGE_TITLE_OVERRIDE';
|
||||||
|
|
||||||
|
/** The override, or null. For the shell. */
|
||||||
|
export const usePageTitleOverride = () => useGlobal<string | null>(PAGE_TITLE_OVERRIDE, null)[0];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Name the page from inside a screen. Pass `null` when there is nothing to say.
|
||||||
|
*
|
||||||
|
* The cleanup is the load-bearing half: navigating away unmounts the publisher without anything setting
|
||||||
|
* the title back, and a leftover value is a header still showing the last chat you had open.
|
||||||
|
*/
|
||||||
|
export function usePublishPageTitle(title: string | null) {
|
||||||
|
const [, setOverride] = useGlobal<string | null>(PAGE_TITLE_OVERRIDE, null);
|
||||||
|
|
||||||
|
// Through a ref: `useGlobal` rebuilds its setter every render, so as a dependency it would re-run the
|
||||||
|
// effect — and therefore the cleanup's `null` — on every render of the publisher.
|
||||||
|
const setOverrideRef = useRef(setOverride);
|
||||||
|
setOverrideRef.current = setOverride;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setOverrideRef.current(title);
|
||||||
|
return () => setOverrideRef.current(null);
|
||||||
|
}, [title]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user