fix(web): align composer context stack
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: 3973c14f2b8fe746549bb74af85a7a60a7d66aea
|
||||
README.zh.md: a6bb15c4cdd53d05bf28147b97d9d64d1c59da2b
|
||||
README.md: b45139ae4f9c5f89927c1ba61c140bc7e0c12e78
|
||||
README.zh.md: ef989e716d1d3c860f101e27757b30a7b3d2d44c
|
||||
|
||||
@@ -16,7 +16,7 @@ A tool call declaring the `terminal` render intent renders its command output in
|
||||
|
||||
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
|
||||
|
||||
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 10` — between Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission <preset>` 命令行。
|
||||
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 10` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 和 Queue 之间),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: 776px;
|
||||
/* Eat InputBar's 6px top padding and tuck the panel 2px under the card;
|
||||
the later composer sibling paints its surface and shadow over this edge. */
|
||||
margin: 0 auto -10px;
|
||||
/* Flex gap still applies after this item; subtract it together with the
|
||||
design's overlap so the later composer paints over the queue edge. */
|
||||
margin: 0 auto calc(
|
||||
0px - var(--dsh-composer-stack-gap) - var(--dsh-queue-composer-overlap)
|
||||
);
|
||||
padding: 2px 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -162,14 +162,14 @@ export const queueDockEntry = {
|
||||
name: 'conversation-queue-dock',
|
||||
inject: ['slots', 'conversation', 'sessions'],
|
||||
/**
|
||||
* Register the queue strip into the input dock (list entry, order 0).
|
||||
* Register the queue strip as the terminal input-dock entry (order 20).
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({
|
||||
name: 'conversation.input.dock',
|
||||
id: 'queue',
|
||||
order: 0,
|
||||
order: 20,
|
||||
inject: (sessionId: SessionId): QueueDockInjected => {
|
||||
const actx = ctx.sessions.scope(sessionId)
|
||||
if (actx === undefined) throw new Error(`queue dock: session "${sessionId}" resolved no scope`)
|
||||
|
||||
@@ -127,14 +127,15 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Composer stack: dock strips above the input card (design §6 MIX order).
|
||||
The stack owns the vertical rhythm: one gap here, entries carry no outer
|
||||
margins — an entry that renders null costs nothing, so spacing stays
|
||||
correct for any dock combination. */
|
||||
/* Composer context stack (Figma 9:937): standalone dock cards share one
|
||||
rhythm; the terminal queue strip additionally tucks under the input card. */
|
||||
.composerStack {
|
||||
--dsh-composer-stack-gap: 6px;
|
||||
--dsh-queue-composer-overlap: 5px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: var(--dsh-composer-stack-gap);
|
||||
}
|
||||
|
||||
/* Common seat for the composer chain (fallback + elected overlay siblings). */
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
/* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419):
|
||||
tip surface, 14px radius, status icons + secondary item labels. Column is
|
||||
calc(100% - 88px) / max 752 (GoalBar's column), centered; the composer
|
||||
stack owns the gap. */
|
||||
/* Todo strip in the composer context stack (Figma 9:959): tip surface,
|
||||
14px radius, status icons + secondary item labels. */
|
||||
|
||||
.root {
|
||||
box-sizing: border-box;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
margin: 0 auto;
|
||||
@@ -21,13 +20,11 @@
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
/* Compact scale (GoalBar reference): collapsed header totals the goal
|
||||
strip's 38px (8+8 pad + 20 line + 2 border). */
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
padding: 9px 15px;
|
||||
}
|
||||
|
||||
.header {
|
||||
@@ -44,8 +41,8 @@
|
||||
|
||||
.title {
|
||||
flex: none;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@@ -135,10 +135,10 @@ export const todoDockEntry = {
|
||||
name: 'conversation-todo-dock',
|
||||
inject: ['slots', 'conversation'],
|
||||
/**
|
||||
* Register the plan strip into the input dock (list entry, above the queue rows).
|
||||
* Register the plan strip between the goal and queue entries (order 10).
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock)
|
||||
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 10 }, TodoDock)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -197,9 +197,14 @@ describe('QueueDock', () => {
|
||||
expect(container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('ships the session-scoped registrant plugin shape', () => {
|
||||
it('registers as the terminal composer-context entry', () => {
|
||||
expect(queueDockEntry.name).toBe('conversation-queue-dock')
|
||||
expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions'])
|
||||
expect(typeof queueDockEntry.apply).toBe('function')
|
||||
const register = vi.fn()
|
||||
queueDockEntry.apply({ slots: { register } } as never)
|
||||
expect(register).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'conversation.input.dock', id: 'queue', order: 20 }),
|
||||
QueueDock,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -92,12 +92,12 @@ describe('TodoDock', () => {
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
})
|
||||
|
||||
it('ships the registrant plugin shape (list entry above the queue rows)', () => {
|
||||
it('registers between the goal and queue entries', () => {
|
||||
expect(todoDockEntry.name).toBe('conversation-todo-dock')
|
||||
expect(todoDockEntry.inject).toEqual(['slots', 'conversation'])
|
||||
const register = vi.fn()
|
||||
todoDockEntry.apply({ slots: { register } } as never)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 10 }, TodoDock)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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-goal/README.md
|
||||
README.md: fed4870f73277b22760417297d668853b8afb2db
|
||||
README.zh.md: cc607edc856e04c6ee42cc8f596aa658679a02ca
|
||||
README.md: 2c109ab1fbe0b566b8749a6af44ec5e0055fe3b2
|
||||
README.zh.md: b81113c67566fd834b3ddb10931d4ecc630aa2f9
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Goal surface plugin, browser half: the `GoalBar` strip in the `conversation.input.dock` list (order 1, tucked against the composer). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no store, no refresh chain, and no event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline (the RPC's compare-and-set is the staleness guard — there is no client fence). Goal creation stays on the `/goal` host command; loading, absent, and completed goals render nothing.
|
||||
Goal surface plugin, browser half: the `GoalBar` strip is the first standalone card in the `conversation.input.dock` composer-context stack (order 0, before Todo and Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no store, no refresh chain, and no event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline (the RPC's compare-and-set is the staleness guard — there is no client fence). Goal creation stays on the `/goal` host command; loading, absent, and completed goals render nothing.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Goal 表面插件(浏览器半件):`conversation.input.dock` 列表中的 `GoalBar` 条带(order 1,紧贴 composer)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现(RPC 的 compare-and-set 即陈旧性防护——客户端没有任何栅栏)。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成三种状态一律不渲染。
|
||||
Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第一张独立卡片(order 0,位于 Todo 和 Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现(RPC 的 compare-and-set 即陈旧性防护——客户端没有任何栅栏)。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成三种状态一律不渲染。
|
||||
|
||||
`/client` 出口面为插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。
|
||||
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
/* GoalBar: the goal strip docked above the composer card. The dock's 44px
|
||||
side padding and the bar's 752px cap match the todo strip's column
|
||||
(TodoPanel.module.css), 24px inside the composer card's edges. The
|
||||
negative bottom margin cancels the composer stack's 8px gap and tucks the
|
||||
bar's square bottom edge 2px under the composer card's top edge (the
|
||||
card, later in DOM order, paints over it). Surface matches the todo
|
||||
strip: tip fill, l1 border — no bottom edge where it disappears under the
|
||||
card. All states share one fixed 38px height so switching between them
|
||||
never resizes the strip. */
|
||||
/* GoalBar: the first standalone card in the composer context stack (Figma
|
||||
9:939). Its 752px column matches Todo and the Queue panel. */
|
||||
|
||||
.dock {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 0 44px;
|
||||
}
|
||||
|
||||
.bar {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
box-sizing: border-box;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
max-width: 752px;
|
||||
height: 38px;
|
||||
margin: 0 auto -10px;
|
||||
padding: 0 14px;
|
||||
height: 36px;
|
||||
margin: 0 auto;
|
||||
padding: 4px 5px 4px 12px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-bottom: none;
|
||||
border-radius: 14px 14px 0 0;
|
||||
border-radius: 14px;
|
||||
background: var(--dsw-specific-tip);
|
||||
}
|
||||
|
||||
@@ -37,8 +32,8 @@
|
||||
flex: none;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-weight: 500;
|
||||
color: var(--dsw-alias-label-primary-dimmed);
|
||||
}
|
||||
|
||||
.objective {
|
||||
@@ -47,7 +42,7 @@
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
color: var(--dsw-alias-label-primary-dimmed);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -92,7 +87,7 @@
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@@ -100,11 +95,11 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
cursor: pointer;
|
||||
|
||||
@@ -59,7 +59,7 @@ export function apply(ctx: ClientContext): void {
|
||||
scope.effect(() => scope.slots.register({
|
||||
name: 'conversation.input.dock',
|
||||
id: 'goal',
|
||||
order: 1,
|
||||
order: 0,
|
||||
inject: (sessionId): GoalBarActions => ({
|
||||
onEdit: async (objective) => {
|
||||
const ref = refOf(sessionId)
|
||||
|
||||
@@ -92,7 +92,7 @@ describe('ui-goal browser plugin', () => {
|
||||
it('registers the GoalBar dock entry with the documented id and order', async () => {
|
||||
const b = bench()
|
||||
await b.fiber.await()
|
||||
expect(b.entry()).toMatchObject({ id: 'goal', order: 1 })
|
||||
expect(b.entry()).toMatchObject({ id: 'goal', order: 0 })
|
||||
expect(b.entry()?.inject).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user