fix(ui): preserve Hero tree when selecting a Workspace
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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` 也会绕过其所有权。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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={() => () => {}}
|
||||
/>
|
||||
</>,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user