fix(ui-conversation): keep composer visible across views

This commit is contained in:
_Kerman
2026-07-29 11:30:50 +08:00
parent 69aa58d31b
commit 5939355b1a
6 changed files with 11 additions and 13 deletions
@@ -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: 7ab9cd3a065d6dd87c47dfc7056738351813a889
README.zh.md: e38cb9ab837212546dd9680d5f2bf37865cb7e28
README.md: 6c0a0d2a883fa21eed8bd162d7782a35d5454415
README.zh.md: 7be07d9891d2dd0bb66c02a16cb80de46bac1926
+1 -1
View File
@@ -16,7 +16,7 @@ The todo surfaces are two registrations over that shape, both plain registrant p
Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop button), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). The active-session composer remains mounted below every conversation view, including Trajectory, so ordinary input and pending takeovers stay accessible while inspecting another view. The resident no-session shell uses `DisabledInputBar` and therefore dispatches no session-scoped control seats.
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
+1 -1
View File
@@ -16,7 +16,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession``sessionId`、全局 `useSessions``useWorkspaces`,以及输入状态机的 `useInput``inputActions`store 表层与 inject factory 提供其余状态和回调。
输入栏声明两个会话作用域的单实例 seat:`'conversation.input.plan'` 位于本地 access 模式控件右侧,而 `'conversation.input.model'` 紧接在 pending 指示器与发送/停止按钮之前;它还为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 会将文本框的 placeholder 切换为 plan 任务文案(它通过标准工具包的 `useProjection` 读取由 host 折叠的值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
输入栏声明两个会话作用域的单实例 seat:`'conversation.input.plan'` 位于本地 access 模式控件右侧,而 `'conversation.input.model'` 紧接在 pending 指示器与发送/停止按钮之前;它还为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。当 `plan` 投影的有效目标为 plan mode 时,InputBar 会将文本框的 placeholder 切换为 plan 任务文案(它通过标准工具包的 `useProjection` 读取由 host 折叠的值;owner 提供的 placeholder 优先)。活跃会话的 composer 始终挂载在每个会话视图下方,包括 Trajectory,因此在查看另一视图时,普通输入和待处理接管仍然可用。常驻无会话壳使用 `DisabledInputBar`,因此不会分发任何会话作用域的控件 seat。
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/``chat/``toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply``inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。
@@ -1,6 +1,6 @@
/** Strict per-session conversation content: header, view ring, and chat store bindings. */
/** Strict per-session conversation content: header, view ring, composer, and chat store bindings. */
import { Fragment, useEffect, useSyncExternalStore } from 'react'
import { useEffect, useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
@@ -32,7 +32,6 @@ export function ConversationSession({
const active = tabs.find(view => view.id === activeId) ?? tabs[0]
const ancestry = useSessions(s => deriveAncestry(s, sessionId), shallowEqual)
const composerPhase = useSession(s => s.composerPhase)
const hasPending = useSession(s => s.pending.length > 0)
const blank = useSession(s => s.blank)
const inputState = useInput(s => s)
const storedDraft = useStore(s => s.draft)
@@ -91,9 +90,7 @@ export function ConversationSession({
{!blankHero && <div className={css.viewArea}>
{active !== undefined && renderSlot('conversation.view', {}, { only: active.id })}
</div>}
{(blankHero || active?.id === 'chat' || hasPending) && (
<Fragment key="composer">{composer}</Fragment>
)}
{composer}
</>
)
}
@@ -217,8 +217,8 @@ describe('ConversationRoot resident composer', () => {
expect(b.view.getByTestId('view-chat')).toBeTruthy()
})
it('keeps pending takeover interaction accessible outside the Chat view', () => {
const b = mount(conversationSnapshot({ pending: [{} as never] }))
it('keeps the composer accessible under the Trajectory view', () => {
const b = mount(conversationSnapshot())
act(() => { b.chat.actions.setView('trajectory') })
expect(b.view.getByTestId('view-trajectory')).toBeTruthy()
expect(b.view.getByRole('textbox')).toBeTruthy()
@@ -151,7 +151,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
}) as unknown as ConversationSessionProps['renderSlot']
return render(
<ConversationSession
composer={null}
composer={<textarea aria-label="Message" />}
sessionId={SID}
SessionProvider={({ children }) => children(SID)}
useSession={useSession}
@@ -203,6 +203,7 @@ describe('tab switching in ConversationRoot', () => {
expect(screen.queryByRole('columnheader')).toBeNull()
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy()
expect(screen.getByRole('textbox', { name: 'Message' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Collapse turns' }))
expect(view.container.querySelector('[data-collapsed-summary="turn"]')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Expand turns' }))