diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx index 15c6fdb9..137bfeea 100644 --- a/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx +++ b/src/apps/officer-web/Screens/Dashboard/Layout/Header/Header.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { Link, NavLink } from 'react-router'; 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 { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'; import type { DockItem } from '../Dock'; @@ -12,42 +13,21 @@ import { usePageTitle } from '@/state/usePageTitle'; // import { BugReportButton } from '../BugReport/BugReportButton'; // 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() { const [title, rename] = usePageTitle(); - const [draft, setDraft] = useState(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 ( - setDraft(ev.target.value)} - onBlur={() => { - rename(draft); - 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 ( - + 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" + /> ); } diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index afa11df1..c8a520f0 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect } from 'react'; import { useLocation } from 'react-router'; +import { usePageTitleOverride } from 'officerdev'; import { useSessionState, writeSessionValue } from 'hooks/useSessionState'; 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 * 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. + * + * Between the two sits a screen's own name for itself, published by whatever is showing (see + * `usePublishPageTitle`): on `/chat/` 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() { const { pathname } = useLocation(); + const override = usePageTitleOverride(); const [label, setLabel] = useSessionState(TAB_LABEL_KEY, null); useEffect(() => onTabLabelDropped(() => setLabel(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; } /** diff --git a/src/workspaces/components/EditableTitle.tsx b/src/workspaces/components/EditableTitle.tsx new file mode 100644 index 00000000..063923fd --- /dev/null +++ b/src/workspaces/components/EditableTitle.tsx @@ -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(null); + + if (readOnly) return {value}; + + if (draft !== null) { + return ( + 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 ( + + ); +} diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx index 1eddfb5e..bdb3e522 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx @@ -1,8 +1,12 @@ import { useCallback } from 'react'; -import { useLocation } from 'react-router'; +import { useLocation, useParams } from 'react-router'; import { Unplug } from 'lucide-react'; +import { toast } from '@/components/ui/sonner'; +import { EditableTitle } from '@/components/EditableTitle'; import { useSelectedChatSession } from '../../channels'; +import { usePublishPageTitle } from '../../page-title'; import { useAuth } from 'hooks/useAuth'; +import { errorText } from 'helpers/error-text'; import { useClaudeSessions } from 'state/useClaudeSessions'; import { useChat, EmbeddableChat } from '../Chat'; import type { ChatMessage } from '../Chat/types'; @@ -47,10 +51,41 @@ type 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 (
-
{sessionTitle ?? 'New chat'}
+ {/* 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. */} {partCount != null && partCount > 1 && ( @@ -162,6 +197,16 @@ function NewChat(props: NewChatProps) { export const ChatDetailPanel = () => { 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/` 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) { return (
diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts index e53297c9..502f88cd 100644 --- a/src/workspaces/officerdev/src/index.ts +++ b/src/workspaces/officerdev/src/index.ts @@ -1,5 +1,7 @@ export * from './hooks'; 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 './WidgetRegistry'; export * from './MusicPlayer'; diff --git a/src/workspaces/officerdev/src/page-title.ts b/src/workspaces/officerdev/src/page-title.ts new file mode 100644 index 00000000..827e4a51 --- /dev/null +++ b/src/workspaces/officerdev/src/page-title.ts @@ -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(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(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]); +}