fix(ui): preserve Hero tree when selecting a Workspace

This commit is contained in:
imccyu
2026-08-06 17:44:06 +08:00
committed by imccyu
parent e69eb9830e
commit f9f72e2f09
29 changed files with 395 additions and 223 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 7bd0d551fc41967326dd9860f5c31a99ea3c254a
README.zh.md: d339f6423d9a9f77c02d86ad0b8e57bd0baba52b
README.md: bbd115eac0eb914914dc11e504639633c801abdd
README.zh.md: 843b49e311fbf1a9157413c42a0ef3e9828284bc

View File

@@ -6,9 +6,9 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro
Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders.
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
The view ring is a slot: the conversation registration declares the session-scoped `'conversation.view'` list in its `children` table, ConversationRoot renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome.
The view ring is a slot: the strict session-body registration declares the session-scoped `'conversation.view'` list in its `children` table, that body renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome.
Approvals take over the composer through the chain this package declares: `ApprovalPanel` registers as a selector-routed `'conversation.composer'` entry (the ui-question pattern) and occupies the composer in place of the InputBar while an approval wait is pending (amber strip, justification headline, paired command line from the running call's args, one-shot refuse/allow). The `PendingApproval` domain face in `contract/slots.ts` owns the wire encoding — the `ApprovalResponsePayload` value with the audit correlation — over the runtime's `PendingWait` carrier; the broadcast `approval/resolved` frame settles the wait and restores the composer. The runtime manager projects every approval or question wait through `SessionSummary.pendingInteraction`, including sessions never instantiated; `ui-workspace` owns its sidebar presentation. Pending waits leave the message flow entirely: questions (ui-question) and approvals (ApprovalPanel) both answer through the composer takeover, so no display-only placeholder card remains. The composer's bottom-row Access seat mounts `PermissionSelect`, fed by the host-computed `permissions` projection through the standard-kit `useProjection` (key absence hides the chip); the chip opens a Menu-primitive dropdown whose kebab-case preset names render as title-case labels. Safe preset picks submit `/permission <preset>` immediately through the bar's injected `command` callback, while `danger-full-access` is presented as `Full access` and first opens an in-page Modal risk confirmation. The enabling action stays disabled until the user checks the acknowledgement; cancel, Escape, close, and mask click submit nothing.

View File

@@ -6,9 +6,9 @@
压缩compaction在检查点自身的消息流位置渲染为一行折叠标记不替换其上方的 transcript文本记录。展开内容来自检查点溯源的 `compact/summary`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏作为普通列 chrome仅显示当前会话标题和视图标签fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock输入区 dock输入栏。该滚动容器无条件预留自己的滚动条槽选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero编辑器子树首个会话到达时彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏作为普通列 chrome仅显示当前会话标题和视图标签fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock输入区 dock输入栏。该滚动容器无条件预留自己的滚动条槽选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
视图环是一个 slot会话注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,ConversationRoot 通过 renderSlot share 渲染活跃配置项(`only: <active id>`视图标签页则从注册选项(`id``order``label`投影而来。聊天视图是该包自身的配置项ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。
视图环是一个 slot严格会话主体注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,并通过自身的 renderSlot share 渲染活跃配置项(`only: <active id>`视图标签页则从注册选项(`id``order``label`投影而来。聊天视图是该包自身的配置项ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。
会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。

View File

@@ -8,7 +8,7 @@ import type {} from '@deepseek-ai/dsh-client-locale/client'
import type { ViewTab } from './contract/views.ts'
import type {
ApprovalWait, ChatScrollPosition, ChatViewInjected, ComposerBarInjected, ComposerChainProps, ConversationInjected,
ConversationSessionInjected, DetailsInjected,
ConversationSessionHeaderInjected, ConversationSessionInjected, DetailsInjected,
} from './contract/slots.ts'
import type { InputNotice } from './input/contract.ts'
import { resolveToolPath } from './contract/tool-call-model.ts'
@@ -33,7 +33,7 @@ import { askQuestionToolview } from './toolviews/ask-question-row.tsx'
import { todoDockEntry } from './skeleton/TodoPanel.tsx'
import { queueDockEntry } from './queue/QueueDock.tsx'
import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
import { ConversationSession } from './skeleton/ConversationSession.tsx'
import { ConversationSession, ConversationSessionHeader } from './skeleton/ConversationSession.tsx'
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
import { en, NS, zh, type ConversationKey } from './locales.ts'
@@ -123,6 +123,11 @@ export function apply(ctx: Context): void {
}
return tabs
}
const views = {
list: viewTabs,
subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn),
version: () => slots.getVersion('conversation.view'),
}
// The per-session input machine registry (InputService face; published as
// ctx.conversation.input by the service below sharing this one instance).
@@ -151,6 +156,7 @@ export function apply(ctx: Context): void {
locale: NS,
children: {
'conversation.session': { kind: 'single', scope: 'session' },
'conversation.session.header': { kind: 'single', scope: 'session' },
'conversation.composer': { kind: 'chain', scope: 'session' },
'conversation.composer.bar': { kind: 'single', scope: 'session-maybe' },
'conversation.input.overlay': { kind: 'list', scope: 'session' },
@@ -176,27 +182,36 @@ export function apply(ctx: Context): void {
}),
}, ConversationRoot)
// The strict session subtree owns only per-session store and view content;
// the resident parent keeps Hero and composer layout identity stable.
// The strict session body fills the resident scrollport without owning it;
// the Hero/composer path therefore stays fixed while the first blank
// session appears after a Workspace pick.
slots.register({
name: 'conversation.session',
locale: NS,
children: {
'conversation.view': { kind: 'list', scope: 'session' },
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
},
store: chatStore,
inject: (sessionId: SessionId, _actions: BoundActions<typeof chatStore>): ConversationSessionInjected => ({
views: {
list: viewTabs,
subscribe: fn => slots.subscribe('conversation.view', fn),
version: () => slots.getVersion('conversation.view'),
},
views,
bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write),
open: (id) => { sessions.open(id) },
}),
}, ConversationSession)
// Header chrome sits above the resident scrollport but shares the same
// per-session chat store (active view) as its body and view entries.
slots.register({
name: 'conversation.session.header',
locale: NS,
children: {
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
},
store: chatStore,
inject: (): ConversationSessionHeaderInjected => ({
views,
open: (id) => { sessions.open(id) },
}),
}, ConversationSessionHeader)
// The default composer body: its own single slot inside the composer
// chain's fallback (decision 20). Public machine surface arrives via the
// provide channel above; the keyboard command face and the stop/retry

View File

@@ -13,19 +13,20 @@ import type { CallId, SelectionTarget, ViewTab } from './views.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/**
* Strict-session content inside the resident conversation shell. This
* subtree owns the per-session chat store, header, and view ring and is
* remounted when the current session id changes.
* Strict-session body inside the resident conversation scrollport. It
* owns the per-session draft mirror and active view ring.
*/
'conversation.session': { kind: 'single'; scope: 'session'; owner: ConversationSessionOwnerProps }
'conversation.session': { kind: 'single'; scope: 'session' }
/** Strict-session header above the resident conversation scrollport. */
'conversation.session.header': { kind: 'single'; scope: 'session' }
/** Session-header actions contributed by feature plugins. */
'conversation.session.header.actions': { kind: 'list'; scope: 'session'; owner: ConversationHeaderActionOwnerProps }
/**
* The conversation view ring: one list entry per view tab (chat here;
* trajectory/waterfall from ui-trajectory), rendered one-at-a-time by
* ConversationRoot via `only: <active id>`. Declared by this package's
* 'conversation' entry (declaring is claiming). Session scope: views read
* the conversation snapshot through the standard kit.
* the session body via `only: <active id>`. Declared by this package's
* body entry (declaring is claiming). Session scope: views read the
* conversation snapshot through the standard kit.
*/
'conversation.view': { kind: 'list'; scope: 'session'; owner: ConvViewOwnerProps }
/**
@@ -122,22 +123,6 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
}
}
/** Owner share of the strict session content seat. */
export interface ConversationSessionOwnerProps {
/**
* Wrap the view ring in the transcript scrollport that also hosts the
* sticky composer seat (whole `'conversation.composer'` chain output).
* Supplied for every real session (hero/settling/active) so the composer
* keeps one tree seat across the blank → active flip; the header stays
* outside that wrapper as ordinary column chrome (`flex: none`), while
* active CSS sticks the seat to the bottom of the same scrollport so wheel
* over the footer scrolls the flow.
* @param view - the session view-ring content (null while blank chrome is hidden).
* @returns the scrollport containing `view` and the sticky composer seat.
*/
wrapActiveBody?: (view: ReactNode) => ReactNode
}
/** Header actions derive their state from the standard session/global kit. */
export interface ConversationHeaderActionOwnerProps {}
@@ -228,7 +213,7 @@ export type CommandRowProps = PropsRuntime<'conversation.chat.commandview'>
*/
export type ConvViewProps = PropsRuntime<'conversation.view'>
/** The shared chat store handle type (apply constructs one; the conversation, details, and chat-view registrations all declare it). */
/** The shared chat store handle type declared by the Session header/body, details, and chat-view registrations. */
export type ChatStore = ReturnType<typeof createChatStore>
/** Business callbacks injected into the conversation slot. */
@@ -240,7 +225,7 @@ export interface ConversationInjected {
selectWorkspace: (workspaceId: WorkspaceId) => Promise<void>
}
/** Business callbacks injected into the strict session content seat. */
/** Business callbacks injected into the strict Session body seat. */
export interface ConversationSessionInjected {
/** Views projected from the `conversation.view` slot ledger. */
views: {
@@ -250,6 +235,16 @@ export interface ConversationSessionInjected {
}
/** Bind the input machine's draft persistence mirror to the session store. */
bindDraftMirror: (write: (text: string) => void) => () => void
}
/** Business callbacks injected into the strict session header seat. */
export interface ConversationSessionHeaderInjected {
/** Views projected from the `conversation.view` slot ledger. */
views: {
list: () => readonly ViewTab[]
subscribe: (fn: () => void) => () => void
version: () => number
}
/** Select a real Session through the runtime navigation owner. */
open: (sessionId: SessionId) => void
}
@@ -354,7 +349,8 @@ export interface ComposerChainProps {
*/
export type ConversationSlotProps =
PropsRuntime<'conversation'> & PropsRenderSlots<
| 'conversation.session' | 'conversation.composer' | 'conversation.composer.bar'
| 'conversation.session' | 'conversation.session.header'
| 'conversation.composer' | 'conversation.composer.bar'
| 'conversation.input.overlay'
| 'conversation.input.dock' | 'conversation.composer.dock'
| 'conversation.input.left' | 'conversation.input.right'
@@ -363,12 +359,19 @@ export type ConversationSlotProps =
& ConversationInjected
& PropsLocale<'conversation'>
/** Full strict-session content props: per-session store, view ring, callbacks, and the locale seat. */
/** Full strict-session body props: per-session store, view ring, and draft mirror. */
export type ConversationSessionSlotProps =
PropsRuntime<'conversation.session'>
& PropsRenderSlots<'conversation.view' | 'conversation.session.header.actions'>
& PropsRenderSlots<'conversation.view'>
& PropsStore<ChatStore>
& ConversationSessionInjected
/** Full strict-session header props: shared store, tabs/actions render shares, navigation, and locale. */
export type ConversationSessionHeaderSlotProps =
PropsRuntime<'conversation.session.header'>
& PropsRenderSlots<'conversation.session.header.actions'>
& PropsStore<ChatStore>
& ConversationSessionHeaderInjected
& PropsLocale<'conversation'>
/** The pending approval carrier the owner dispatches into the composer chain. */

View File

@@ -15,7 +15,8 @@ export type { ConversationKey } from './locales.ts'
export type {
ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected,
ComposerChainProps, ConversationInjected,
ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps,
ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps,
EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps,
} from './contract/slots.ts'
// Export discipline: packages/client/AGENTS.md.

View File

@@ -31,8 +31,8 @@
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
/* Blank hero/settling: keep the header node mounted (stable Session tree for
the wrapActiveBody composer) without taking column space. */
/* Blank hero/settling: keep the strict Session header mounted without taking
column space; the root-owned scrollport and composer remain below it. */
.headerHidden {
display: none;
}

View File

@@ -2,7 +2,7 @@
// chain, AND the composer bar (session-maybe slot) stay mounted across
// no-session/session transitions — the bar renders inert via owner props.
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConversationSlotProps, InputZone } from '../contract/slots.ts'
@@ -31,9 +31,8 @@ export function ConversationRoot({
// Publishes the seat's live height as --dsh-composer-height on the scroll
// body so floating controls (ChatView back-to-bottom) clear the composer as
// it grows. Callback ref, not an effect: the seat remounts when the tree
// moves between the no-session and session paths. Stable identity so React
// reattaches only on those remounts, not on every render.
// it grows. Callback ref, not an effect; stable identity prevents observer
// churn while the first blank session fills the resident body outlet.
const seatObserver = useRef<ResizeObserver | null>(null)
const seatResizeRef = useCallback((seat: HTMLDivElement | null): void => {
seatObserver.current?.disconnect()
@@ -167,28 +166,13 @@ export function ConversationRoot({
</div>
)
// Header stays column chrome above this scrollport; the sticky composer
// seat lives inside it with the transcript. Always wrap while a session
// exists (hero/settling/active) so the composer keeps one tree seat across
// the blank → active flip — relocating it only in active remounted the textarea.
const wrapActiveBody = (view: ReactNode): ReactNode => (
<div className={css.scrollBody} data-conversation-scroll="">
{view}
{composerSeat}
</div>
)
return (
<div className={css.root} data-phase={phase}>
{/* Mounted for every real session, hero included: ConversationSession
keeps a chrome-hidden shell while blank and owns the draft-
persistence mirror bind — unmounting it in the hero would lose
pre-first-send text on a refresh or scope rebuild. */}
{sessionId !== undefined && renderSlot(
'conversation.session',
{ wrapActiveBody },
)}
{sessionId === undefined ? wrapActiveBody(null) : null}
{renderSlot('conversation.session.header', {})}
<div className={css.scrollBody} data-conversation-scroll="">
{renderSlot('conversation.session', {})}
{composerSeat}
</div>
</div>
)
}

View File

@@ -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,10 +43,15 @@ function equalBreadcrumbs(left: readonly Breadcrumb[], right: readonly Breadcrum
})
}
export function ConversationSession({
sessionId, useSession, useSessions, useInput, inputActions, useStore, actions,
renderSlot, views, bindDraftMirror, open, wrapActiveBody, t,
}: ConversationSessionProps) {
/**
* 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'
@@ -49,6 +59,77 @@ export function ConversationSession({
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.
@@ -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}
</>
)
}

View File

@@ -123,9 +123,9 @@ export function HeroShell({ t, children }: HeroShellProps) {
<span className={css.previewBadge}>{t('hero.preview')}</span>
</div>
<div className={css.body}>
{/* The resident composer (ConversationRoot wrapActiveBody seat; the
workspace row rides the stack above the card) is CSS-centered in
the session scroll body during hero — see
{/* The resident composer (ConversationRoot's root-owned scrollport;
the workspace row rides the stack above the card) is CSS-centered
in that scroll body during hero — see
ConversationRoot.module.css [data-phase='hero']. */}
</div>
</div>

View File

@@ -21,7 +21,8 @@ import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { ISession, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionInjected, DetailsInjected,
ChatViewInjected, ComposerBarInjected, ConversationInjected, ConversationSessionHeaderInjected,
ConversationSessionInjected, DetailsInjected,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { createChatStore } from '../src/client/stores.ts'
@@ -70,7 +71,7 @@ async function bench() {
// The host face (store resolution) exists only inside the installed
// renderer, so materialize it the way the shell does.
runtime.renderRoot()
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.composer.bar' | 'conversation.view' | 'details') =>
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar' | 'conversation.view' | 'details') =>
runtime.slots.entries(key)[0]!
/** Resolve store instance + call the inject the way the outlet would. */
const conversationSurface = (id: SessionId) => {
@@ -80,6 +81,13 @@ async function bench() {
id, instance.actions)
return { instance, injected }
}
const conversationHeaderSurface = (id: SessionId) => {
const entry = entryOf('conversation.session.header')
const instance = runtime.storeOf('conversation.session.header', id) as ChatInstance
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionHeaderInjected)(
id, instance.actions)
return { instance, injected }
}
const residentSurface = (id: SessionId | undefined) => {
const entry = entryOf('conversation')
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id)
@@ -111,7 +119,7 @@ async function bench() {
}
return {
runtime, feature, slots: runtime.slots, entryOf,
conversationSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
conversationSurface, conversationHeaderSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
sessionFake, layoutFake,
}
}

View File

@@ -21,11 +21,12 @@
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, waitFor, within } from '@testing-library/react'
import { useState } from 'react'
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
import type { ISession, SessionId, TodoItem, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import { SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { apply, inject, type EmptyWorkspaceOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
// The service reads its initial locale from the browser; these specs assert
// the shipped Chinese copy, so they state the browser they assume.
@@ -83,6 +84,16 @@ const LAYOUT_CHILDREN = {
'details': { kind: 'single', scope: 'session' },
} as const
/** Stateful occupant proving the root-scoped Hero workspace outlet is not rebuilt. */
function WorkspaceProbe({ open }: EmptyWorkspaceOwnerProps) {
const [count, setCount] = useState(0)
return (
<button data-testid="workspace-probe" onClick={() => { setCount(value => value + 1) }}>
{String(open)}:{count}
</button>
)
}
async function bench(nodes: ToolResultNode[], opts?: { blank?: boolean }) {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
@@ -188,6 +199,49 @@ describe('resident composer', () => {
await runtime.dispose()
})
it('keeps the complete Hero tree mounted when the first Workspace session appears', async () => {
const runtime = await SlotTestRuntime.create()
runtime.provide('layout', { openDetails: vi.fn(), closeDetails: vi.fn() })
const locale = new LocaleService(runtime.ctx)
runtime.provide('locale', locale)
runtime.slots.installLocale(locale)
await runtime.workspaces.update((draft) => {
draft.items = [{ workspaceId: 'w1', title: 'Proj', path: '/proj', sessionIds: [SID] }] as never
})
await runtime.root.declare(LAYOUT_CHILDREN, AppRoot)
await runtime.mount({ inject: [...inject], apply })
runtime.slots.register({ name: 'conversation.hero.workspace' }, WorkspaceProbe)
const view = runtime.renderRoot()
const root = view.container.querySelector('[data-phase="hero"]')!
const scrollBody = view.container.querySelector('[data-conversation-scroll]')!
const composerSeat = view.container.querySelector('[data-composer-seat]')!
const textarea = view.container.querySelector('textarea')!
const workspaceChip = view.getByRole('button', { name: '选择工作区' })
const workspaceProbe = view.getByTestId('workspace-probe')
expect(textarea.disabled).toBe(true)
fireEvent.click(workspaceChip)
fireEvent.click(workspaceProbe)
expect(workspaceProbe.textContent).toBe('true:1')
await runtime.sessions.add({
id: SID,
summary: { title: 'S', displayTitle: 'S', cwd: '/proj', blank: true },
snapshot: { blank: true, composerPhase: 'blank' },
})
expect(view.container.querySelector('[data-phase="hero"]')).toBe(root)
expect(view.container.querySelector('[data-conversation-scroll]')).toBe(scrollBody)
expect(view.container.querySelector('[data-composer-seat]')).toBe(composerSeat)
expect(view.container.querySelector('textarea')).toBe(textarea)
expect(view.getByRole('button', { name: '选择工作区' })).toBe(workspaceChip)
expect(view.getByTestId('workspace-probe')).toBe(workspaceProbe)
expect(workspaceProbe.textContent).toBe('true:1')
expect(textarea.disabled).toBe(false)
await runtime.dispose()
})
it('the textarea survives the blank→active conversion as the same DOM node', async () => {
const runtime = await bench([], { blank: true })

View File

@@ -45,7 +45,7 @@ async function bench() {
}
/** First stored entry for a key (inject/store live directly on StoredEntry). */
function renderEntryOf(slots: Awaited<ReturnType<typeof bench>>['slots'], key: 'conversation' | 'conversation.session' | 'conversation.view' | 'details') {
function renderEntryOf(slots: Awaited<ReturnType<typeof bench>>['slots'], key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.view' | 'details') {
return slots.entries(key)[0] as undefined | { inject?: unknown; store?: unknown }
}
@@ -73,6 +73,7 @@ describe('apply wiring', () => {
const b = await bench()
const conversation = renderEntryOf(b.slots, 'conversation')
const conversationSession = renderEntryOf(b.slots, 'conversation.session')
const conversationHeader = renderEntryOf(b.slots, 'conversation.session.header')
const chatView = renderEntryOf(b.slots, 'conversation.view')
const details = renderEntryOf(b.slots, 'details')
expect(conversation?.inject).toBeTypeOf('function')
@@ -81,6 +82,7 @@ describe('apply wiring', () => {
// The shared handle: one apply-built store value on ALL session entries
// (the session-maybe 'conversation' shell carries no store by design).
expect(conversationSession?.store).toBeDefined()
expect(conversationHeader?.store).toBe(conversationSession?.store)
expect(details?.store).toBe(conversationSession?.store)
expect(chatView?.store).toBe(conversationSession?.store)
// The hero workspace picker hole rides the conversation entry's children

View File

@@ -15,16 +15,17 @@ type ChatInstance = ReturnType<ReturnType<typeof createChatStore>['create']>
async function bench() {
const runtime = await SlotTestRuntime.create()
const chat = createChatStore()
// The apply.ts shape: one shared handle across both strict-session slot
// registrations ('conversation.session'/'details'); the session-maybe
// 'conversation' shell carries no store by design. The slots must first
// exist in the ledger — the test root declares them (the AppFrame role).
// The apply.ts shape: one shared handle across the strict Session header,
// body, and details registrations; the session-maybe 'conversation' shell
// carries no store by design. The slots must first exist in the ledger.
await runtime.root.declare({
'conversation': { kind: 'single', scope: 'session-maybe' },
'conversation.session': { kind: 'single', scope: 'session' },
'conversation.session.header': { kind: 'single', scope: 'session' },
'details': { kind: 'single', scope: 'session' },
}, (_p: { renderSlot?: unknown }) => null)
runtime.slots.register({ name: 'conversation.session', store: chat }, () => null)
runtime.slots.register({ name: 'conversation.session.header', store: chat }, () => null)
runtime.slots.register({ name: 'details', store: chat }, () => null)
runtime.renderRoot() // materializes the host face storeOf resolves through
return { runtime, chat }

View File

@@ -18,7 +18,7 @@ import { createChatStore } from '../src/client/stores.ts'
import { SessionInputShell } from '../src/client/input/facade.ts'
import { en, zh } from '../src/client/locales.ts'
import { ConversationRoot } from '../src/client/skeleton/ConversationRoot.tsx'
import { ConversationSession } from '../src/client/skeleton/ConversationSession.tsx'
import { ConversationSession, ConversationSessionHeader } from '../src/client/skeleton/ConversationSession.tsx'
import { HeroShell } from '../src/client/skeleton/EmptyHero.tsx'
import { InputBar } from '../src/client/skeleton/InputBar.tsx'
import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
@@ -122,6 +122,33 @@ function mount(
const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => {
slotCalls.push(key)
if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null }
if (key === 'conversation.session.header') {
return (
<ConversationSessionHeader
sessionId={SID}
SessionProvider={({ children }) => children(SID)}
useSession={useSession}
useSessions={props.useSessions}
useWorkspaces={props.useWorkspaces}
useProjection={(() => undefined)}
useInput={useInput}
inputActions={inputActions}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
renderSlot={renderSlot as never}
views={{
list: () => [
{ id: 'chat', label: 'Chat' },
{ id: 'trajectory', label: 'Trajectory' },
],
subscribe: () => () => {},
version: () => 1,
}}
open={open}
t={t}
/>
)
}
if (key === 'conversation.session') {
return (
<ConversationSession
@@ -145,9 +172,6 @@ function mount(
version: () => 1,
}}
bindDraftMirror={write => wiring.bindMirror(write)}
open={open}
t={t}
{...owner}
/>
)
}
@@ -340,7 +364,7 @@ describe('ConversationRoot resident composer', () => {
const before = b.view.getByRole('textbox')
fireEvent.change(before, { target: { value: 'kept across flip' } })
// First message landed: content exists, phase leaves blank. Composer
// already sat in the Session scrollport during hero, so the textarea
// already sat in the resident scrollport during hero, so the textarea
// node and InputHub draft both survive.
b.session.set(conversationSnapshot({ composerPhase: 'active', blank: false }))
b.rerender()

View File

@@ -21,7 +21,10 @@ import type {
SessionHistorySnapshot, SessionId, SessionListState, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
import {
ConversationSession, ConversationSessionHeader,
type ConversationSessionHeaderProps, type ConversationSessionProps,
} from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
@@ -35,12 +38,9 @@ import { createTrajectoryDurationStore } from '../src/client/duration-store.ts'
import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
const SID = 's1' as SessionId
// Stub of the conversation package's standard locale seat (this spec mounts
// its ConversationSession chrome); answers from the zh dictionary and falls
// back to the key like the real chain.
const tConversation: ConversationSessionProps['t'] =
const tConversation: ConversationSessionHeaderProps['t'] =
key => (conversationZh as Record<string, string>)[key] ?? key
afterEach(cleanup)
// The chat store persists under its declared key; clear so one case's active
// view cannot rehydrate into the next.
@@ -180,7 +180,7 @@ function tabsOf(slots: SlotsService): ViewTab[] {
.map(e => ({ id: e.options.id!, label: resolveSlotLabel(e.options.label) ?? e.options.id! }))
}
/** Mount the strict session content over the ring ledger with an outlet-faithful renderSlot. */
/** Mount the strict Session header/body over the ring ledger with outlet-faithful render shares. */
function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) {
const sessionSnapshot = createSnapshotStore({
running: false, removed: false, promptError: null, nodes,
@@ -190,6 +190,13 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
})
const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession<ConversationSnapshot>
const chat = createChatStore().create()
const views = {
list: () => tabsOf(slots),
subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn),
version: () => slots.getVersion('conversation.view'),
}
const useInput = bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never
const inputActions = { setDraft: vi.fn(), submit: vi.fn() }
// Minimal outlet twin: resolve the ring entry by the `only` filter and
// render it with the session standard kit (what SlotOutlet does for a
// list-kind session slot, minus machinery).
@@ -222,27 +229,39 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
)
}) as unknown as ConversationSessionProps['renderSlot']
return render(
<ConversationSession
sessionId={SID}
t={tConversation}
SessionProvider={({ children }) => children(SID)}
useSession={useSession}
useSessions={emptySessions()}
useWorkspaces={emptyWorkspaces()}
useProjection={(() => undefined)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
renderSlot={renderSlot}
views={{
list: () => tabsOf(slots),
subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn),
version: () => slots.getVersion('conversation.view'),
}}
useInput={bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never}
inputActions={{ setDraft: vi.fn(), submit: vi.fn() }}
bindDraftMirror={() => () => {}}
open={vi.fn()}
/>,
<>
<ConversationSessionHeader
sessionId={SID}
SessionProvider={({ children }) => children(SID)}
useSession={useSession}
useSessions={emptySessions()}
useWorkspaces={emptyWorkspaces()}
useProjection={(() => undefined)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
renderSlot={() => null}
views={views}
useInput={useInput}
inputActions={inputActions}
open={vi.fn()}
t={tConversation}
/>
<ConversationSession
sessionId={SID}
SessionProvider={({ children }) => children(SID)}
useSession={useSession}
useSessions={emptySessions()}
useWorkspaces={emptyWorkspaces()}
useProjection={(() => undefined)}
useStore={bindSnapshotSelector(chat)}
actions={chat.actions}
renderSlot={renderSlot}
views={views}
useInput={useInput}
inputActions={inputActions}
bindDraftMirror={() => () => {}}
/>
</>,
)
}