156 lines
5.9 KiB
TypeScript
156 lines
5.9 KiB
TypeScript
/** Strict per-session header/body content inserted into the resident conversation layout. */
|
|
|
|
import { useEffect, useSyncExternalStore } from 'react'
|
|
import clsx from 'clsx'
|
|
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import type {
|
|
ConversationSessionHeaderSlotProps, ConversationSessionSlotProps,
|
|
} from '../contract/slots.ts'
|
|
import css from './ConversationRoot.module.css'
|
|
|
|
/** Full props composed from the strict session body contract. */
|
|
export type ConversationSessionProps = ConversationSessionSlotProps
|
|
|
|
/** Full props composed from the strict session header contract. */
|
|
export type ConversationSessionHeaderProps = ConversationSessionHeaderSlotProps
|
|
|
|
interface Breadcrumb {
|
|
readonly id: SessionId
|
|
readonly displayTitle: string
|
|
}
|
|
|
|
function deriveAncestry(list: SessionListState, id: SessionId): readonly Breadcrumb[] {
|
|
const chain: Breadcrumb[] = []
|
|
const seen = new Set<SessionId>()
|
|
let cursor: SessionId | undefined = id
|
|
while (cursor !== undefined) {
|
|
if (seen.has(cursor)) break
|
|
seen.add(cursor)
|
|
const summary: SessionSummary | undefined = list.byId[cursor]
|
|
if (summary === undefined) break
|
|
chain.unshift({ id: summary.id, displayTitle: summary.displayTitle })
|
|
if (summary.origin !== 'subagent') break
|
|
cursor = summary.parentId
|
|
}
|
|
return chain
|
|
}
|
|
|
|
function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrumb[]): boolean {
|
|
return left.length === right.length
|
|
&& left.every((item, index) => {
|
|
const other = right.at(index)
|
|
return other !== undefined && item.id === other.id && item.displayTitle === other.displayTitle
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Renders Session header chrome above the resident conversation scrollport.
|
|
* @param props - Strict Session store, view ledger, navigation, render, and locale shares.
|
|
* @returns the hidden blank-session header or visible title and tabs.
|
|
*/
|
|
export function ConversationSessionHeader({
|
|
sessionId, useSession, useSessions, useStore, actions,
|
|
renderSlot, views, open, t,
|
|
}: ConversationSessionHeaderProps) {
|
|
useSyncExternalStore(views.subscribe, views.version)
|
|
const tabs = views.list()
|
|
const activeId = useStore(s => s.view) ?? 'chat'
|
|
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
|
|
const ancestry = useSessions(s => deriveAncestry(s, sessionId), equalBreadcrumbs)
|
|
const composerPhase = useSession(s => s.composerPhase)
|
|
const blank = useSession(s => s.blank)
|
|
const hideChrome = blank && composerPhase === 'blank'
|
|
|
|
return (
|
|
<header
|
|
className={clsx(css.header, hideChrome && css.headerHidden)}
|
|
aria-hidden={hideChrome || undefined}
|
|
>
|
|
{!hideChrome && (
|
|
<>
|
|
<div className={css.titleRow}>
|
|
<nav className={css.crumbs} aria-label={t('session.hierarchy')}>
|
|
{ancestry.map((summary, index) => {
|
|
const last = index === ancestry.length - 1
|
|
return (
|
|
<span key={summary.id} className={css.crumbSeg}>
|
|
{index > 0 && <span className={css.crumbSep}>/</span>}
|
|
<button
|
|
type="button"
|
|
className={clsx(css.crumb, last && css.crumbCurrent)}
|
|
disabled={last}
|
|
onClick={() => { open(summary.id) }}
|
|
>
|
|
{summary.displayTitle}
|
|
</button>
|
|
</span>
|
|
)
|
|
})}
|
|
{ancestry.length === 0 && <span className={css.crumbCurrent}>{sessionId}</span>}
|
|
</nav>
|
|
<div className={css.headerActions}>
|
|
{renderSlot('conversation.session.header.actions', {})}
|
|
</div>
|
|
</div>
|
|
{tabs.length > 1 && (
|
|
<div className={css.tabs} role="tablist">
|
|
{tabs.map(viewTab => (
|
|
<button
|
|
key={viewTab.id}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={viewTab.id === active?.id}
|
|
className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)}
|
|
onClick={() => { actions.setView(viewTab.id) }}
|
|
>
|
|
{viewTab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</header>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Renders the active Session view inside the resident scrollport and keeps
|
|
* the input draft mirrored while blank Hero chrome is visible.
|
|
* @param props - Strict Session input/store, view ledger, and render shares.
|
|
* @returns the active view area, or null while the Session remains blank.
|
|
*/
|
|
export function ConversationSession({
|
|
useSession, useInput, inputActions, useStore, actions,
|
|
renderSlot, views, bindDraftMirror,
|
|
}: ConversationSessionProps) {
|
|
useSyncExternalStore(views.subscribe, views.version)
|
|
const tabs = views.list()
|
|
const activeId = useStore(s => s.view) ?? 'chat'
|
|
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
|
|
const composerPhase = useSession(s => s.composerPhase)
|
|
const blank = useSession(s => s.blank)
|
|
const inputState = useInput(s => s)
|
|
const storedDraft = useStore(s => s.draft)
|
|
// `?? null`: persisted snapshots from before the inspect field rehydrate without it.
|
|
const inspect = useStore(s => s.inspect ?? null)
|
|
|
|
useEffect(() => {
|
|
if (inputState.draft === '' && storedDraft !== '') inputActions.setDraft(storedDraft)
|
|
const unmirror = bindDraftMirror(actions.setDraft)
|
|
return () => { unmirror() }
|
|
// Mount-only (deps pinned to inputActions): later store writes come from
|
|
// the machine mirror, not this seed effect.
|
|
}, [inputActions])
|
|
|
|
if (blank && composerPhase === 'blank') return null
|
|
return (
|
|
<div className={css.viewArea}>
|
|
{active !== undefined && renderSlot('conversation.view', {
|
|
inspect,
|
|
onInspectDone: () => { actions.setInspect(null) },
|
|
}, { only: active.id })}
|
|
</div>
|
|
)
|
|
}
|