Merge pull request #6321 from deepseek-harness/worktree-chatwhalepos

feat(ui-chat): anchor running status to the running turn tail
This commit is contained in:
imccyu authored and GitHub committed 2026-10-18 16:00:42 +08:00
commit 586e3a3f59
21 files changed
+813 -57

No files matched your search

@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-10-16-steering-batch-start-in-definition-state.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-16-steering-batch-start-in-definition-state.md
/agent-note-precompute-the-steering-batch-start-in-conversation-node-definition-state:
en: 6f27c4f6133966ba
zh: 36b1e3bf1b809f09
/agent-note-precompute-the-steering-batch-start-in-conversation-node-definition-state/problem:
en: 6901df0549909883
zh: 3e7f9698a26fe568
/agent-note-precompute-the-steering-batch-start-in-conversation-node-definition-state/decision:
en: 581f8768c22fda63
zh: 1db7d743dbe65e96
/agent-note-precompute-the-steering-batch-start-in-conversation-node-definition-state/alternatives-considered:
en: 479dc0bcce2c7674
zh: a9fa5ed6eaa40919
/agent-note-precompute-the-steering-batch-start-in-conversation-node-definition-state/consequences:
en: 66b21a77e1fa35f1
zh: 8a3e13e94a27a3bf
/agent-note-precompute-the-steering-batch-start-in-conversation-node-definition-state/testing:
en: 4b0da9e7eeb3906a
zh: 35ea5569f39939d6
@@ -0,0 +1,37 @@
# Agent Note: Precompute the steering batch start in Conversation Node Definition state
Status: implemented
English | [中文](2026-10-16-steering-batch-start-in-definition-state.zh.md)
## Problem
Chat keeps a 44px slot, the height of the running status row, above the first steering row of each claim that lands after the running Turn's process output, so admitting steering swaps the status and the bubble without moving the bubble ([conversation-nodes README](../../../../packages/client/ui-chat/src/client/conversation-nodes/README.md)). Deciding that a steering row starts such a batch needs two facts its own `user/message` event does not carry: whether it is the first human message of its `next-step` claim, and whether the same Turn already has durable visible output before it, which places the row after the Turn's process control. The answer must hold for live appends, replay, and loading earlier history, and must not scan the event window or the Chat Nodes.
## Decision
The batch start is a fact in Conversation Node State, computed by Definitions through the existing [`reader.previous`](2026-08-09-client-conversation-node-assembly.md) lookup:
- [`inbox.ts`](../../../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts) records `firstClaimedHuman`, the first human message removed by each `next-step` claim, in the Client Inbox State.
- [`turn-process.ts`](../../../../packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts) registers `processEvidenceDefinition` (kind `process-evidence`, `publication: () => 'none'`): one Context per Step with durable visible output, id `turn:step`, matched from `assistant/message`, `tool/call`, `tool/result`, and `llm/retry` through the existing `processEvidence` test. Streaming chunks never reach it. Its State holds the Turn number; the Context's matches accumulate that Step's evidence events, and nothing reads them.
- [`message.ts`](../../../../packages/client/ui-chat/src/client/conversation-nodes/message.ts) calls `reader.previous('process-evidence')` in `start`, only for the claim's first human message. When the nearest earlier record belongs to the same Turn, the steering State carries `batchStart: true`. Later appends have larger seqs and cannot change the answer; loading earlier history replays the dependency through the assembler's tracking of `reader.previous`, as Message already does for Inbox.
- The steering renderer reads only `data.batchStart` and writes `data-chat-steering-batch="start"` on its bubble root; CSS gives the containing row the slot.
The lookup costs O(log N) per claim-leading human message, where N is the number of Steps with output in the loaded window. The change adds no framework API and no persisted or wire data.
## Alternatives considered
**Compute it in the renderer from props.** The renderer would need neighbouring rows and the Turn's process state, which puts a steering branch into the business-free node list and Seat, and the row would not re-render when only a neighbour changes.
**Compute it in the Chat snapshot builder.** A projector over the ordered entries would put a steering-specific rule and an extra pass into the generic build layer.
**Add tracked Turn-data reads to the framework (`reader.turnData`).** It changes the `ConversationNodeBuildReader` contract and the assembler's dependency tracking for one consumer, while `reader.previous` already gives the needed ordering, dependency, and replay.
## Consequences
The decision lives in node data, follows replay and earlier-history loading through existing dependency tracking, and leaves the renderer, Seat, node list, and ui-conversation framework unchanged. It costs one more Definition and one Context per Step with output; each State is one number, and each Context holds references to its Step's evidence events. The slot follows the claim's first human message rather than its first rendered row, so a runtime-context notice logged before that message in the same claim sits above the slot; this follows from the README's rule that the claim's first human message keeps the slot.
## Testing
- [conversation-node-definitions.client.spec.ts](../../../../packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts): `records durable process evidence once per Step and ignores live chunks and invisible replies` pins evidence matching; `marks each claim lead and starts a batch only after the Turn control`, run for replay, live appends, and prepends, pins batch starts for the first and a later claim, no batch start for the second row of a claim, an idle claim that opens a Turn, or a claim after a Step without output.
- [chat-view.client.spec.tsx](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx): `marks the steering row whose data carries a batch start, and no other input` pins the rendered attribute; [turn-tail-spacing.client.spec.ts](../../../../packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts) pins the 44px slot rule.
@@ -0,0 +1,37 @@
# Agent Note: 在 Conversation Node Definition 的 State 中预计算插话批首
Status: implemented
[English](2026-10-16-steering-batch-start-in-definition-state.md) | 中文
## 问题
Chat 在每次 claim 的第一条插话行上方保留 44px 的位置(等于运行状态行的高度),条件是这批插话落在运行中 Turn 的过程输出之后;这样插话被领取时,运行状态与气泡交换位置,气泡不动([conversation-nodes README](../../../../packages/client/ui-chat/src/client/conversation-nodes/README.zh.md))。判断一条插话是否是批首,需要两个它自己的 `user/message` 事件里没有的事实:它是否是所在 `next-step` claim 的第一条人类消息;同一 Turn 在它之前是否已有持久可见的输出,也就是这一行是否排在 Turn 过程控件之后。结论在实时追加、回放和加载更早历史时都必须成立,并且不能扫描事件窗口或 Chat Node。
## 决策
批首是 Conversation Node State 中的事实,由 Definition 通过现有的 [`reader.previous`](2026-08-09-client-conversation-node-assembly.zh.md) 查询计算:
- [`inbox.ts`](../../../../packages/client/ui-chat/src/client/conversation-nodes/inbox.ts) 在 Client Inbox State 中记录 `firstClaimedHuman`,即每次 `next-step` claim 取走的第一条人类消息。
- [`turn-process.ts`](../../../../packages/client/ui-chat/src/client/conversation-nodes/turn-process.ts) 注册 `processEvidenceDefinition`(kind `process-evidence`,`publication: () => 'none'`):每个有持久可见输出的 Step 一个 Context,id 为 `turn:step`,通过现有的 `processEvidence` 判断从 `assistant/message`、`tool/call`、`tool/result`、`llm/retry` 匹配。流式 chunk 不会进入它。它的 State 只保存 Turn 编号;Context 的 matches 累积该 Step 的证据事件,没有任何代码读取它们。
- [`message.ts`](../../../../packages/client/ui-chat/src/client/conversation-nodes/message.ts) 只对 claim 的第一条人类消息,在 `start` 中调用 `reader.previous('process-evidence')`。最近的一条更早记录属于同一 Turn 时,插话 State 带上 `batchStart: true`。之后追加的事件 seq 都更大,不会改变结论;加载更早历史时,组装器对 `reader.previous` 的依赖追踪会重放这个依赖,与 Message 读取 Inbox 的方式相同。
- 插话渲染器只读取 `data.batchStart`,在气泡根元素上写 `data-chat-steering-batch="start"`;CSS 给所在行保留这个位置。
每条领头的人类消息做一次 O(log N) 查询,N 为已加载窗口中有输出的 Step 数。这一改动不新增框架 API,也不涉及持久化或传输数据。
## 考虑过的替代方案
**在渲染器里根据 props 计算。** 渲染器需要相邻行和 Turn 的过程状态,这会把插话分支带进本应不含业务的节点列表和 Seat;而且只有相邻行变化时,这一行不会重新渲染。
**在 Chat 快照构建层计算。** 对有序条目做一次投影,会把插话专用的规则和额外一轮遍历放进通用构建层。
**给框架增加带追踪的 Turn 数据读取(`reader.turnData`)。** 为了一个使用方修改 `ConversationNodeBuildReader` 的约定和组装器的依赖追踪;而 `reader.previous` 已经提供所需的顺序、依赖与重放。
## 后果
结论保存在节点数据中,借助现有依赖追踪在回放和加载更早历史时保持正确;渲染器、Seat、节点列表和 ui-conversation 框架都不变。代价是多一个 Definition,每个有输出的 Step 多一个 Context:每个 State 只有一个数字,每个 Context 持有该 Step 证据事件的引用。保留的位置跟随 claim 的第一条人类消息,而不是该批第一条渲染出来的行;因此同一次 claim 中记录在它之前的 runtime-context 通知会排在这个位置上方;这由 README 中“claim 的第一条人类消息保留该位置”的规则推出。
## 测试
- [conversation-node-definitions.client.spec.ts](../../../../packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts):`records durable process evidence once per Step and ignores live chunks and invisible replies` 固定证据匹配;`marks each claim lead and starts a batch only after the Turn control` 分别以回放、实时追加和加载更早历史三种方式运行,固定第一次和之后一次 claim 的批首,以及同一 claim 的第二行、开启 Turn 的空闲 claim、无输出 Step 之后的 claim 都不是批首。
- [chat-view.client.spec.tsx](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx):`marks the steering row whose data carries a batch start, and no other input` 固定渲染出的属性;[turn-tail-spacing.client.spec.ts](../../../../packages/client/ui-chat/tests/turn-tail-spacing.client.spec.ts) 固定 44px 位置的规则。
@@ -9,12 +9,11 @@
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy"
- button "Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that."
- text: "Interjection: include the word BANANA in your final reply."
- status: Deep diving
- text: "Deep diving for {{duration}} ··· Interjection: include the word BANANA in your final reply."
- button "Copy"
- text: "Interjection: include the word ORANGE in your final reply."
- button "Copy"
- status: Deep diving
- text: Deep diving for {{duration}} ···
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
- 'button "Access mode, current: Workspace Write"': Workspace Write
@@ -1,5 +1,6 @@
/** Ordered Chat rows, local echoes, and running status share the flow slot's parent. */
import { memo } from 'react'
import { memo, type ReactElement } from 'react'
import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client'
import { assertNever } from '@deepseek-ai/dsh-util-values'
import type { ChatFlowSlotProps } from '../contract/slots.ts'
import { ChatNodeSeat } from './ChatNodeSeat.tsx'
@@ -8,6 +9,19 @@ import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.ts
import { chatRenderKey } from './render-entry.ts'
import { RunningStatus } from './RunningStatus.tsx'
/** A local submission echo, or a Host-pending steering item without a local submission. */
type PendingInput = ChatFlowSlotProps['pendingInputs'][number]
/** Whether the echo is a local submission rather than a Host-pending steering item. */
function isLocalSubmission(item: PendingInput): item is PendingSubmission {
return 'requestId' in item
}
/** Whether the echo is a local submission that opens a new Turn. */
function isTranscriptEcho(item: PendingInput): boolean {
return isLocalSubmission(item) && item.placement === 'transcript'
}
/** Render the flow without adding a DOM parent or changing keyed row positions. */
export const ChatFlow = memo(function ChatFlow({
entries, pendingInputs, lastInputTurn, deferCollapse,
@@ -41,21 +55,37 @@ export const ChatFlow = memo(function ChatFlow({
return assertNever(entry)
}
})
const pendingRows = pendingInputs.map(item => 'requestId' in item ? (
<PendingSubmissionBubble key={item.requestId} submission={item}
loadImage={loadImage} renderFactorySlot={renderFactorySlot} t={t} />
) : (
<PendingSteeringBubble key={item.id} content={item.content}
loadImage={loadImage} renderFactorySlot={renderFactorySlot} t={t} />
))
// Transcript echoes open the running Turn and precede the status; steering
// and Inbox echoes follow it, so the status stays on the running Turn's tail.
// The first transcript echo opens the next Turn, whose first claim takes the
// steering already pending ahead of it before that prompt; those echoes lead
// the same Turn in claim order.
const firstTranscript = pendingInputs.findIndex(isTranscriptEcho)
const leadingRows: ReactElement[] = []
const openingRows: ReactElement[] = []
const pendingRows: ReactElement[] = []
for (const [index, item] of pendingInputs.entries()) {
const row = isLocalSubmission(item)
? <PendingSubmissionBubble key={item.requestId} submission={item}
loadImage={loadImage} renderFactorySlot={renderFactorySlot} t={t} />
: <PendingSteeringBubble key={item.id} content={item.content}
loadImage={loadImage} renderFactorySlot={renderFactorySlot} t={t} />
if (index <= firstTranscript) leadingRows.push(row)
else if (isTranscriptEcho(item)) openingRows.push(row)
else pendingRows.push(row)
}
const tail = entries.at(-1)
const node = tail?.kind === 'node' ? nodeStore.get(tail.key) : undefined
// An empty opening control follows one local transcript echo, never steering.
// All rows share this keyed list so inserting the control keeps the echo mounted.
// An empty opening control follows the echoes that lead its Turn.
// All rows share this keyed list so inserting the control keeps the echoes mounted.
if (node?.kind === 'turn-process' && node.location.kind === 'turn'
&& node.location.turn.status === 'open' && node.location.turn.turn !== lastInputTurn) {
const index = pendingInputs.findIndex(item => 'requestId' in item && item.placement === 'transcript')
if (index !== -1) rows.splice(rows.length - 1, 0, ...pendingRows.splice(index, 1))
rows.splice(rows.length - 1, 0, ...leadingRows.splice(0))
}
return [...rows, ...pendingRows, ...running ? [<RunningStatus key="running" startTime={runningStartTime} t={t} />] : []]
// Rows enter before the keyed status and echoes after it, so React never moves its DOM node.
return [
...rows, ...leadingRows, ...openingRows,
...running ? [<RunningStatus key="running" startTime={runningStartTime} t={t} />] : [],
...pendingRows,
]
})
@@ -13,6 +13,8 @@
color: var(--dsw-alias-label-tertiary);
font: inherit;
font-size: var(--dsh-content-font-size, 14px);
/* One process-row height, so a header fills the slot the running status keeps for it. */
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
text-align: left;
cursor: pointer;
transition: color 100ms ease;
@@ -79,7 +79,7 @@
}
:is(.column, :global([data-slot="conversation.chat.flow"])) > [data-chat-group-part="response"]:not([hidden]):not(:empty)
+ :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))):not(.running) {
+ :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))):not(.running):not(:has(> [data-slot="conversation.chat.node"] > [data-chat-steering-batch])) {
--dsh-chat-flow-gap: 12px;
}
@@ -113,13 +113,118 @@
}
}
/* The Turn header has 16px clearance from its input and following content. */
/* The Turn header has 16px clearance from its input and following content. A
running Turn's control renders empty until the Turn closes; an echo after it
is still an opening input of that Turn, so it keeps the input gap. */
.flowItem[data-chat-flow-kind="turn-process"],
.flowItem[data-turn-process-answer],
:is(.column, :global([data-slot="conversation.chat.flow"])) > [data-chat-flow-kind="turn-process"] + :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))):not(.running) {
:is(.column, :global([data-slot="conversation.chat.flow"])) > [data-chat-flow-kind="turn-process"]:not(:empty, :has(> [data-slot="conversation.chat.node"]:empty)) + :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))):not(.running):not(:has(> [data-slot="conversation.chat.node"] > [data-chat-steering-batch])),
:is(.column, :global([data-slot="conversation.chat.flow"])) > [data-chat-flow-kind="turn-process"] + :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))):not(.running):not([data-submission-echo], [data-pending-steering]):not(:has(> [data-slot="conversation.chat.node"] > [data-chat-steering-batch])) {
--dsh-chat-flow-gap: 16px;
}
/* Tail slot: the running status and a completed Turn's actions row both take
a 16px offset plus a 28px row. The steering renderer marks the first
steering row of each claim that Chat orders after the Turn's process
control; that row keeps the slot above it, the way a completed Turn's
actions row precedes the next user message, while rows claimed together
stay 6px apart and opening inputs keep ordinary spacing. The first waiting
echo sits directly below the running status, so admission swaps the two
without moving any bubble. Neighbour gap rules exclude the batch start so
this slot always applies. */
:is(.column, :global([data-slot="conversation.chat.flow"])) > .flowItem:has(> [data-slot="conversation.chat.node"] > [data-chat-steering-batch="start"]) {
--dsh-chat-flow-gap: calc(44px + var(--dsh-content-font-delta, 0px));
}
.running + [data-pending-steering] {
--dsh-chat-flow-gap: 0px;
}
/* A completed Turn's actions row owns the tail slot; the status can trail it
for one frame before the Session stops running, or between queued Turns. */
.flowItem[data-chat-flow-kind="turn-tail"] + .running {
display: none;
}
/* Steering rows keep their full layout from insertion. Only the status moves:
it enters its new slot below them from one slot higher, independent of how
many admitted rows it passes or how tall they are. The start therefore sits
below the status's previous position by the admitted rows' height; the
fade-in covers that difference, so this entrance approximates the move. */
@media (prefers-reduced-motion: no-preference) {
:where(.column > :global([data-slot="conversation.chat.flow"]) > .flowItem[data-chat-flow-kind="steering"]) + .running {
animation: running-enters-slot 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
}
@keyframes running-enters-slot {
from {
opacity: 0;
translate: 0 calc(-44px - var(--dsh-content-font-delta, 0px));
}
40% {
opacity: 1;
}
}
/* While the running Step has no output, the status sits directly below the
input. When the first output row appears, the status enters its slot below
that row from where it stood: it travels the row's gap plus one 24px row,
which follows the content font size. The gap is 6px for a process row or
group header (the default declared on `.running`), 12px for a response, and
16px after the running Turn's process control, which renders empty until the
Turn closes, so it precedes a new Turn's first output. The default sits on
the single-class `.running` selector so both variants outrank it. Later
output keeps the same animation and does not restart it. */
:is(.column, :global([data-slot="conversation.chat.flow"])) > :nth-last-child(2 of :not([hidden]):not([data-pending-steering]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty))))[data-chat-group-part="response"] ~ .running {
--dsh-running-follow: calc(12px + 24px + var(--dsh-content-font-delta, 0px));
}
:is(.column, :global([data-slot="conversation.chat.flow"])) > [data-chat-flow-kind="turn-process"] + :nth-last-child(2 of :not([hidden]):not([data-pending-steering]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))) ~ .running {
--dsh-running-follow: calc(16px + 24px + var(--dsh-content-font-delta, 0px));
}
@media (prefers-reduced-motion: no-preference) {
:where(.column > :global([data-slot="conversation.chat.flow"]) > :nth-last-child(2 of :not([hidden]):not([data-pending-steering]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))):is(
.flowItem:not([data-chat-flow-kind="user"]):not([data-chat-flow-kind="steering"]):not([data-chat-flow-kind="turn-trigger"]),
[data-chat-group-key]
)) ~ .running {
animation: running-follows-output 110ms cubic-bezier(0.4, 0, 0.2, 1);
}
/* Switches only change the duration of a move its structural change already
created. A duration change applies as if the move had it from its start, so
turning a switch off never replays a move whose structural change is older
than the move's duration, and plays the remainder of one that is younger;
turning a switch on completes a running move at once. The structural rules
above are wrapped in :where() so these rules outrank them. An input fold
runs both moves at zero duration. */
.column[data-input-fold] .running {
animation-duration: 0s;
}
/* While the tail is pinned, following scrolls absorb output growth and the
status keeps its on-screen position, so the first-output slide runs at zero
duration. Admitted steering still passes its pending echo below the status,
so the steering entrance keeps its duration. */
.column[data-chat-tail-pinned] > :global([data-slot="conversation.chat.flow"]) > :nth-last-child(2 of :not([hidden]):not([data-pending-steering]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))):is(
.flowItem:not([data-chat-flow-kind="user"]):not([data-chat-flow-kind="steering"]):not([data-chat-flow-kind="turn-trigger"]),
[data-chat-group-key]
) ~ .running {
animation-duration: 0s;
}
}
@keyframes running-follows-output {
from {
opacity: 0;
translate: 0 calc(-1 * var(--dsh-running-follow));
}
40% {
opacity: 1;
}
}
/* Empty Seats, including retained empty slot anchors, preserve Turn lookup
without contributing height or the sibling gap. */
.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)) {
@@ -140,10 +245,12 @@
}
.running {
position: relative;
display: flex;
flex-direction: column;
align-items: flex-start;
--dsh-chat-flow-gap: 12px;
--dsh-running-follow: calc(6px + 24px + var(--dsh-content-font-delta, 0px));
--dsw-alias-label-shimmer: var(--dsw-alias-label-deep-diving-shimmer);
color: var(--dsw-alias-label-deep-diving);
font-size: calc(var(--dsh-content-font-size, 14px) - 2px);
@@ -151,28 +258,42 @@
}
/* The last visible row before the status must contain output, not an input.
Hidden and empty seats do not separate a user message from the status. */
:is(.column, :global([data-slot="conversation.chat.flow"])) > :nth-last-child(2 of :not([hidden]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))):is(
Hidden and empty seats do not separate a user message from the status, and
the steering/Inbox echoes after the status are outside the count. */
:is(.column, :global([data-slot="conversation.chat.flow"])) > :nth-last-child(2 of :not([hidden]):not([data-pending-steering]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))):is(
.flowItem:not([data-chat-flow-kind="user"]):not([data-chat-flow-kind="steering"]):not([data-chat-flow-kind="turn-trigger"]),
[data-chat-group-key]
) ~ .running > .runningDivider {
display: block;
opacity: 1;
}
/* Drawn inside the 16px offset above the status row, so showing or hiding it
never changes the tail slot's height. */
.runningDivider {
display: none;
width: 100%;
position: absolute;
top: -4px;
left: 0;
right: 0;
height: 0.5px;
margin: 8px 0 10px;
background: color-mix(in srgb, var(--dsw-alias-border-l1) 75%, var(--dsw-alias-border-l2));
opacity: 0;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.runningDivider {
transition: opacity 160ms ease;
}
}
/* Same offset and height as the completed Turn's tail actions row. */
.runningContent {
display: inline-flex;
align-items: center;
min-width: 0;
gap: 6px;
margin-top: 4px;
height: calc(28px + var(--dsh-content-font-delta, 0px));
}
.runningText {
@@ -162,8 +162,8 @@ function TurnMaxTokensItem({ t }: {
/** Right-aligned bubble shared by user and steering rows. */
function UserStyleBubble({
content, loadImage, renderFactorySlot, actions, pending = false, echo = false, referenceLabels, skillNames,
previewAttachments, facts: inputFacts = EMPTY_TEXT_FACTS, interactive = false,
content, loadImage, renderFactorySlot, actions, pending = false, echo = false, batchStart = false,
referenceLabels, skillNames, previewAttachments, facts: inputFacts = EMPTY_TEXT_FACTS, interactive = false,
}: {
content: readonly unknown[]
renderFactorySlot: PropsRenderFactories['renderFactorySlot']
@@ -173,6 +173,8 @@ function UserStyleBubble({
pending?: boolean
/** Whether this is a local submission echo (invisible marker; the echo renders exactly like its durable replacement). */
echo?: boolean
/** Whether this admitted steering row starts its claim's batch and keeps the Turn tail slot above it. */
batchStart?: boolean
/** Exact session mention labels associated by the adjacent recall node. */
referenceLabels?: readonly string[]
/** Skill names the step's `skill-invocation` injections loaded for this message. */
@@ -199,6 +201,7 @@ function UserStyleBubble({
className={css.userRow}
data-pending-steering={pending || undefined}
data-submission-echo={echo || undefined}
data-chat-steering-batch={batchStart ? 'start' : undefined}
>
{renderFactorySlot('conversation.chat.user.content', {
content: prepared,
@@ -305,6 +308,7 @@ export const UserMessageNodeView = memo(function UserMessageNodeView({
interactive
renderFactorySlot={renderFactorySlot}
loadImage={loadImage}
batchStart={node.kind === 'steering' && node.data.batchStart === true}
{...data.referenceLabels === undefined ? {} : { referenceLabels: data.referenceLabels }}
{...data.skillNames === undefined ? {} : { skillNames: data.skillNames }}
actions={text => (
@@ -217,6 +217,8 @@ export class ChatReading {
private publish(state: ChatReadingState): void {
this.follow.setFollowing(state.followingTail)
// Follow intent and pending sampling settle here, not only on the next scroll or resize.
this.viewport.refreshTailPin()
if (state.initialized === this.state.initialized && state.followingTail === this.state.followingTail
&& state.activeTurn === this.state.activeTurn) return
this.state = state
@@ -65,6 +65,10 @@ export class ChatViewport {
private observation: { top: number; landing: ViewportLanding | null } = { top: 0, landing: null }
private paging: PagingPosition | null = null
private reader: FoldReader | null = null
/** Content overflow at the latest scroll or resize measurement; follow-intent commits reuse it. */
private overflowing = false
/** Whether the column carries `data-chat-tail-pinned`. */
private tailPinned = false
/** The pending or running fold batch lands at the tail. */
private tailRequested = false
/** Switches the running batch to a tail landing; null while no batch runs. */
@@ -96,9 +100,12 @@ export class ChatViewport {
if (typeof ResizeObserver !== 'undefined') {
this.observer = new ResizeObserver(() => {
if (this.elements !== elements) return
// Read while layout is still clean, before this callback writes room or scroll positions.
const overflowing = this.contentOverflows(scrollMetrics(elements.scroller))
this.reclaimBelow()
this.invalidate()
this.events?.resize()
this.refreshTailPin(overflowing)
})
this.observer.observe(column)
this.observer.observe(scroller)
@@ -110,7 +117,12 @@ export class ChatViewport {
detach(): void {
this.stopPreserving()
this.motion.clear()
if (this.elements !== null) this.elements.scroller.style.overflowAnchor = ''
if (this.elements !== null) {
this.elements.scroller.style.overflowAnchor = ''
delete this.elements.column.dataset.chatTailPinned
}
this.overflowing = false
this.tailPinned = false
this.elements?.scroller.removeEventListener('scroll', this.onScroll)
this.elements?.scroller.removeEventListener('scrollend', this.onScrollEnd, true)
for (const type of READING_INTENTS) this.elements?.scroller.removeEventListener(type, this.onIntent, true)
@@ -174,7 +186,12 @@ export class ChatViewport {
*/
bindReader(reader: FoldReader): () => void {
this.reader = reader
return () => { if (this.reader === reader) this.reader = null }
this.refreshTailPin()
return () => {
if (this.reader !== reader) return
this.reader = null
this.refreshTailPin()
}
}
/**
@@ -754,16 +771,45 @@ export class ChatViewport {
this.invalidate()
if (this.paging !== null) {
this.events?.resize()
this.refreshTailPin()
return
}
const scroll = this.readScroll()
if (scroll !== null) this.events?.scroll(scroll)
this.refreshTailPin(scroll === null ? undefined : this.contentOverflows(scroll.metrics))
}
/**
* Mark the column while the tail is pinned: the reader follows the floor with no unsampled reader
* movement, the test fold batches use for a tail landing, and the content overflows, so following scrolls
* absorb output growth and the status keeps its on-screen position. The first-output slide runs at zero
* duration there. A resize calls this after its own follow scroll, before paint, so a slide that starts in
* that frame finishes before it is drawn; scroll and scrollend call it after delivering the sample, and
* follow-intent commits call it with the latest measurement. The attribute changes only with the pin.
* @param overflowing - a fresh overflow measurement; omitted, the latest one is reused.
*/
refreshTailPin(overflowing = this.overflowing): void {
this.overflowing = overflowing
const elements = this.elements
if (elements === null) return
const reader = this.reader
const pinned = overflowing && reader !== null && reader.follow.active && !reader.pending()
if (pinned === this.tailPinned) return
this.tailPinned = pinned
if (pinned) elements.column.dataset.chatTailPinned = ''
else delete elements.column.dataset.chatTailPinned
}
/** Room below closing rows is not content: only rows taller than the scrollport scroll on growth. */
private contentOverflows(metrics: ViewportMetrics): boolean {
return metrics.floor - this.spacerHeight() > 0
}
private readonly onScrollEnd = (event: Event): void => {
if (event.target === this.elements?.scroller) {
this.events?.scrollEnd(true)
this.reclaimBelow()
this.refreshTailPin()
} else if (event.target instanceof HTMLElement && event.target.hasAttribute('data-step-process-body')) this.events?.scrollEnd(false)
}
@@ -24,8 +24,8 @@
en: 91b35cbebe8c2a1d
zh: a4fee6d7102bc874
/chat-conversation-node-rules/whole-turn-folding:
en: 42c6f6b9e2433263
zh: 651ceb5c22314259
en: b3c769cdc1d9ac00
zh: 1c31066c7f7e803d
/chat-conversation-node-rules/display-modes:
en: ada5a60d2c46f6a8
zh: f3eaeb5cb4d9b3de
@@ -36,8 +36,8 @@
en: ad74c1a610fc50d2
zh: 13de05ffedf63f0c
/chat-conversation-node-rules/display-modes/group-title-rules:
en: 2392290662bc69e8
zh: 8872d651c07a8f4c
en: 2b23813edc87faed
zh: bc5ddb49c9d128e7
/chat-conversation-node-rules/process-grouping:
en: 3d28b3bb8757bf55
zh: 3b0f451b48db6048
@@ -76,7 +76,7 @@ The Turn control follows all its opening inputs, including human steering and no
| Admitted input | Chat presentation |
|---|---|
| Human message claimed from `next-step` | Steering message, outside process folding. |
| Human message claimed from `next-step` | Steering message, outside process folding. When Chat orders the claim's first human message after the Turn control, that message keeps the Turn tail slot above it; the claim's later messages follow at the ordinary input gap. |
| Non-human message in the current `next-turn` claim | Independent, initially collapsed Turn-trigger notice. |
| Non-human message in an idle `next-step` claim | Turn-trigger notice only in Step 1, with a loaded Turn start, a claim after that start, no `next-turn` claim in this Turn, and no human in the same `next-step` batch. |
| Other non-human input | Ordinary Context, retained in the Node Store but omitted from Chat. Unclaimed, canceled, or requeued messages do not establish a waking claim. |
@@ -102,7 +102,7 @@ Clicking Load older anchors the first visible content item below that button in
Paging adds older content above the retained anchor without jumping to the new top. A capped group absorbs the displacement within its scroll range; the outer transcript absorbs the remainder, including when the group first reaches its cap. If the available scroll range is insufficient, compensation stops at the actual limit without adding bottom space. Later content growth keeps the same anchor until a reading gesture or explicit navigation releases it. Typing or clicking within the composer and non-scrolling transcript keys retain the anchor. If the reader scrolls while a page is loading, scrolling takes priority and its settled reading position becomes the new paging anchor.
While the Session runs, Chat appends a Session-level indicator at the bottom of the current transcript, after pending submission or steering rows: a whale tail beside shimmering elapsed time. Before the current Turn start time arrives, the indicator shows status text without a duration. The clock updates in whole seconds, starts at one second, and uses hours from 60 minutes. When the Session stops running, the indicator disappears; the closed Turn process control shows the fixed duration, cancellation, or failure status. Lifecycle changes have a polite announcement; clock ticks do not. The whale is hidden from assistive technology and remains static under reduced motion.
While the Session runs, Chat shows a Session-level indicator at the running Turn's tail: a whale tail beside shimmering elapsed time. It follows the transcript rows and the echoes of submissions that open a Turn; steering still pending when a submission opens the next Turn is claimed first, so its echoes precede that submission's echo. Echoes of steering that waits for the running Turn's next Step follow the indicator. While the running Step has no output, the indicator sits directly below the input. When the first output row appears, the indicator slides from that position into its slot below the row; the travel equals the row's gap plus one row height, which follows the font size setting, so it starts exactly where it stood. Later output moves it without animation. When the transcript overflows the viewport and follows its bottom, following scrolls keep the indicator in place as output grows, so this slide does not play; admitted steering still moves the indicator past its pending echo, so the steering entrance plays while the page scrolls by the added slot. The steering entrance starts one slot above the indicator's new position rather than where it stood, and its fade-in covers the difference. Before the current Turn start time arrives, the indicator shows status text without a duration. The clock updates in whole seconds, starts at one second, and uses hours from 60 minutes. When the Session stops running, the indicator disappears; the closed Turn process control shows the fixed duration, cancellation, or failure status. Lifecycle changes have a polite announcement; clock ticks do not. The whale is hidden from assistive technology and remains static under reduced motion.
The top duration/status control and trigger title use the group-title font size and follow the font-size setting. Completed duration digits use the code font with tabular numerals; running and completed minutes and seconds have no leading zero. The timed running label ends with a space and “···”; completion shows “Completed in”.
@@ -181,7 +181,7 @@ The labels below describe recorded activity, not successful outcomes. For exampl
English lowercases the initial letter of joined labels after the first. Closing a group immediately selects the completed summary; the 150ms minimum applies to running-title changes, not to delaying completion. Detailed hides group headers in running Turns, including groups ended by a reply or steering before their Turn ends. Verbose also hides historical group headers.
Group headers show a category icon, replace it with a down arrow on hover or keyboard focus, and show an up arrow while open. Manually expanded group bodies use 6px row spacing, a `min(400px, 50vh)` height cap, and 24px directional fades. Wheel scrolling can continue into the outer transcript at an edge. Detailed removes the group-level cap in running Turns; Verbose applies this layout to historical Turns as well. Process rows retain 6px spacing in both modes; Assistant responses have 12px clearance. Expanded group titles sit 8px above their content; Turn process controls retain 16px clearance.
Group headers show a category icon, replace it with a down arrow on hover or keyboard focus, and show an up arrow while open. Manually expanded group bodies use 6px row spacing, a `min(400px, 50vh)` height cap, and 24px directional fades. Wheel scrolling can continue into the outer transcript at an edge. Detailed removes the group-level cap in running Turns; Verbose applies this layout to historical Turns as well. Process rows retain 6px spacing in both modes; Assistant responses have 12px clearance. Group headers have the same row height as process rows. Expanded group titles sit 8px above their content; Turn process controls retain 16px clearance.
An open capped group follows content growth only while its own scroll position is at the bottom. Scrolling away pauses that group's following; returning to the bottom resumes it, independently of outer transcript following. Manually opening an unclosed group starts at the bottom and follows growth; manually opening a closed group starts at the top with following disabled, even when its initial content fits without scrolling. Closing the group in the data or restoring its height cap through a mode change does not reset an already-open reader's position. Browser find retains its own reveal position.
@@ -76,7 +76,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
| 已进入请求的输入 | Chat 展示 |
|---|---|
| 从 `next-step` 领取的人工消息 | Steering 消息,不参与过程折叠。 |
| 从 `next-step` 领取的人工消息 | Steering 消息,不参与过程折叠。若 Chat 将该次领取的第一条人工消息排在整轮控件之后,它上方保留轮次尾部槽位;同一次领取的后续消息按普通输入间距跟随。 |
| 当前 `next-turn` 领取批次中的非人工消息 | 独立、默认收起的轮次触发通知。 |
| 空闲时从 `next-step` 领取的非人工消息 | 仅在 Step 1、已加载轮次起点、领取发生于起点之后、本轮没有 `next-turn` 领取、同一 `next-step` 批次没有人工消息时,作为轮次触发通知。 |
| 其他非人工输入 | 普通 Context,保留在 Node Store,但不显示在 Chat 中。未领取、已取消或重新入队的消息不构成唤醒领取。 |
@@ -102,7 +102,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
分页把更早内容加到保留的锚点上方,不主动跳到新内容顶部。限高组先在自己的滚动范围内吸收位移,剩余部分由外层文本滚动补偿,包括组正文刚达到限高的情况。可滚动范围不足时,补偿停在实际边界,不增加底部空白。后续内容撑高沿用同一个锚点,直到阅读手势或显式导航释放它。在 composer 中打字或点击,以及 transcript 内的非滚动按键,保留锚点。分页请求期间若读者滚动,优先服从滚动,停止后以新的阅读位置作为分页锚点。
会话运行期间,Chat 在当前 transcript 最底部、待提交或 steering 行之后附加 Session 级运行指示:鲸鱼摆尾旁显示闪烁的已用时间。当前 Turn 的开始时间到达前,运行指示只显示状态文字,不显示时长。时钟按整秒更新,最少显示一秒,满 60 分钟使用小时。会话停止运行后,该指示消失;已结束 Turn 的过程控件显示固定时长、取消或失败状态。生命周期变化有礼貌播报,时钟跳动不播报。鲸鱼对辅助技术隐藏,启用减少动态效果时保持静态。
会话运行期间,Chat 在运行中 Turn 的末尾显示 Session 级运行指示:鲸鱼摆尾旁显示闪烁的已用时间。它位于 transcript 行和开启 Turn 的提交回显之后;某次提交开启下一个 Turn 时仍在等待的 steering 会先被领取,因此其回显排在该提交回显之前。等待运行中 Turn 下一个 Step 的 steering 回显位于运行指示之后。运行中的 Step 尚无输出时,运行指示紧贴在输入下方。第一条输出行出现时,运行指示从原位置滑入该行下方的位置;滑动距离等于该行间距加一行高度(随字号设置变化),因此起点正是它原来的位置。之后的输出不再带动画。transcript 超出视口并跟随底部时,输出增长由跟随滚动吸收,运行指示保持原位,因此这次下滑不播放;被领取的 steering 仍会让运行指示越过其等待中的回显,因此 steering 进场照常播放,同时页面随新增的槽位滚动。steering 进场从运行指示新位置上方一个槽位处开始,而不是从它原来的位置开始,淡入掩盖了这段差距。当前 Turn 的开始时间到达前,运行指示只显示状态文字,不显示时长。时钟按整秒更新,最少显示一秒,满 60 分钟使用小时。会话停止运行后,该指示消失;已结束 Turn 的过程控件显示固定时长、取消或失败状态。生命周期变化有礼貌播报,时钟跳动不播报。鲸鱼对辅助技术隐藏,启用减少动态效果时保持静态。
顶部用时/状态控件与唤醒标题沿用二级汇总标题字号,并跟随字号设置。已完成用时的数字使用代码等宽字体;运行和完成状态的分钟和秒数均不补前导零。含用时的运行文案末尾附加一个空格及“···”,完成后显示“已完成,用时”。
@@ -181,7 +181,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
英文组合标题将第一项之后各项的首字母转为小写。组结束时立即切换完成摘要;150ms 最短保留时间针对运行中标题变化,不延迟完成状态。详细模式隐藏运行中轮次的组头,包括被回复或 steering 截断、但所属轮次尚未结束的小组;完全展开模式也隐藏历史组头。
组头显示类别图标,悬停或键盘聚焦时替换为向下箭头,展开时显示向上箭头。手动展开的组正文使用 6px 行间距、`min(400px, 50vh)` 限高及 24px 方向渐隐;到达边缘时滚轮可以继续滚动外层文本记录。详细模式对运行中轮次取消组级限高;完全展开模式对历史轮次也采用该布局。两种模式下过程行均保留 6px 间距,Assistant 回复周围保留 12px 间距。展开组头与内容相隔 8px,整轮过程控件周围保留 16px 间距。
组头显示类别图标,悬停或键盘聚焦时替换为向下箭头,展开时显示向上箭头。手动展开的组正文使用 6px 行间距、`min(400px, 50vh)` 限高及 24px 方向渐隐;到达边缘时滚轮可以继续滚动外层文本记录。详细模式对运行中轮次取消组级限高;完全展开模式对历史轮次也采用该布局。两种模式下过程行均保留 6px 间距,Assistant 回复周围保留 12px 间距。组头与过程行同高。展开组头与内容相隔 8px,整轮过程控件周围保留 16px 间距。
已展开的限高组仅在自己的滚动位置处于底部时跟随内容增长。滚离底部暂停该组跟随,滚回底部后恢复,与外层文本记录的跟随状态独立。手动展开未关闭的组时定位到底部并跟随增长;手动展开已关闭的组时从顶部开始且不启用跟随,即使初始内容无需滚动即可容纳。数据中的组变为已关闭,或通过模式切换恢复限高时,不重置已展开组的阅读位置。浏览器查找沿用自身的展开定位。
@@ -37,7 +37,12 @@ export interface InboxState {
/** Message ids in the current claim, shared until the next claim. */
readonly currentClaimed: ReadonlySet<string>
readonly claimSeq: number
readonly claimedHuman: boolean
/**
* First human message id of the current claim in Inbox order; undefined
* when the claim took no human message. The AgentLoop logs a claim's
* messages in this order, so this message is the claim's first steering row.
*/
readonly firstClaimedHuman: string | undefined
}
const EMPTY_PENDING: PendingState = { kind: 'snapshot', ids: [] }
@@ -91,7 +96,7 @@ function applySplice(
pending: { kind: 'snapshot', ids: pending },
currentClaimed: new Set(removed.map(message => message.id)),
claimSeq: seq,
claimedHuman: removed.some(message => message.source.kind === 'user'),
firstClaimedHuman: removed.find(message => message.source.kind === 'user')?.id,
}
}
const currentClaimed = withoutInserted(
@@ -108,7 +113,7 @@ function applySplice(
},
currentClaimed,
claimSeq: previous?.state.claimSeq ?? -1,
claimedHuman: previous?.state.claimedHuman ?? false,
firstClaimedHuman: previous?.state.firstClaimedHuman,
}
}
@@ -1,9 +1,11 @@
import type { Context } from '@deepseek-ai/cordis'
import type {
ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode,
ContextMessageNode, ConversationContextReader, ConversationLocation, ConversationNodeDefinition,
SteeringMessageNode, UserMessageNode,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
import type { InboxState } from './inbox.ts'
import type { ProcessEvidenceState } from './turn-process.ts'
import { chatNode, contextLocation } from './common.ts'
import { contextForm, contextProducer } from './event-projection.ts'
@@ -19,6 +21,11 @@ interface ReferencedSteeringMessageNode extends SteeringMessageNode {
readonly referenceLabels?: readonly string[]
/** Skill names the same step's `skill-invocation` injections loaded. */
readonly skillNames?: readonly string[]
/**
* First human message of its `next-step` claim that Chat orders after its Turn's process control;
* the row keeps the Turn tail slot above it.
*/
readonly batchStart?: true
}
type MessageNode = ReferencedUserMessageNode | ReferencedSteeringMessageNode | (ContextMessageNode & { readonly waking?: boolean })
@@ -58,6 +65,19 @@ function contextMessage(
}
}
/**
* Decide whether an input follows its Turn's process control in Chat order. The control anchors at the
* Turn's first process evidence, so the input follows it exactly when its Turn recorded evidence earlier;
* the nearest earlier evidence record then belongs to the input's own Turn.
* @param location - the input's Location.
* @param reader - strictly-backward Context lookup of the input's start.
* @returns whether Chat orders the input after the control.
*/
function followsProcessEvidence(location: ConversationLocation, reader: ConversationContextReader): boolean {
if (location.kind !== 'turn' && location.kind !== 'step') return false
return reader.previous<ProcessEvidenceState>('process-evidence')?.state.turn === location.turn.turn
}
/** User, steering, and injected-context message classification Definition. */
export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
kind: 'input-message',
@@ -82,17 +102,17 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
// A human in that same next-step claim owns the opening instead of its notices.
const idleSteer = location.kind === 'step' && location.step.step === 1
&& turnStart !== undefined && (nextStep?.claimSeq ?? -1) > turnStart
&& (nextTurn?.claimSeq ?? -1) < turnStart && nextStep?.claimedHuman === false
&& nextStep.currentClaimed.has(String(event.data.id))
&& (nextTurn?.claimSeq ?? -1) < turnStart && nextStep !== undefined
&& nextStep.firstClaimedHuman === undefined && nextStep.currentClaimed.has(String(event.data.id))
return {
...contextMessage(event, event.data),
waking: nextTurn?.currentClaimed.has(String(event.data.id)) === true || idleSteer,
}
}
const claimed = reader.previous<InboxState>('inbox-next-step')
?.state.currentClaimed.has(String(event.data.id)) === true
return claimed
const id = String(event.data.id)
const claim = reader.previous<InboxState>('inbox-next-step')?.state
return claim?.currentClaimed.has(id) === true
? {
kind: 'steering',
messageId: event.data.id,
@@ -100,6 +120,9 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
time: event.time,
content: event.data.content,
source: event.data.source,
...claim.firstClaimedHuman === id && followsProcessEvidence(match.location, reader)
? { batchStart: true }
: {},
}
: {
kind: 'user',
@@ -88,6 +88,46 @@ function processEvidence(event: ConversationEvent): ProcessEvidence | undefined
return undefined
}
/** Turn of one Step that recorded durable process evidence. */
export interface ProcessEvidenceState {
readonly turn: number
}
function durableEvidenceStep(event: ConversationEvent): { readonly turn: number; readonly step: number } | undefined {
if (processEvidence(event) === undefined) return undefined
switch (event.type) {
case 'assistant/message':
case 'tool/call':
case 'tool/result':
case 'llm/retry':
return { turn: event.data.turn, step: event.data.step }
default:
return undefined
}
}
/**
* State-only record of each Step whose durable events include process evidence, keyed by Turn and Step.
* An input follows its Turn's process control exactly when its Turn recorded evidence before it, so an
* input reads the nearest earlier record through `reader.previous`. A Step's evidence is logged before
* a later Step admits input, so the record a later input reads no longer changes; transient live chunks
* are not needed because a Step's durable settlement precedes the next Step's input.
*/
export const processEvidenceDefinition: ConversationNodeDefinition<ProcessEvidenceState> = {
kind: 'process-evidence',
match: (event) => {
const coordinates = durableEvidenceStep(event)
return coordinates === undefined ? null : { id: `${coordinates.turn}:${coordinates.step}`, role: 'start' }
},
start: (_context, match) => {
const coordinates = durableEvidenceStep(match.event)
if (coordinates === undefined) throw new Error('process-evidence start requires durable process evidence')
return { turn: coordinates.turn }
},
update: context => context.state,
publication: () => 'none',
}
function turnLocation(context: ConversationNodeContext<TurnProcessState>): TurnLocation | undefined {
const location: ConversationLocation | undefined = context.start?.location ?? context.matches.at(-1)?.location
return location?.kind === 'turn' || location?.kind === 'step' ? location.turn : undefined
@@ -302,7 +342,7 @@ export const turnProcessDefinition: ConversationNodeDefinition<TurnProcessState>
}
/**
* Register the Turn-scoped process disclosure projection.
* Register the Turn-scoped process disclosure projection and its per-Step evidence record.
* @param ctx - owning UI Conversation context.
*/
export function registerTurnProcess(ctx: Context): void {
@@ -321,4 +361,14 @@ export function registerTurnProcess(ctx: Context): void {
'turn/end': match,
},
})
const evidence = processEvidenceDefinition.match.bind(processEvidenceDefinition)
ctx.uiConversation.events.register({
...processEvidenceDefinition,
match: {
'assistant/message': evidence,
'tool/call': evidence,
'tool/result': evidence,
'llm/retry': evidence,
},
})
}
@@ -1829,8 +1829,9 @@ describe('ChatView', () => {
expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
expect(turnProcessControl(view.container)).toBeNull()
expect(runningContent(view.container)?.textContent).toMatch(/^深度求索中,用时 \d+秒 ···$/)
// The status stays on the running Turn's tail; the steering waiting for the next Step follows it.
expect(view.getByRole('status').compareDocumentPosition(view.getByText('interrupt now'))
& Node.DOCUMENT_POSITION_PRECEDING).not.toBe(0)
& Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
act(() => {
h.setSession({ testInbox: { 'next-turn': [queued], 'next-step': [] } })
@@ -2056,6 +2057,69 @@ describe('ChatView', () => {
},
)
it('keeps steering a stopped Turn left pending ahead of the next opening echo, in claim order', () => {
const local = (requestId: string, placement: 'steering' | 'transcript', text: string) => ({
requestId: requestId as never, placement, time: 1_000, text, attachments: [],
})
const steers = [local('s1', 'steering', 'steer one'), local('s2', 'steering', 'steer two')]
const next = local('next', 'transcript', 'next prompt')
const inbox: InboxState = { 'next-turn': [], 'next-step': steers.map(item => ({
id: item.requestId, role: 'user', source: { kind: 'user', rpcId: item.requestId },
content: [{ type: 'text', text: item.text }],
})) }
const h = makeHarness({}, { pendingSubmissions: [...steers, next], testInbox: inbox })
const view = render(<h.ChatView {...h.props} />)
const flow = () => [...view.container.querySelectorAll<HTMLElement>(
'[data-submission-echo], [data-chat-flow-kind="turn-process"], [data-chat-running]',
)].map(element => element.hasAttribute('data-chat-running') ? 'status'
: element.matches('[data-chat-flow-kind="turn-process"]') ? 'title'
: String([...steers, next].find(input => element.textContent?.includes(input.text))?.requestId))
// The next Turn's first claim logs the pending steering before the prompt.
expect(flow()).toEqual(['s1', 's2', 'next'])
act(() => { h.setSession({ running: true }) })
expect(flow()).toEqual(['s1', 's2', 'next', 'status'])
act(() => { h.setChat({ turnTimings: new Map([[1, { startTime: 1_000 }]]) }) })
expect(flow()).toEqual(['s1', 's2', 'next', 'title', 'status'])
})
it('keeps the rest of a stopped Turn\'s claim as echoes after the empty control as its rows become durable', () => {
const local = (requestId: string, placement: 'steering' | 'transcript', text: string) => ({
requestId: requestId as never, placement, time: 1_000, text, attachments: [],
})
const steers = [local('s1', 'steering', 'steer one'), local('s2', 'steering', 'steer two')]
const next = local('next', 'transcript', 'next prompt')
const inputs = [...steers, next]
const h = makeHarness({ turnTimings: new Map([[1, { startTime: 1_000 }]]) }, {
running: true, pendingSubmissions: inputs, testInbox: { 'next-turn': [], 'next-step': [] },
})
const view = render(<h.ChatView {...h.props} />)
const flow = () => [...view.container.querySelectorAll<HTMLElement>(
'[data-submission-echo], [data-chat-flow-kind="steering"], [data-chat-flow-kind="user"], '
+ '[data-chat-flow-kind="turn-process"], [data-chat-running]',
)].map(element => element.hasAttribute('data-chat-running') ? 'status'
: element.matches('[data-chat-flow-kind="turn-process"]') ? 'title'
: String(inputs.find(input => element.textContent?.includes(input.text))?.requestId)
+ (element.hasAttribute('data-submission-echo') ? '*' : ''))
const durableSteering = (seq: number, item: typeof steers[number]) => ({
...steering(seq, item.text, 1), source: { kind: 'user' as const, rpcId: item.requestId },
})
expect(flow()).toEqual(['s1*', 's2*', 'next*', 'title', 'status'])
// The claim logs both steering rows before its prompt. The remaining echoes
// follow the running Turn's empty control, which has no height; the
// stylesheet keeps them at the input gap (turn-tail-spacing spec).
act(() => { h.setChat({ nodes: [durableSteering(2, steers[0]!)] }) })
expect(flow()).toEqual(['s1', 'title', 's2*', 'next*', 'status'])
act(() => { h.setChat({ nodes: [durableSteering(2, steers[0]!), durableSteering(3, steers[1]!)] }) })
expect(flow()).toEqual(['s1', 's2', 'title', 'next*', 'status'])
const prompt: UserMessageNode & { turn: number } = {
...user(4, next.text), turn: 1, source: { kind: 'user', rpcId: next.requestId },
}
act(() => {
h.setChat({ nodes: [durableSteering(2, steers[0]!), durableSteering(3, steers[1]!), prompt] })
})
expect(flow()).toEqual(['s1', 's2', 'next', 'title', 'status'])
})
it('leaves an opening echo below an empty historical Turn title', () => {
const h = makeHarness({ turnTimings: new Map([[1, { startTime: 1_000 }]]), turnEnds: new Map([[1, 2]]) }, {
pendingSubmissions: [{ requestId: 'opening' as never, placement: 'transcript',
@@ -4404,4 +4468,21 @@ describe('ChatView', () => {
act(() => { h.setSession({ running: false }) })
expect(column.querySelector('[data-chat-running]')).toBeNull()
})
it('marks the steering row whose data carries a batch start, and no other input', () => {
const batchStart = (seq: number, text: string) => ({ ...steering(seq, text, 1), batchStart: true as const })
const h = makeHarness({
nodes: [
userInTurn(1, 'question', 1), assistant(2, 'working', 1, 1),
batchStart(3, 'first direction'),
steering(4, 'second direction', 1),
assistant(5, 'answer', 1, 2),
],
turnTimings: new Map([[1, { startTime: 0 }]]),
})
const view = render(<h.ChatView {...h.props} />)
const rows = [...view.container.querySelectorAll('[data-chat-steering-batch="start"]')]
.map(element => element.closest<HTMLElement>('[data-chat-node-key]')?.dataset.chatNodeKey)
expect(rows).toEqual(['fixture:steering:3'])
})
})
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import { expect, it, onTestFinished, vi } from 'vitest'
import { ChatReading } from '../src/client/chat/use-chat-reading.ts'
import { ChatViewport } from '../src/client/chat/use-chat-viewport.ts'
import { ScrollFollow, scrollMetrics } from '../src/client/chat/use-scroll-follow.ts'
@@ -560,3 +561,97 @@ it('releases a removed paging row without navigating to unrelated content', () =
expect(h.viewport.preserving).toBe(false)
expect(h.scroller.scrollTop).toBe(80)
})
it('pins the tail only while sampled follow intent holds over overflowing content, writing the mark on change', async () => {
vi.useFakeTimers()
let notifyResize = () => {}
class Observer implements ResizeObserver {
constructor(callback: ResizeObserverCallback) { notifyResize = () => { callback([], this) } }
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
vi.stubGlobal('ResizeObserver', Observer)
const scroller = document.createElement('div')
const column = document.createElement('div')
scroller.append(column)
document.body.append(scroller)
let content = 200
let top = 0
const floor = () => Math.max(0, content - 300)
Object.defineProperties(scroller, {
clientHeight: { configurable: true, get: () => 300 },
scrollHeight: { configurable: true, get: () => Math.max(300, content) },
scrollTop: {
configurable: true,
get: () => Math.min(top, floor()),
set: (value: number) => { top = Math.max(0, Math.min(value, floor())) },
},
})
const viewport = new ChatViewport()
viewport.attach(scroller, column)
const follow = new ScrollFollow(true, 4)
const reading = new ChatReading(viewport, { read: () => null, save: vi.fn() }, {
initialized: true, followingTail: true, activeTurn: 1,
}, vi.fn(), follow)
const unbind = reading.bindFolds()
viewport.connect({
scroll: reading.onScroll, scrollEnd: reading.onScrollEnd, interact: () => {},
resize: () => { reading.onResize() },
})
const writes: (string | null)[] = []
const mutations = new MutationObserver((records) => {
for (const record of records) writes.push(record.oldValue)
})
mutations.observe(column, { attributes: true, attributeFilter: ['data-chat-tail-pinned'], attributeOldValue: true })
onTestFinished(() => {
mutations.disconnect()
unbind()
reading.dispose()
viewport.detach()
scroller.remove()
vi.useRealTimers()
vi.unstubAllGlobals()
})
const pinned = () => column.hasAttribute('data-chat-tail-pinned')
const scrollTo = (value: number) => {
scroller.scrollTop = value
scroller.dispatchEvent(new Event('scroll'))
}
// Content that fits moves the tail on screen, so status moves keep their duration.
notifyResize()
expect(pinned()).toBe(false)
// Growth that overflows is followed to the floor within the same resize, then pinned.
content = 900
notifyResize()
expect(scroller.scrollTop).toBe(600)
expect(pinned()).toBe(true)
// Further growth and scroll deliveries at the floor keep the pin without rewriting it.
content = 950
notifyResize()
scroller.dispatchEvent(new Event('scroll'))
expect(pinned()).toBe(true)
// A reader scroll away leaves follow intent set until its sample settles; growth in that window is not
// followed, so the pin drops at once.
scrollTo(300)
expect(reading.pending).toBe(true)
expect(follow.active).toBe(true)
expect(pinned()).toBe(false)
content = 1_000
notifyResize()
expect(scroller.scrollTop).toBe(300)
expect(pinned()).toBe(false)
// The settled sample releases follow intent; returning to the floor restores both.
await vi.advanceTimersByTimeAsync(500)
expect(follow.active).toBe(false)
expect(pinned()).toBe(false)
scrollTo(700)
expect(follow.active).toBe(true)
expect(pinned()).toBe(true)
await Promise.resolve()
// Set, removed, set: one write per pin change.
expect(writes).toEqual([null, '', null])
viewport.detach()
expect(pinned()).toBe(false)
})
@@ -30,9 +30,9 @@ import { toolDefinition } from '../src/client/conversation-nodes/tool.ts'
import { turnErrorDefinition } from '../src/client/conversation-nodes/turn-error.ts'
import { turnMaxTokensDefinition } from '../src/client/conversation-nodes/turn-max-tokens.ts'
import { turnTailDefinition } from '../src/client/conversation-nodes/turn-tail.ts'
import { turnProcessDefinition } from '../src/client/conversation-nodes/turn-process.ts'
import { processEvidenceDefinition, turnProcessDefinition } from '../src/client/conversation-nodes/turn-process.ts'
import type {
AssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData, TurnTailChatData,
AssistantChatData, ChatNode, ManualCompactionChatData, RetryChatData, ToolChatData, TurnTailChatData,
} from '../src/client/contract/chat-nodes.ts'
const DEFINITIONS: readonly ConversationNodeDefinition[] = [
@@ -44,6 +44,7 @@ const DEFINITIONS: readonly ConversationNodeDefinition[] = [
requestPromptDefinition(inspectRequestPrompt),
assistantDefinition,
turnProcessDefinition,
processEvidenceDefinition,
toolDefinition,
commandDefinition,
compactionDefinition,
@@ -772,6 +773,132 @@ describe('built-in conversation node Definitions', () => {
])
})
it.each(['replay', 'live', 'prepend'] as const)('marks each claim lead and starts a batch only after the Turn control (%s)', (mode) => {
const opening = textMessage('batch-opening', 'start')
const notice = { ...textMessage('batch-notice', 'job finished'), source: { kind: 'schedule' } }
const first = textMessage('batch-first', 'first')
const second = textMessage('batch-second', 'second')
const late = textMessage('batch-late', 'arrives while the claim enters')
const idle = textMessage('batch-idle', 'opens the next Turn')
const quiet = textMessage('batch-quiet', 'after a silent step')
const entries = [
at(1, 'agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [opening] }),
at(2, 'turn/start', { turn: 1 }),
at(3, 'agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }),
at(4, 'step/start', { turn: 1, step: 1 }),
at(5, 'user/message', opening, { surfaceOp: 'append' }),
at(6, 'assistant/message', { turn: 1, step: 1, message: assistantMessage('batch-a1', 'working') }, { surfaceOp: 'append' }),
at(7, 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [notice] }),
at(8, 'agent/inbox/spliced', { target: 'next-step', start: 1, inserted: [first] }),
at(9, 'agent/inbox/spliced', { target: 'next-step', start: 2, inserted: [second] }),
at(10, 'step/end', { turn: 1, step: 1 }),
// The claim takes [notice, first, second]; `late` is queued before the claim's messages are logged.
at(11, 'agent/inbox/spliced', { target: 'next-step', start: 0, removedCount: 3, inserted: [] }),
at(12, 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [late] }),
at(13, 'step/start', { turn: 1, step: 2 }),
at(14, 'user/message', notice, { surfaceOp: 'append' }),
at(15, 'user/message', first, { surfaceOp: 'append' }),
at(16, 'user/message', second, { surfaceOp: 'append' }),
at(17, 'assistant/message', { turn: 1, step: 2, message: assistantMessage('batch-a2', 'adjusting') }, { surfaceOp: 'append' }),
at(18, 'step/end', { turn: 1, step: 2 }),
at(19, 'agent/inbox/spliced', { target: 'next-step', start: 0, removedCount: 1, inserted: [] }),
at(20, 'step/start', { turn: 1, step: 3 }),
at(21, 'user/message', late, { surfaceOp: 'append' }),
at(22, 'assistant/message', { turn: 1, step: 3, message: assistantMessage('batch-a3', 'done') }, { surfaceOp: 'append' }),
at(23, 'step/end', { turn: 1, step: 3 }),
at(24, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
// An idle steer opens Turn 2; its silent Step 1 leaves the Step 2 steer an opening input.
at(25, 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [idle] }),
at(26, 'turn/start', { turn: 2 }),
at(27, 'agent/inbox/spliced', { target: 'next-step', start: 0, removedCount: 1, inserted: [] }),
at(28, 'step/start', { turn: 2, step: 1 }),
at(29, 'user/message', idle, { surfaceOp: 'append' }),
at(30, 'step/end', { turn: 2, step: 1 }),
at(31, 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [quiet] }),
at(32, 'agent/inbox/spliced', { target: 'next-step', start: 0, removedCount: 1, inserted: [] }),
at(33, 'step/start', { turn: 2, step: 2 }),
at(34, 'user/message', quiet, { surfaceOp: 'append' }),
at(35, 'assistant/message', { turn: 2, step: 2, message: assistantMessage('batch-a4', 'answer') }, { surfaceOp: 'append' }),
at(36, 'step/end', { turn: 2, step: 2 }),
at(37, 'turn/end', { turn: 2, reason: { kind: 'completed' } }),
]
const split = entries.findIndex(entry => entry.event.seq === 15)
const value = assembler(mode === 'replay' ? entries : mode === 'prepend' ? entries.slice(split) : [], mode === 'prepend')
if (mode === 'live') {
const silent = entries.findIndex(entry => entry.event.seq === 35)
for (const entry of entries.slice(0, silent)) value.append(entry)
value.flush()
// Before Turn 2 records process evidence, its control anchors at the Turn start.
const waiting = snapshot(value)
const quietKey = waiting.order.find((key) => {
const candidate = waiting.nodes.get(key) as ChatNode | undefined
return candidate?.kind === 'steering' && candidate.data.messageId === 'batch-quiet'
})!
const presentation = waiting.nodes.processSource(quietKey).getSnapshot()
expect(presentation?.spec.controlAnchorSeq).toBe(26)
expect((waiting.nodes.get(quietKey) as ChatNode<'steering'>).data.batchStart).toBeUndefined()
for (const entry of entries.slice(silent)) value.append(entry)
}
if (mode === 'prepend') {
value.flush()
const partial = snapshot(value)
expect(partial.nodes.values().filter(candidate => candidate.kind === 'steering')
.map(candidate => String((candidate as ChatNode<'steering'>).data.messageId)).sort()).toEqual(['batch-idle', 'batch-quiet'])
value.prepend(entries.slice(0, split), false)
}
value.flush()
const current = snapshot(value)
const rows = current.order.flatMap((key, index) => {
const row = current.nodes.get(key) as ChatNode | undefined
if (row?.kind !== 'steering') return []
const turn = row.location.kind === 'turn' || row.location.kind === 'step' ? row.location.turn.turn : undefined
const control = current.order.findIndex((other) => {
const candidate = current.nodes.get(other) as ChatNode | undefined
return candidate?.kind === 'turn-process' && candidate.data.turn === turn
})
return [{
id: row.data.messageId,
afterControl: control !== -1 && index > control,
batchStart: row.data.batchStart === true,
}]
})
// `second` follows the control but is not its claim's first human message; `idle` and `quiet`
// lead their claims as opening inputs before the control.
expect(rows).toEqual([
{ id: 'batch-first', afterControl: true, batchStart: true },
{ id: 'batch-second', afterControl: true, batchStart: false },
{ id: 'batch-late', afterControl: true, batchStart: true },
{ id: 'batch-idle', afterControl: false, batchStart: false },
{ id: 'batch-quiet', afterControl: false, batchStart: false },
])
expect(rows.every(row => !row.batchStart || row.afterControl)).toBe(true)
})
it('records durable process evidence once per Step and ignores live chunks and invisible replies', () => {
const matchOf = (entry: SessionLiveEventEntry) => processEvidenceDefinition.match(entry.event)
expect(matchOf(at(1, 'assistant/message', {
turn: 2, step: 3, message: assistantMessage('evidence-reply', 'reply'),
}, { surfaceOp: 'append' }))).toEqual({ id: '2:3', role: 'start' })
expect(matchOf(at(2, 'tool/call', {
turn: 2, step: 3, callId: 'evidence-call', name: 'read', arguments: '{}',
}))).toEqual({ id: '2:3', role: 'start' })
expect(matchOf(at(3, 'tool/result', {
turn: 2, step: 3, message: toolResult('evidence-call', 'done'),
}, { surfaceOp: 'append' }))).toEqual({ id: '2:3', role: 'start' })
expect(matchOf(at(4, 'llm/retry', { turn: 2, step: 4 }))).toEqual({ id: '2:4', role: 'start' })
expect(matchOf(at(5, 'assistant/live-chunk', {
turn: 2, step: 5, chunk: { type: 'text-delta', index: 0, text: 'streaming' },
}))).toBeNull()
expect(matchOf(at(6, 'assistant/message', {
turn: 2, step: 5, message: assistantMessage('evidence-empty', ' '),
}, { surfaceOp: 'append' }))).toBeNull()
expect(matchOf(at(7, 'tool/result', {
turn: 2, step: 5, message: toolResult('evidence-call', 'replaced'),
}, { surfaceOp: { op: 'replace', startSeq: 3, endSeq: 3 }, sourceEventSeqs: [3] }))).toBeNull()
})
it('orders a command-started Turn first steering before its process control', () => {
const steering = textMessage('command-task', 'plan this change')
const value = assembler([
@@ -1,4 +1,4 @@
/** CSS checks for the completed-turn footer's content spacing. */
/** CSS checks for the Turn tail slot and the running status's move below a Step's first output. */
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
@@ -6,11 +6,87 @@ import { describe, expect, it } from 'vitest'
const read = (name: string): string =>
readFileSync(fileURLToPath(new URL(`../src/client/chat/${name}`, import.meta.url)), 'utf8')
describe('completed-turn spacing', () => {
describe('Turn tail slot spacing', () => {
it('combines the flow and footer offsets around turn-tail content', () => {
expect(read('ChatView.module.css')).toMatch(/margin-top:\s*var\(--dsh-chat-flow-gap, 6px\)/)
const tail = read('TurnTailNodeView.module.css')
expect(tail).toMatch(/\.root\s*\{[^}]*gap:\s*16px/s)
expect(tail).toMatch(/\.actions\s*\{[^}]*margin-top:\s*4px/s)
})
it('keeps the tail slot above a marked steering batch start and out of neighbour gaps', () => {
const view = read('ChatView.module.css')
const marked = String.raw`:has\(> \[data-slot="conversation\.chat\.node"\] > \[data-chat-steering-batch`
expect(view).toMatch(new RegExp(String.raw`\.flowItem${marked}="start"\]\)\s*\{\s*--dsh-chat-flow-gap:\s*calc\(44px \+ var\(--dsh-content-font-delta, 0px\)\);`))
expect(view).toMatch(new RegExp(String.raw`:not\(\.running\):not\(${marked}\]\)\)\s*\{\s*--dsh-chat-flow-gap:\s*12px;`))
expect(view).toMatch(new RegExp(String.raw`:not\(\.running\):not\(\[data-submission-echo\], \[data-pending-steering\]\):not\(${marked}\]\)\)\s*\{\s*--dsh-chat-flow-gap:\s*16px;`))
})
it('keeps echoes after a running Turn\'s empty control at the input gap', () => {
const view = read('ChatView.module.css')
const header = view.slice(view.indexOf('/* The Turn header has 16px clearance'))
const rule = header.slice(0, header.indexOf('}') + 1)
const empty = String.raw`:empty, :has(> [data-slot="conversation.chat.node"]:empty)`
// A visible control keeps its 16px clearance for every following row, echoes included.
expect(rule).toContain(String.raw`[data-chat-flow-kind="turn-process"]:not(${empty}) + :not([hidden])`)
// After any control, including an empty running one, the 16px clearance skips local echoes,
// which stay at the input gap until their durable rows replace them.
expect(rule).toContain(String.raw`[data-chat-flow-kind="turn-process"] + :not([hidden]):not(.flowItem:is(${empty}))`
+ ':not(.running):not([data-submission-echo], [data-pending-steering])')
})
})
describe('Running status move below the first output', () => {
const row = String.raw`calc\(24px \+ var\(--dsh-content-font-delta, 0px\)\)`
const travel = (gap: number) => String.raw`--dsh-running-follow: calc\(${gap}px \+ 24px \+ var\(--dsh-content-font-delta, 0px\)\);`
it('travels the first output row gap plus one font-scaled row', () => {
const view = read('ChatView.module.css')
// A process row or group header after a mid-Turn input: the default sits on the single-class
// status rule, the lowest-specificity selector, so each variant below outranks it.
expect(view).toMatch(new RegExp(String.raw`\n\.running \{[^}]*${travel(6)}`))
expect(view.match(/--dsh-running-follow: calc\(/g)).toHaveLength(3)
// A response keeps its 12px clearance.
expect(view).toMatch(new RegExp(String.raw`\)\)\)\)\[data-chat-group-part="response"\] ~ \.running \{\s*${travel(12)}`))
// A new Turn's first output follows the empty running process control at its 16px clearance.
expect(view).toMatch(new RegExp(String.raw`> \[data-chat-flow-kind="turn-process"\] \+ :nth-last-child\(2 of [^{]*\) ~ \.running \{\s*${travel(16)}`))
})
it('slides the status from where it stood, like the steering entrance, outside reduced motion', () => {
const view = read('ChatView.module.css')
expect(view).toMatch(new RegExp(String.raw`@media \(prefers-reduced-motion: no-preference\) \{\s*:where\(\.column > [^{]*\[data-chat-group-key\]\s*\)\) ~ \.running \{\s*`
+ String.raw`animation: running-follows-output 110ms cubic-bezier\(0\.4, 0, 0\.2, 1\);`))
expect(view).toMatch(new RegExp(String.raw`@keyframes running-follows-output \{\s*from \{\s*opacity: 0;\s*`
+ String.raw`translate: 0 calc\(-1 \* var\(--dsh-running-follow\)\);\s*\}\s*40% \{\s*opacity: 1;`))
})
it('lets input folds and the pinned tail change only the duration of a status move', () => {
const view = read('ChatView.module.css')
const normalize = (selector: string) => selector.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ').trim()
// Structure alone names each move, so a switch turning off never adds an animation name and replays a
// finished move. The structure sits in :where() so the switch rules outrank it.
const moves = [...view.matchAll(/([^{}]+)\{\s*animation: running-(?:enters-slot|follows-output) /g)].map(match => normalize(match[1]!))
expect(moves).toHaveLength(2)
for (const selector of moves) {
expect(selector).toMatch(/^:where\(/)
expect(selector).not.toMatch(/data-input-fold|data-chat-tail-pinned/)
}
// An input fold zeroes both moves; the pinned tail zeroes only the first-output slide, whose structure it
// repeats, and leaves the entrance after admitted steering at its duration.
const slide = moves.find(selector => selector.includes('[data-chat-group-key]'))!
expect(slide.endsWith(') ~ .running')).toBe(true)
const slideStructure = slide.slice(':where('.length, -') ~ .running'.length)
const switches = [...view.matchAll(/([^{}]+)\{\s*animation-duration: 0s;\s*\}/g)].map(match => normalize(match[1]!))
expect(switches).toEqual([
'.column[data-input-fold] .running',
`${slideStructure.replace(/^\.column/, '.column[data-chat-tail-pinned]')} ~ .running`,
])
expect(view.match(/data-chat-tail-pinned/g)).toHaveLength(1)
})
it('sizes every first-output row to the travelled row height', () => {
expect(read('ReasoningRow.module.css')).toMatch(new RegExp(String.raw`\.root:not\(\[data-expanded\]\) \{[^}]*height: ${row};`))
expect(read('ChatGroupSeat.module.css')).toMatch(new RegExp(String.raw`\.title \{[^}]*line-height: ${row};`))
expect(read('AssistantMarkdown.module.css')).toMatch(new RegExp(String.raw`\.root \{[^}]*line-height: ${row};`))
})
})
+2 -3
View File
@@ -10,10 +10,9 @@
- button "Copy"
- text: Running
- button "Ask question waiting"
- text: "Interjection: include the word BANANA in your final reply."
- button "Copy"
- status: Deep diving
- text: Deep diving for {{duration}} ···
- text: "Deep diving for {{duration}} ··· Interjection: include the word BANANA in your final reply."
- button "Copy"
- region "Ready to continue?":
- text: Checkpoint
- heading "Ready to continue?" [level=2]