From 53406b2e974a62b498fefbabee275bfcb3dccd18 Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Thu, 16 Jul 2026 12:30:21 +0800 Subject: [PATCH] fix(web): sync browser tab title with session (#1034) * test: reproduce issue #712 * fix: sync browser title with session (closes #712) --- web/src/components/SessionHeader.tsx | 15 +---- web/src/components/SessionList.tsx | 15 +---- web/src/hooks/useSessionBrowserTitle.test.tsx | 64 +++++++++++++++++++ web/src/hooks/useSessionBrowserTitle.ts | 17 +++++ web/src/lib/sessionTitle.ts | 22 +++++++ web/src/router.tsx | 4 +- 6 files changed, 109 insertions(+), 28 deletions(-) create mode 100644 web/src/hooks/useSessionBrowserTitle.test.tsx create mode 100644 web/src/hooks/useSessionBrowserTitle.ts create mode 100644 web/src/lib/sessionTitle.ts diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index 5470307c..199f33a3 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -13,20 +13,7 @@ import { getSessionModelLabel } from '@/lib/sessionModelLabel' import { useTranslation } from '@/lib/use-translation' import { AgentFlavorIcon } from '@/components/AgentFlavorIcon' import { isFastServiceTier } from '@/components/AssistantChat/codexFastMode' - -function getSessionTitle(session: Session): string { - if (session.metadata?.name) { - return session.metadata.name - } - if (session.metadata?.summary?.text) { - return session.metadata.summary.text - } - if (session.metadata?.path) { - const parts = session.metadata.path.split('/').filter(Boolean) - return parts.length > 0 ? parts[parts.length - 1] : session.id.slice(0, 8) - } - return session.id.slice(0, 8) -} +import { getSessionTitle } from '@/lib/sessionTitle' function FilesIcon(props: { className?: string }) { return ( diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 4b90e8be..aceb74ef 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -22,6 +22,7 @@ import { formatRelativeTime } from '@/lib/relativeTime' import { formatScheduledTooltipDetail } from '@/lib/scheduledTime' import { getCodexImportedAt, subscribeCodexImportedSessions } from '@/lib/codexImportedSessions' import { formatReopenError } from '@/lib/reopenError' +import { getSessionTitle } from '@/lib/sessionTitle' import type { Machine } from '@/types/api' import { getMachinePlatform, presentMachineHealth } from '@/lib/machineHealth' import { MachineGroupHeader } from '@/components/MachineGroupHeader' @@ -439,19 +440,7 @@ function ChevronIcon(props: { className?: string; collapsed?: boolean }) { ) } -export function getSessionTitle(session: SessionSummary): string { - if (session.metadata?.name) { - return session.metadata.name - } - if (session.metadata?.summary?.text) { - return session.metadata.summary.text - } - if (session.metadata?.path) { - const parts = session.metadata.path.split('/').filter(Boolean) - return parts.length > 0 ? parts[parts.length - 1] : session.id.slice(0, 8) - } - return session.id.slice(0, 8) -} +export { getSessionTitle } from '@/lib/sessionTitle' function getTodoProgress(session: SessionSummary): { completed: number; total: number } | null { if (!session.todoProgress) return null diff --git a/web/src/hooks/useSessionBrowserTitle.test.tsx b/web/src/hooks/useSessionBrowserTitle.test.tsx new file mode 100644 index 00000000..8f9649a9 --- /dev/null +++ b/web/src/hooks/useSessionBrowserTitle.test.tsx @@ -0,0 +1,64 @@ +import { renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import type { Session } from '@/types/api' +import { useSessionBrowserTitle } from './useSessionBrowserTitle' + +function makeSession(metadata: Session['metadata']): Session { + return { + id: '1234567890abcdef', + active: true, + thinking: false, + activeAt: 0, + updatedAt: 0, + metadata, + } as Session +} + +describe('useSessionBrowserTitle', () => { + it('tracks session title updates and restores the app title on unmount', () => { + document.title = 'HAPI' + const initialSession = makeSession({ + path: '/work/hapi', + host: 'localhost', + summary: { text: 'Initial summary', updatedAt: 1 }, + }) + + const { rerender, unmount } = renderHook( + ({ session }) => useSessionBrowserTitle(session), + { initialProps: { session: initialSession } }, + ) + + expect(document.title).toBe('Initial summary - HAPI') + + rerender({ + session: makeSession({ + ...initialSession.metadata!, + name: 'Renamed session', + }), + }) + + expect(document.title).toBe('Renamed session - HAPI') + + unmount() + expect(document.title).toBe('HAPI') + }) + + it('uses the app title while loading and the shared session fallbacks when titles are missing', () => { + document.title = 'Stale session - HAPI' + + const { rerender } = renderHook( + ({ session }: { session: Session | null }) => useSessionBrowserTitle(session), + { initialProps: { session: null as Session | null } }, + ) + + expect(document.title).toBe('HAPI') + + rerender({ + session: makeSession({ path: '/work/hapi', host: 'localhost' }), + }) + expect(document.title).toBe('hapi - HAPI') + + rerender({ session: makeSession(null) }) + expect(document.title).toBe('12345678 - HAPI') + }) +}) diff --git a/web/src/hooks/useSessionBrowserTitle.ts b/web/src/hooks/useSessionBrowserTitle.ts new file mode 100644 index 00000000..43da5536 --- /dev/null +++ b/web/src/hooks/useSessionBrowserTitle.ts @@ -0,0 +1,17 @@ +import { useEffect } from 'react' +import type { Session } from '@/types/api' +import { getSessionTitle } from '@/lib/sessionTitle' + +const APP_TITLE = 'HAPI' + +export function useSessionBrowserTitle(session: Session | null): void { + const sessionTitle = session ? getSessionTitle(session) : null + + useEffect(() => { + document.title = sessionTitle ? `${sessionTitle} - ${APP_TITLE}` : APP_TITLE + + return () => { + document.title = APP_TITLE + } + }, [sessionTitle]) +} diff --git a/web/src/lib/sessionTitle.ts b/web/src/lib/sessionTitle.ts new file mode 100644 index 00000000..0a145b85 --- /dev/null +++ b/web/src/lib/sessionTitle.ts @@ -0,0 +1,22 @@ +type SessionTitleSource = { + id: string + metadata?: { + name?: string + summary?: { text: string } + path?: string + } | null +} + +export function getSessionTitle(session: SessionTitleSource): string { + if (session.metadata?.name) { + return session.metadata.name + } + if (session.metadata?.summary?.text) { + return session.metadata.summary.text + } + if (session.metadata?.path) { + const parts = session.metadata.path.split('/').filter(Boolean) + return parts.length > 0 ? parts[parts.length - 1] : session.id.slice(0, 8) + } + return session.id.slice(0, 8) +} diff --git a/web/src/router.tsx b/web/src/router.tsx index 067c806f..0b9fcc6d 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -41,6 +41,7 @@ import { fetchLatestMessages, seedMessageWindowFromSession } from '@/lib/message import { clearDraftsAfterSend } from '@/lib/clearDraftsAfterSend' import { inactiveSessionCanResume } from '@/lib/sessionResume' import { markSessionSeen } from '@/lib/sessionLastSeen' +import { useSessionBrowserTitle } from '@/hooks/useSessionBrowserTitle' import { clearCodexImportedSession, markCodexSessionsImported } from '@/lib/codexImportedSessions' import type { Machine, CodexDuplicateSessionGroup, CodexLocalSessionSummary } from '@/types/api' import FilesPage from '@/routes/sessions/files' @@ -949,7 +950,8 @@ function SessionDetailRoute() { const pathname = useLocation({ select: location => location.pathname }) const { sessionId } = useParams({ from: '/sessions/$sessionId' }) const navigate = useNavigate() - const { notFound: sessionNotFound } = useSession(api, sessionId) + const { session, notFound: sessionNotFound } = useSession(api, sessionId) + useSessionBrowserTitle(session) const basePath = `/sessions/${sessionId}` const isChat = pathname === basePath || pathname === `${basePath}/`