|
|
@@ -1,14 +1,19 @@
|
|
|
-/** Strict per-session conversation content: header, view ring, and chat store bindings. */
|
|
|
+/** Strict per-session header/body content inserted into the resident conversation layout. */
|
|
|
|
|
|
-import { useEffect, useSyncExternalStore, type ReactNode } from 'react'
|
|
|
+import { useEffect, useSyncExternalStore } from 'react'
|
|
|
import clsx from 'clsx'
|
|
|
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
|
|
-import type { ConversationSessionSlotProps } from '../contract/slots.ts'
|
|
|
+import type {
|
|
|
+ ConversationSessionHeaderSlotProps, ConversationSessionSlotProps,
|
|
|
+} from '../contract/slots.ts'
|
|
|
import css from './ConversationRoot.module.css'
|
|
|
|
|
|
-/** Full props composed from the strict session slot contract. */
|
|
|
+/** 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
|
|
|
@@ -38,15 +43,91 @@ function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrum
|
|
|
})
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * 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({
|
|
|
- sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
|
|
|
- renderSlot, views, bindDraftMirror, open, wrapActiveBody, t,
|
|
|
+ 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 ancestry = useSessions(s => deriveAncestry(s, sessionId), equalBreadcrumbs)
|
|
|
const composerPhase = useSession(s => s.composerPhase)
|
|
|
const blank = useSession(s => s.blank)
|
|
|
const inputState = useInput(s => s)
|
|
|
@@ -62,13 +143,8 @@ export function ConversationSession({
|
|
|
// the machine mirror, not this seed effect.
|
|
|
}, [inputActions])
|
|
|
|
|
|
- // Blank hero/settling: keep the same header + body tree shape so a
|
|
|
- // wrapActiveBody-hosted composer keeps its DOM identity across the first
|
|
|
- // send (hero → active). Chrome is hidden; the draft-persistence mirror
|
|
|
- // still runs because this component stays mounted.
|
|
|
- const hideChrome = blank && composerPhase === 'blank'
|
|
|
-
|
|
|
- const view: ReactNode = hideChrome ? null : (
|
|
|
+ if (blank && composerPhase === 'blank') return null
|
|
|
+ return (
|
|
|
<div className={css.viewArea}>
|
|
|
{active !== undefined && renderSlot('conversation.view', {
|
|
|
inspect,
|
|
|
@@ -76,59 +152,4 @@ export function ConversationSession({
|
|
|
}, { only: active.id })}
|
|
|
</div>
|
|
|
)
|
|
|
-
|
|
|
- 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>
|
|
|
- {wrapActiveBody !== undefined ? wrapActiveBody(view) : view}
|
|
|
- </>
|
|
|
- )
|
|
|
}
|