diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index 3693145e..1d9e666c 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { useLocation } from 'react-router'; import type { PageTitleOverride } from 'officerdev'; -import { usePageTitleOverride } from 'officerdev'; +import { usePageTitleOverride, useChatTabName } from 'officerdev'; import { useSessionState, writeSessionValue } from 'hooks/useSessionState'; type TitleRule = { match: (p: string) => boolean; title: string }; @@ -160,6 +160,7 @@ claimTabIdentity(); export function usePageTitle() { const { pathname } = useLocation(); const override = usePageTitleOverride(); + const chatTabName = useChatTabName(); const [label, setLabel] = useSessionState(TAB_LABEL_KEY, null); useEffect(() => onTabLabelDropped(() => setLabel(null)), [setLabel]); @@ -185,7 +186,10 @@ export function usePageTitle() { const rename = useCallback((next: string) => setLabel(next.trim() || null), [setLabel]); - return [label ?? override?.title ?? titleForPath(pathname), rename] as const; + // A chat tab's own name is the most specific thing anyone has said about this page — more specific + // than the conversation inside it (there may be three) and more deliberate than a browser-tab name + // typed earlier on a different screen. So it wins outright. + return [chatTabName ?? label ?? override?.title ?? titleForPath(pathname), rename] as const; } /** diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatTabs.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatTabs.tsx index 8db421fb..896c2d28 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatTabs.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatTabs.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { Columns2, Plus, X } from 'lucide-react'; import { connectionLabel } from 'hooks/connections'; +import { usePublishChatTabName } from '../../page-title'; import { ChatPane } from './ChatPane'; import type { SelectedSession } from './ChatDetailPanel'; @@ -59,6 +60,8 @@ function load(): Tab[] { export const ChatTabs = () => { const [tabs, setTabs] = useState(load); const [activeKey, setActiveKey] = useState(() => ''); + const [renamingKey, setRenamingKey] = useState(null); + const [renameValue, setRenameValue] = useState(''); const restored = useRef(false); // First render picks the first tab; afterwards the user owns it. @@ -78,6 +81,11 @@ export const ChatTabs = () => { const active = tabs.find((tab) => tab.key === activeKey) ?? tabs[0]; + // Only a name YOU typed is published — a label derived from the conversation would just restate the + // title the chat already publishes, one tier lower, and would then outrank a browser-tab name for no + // reason the user could see. + usePublishChatTabName(active?.title?.trim() || null); + const update = (tabKey: string, fn: (tab: Tab) => Tab) => setTabs((prev) => prev.map((tab) => (tab.key === tabKey ? fn(tab) : tab))); @@ -97,6 +105,21 @@ export const ChatTabs = () => { }); }; + const startRename = (tab: Tab) => { + setRenamingKey(tab.key); + // Seeded with the typed name only, not the derived label: pre-filling a name the user never chose + // makes Enter silently adopt it as if they had. + setRenameValue(tab.title ?? ''); + }; + + const commitRename = () => { + if (!renamingKey) return; + const next = renameValue.trim(); + // Empty hands the tab back to its derived name — the only way out, and no third state. + update(renamingKey, (tab) => ({ ...tab, title: next || undefined })); + setRenamingKey(null); + }; + const splitPane = () => active && update(active.key, (tab) => @@ -131,11 +154,32 @@ export const ChatTabs = () => { tab.title || first?.title || (tab.panes.length > 1 ? `${tab.panes.length} panes` : connectionLabel(first?.serverId, 'Chat')); + if (renamingKey === tab.key) { + return ( + setRenameValue(ev.target.value)} + onKeyDown={(ev) => { + if (ev.key === 'Enter') commitRename(); + if (ev.key === 'Escape') setRenamingKey(null); + }} + onBlur={commitRename} + aria-label="Tab name" + placeholder={label} + className="w-32 shrink-0 rounded-t border-b border-primary/40 bg-muted px-2 py-1 text-xs outline-none" + /> + ); + } + return (