name a chat tab, and let that name win the page title
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
import type { PageTitleOverride } from 'officerdev';
|
import type { PageTitleOverride } from 'officerdev';
|
||||||
import { usePageTitleOverride } from 'officerdev';
|
import { usePageTitleOverride, useChatTabName } 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 };
|
||||||
@@ -160,6 +160,7 @@ claimTabIdentity();
|
|||||||
export function usePageTitle() {
|
export function usePageTitle() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const override = usePageTitleOverride();
|
const override = usePageTitleOverride();
|
||||||
|
const chatTabName = useChatTabName();
|
||||||
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]);
|
||||||
@@ -185,7 +186,10 @@ export function usePageTitle() {
|
|||||||
|
|
||||||
const rename = useCallback((next: string) => setLabel(next.trim() || null), [setLabel]);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Columns2, Plus, X } from 'lucide-react';
|
import { Columns2, Plus, X } from 'lucide-react';
|
||||||
import { connectionLabel } from 'hooks/connections';
|
import { connectionLabel } from 'hooks/connections';
|
||||||
|
import { usePublishChatTabName } from '../../page-title';
|
||||||
import { ChatPane } from './ChatPane';
|
import { ChatPane } from './ChatPane';
|
||||||
import type { SelectedSession } from './ChatDetailPanel';
|
import type { SelectedSession } from './ChatDetailPanel';
|
||||||
|
|
||||||
@@ -59,6 +60,8 @@ function load(): Tab[] {
|
|||||||
export const ChatTabs = () => {
|
export const ChatTabs = () => {
|
||||||
const [tabs, setTabs] = useState<Tab[]>(load);
|
const [tabs, setTabs] = useState<Tab[]>(load);
|
||||||
const [activeKey, setActiveKey] = useState<string>(() => '');
|
const [activeKey, setActiveKey] = useState<string>(() => '');
|
||||||
|
const [renamingKey, setRenamingKey] = useState<string | null>(null);
|
||||||
|
const [renameValue, setRenameValue] = useState('');
|
||||||
const restored = useRef(false);
|
const restored = useRef(false);
|
||||||
|
|
||||||
// First render picks the first tab; afterwards the user owns it.
|
// 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];
|
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) =>
|
const update = (tabKey: string, fn: (tab: Tab) => Tab) =>
|
||||||
setTabs((prev) => prev.map((tab) => (tab.key === tabKey ? fn(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 = () =>
|
const splitPane = () =>
|
||||||
active &&
|
active &&
|
||||||
update(active.key, (tab) =>
|
update(active.key, (tab) =>
|
||||||
@@ -131,11 +154,32 @@ export const ChatTabs = () => {
|
|||||||
tab.title ||
|
tab.title ||
|
||||||
first?.title ||
|
first?.title ||
|
||||||
(tab.panes.length > 1 ? `${tab.panes.length} panes` : connectionLabel(first?.serverId, 'Chat'));
|
(tab.panes.length > 1 ? `${tab.panes.length} panes` : connectionLabel(first?.serverId, 'Chat'));
|
||||||
|
if (renamingKey === tab.key) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
key={tab.key}
|
||||||
|
autoFocus
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(ev) => 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 (
|
return (
|
||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setActiveKey(tab.key)}
|
onClick={() => (tab.key === active.key ? startRename(tab) : setActiveKey(tab.key))}
|
||||||
|
onDoubleClick={() => startRename(tab)}
|
||||||
|
title={tab.key === active.key ? 'Click to rename' : label}
|
||||||
className={`group flex max-w-[14rem] shrink-0 items-center gap-1 rounded-t px-2 py-1 text-xs transition-colors ${
|
className={`group flex max-w-[14rem] shrink-0 items-center gap-1 rounded-t px-2 py-1 text-xs transition-colors ${
|
||||||
tab.key === active.key
|
tab.key === active.key
|
||||||
? 'bg-muted text-foreground'
|
? 'bg-muted text-foreground'
|
||||||
|
|||||||
@@ -1,7 +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.
|
// For the shell: a screen naming itself better than its route can — see usePageTitle's precedence.
|
||||||
export { usePageTitleOverride, usePublishPageTitle } from './page-title';
|
export { usePageTitleOverride, usePublishPageTitle, useChatTabName, usePublishChatTabName } from './page-title';
|
||||||
export type { PageTitleOverride } from './page-title';
|
export type { PageTitleOverride } from './page-title';
|
||||||
export * from './AppRegistry';
|
export * from './AppRegistry';
|
||||||
export * from './WidgetRegistry';
|
export * from './WidgetRegistry';
|
||||||
|
|||||||
@@ -53,3 +53,35 @@ export function usePublishPageTitle(override: PageTitleOverride | null) {
|
|||||||
return () => setOverrideRef.current(null);
|
return () => setOverrideRef.current(null);
|
||||||
}, [id, title]);
|
}, [id, title]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── The chat tab's own name, which outranks everything ──
|
||||||
|
//
|
||||||
|
// `/chat` can hold several tabs, each with several conversations. A name you type on a chat tab is the
|
||||||
|
// most specific statement anyone has made about what this page is: more specific than the conversation
|
||||||
|
// showing inside it (there may be three), and more deliberate than a browser-tab name typed earlier for
|
||||||
|
// a different screen.
|
||||||
|
//
|
||||||
|
// So it sits ABOVE both in `usePageTitle`: chatTabName ?? label ?? override ?? route. Clearing it hands
|
||||||
|
// the page back to the chain below, which is the only way out and leaves no third state to get stuck in.
|
||||||
|
const CHAT_TAB_NAME = 'CHAT_TAB_NAME';
|
||||||
|
|
||||||
|
/** The active chat tab's typed name, or null. For the shell. */
|
||||||
|
export const useChatTabName = () => useGlobal<string | null>(CHAT_TAB_NAME, null)[0];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish the active chat tab's name. `null` when it has none, or when leaving /chat.
|
||||||
|
*
|
||||||
|
* Cleared on unmount for the same reason the override is: navigating away leaves nothing to reset it,
|
||||||
|
* and a leftover name is a header calling every other screen by the chat tab you last had open.
|
||||||
|
*/
|
||||||
|
export function usePublishChatTabName(name: string | null) {
|
||||||
|
const [, setName] = useGlobal<string | null>(CHAT_TAB_NAME, null);
|
||||||
|
const nameRef = useRef(name);
|
||||||
|
nameRef.current = name;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setName(nameRef.current);
|
||||||
|
}, [name, setName]);
|
||||||
|
|
||||||
|
useEffect(() => () => setName(null), [setName]);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user