feat(web): render durable session titles
This commit is contained in:
22
packages/client/web/src/DocumentTitle.tsx
Normal file
22
packages/client/web/src/DocumentTitle.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/** Props for the shell-owned browser title projection. */
|
||||
export interface DocumentTitleProps {
|
||||
/** Durable title of the selected session, or undefined for the product title. */
|
||||
title?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the selected durable session title into the browser title and
|
||||
* restore the shell's original product title when unmounted.
|
||||
* @param props - selected session title projection.
|
||||
* @returns no rendered content.
|
||||
*/
|
||||
export function DocumentTitle({ title }: DocumentTitleProps): null {
|
||||
const original = useRef(document.title)
|
||||
useEffect(() => {
|
||||
document.title = title === undefined ? original.current : `${title} — ${original.current}`
|
||||
return () => { document.title = original.current }
|
||||
}, [title])
|
||||
return null
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
createSessionProvider, RootBindingProvider, scopedSlots,
|
||||
} from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { DocumentTitle } from './DocumentTitle.tsx'
|
||||
|
||||
type LayoutExports = typeof import('@deepseek-ai/dsh-client-ui-layout/client')
|
||||
|
||||
@@ -47,6 +48,11 @@ export function buildRenderApp(deps: AssemblyDeps): () => ReactNode {
|
||||
const useDetails = layout.details.useSelector
|
||||
const setSidebarWidth = (px: number): void => { layout.setSidebarWidth(px) }
|
||||
const setDetailsWidth = (px: number): void => { layout.setDetailsWidth(px) }
|
||||
const SessionDocumentTitle = (): ReactNode => {
|
||||
const id = useCurrent()
|
||||
const title = sessions.list.useSelector(state => id === undefined ? undefined : state.byId[id]?.title)
|
||||
return <DocumentTitle {...title === undefined ? {} : { title }} />
|
||||
}
|
||||
|
||||
const renderBody = (id: SessionId): ReactNode => (
|
||||
<>
|
||||
@@ -75,6 +81,7 @@ export function buildRenderApp(deps: AssemblyDeps): () => ReactNode {
|
||||
|
||||
return () => (
|
||||
<RootBindingProvider value={rootBinding}>
|
||||
<SessionDocumentTitle />
|
||||
<AppFrame
|
||||
useSidebar={useSidebar}
|
||||
useDetails={useDetails}
|
||||
|
||||
@@ -8,4 +8,5 @@
|
||||
export { bootWebShell } from './boot.tsx'
|
||||
export { AppRoot, type AppRootProps } from './AppRoot.tsx'
|
||||
export { buildRenderApp, type AssemblyDeps } from './app.tsx'
|
||||
export { DocumentTitle, type DocumentTitleProps } from './DocumentTitle.tsx'
|
||||
export { seedModules } from './seed.ts'
|
||||
|
||||
@@ -38,7 +38,7 @@ window.DSHClientProxy.loadPlugin({
|
||||
ctx,
|
||||
}
|
||||
ctx.provide('sessions', {
|
||||
list: createSnapshotStore({ ids: ['s1'], byId: { s1: { id: 's1', title: 'S1', running: false, updatedAt: 1 } } }),
|
||||
list: createSnapshotStore({ ids: ['s1'], byId: { s1: { id: 's1', title: 'S1', displayTitle: 'S1', running: false, updatedAt: 1 } } }),
|
||||
binding: (id) => (id === 's1' ? binding : undefined),
|
||||
})
|
||||
},
|
||||
@@ -119,6 +119,7 @@ afterEach(() => {
|
||||
delete win.__TEST_NAV__
|
||||
document.body.innerHTML = ''
|
||||
document.head.querySelectorAll('script').forEach((s) => { s.remove() })
|
||||
document.title = ''
|
||||
})
|
||||
|
||||
describe('bootWebShell (real loader + real script execution)', () => {
|
||||
@@ -130,6 +131,7 @@ describe('bootWebShell (real loader + real script execution)', () => {
|
||||
],
|
||||
}
|
||||
const el = mountPoint()
|
||||
document.title = 'DeepSeek Harness'
|
||||
let unmount: (() => void) | undefined
|
||||
const s = seams({
|
||||
'/plugins/fake-runtime.js': RUNTIME_STUB,
|
||||
@@ -145,9 +147,11 @@ describe('bootWebShell (real loader + real script execution)', () => {
|
||||
// Selected session: SessionProvider resolved the binding and renderBody
|
||||
// mounted the conversation slot content into the center column.
|
||||
expect(el.querySelector('[data-testid="conv-body"]')).not.toBeNull()
|
||||
expect(document.title).toBe('S1 — DeepSeek Harness')
|
||||
|
||||
act(() => { unmount!() })
|
||||
expect(el.childElementCount).toBe(0)
|
||||
expect(document.title).toBe('DeepSeek Harness')
|
||||
})
|
||||
|
||||
it('no selected session: renderEmpty keeps the grid and forwards width setters', async () => {
|
||||
@@ -159,6 +163,7 @@ describe('bootWebShell (real loader + real script execution)', () => {
|
||||
],
|
||||
}
|
||||
const el = mountPoint()
|
||||
document.title = 'DeepSeek Harness'
|
||||
const s = seams({
|
||||
'/plugins/fake-runtime.js': RUNTIME_STUB,
|
||||
'/plugins/fake-layout.js': LAYOUT_STUB.replace("id: 'fake-layout'", `id: '${LAYOUT_ID}'`),
|
||||
@@ -169,6 +174,7 @@ describe('bootWebShell (real loader + real script execution)', () => {
|
||||
expect(frame).not.toBeNull()
|
||||
// Empty path: no conversation body (nothing registered into conversation.empty → fallback null).
|
||||
expect(el.querySelector('[data-testid="conv-body"]')).toBeNull()
|
||||
expect(document.title).toBe('DeepSeek Harness')
|
||||
// Width setter/selector pass-through (assembly closures over ctx.layout).
|
||||
expect((frame as HTMLElement).dataset['widths']).toBe('300x360')
|
||||
act(() => { (frame as HTMLElement).click() })
|
||||
|
||||
28
packages/client/web/tests/document-title.spec.tsx
Normal file
28
packages/client/web/tests/document-title.spec.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import { DocumentTitle } from '../src/DocumentTitle.tsx'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
document.title = ''
|
||||
})
|
||||
|
||||
describe('DocumentTitle', () => {
|
||||
it('preserves the product title without a durable title and restores it on unmount', () => {
|
||||
document.title = 'DeepSeek Harness'
|
||||
const mounted = render(<DocumentTitle />)
|
||||
expect(document.title).toBe('DeepSeek Harness')
|
||||
|
||||
mounted.rerender(<DocumentTitle title="First title" />)
|
||||
expect(document.title).toBe('First title — DeepSeek Harness')
|
||||
|
||||
mounted.rerender(<DocumentTitle title="Revised title" />)
|
||||
expect(document.title).toBe('Revised title — DeepSeek Harness')
|
||||
|
||||
mounted.rerender(<DocumentTitle />)
|
||||
expect(document.title).toBe('DeepSeek Harness')
|
||||
mounted.unmount()
|
||||
expect(document.title).toBe('DeepSeek Harness')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user