From 8bab607366a024a85c4fc397297847d8c0ba6f5b Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 21:36:44 +0100 Subject: [PATCH] name a chat tab, and let that name win the page title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Click the active tab (or double-click any) to rename it inline — Enter commits, Escape cancels, blur commits, and an empty value hands the tab back to its derived name. Same shape as renaming a conversation, which is the gesture that already exists here. The name outranks everything: chatTabName ?? label ?? override ?? route. It is the most specific statement anyone has made about the page — more specific than the conversation inside it, since there may be three, and more deliberate than a browser-tab name typed earlier on a different screen. Only a name you TYPED is published. Publishing the derived label would restate the title the chat already publishes one tier down, and would then outrank a browser-tab name for no reason the user could see. Cleared on unmount, or every other screen would keep being called by the chat tab you last had open. The rename field seeds from the typed name only, never the derived one — pre-filling a name the user never chose makes Enter silently adopt it as if they had. Co-Authored-By: Claude Opus 5 --- src/apps/officer-web/state/usePageTitle.ts | 8 +++- .../src/apps/ChatHistory/ChatTabs.tsx | 46 ++++++++++++++++++- src/workspaces/officerdev/src/index.ts | 2 +- src/workspaces/officerdev/src/page-title.ts | 32 +++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) 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 (