fix(ui-chat): keep notice-woken Turns in the human exchange

Under "On next message" folding, a Turn woken by a notice (settled
subagent, goal round, schedule, background job) advanced the bottom Turn
and folded the finished Turn without an own-input driver, so the generic
fold room left a following reader looking at blank space.

Chat Nodes now declare a group-tree position (`groupTree`, ordered
`exchange` > `turn` > `process`). Human input claimed in Step 1 marks its
Turn as opening an exchange; notices and later steering carry no
position. `GroupTreeSegments` splits the loaded Turns at opening Turns,
and `bottomSource` covers the whole latest exchange, so notice-woken
Turns no longer fold earlier ones while the next human message folds the
exchange through the own-input path. Only `exchange` is read today; the
`turn` and `process` positions are reserved for the existing kind lists.
This commit is contained in:
imccyu committed 2026-10-12 10:35:05 +08:00
1 parent 77dca95039
commit c62f26aaa1
27 files changed
+787 -49

No files matched your search

@@ -0,0 +1,25 @@
# Bilingual-pair consistency record for 2026-10-11-chat-group-tree-exchanges.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-11-chat-group-tree-exchanges.md
/agent-note-chat-group-tree-positions-and-exchanges:
en: b26212eda152d6c6
zh: a801a75ee0c07911
/agent-note-chat-group-tree-positions-and-exchanges/problem:
en: 84858bc0e1e268ab
zh: 39ebb2979968d6cc
/agent-note-chat-group-tree-positions-and-exchanges/decision:
en: c8c4091863dec269
zh: 57a0f26683867350
/agent-note-chat-group-tree-positions-and-exchanges/why-mark-the-opening-rather-than-the-notice:
en: db8d77cd773ad646
zh: ab2f03b8fc58e952
/agent-note-chat-group-tree-positions-and-exchanges/alternatives-considered:
en: 635ddcb04fefe8e2
zh: 360c171ccb613ed5
/agent-note-chat-group-tree-positions-and-exchanges/consequences:
en: c8cfdc3009b19b12
zh: 36c541cd9692057d
/agent-note-chat-group-tree-positions-and-exchanges/testing:
en: 0d7f5281e66b0c39
zh: 5be221de46e9e0f7
@@ -0,0 +1,51 @@
# Agent Note: Chat group-tree positions and exchanges
Status: implemented
English | [中文](2026-10-11-chat-group-tree-exchanges.zh.md)
## Problem
On next message folding kept only the last Turn of the loaded timeline expanded. A Turn woken by a notice (a settled subagent, goal round, schedule, or background job) is not submitted by this Client, yet its arrival made the preceding Turn historical and folded it. No own-input driver computed the exact remaining room for that fold, so the scroll layer reserved generic bottom space and a reader following the tail saw blank space. In the recorded Session the notice woke the next Turn 13–15 ms after the previous Turn ended; the minimal reproduction left 596 px blank that had not recovered after five seconds.
## Decision
A notice is neither body text nor a user message: folding and bottom advancement ignore it. The bottom changes from the last Turn to the latest exchange.
- **Group-tree positions.** `ChatConversationViewNode.groupTree` declares a Node's position in the group tree, typed `ChatGroupTree = 'exchange' | 'turn' | 'process'` and ordered outermost first; a Node at a position also stands outside every inner grouping. `isAtOrOutside(node, position)` is the single reader of that order. Only `exchange` is read today; `turn` and `process` are reserved for migrating the `TURN_PROCESS_INDEPENDENT_KINDS` and process-group `INDEPENDENT` kind lists later without changing what `exchange` means.
- **Exchanges.** A Turn holding an `exchange` Node opens an exchange, which lasts until the next opening Turn. The business message Definition marks only human input claimed in Step 1 (`user` or `steering`) as `exchange`; notices and later steering carry no position.
- **Mechanism.** `GroupTreeSegments(position)` splits the loaded Turns at the Turns holding a Node at that position. The latest segment starts at the last opening Turn, or at the first loaded Turn when the window holds none. It counts per Turn incrementally without scanning loaded Nodes, and a start move returns only the Turns that entered or left the latest segment. The Chat Builder's `bottomSource` is "the Node is visible and belongs to the latest exchange".
As a result, a notice-woken Turn no longer folds earlier Turns, so no room is reserved; the human's next ordinary message folds the whole exchange through the own-input path.
## Why mark the opening rather than the notice
The host claims the waking input right after `turn/start`, assembles the system prompt, and only then logs `step/start` and `user/message`; the recorded Session spent 187 ms in that gap. Marking only the notice leaves the new Turn untagged during the gap, so the bottom still advances and the previous Turn folds and then reopens. Marking the human opening and letting unmarked Turns continue the exchange keeps the gap inert.
## Alternatives considered
**Tag the notice as a continuation.** The 187 ms gap above makes it fold and reopen, and a claim that mixes a notice with human input needs extra classification.
**Name opening kinds in a list (`user`, `steering`).** A kind cannot separate Step 1 steering from mid-Turn steering, so a human steering into a notice-woken Turn would wrongly fold the previous exchange.
**Publish Turn-level Location data.** Only one Context may publish a key for a Turn, while opening input comes from several message Contexts; aggregating per Turn would repeat the inbox claim classification in a new Definition and read every Turn on each update.
**Declare a business role (`input`, `notice`) on Nodes.** That places business semantics in the grouping mechanism; group-tree positions describe structure only, and each business Definition chooses its position.
**Correct the scroll room for folds not caused by own input.** Notices should not fold at all, and correcting the room would not remove that unnecessary fold; it remains a residual issue for human input from another tab.
**Migrate both kind lists now.** That reaches well beyond this defect; the type reserves the positions and the migration is separate work.
## Consequences
- Notice-woken Turns no longer cause a fold, reserved room, or blank space, and the grouping mechanism reads declared positions instead of naming kinds.
- Under On next message, a long run of notice-woken Turns (up to 31 goal-mode Turns in sampled Sessions) stays expanded until the human types again.
- When the loaded window holds no opening Turn, every loaded Turn belongs to the latest exchange.
- Human input from another tab folds when its `user/message` arrives, about 200 ms later than the former `turn/start` fold; that fold still uses generic room and is unchanged here.
## Testing
- `packages/client/ui-chat/tests/group-tree-segments.client.spec.ts`: position order, segmentation, count fallback, and the empty window.
- `packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts`: latest-exchange membership and notification scope.
- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`: a real assembler across an opening Turn, mid-Turn steering, the pre-Step gap, a notice wake, and an idle steer.
- `apps/web/tests/notice-wake-tail.e2e.ts`: after a long Turn a notice wakes the next Turn; the long Turn stays expanded through the pre-Step gap and after the notice reply, and a following reader sees the notice and the reply.
@@ -0,0 +1,51 @@
# Agent Note: Chat 分组树位置与交互段
Status: implemented
[English](2026-10-11-chat-group-tree-exchanges.md) | 中文
## Problem
「下次发送时收起」原本只让已加载时间线的最后一个 Turn 保持展开。由通知唤醒的 Turn(子 agent 完成、Goal Round、定时任务、后台作业)不是本客户端提交的,它一出现,上一轮就被当作历史折叠。这次折叠没有自己输入的驱动来计算精确的剩余补白,滚动层只能按通用规则预留底部空间,正在跟尾的读者因此看到一片空白。录制的真实 Session 中,通知在上一轮结束后 13–15 ms 唤醒新轮次;最小复现里空白高 596 px,五秒后仍未恢复。
## Decision
通知不算正文,也不算用户消息:折叠和推进底部时都忽略它。底部从"最后一个 Turn"改为"最新交互段"。
- **分组树位置。** `ChatConversationViewNode.groupTree` 声明节点在分组树中的位置,类型为 `ChatGroupTree = 'exchange' | 'turn' | 'process'`,由外到内有序;站在某个位置的节点同时也站在所有内层分组之外。`isAtOrOutside(node, position)` 是读取这一顺序的唯一入口。目前只读取 `exchange`;`turn` 和 `process` 预留给以后把 `TURN_PROCESS_INDEPENDENT_KINDS` 与过程组 `INDEPENDENT` 两份 kind 名单迁移过来,届时不改变 `exchange` 的含义。
- **交互段。** 含 `exchange` 节点的 Turn 开启一个交互段,交互段持续到下一个开启 Turn。业务层的 message Definition 只给 Step 1 中被认领的真人输入(`user` 或 `steering`)标 `exchange`;通知和之后的 steering 不标。
- **机制。** `GroupTreeSegments(position)` 按含该位置节点的 Turn 切分已加载的 Turn,最新一段从最后一个开启 Turn 开始,窗口中没有开启 Turn 时从第一个已加载 Turn 开始。它按 Turn 增量计数,不扫描已加载节点;起点移动时只返回进入或离开最新一段的 Turn。Chat Builder 的 `bottomSource` 为"节点可见且属于最新交互段"。
结果是:通知唤醒的 Turn 不再折叠前面的 Turn,没有折叠就没有补白;真人下一次普通输入时,整个交互段经由自己输入的路径一起折叠。
## 为什么标开头而不是标通知
Host 在 `turn/start` 之后立即认领唤醒输入,然后组装系统提示,再写 `step/start` 和 `user/message`。录制的 Session 中这段间隔为 187 ms。只给通知打标的话,这段时间新 Turn 还没有标签,底部照样推进,上一轮会先折叠再展开。把标签放在真人开头上、未标的 Turn 默认续接,空白间隔里什么都不会发生。
## Alternatives considered
**给通知打"续接"标签。** 上面那 187 ms 的间隔会让它先折后展;同一次认领里通知和真人输入并存时还要额外判断。
**按 kind 写一张开启名单(`user`、`steering`)。** kind 区分不了 Step 1 的 steering 与 Turn 中途的 steering,真人往通知唤醒的 Turn 里插话时会错误地折叠前一个交互段。
**发布到 Turn 级 Location data。** 同一 Turn 的同一个 key 只允许一个 Context 发布,而开启输入来自多个消息 Context;改成按 Turn 汇总就得在新的 Definition 里重复 inbox 认领分类,并且每次都要读取所有 Turn。
**节点声明业务角色(`input`、`notice`)。** 这会把业务语义放进分组机制;分组树位置只描述结构,业务 Definition 自行决定位置。
**修正非自身输入折叠时的滚动补白。** 通知本来就不应触发折叠,修补白不能消除这次不必要的折叠;它仍是另一个标签页里真人输入的残留问题。
**这次就迁移两份 kind 名单。** 改动面明显超出这次缺陷;类型先预留位置,迁移另做。
## Consequences
- 通知唤醒的 Turn 不再产生折叠、补白和空白;分组机制读取节点声明的位置,而不是点名 kind。
- 「下次发送时收起」下,一长串通知唤醒的 Turn(抽样会话中 Goal 模式最长 31 轮)会一直展开,直到真人再次输入。
- 已加载窗口里没有开启 Turn 时,所有已加载 Turn 都属于最新交互段。
- 另一个标签页里的真人输入在它的 `user/message` 到达时才折叠,比原来在 `turn/start` 时晚约 200 ms;这类折叠仍使用通用补白,本次没有修改。
## Testing
- `packages/client/ui-chat/tests/group-tree-segments.client.spec.ts`:位置顺序、切分、计数回退与空窗口。
- `packages/client/ui-chat/tests/chat-node-bottom.client.spec.ts`:最新交互段的成员与通知范围。
- `packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts`:真实 assembler 下的开轮、中途 steering、Step 1 前的间隔、通知唤醒与空闲 steer。
- `apps/web/tests/notice-wake-tail.e2e.ts`:长 Turn 之后由通知唤醒,在 Step 1 前的间隔和通知回复之后,上一轮都保持展开,跟尾读者看到通知与回复。
+230
View File
@@ -0,0 +1,230 @@
/**
* A Turn this Client did not submit (a subagent-settled notice) wakes while the
* reader follows the tail of a long Turn under "On next message" folding. The
* notice Turn belongs to the long Turn's exchange, so the long Turn stays
* expanded through the host's pre-Step gap and after the notice arrives, and a
* reader who was following sees the notice and its reply at the tail.
*/
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { SubagentSettledMessageSource } from '@deepseek-ai/dsh-subagent'
import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage } from './support.ts'
/** Settled Turns before the long one, so every work-details mode overflows the viewport. */
const HISTORY_TURNS = 3
const LONG_TURN = HISTORY_TURNS + 1
const NOTICE_TURN = HISTORY_TURNS + 2
/** Tool Steps in the finished Turn; their folded height must exceed the notice Turn's height. */
const TOOL_STEPS = 12
const OUTPUT_LINES = 40
/** A settled tail leaves only composer padding uncovered (about 55-105 px at this viewport). */
const BLANK_LIMIT = 150
/** The recorded host gap between the waking claim and Step 1, spent assembling the system prompt. */
const PRE_STEP_GAP_MS = 187
const REPLY = 'NOTICE_WAKE_REPLY'
interface Tail {
/** Viewport pixels above the composer that no painted row covers. */
readonly blank: number
/** Height of the room reserved below the flow. */
readonly spacer: number
readonly noticeVisible: boolean
readonly replyVisible: boolean
/** Whether the long Turn's process disclosure reports itself expanded. */
readonly longTurnOpen: boolean
}
function readTail(page: Page): Promise<Tail> {
return page.evaluate(([reply, longTurn]) => {
const host = document.querySelector<HTMLElement>('[data-conversation-scroll]')
if (host === null) throw new Error('conversation scrollport is absent')
const viewTop = host.getBoundingClientRect().top
const viewBottom = host.querySelector<HTMLElement>('[data-composer-seat]')?.getBoundingClientRect().top
?? host.getBoundingClientRect().bottom
const inView = (element: Element): boolean => {
const rect = element.getBoundingClientRect()
return rect.height > 0.5 && rect.bottom > viewTop && rect.top < viewBottom
}
const spans = [...host.querySelectorAll<HTMLElement>('[data-chat-anchor-key]')]
.map(row => row.getBoundingClientRect())
.filter(rect => rect.height > 0.5)
.map(rect => [Math.max(rect.top, viewTop), Math.min(rect.bottom, viewBottom)] as const)
.filter(([top, bottom]) => bottom > top)
.sort((left, right) => left[0] - right[0])
let covered = 0
let reach = viewTop
for (const [top, bottom] of spans) {
if (bottom <= reach) continue
covered += bottom - Math.max(top, reach)
reach = bottom
}
const spacer = host.querySelector<HTMLElement>('[data-chat-turn-spacer]')
return {
blank: Math.round(viewBottom - viewTop - covered),
spacer: spacer === null ? 0 : Number.parseFloat(spacer.style.height) || 0,
noticeVisible: [...host.querySelectorAll('[data-turn-trigger]')].some(inView),
replyVisible: [...host.querySelectorAll('[data-chat-flow-kind="assistant-step"]')]
.some(row => (row.textContent ?? '').includes(reply) && inView(row)),
longTurnOpen: host.querySelector(`[data-turn-process="${longTurn}"]`)?.getAttribute('aria-expanded') === 'true',
}
}, [REPLY, String(LONG_TURN)] as const)
}
describe('web e2e: notice Turn after a long Detailed Turn', () => {
let browser: Browser
beforeAll(async () => {
browser = await chromium.launch({ headless: true })
}, 60_000)
afterAll(async () => {
await browser?.close()
})
it.each(['detailed', 'standard'] as const)('keeps the new tail visible in %s work details', async (view) => {
const scaffold: WebScaffold = await launchWebScaffold({})
const page = await newEnglishPage(browser)
const tripwire = watchConsole(page)
try {
await page.setViewportSize({ width: 1280, height: 800 })
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
await scaffold.ctx.settings.update('ui-chat', { transcriptView: view, collapseTiming: 'next-input' })
const agent = scaffold.ctx.agents.list().at(-1)
if (agent === undefined) throw new Error('connected workspace created no Agent')
const session = agent.session
const setRunning = (running: boolean): void => { scaffold.ctx.emit('api-session/status', agent.id, running) }
for (let turn = 1; turn <= HISTORY_TURNS; turn += 1) {
session.append('turn/start', { turn })
session.append('step/start', { turn, step: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `Earlier question ${String(turn)}.` }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('assistant/message', {
stream: [],
turn,
step: 1,
message: createAssistantMessage({
content: [{ type: 'text', text: `Earlier answer ${String(turn)}. ${'This settled reply wraps across several lines of prose. '.repeat(24)}` }],
source: { provider: 'fixture', model: 'fixture' },
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn, step: 1 })
session.append('turn/end', { turn, reason: { kind: 'completed' } })
}
// A long running Turn streams Step by Step while the reader follows the tail.
setRunning(true)
session.append('turn/start', { turn: LONG_TURN })
session.append('step/start', { turn: LONG_TURN, step: 1 })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Run the long diagnostic.' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
for (let step = 1; step <= TOOL_STEPS; step += 1) {
const callId = ToolCallId(`notice-wake-${String(step)}`)
const args = JSON.stringify({ command: `diagnose ${String(step)}`, description: `Diagnostic ${String(step)}` })
session.append('assistant/message', {
stream: [],
turn: LONG_TURN,
step,
message: createAssistantMessage({
content: [
{ type: 'reasoning', text: `Checking diagnostic ${String(step)}.` },
{ type: 'tool-call', id: callId, name: 'bash', arguments: args },
],
source: { provider: 'fixture', model: 'fixture' },
}),
}, { surfaceOp: 'append' })
const call = session.append('tool/call', { turn: LONG_TURN, step, callId, name: 'bash', arguments: args })
session.append('tool/result', {
turn: LONG_TURN,
step,
message: createToolResultMessage({
callId,
content: [{
type: 'text',
text: Array.from({ length: OUTPUT_LINES }, (_, line) => `diagnostic ${String(step)} line ${String(line + 1)}`).join('\n'),
}],
isError: false,
}),
}, { surfaceOp: 'append', sourceEventSeqs: [call.seq] })
session.append('step/end', { turn: LONG_TURN, step })
session.append('step/start', { turn: LONG_TURN, step: step + 1 })
await scaffold.ctx.sessions.flush(session)
await page.waitForTimeout(30)
}
session.append('assistant/message', {
stream: [],
turn: LONG_TURN,
step: TOOL_STEPS + 1,
message: createAssistantMessage({
content: [{ type: 'text', text: 'All diagnostics finished.' }],
source: { provider: 'fixture', model: 'fixture' },
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: LONG_TURN, step: TOOL_STEPS + 1 })
await scaffold.ctx.sessions.flush(session)
await page.getByText('All diagnostics finished.').waitFor({ timeout: 30_000 })
await page.evaluate(() => {
const host = document.querySelector<HTMLElement>('[data-conversation-scroll]')
if (host !== null) host.scrollTop = host.scrollHeight
})
await page.waitForTimeout(600)
// The Turn ends; 15 ms later (the recorded production gap) a settled subagent wakes the next Turn.
session.append('turn/end', { turn: LONG_TURN, reason: { kind: 'completed' } })
await scaffold.ctx.sessions.flush(session)
setRunning(false)
await page.waitForTimeout(15)
const source: SubagentSettledMessageSource = {
kind: 'subagent-settled',
form: 'notice',
summary: 'Background subagent finished.',
senderSessionId: SessionId('notice-wake-child'),
}
const notice = createUserMessage({
content: [{ type: 'text', text: 'Background subagent notice-wake-child finished.' }],
source,
})
session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [notice] })
session.append('turn/start', { turn: NOTICE_TURN })
session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] })
setRunning(true)
await scaffold.ctx.sessions.flush(session)
await page.waitForTimeout(PRE_STEP_GAP_MS)
const gap = await readTail(page)
session.append('step/start', { turn: NOTICE_TURN, step: 1 })
session.append('user/message', notice, { surfaceOp: 'append' })
await scaffold.ctx.sessions.flush(session)
await page.waitForTimeout(300)
session.append('assistant/message', {
stream: [],
turn: NOTICE_TURN,
step: 1,
message: createAssistantMessage({
content: [{ type: 'text', text: `${REPLY} the subagent result is acknowledged.` }],
source: { provider: 'fixture', model: 'fixture' },
}),
}, { surfaceOp: 'append' })
await scaffold.ctx.sessions.flush(session)
await page.waitForTimeout(3000)
const tail = await readTail(page)
expect(gap).toMatchObject({ longTurnOpen: true, spacer: 0 })
expect(tail).toMatchObject({ noticeVisible: true, replyVisible: true, longTurnOpen: true })
expect(tail.blank).toBeLessThan(BLANK_LIMIT)
expect(tripwire.pageErrors).toEqual([])
} finally {
await page.context().close()
await scaffold.close()
}
}, 180_000)
})
+1
View File
@@ -23,6 +23,7 @@
// cannot see both sides of the cordis Context merges).
"exclude": [
"tests/collapse-timing.e2e.ts",
"tests/notice-wake-tail.e2e.ts",
"tests/chat-flow-layout.e2e.ts",
"tests/layout-bottom.e2e.ts",
"tests/shortcuts-desktop.e2e.ts",
+2 -2
View File
@@ -18,8 +18,8 @@
en: 38a55fc61b359ace
zh: 567cae201b3a1bdf
/glossary/loop-hierarchy:
en: 343a7cb31b8191c2
zh: 6831415a7fe1726a
en: 6bf1ad7dd53f79cc
zh: 6231e68c769fdefa
/glossary/ralph:
en: 5059f8a377980b9d
zh: 39bf11c10e5c0a84
+1
View File
@@ -37,6 +37,7 @@ Domain vocabulary for DeepSeek Harness uses one canonical term per concept. Term
- **turn** — one drain of admitted input in a session, ending after the model and its tools stop or a terminal policy intervenes. <a id="turn"></a>
- **step** — one model request plus the tool executions caused by its response; a turn contains zero or more steps. <a id="step"></a>
- **round** — an outer policy iteration containing a turn, such as a [goal round](#goal-round) or one fresh-agent Ralph attempt. Round counters belong to that policy and do not count every turn in a session. <a id="round"></a>
- **exchange** — consecutive [turns](#turn) from a turn whose first [step](#step) admits human input until the next such turn; turns woken by notices, such as settled subagents, goal rounds, or schedules, stay in the preceding exchange. Chat's next-message folding keeps the latest exchange expanded. <a id="exchange"></a>
## Ralph
+1
View File
@@ -37,6 +37,7 @@ DeepSeek Harness 的领域词汇为每个概念规定一个规范术语。各术
- **轮次**:会话中一次对已接纳输入的排空过程,在模型及其工具停止工作或终止策略介入后结束。<a id="turn"></a>
- **步骤**:一次模型请求,以及由模型响应引发的工具执行;一个轮次包含零个或多个步骤。<a id="step"></a>
- **Round**:承载一个轮次的外层策略迭代,例如一个 [Goal Round](#goal-round) 或一次使用全新 agent 的 Ralph 尝试。Round 计数器归该策略所有,并不统计会话中的每个轮次。<a id="round"></a>
- **交互段**:连续的[轮次](#turn),从首个[步骤](#step)接纳真人输入的轮次起,到下一个这样的轮次为止;由通知(子 agent 完成、Goal Round、定时任务等)唤醒的轮次归入前面的交互段。Chat 的「下次发送时收起」保持最新交互段展开。<a id="exchange"></a>
## Ralph
+2 -2
View File
@@ -33,8 +33,8 @@
en: 4cd7c81d70d2f365
zh: 18eca6d16e44b6c2
/deepseek-ai-dsh-client-ui-chat/grouped-rendering:
en: 106438891c5175ce
zh: 34dd30092f34f49b
en: bc8d1d786c9affea
zh: 3139f0228527a174
/deepseek-ai-dsh-client-ui-chat/scroll-ownership:
en: 3f4e6c161b4583bb
zh: e4265bb2bce8eb1c
+1 -1
View File
@@ -103,7 +103,7 @@ Chat registers its process Group Definition through `uiConversation.groups`. Rea
`groupPart` selects reasoning or response in the Assistant renderer without copying Node payloads. A Tool node owns its preparing, dispatched, and result stages under one callId. Each part has a distinct DOM anchor for reading-position restoration; Turn navigation addresses the original Node key and lands on its first visible part. Group sources, member parents, and keys survive display-mode changes and newly loaded prefixes that extend an intact group. The source Node Store remains the only Node-data owner, and a replaced Builder rebinds keyed subscriptions without remounting seats. Mode changes retain size observers and reuse the Turn-state selector.
The Chat Builder publishes `bottomSource` per Node: true for visible Nodes belonging to the last Turn in the loaded timeline, not just the final row. Advancing the bottom Turn notifies only Nodes in the old and new bottom Turns. Groups read their first member's position. Pending-input deduplication and collapse decisions remain in the Chat view, outside the Builder.
The Chat Builder publishes `bottomSource` per Node: true for visible Nodes belonging to the latest loaded exchange, not just the final row. Nodes declare their group-tree position through `groupTree`; a Turn holding an `exchange` Node opens an exchange, and the exchange lasts until the next opening Turn. Moving the exchange start notifies only Nodes in Turns that entered or left it. Groups read their first member's position. Pending-input deduplication and collapse decisions remain in the Chat view, outside the Builder.
Live tool deltas share reasoning's frame-batched publication; durable calls and results publish immediately. Native and nested results retain event presentation metadata, so historical file and command labels use the operation’s location. Repeated deltas retain the Tool node when observed argument answers, the anchor, location, and visibility are unchanged. The Definition supplies the same lazy argument reader to tool rows and group detail, without tool-specific registration.
+1 -1
View File
@@ -103,7 +103,7 @@ Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过
`groupPart` 在 Assistant 渲染器中选择推理或回复,不复制 Node 载荷。同一个 callId 的准备、派发与结果阶段由 Tool 节点自己拥有。每个部分有独立的 DOM 锚点用于恢复阅读位置;轮次导航使用原 Node key,落到它的第一个可见部分。展示模式切换以及为完整旧组补入更早成员时,保留组来源、成员父级及 key。原 Node Store 仍是唯一节点数据所有者,替换 Builder 时重新绑定按键订阅,不重挂载容器。模式变化保留尺寸观察器,并复用整轮状态选择器。
Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载时间线的最后一轮时为 true,而不只是列表最后一行。底部轮次推进时,只有旧、新底部轮次的节点收到位置变化通知。Group 读取其首个成员的位置。待发送消息去重与收起判断仍由 Chat 视图处理,不进入 Builder。
Chat Builder 按节点发布 `bottomSource`:节点可见且属于已加载的最新交互段时为 true,而不只是列表最后一行。节点通过 `groupTree` 声明自己在分组树中的位置;含 `exchange` 节点的轮次开启一个交互段,交互段持续到下一个开启轮次。交互段起点移动时,只有进入或离开该交互段的轮次中的节点收到通知。Group 读取其首个成员的位置。待发送消息去重与收起判断仍由 Chat 视图处理,不进入 Builder。
实时工具 delta 与推理共用按帧合并的发布节奏;持久调用和结果立即发布。原生与嵌套结果保留事件中的展示元数据,使历史文件与命令标签使用执行时的位置。当已读取参数的答案、锚点、位置及可见性均未变化时,重复 delta 保持 Tool 节点不变。Definition 向工具行和分组详情提供同一个懒计算参数读器,无需按工具注册。
@@ -3,6 +3,7 @@ import type {
ConversationLocation, ConversationViewNode, ModelRetryNode, RunningToolCall,
ToolCallBlock,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatGroupTree } from './group-tree.ts'
/** Final Chat render unit produced by a Chat business Definition. */
export interface ChatConversationViewNode extends ConversationViewNode {
@@ -10,6 +11,13 @@ export interface ChatConversationViewNode extends ConversationViewNode {
readonly anchorSeq: number
readonly location: ConversationLocation
readonly visibility: 'visible' | 'hidden'
/**
* Group-tree position, visible or hidden. An `exchange` Node's Turn and every
* later Turn before the next opening Turn form one exchange; the latest
* loaded exchange starts at the last loaded opening Turn, or at the first
* loaded Turn when none is loaded.
*/
readonly groupTree?: ChatGroupTree | undefined
}
/** Merge-extensible payload registry keyed by final Chat renderer kind. */
@@ -0,0 +1,27 @@
import type { ChatConversationViewNode } from './chat-nodes.ts'
/**
* Chat group-tree positions from the outermost grouping inward; a Node at a
* position also stands outside every inner grouping.
* - `exchange`: the Node's Turn opens an exchange, the run of Turns that lasts
* until the next opening Turn.
* - `turn`: the Node stays in its Turn outside the Turn's process disclosure.
* - `process`: the Node stays in the process disclosure outside step runs.
*
* Only `exchange` is read today; process disclosure and step runs still select
* independent Nodes by kind (`TURN_PROCESS_INDEPENDENT_KINDS`, the process-group
* `INDEPENDENT` set), so no Definition publishes `turn` or `process` yet.
*/
export type ChatGroupTree = 'exchange' | 'turn' | 'process'
const DEPTH: Readonly<Record<ChatGroupTree, number>> = { exchange: 0, turn: 1, process: 2 }
/**
* Test whether a Node stands at a group-tree position or outside it.
* @param node - Chat Node.
* @param position - group-tree position to compare against.
* @returns whether the Node's position is `position` or an outer one.
*/
export function isAtOrOutside(node: Pick<ChatConversationViewNode, 'groupTree'>, position: ChatGroupTree): boolean {
return node.groupTree !== undefined && DEPTH[node.groupTree] <= DEPTH[position]
}
@@ -35,7 +35,10 @@ export interface ChatNodeStore {
get(key: string): ChatConversationViewNode | undefined
/** @param key - stable Conversation Context key. @returns its identity-stable observable source. */
source(key: string): ChatNodeSource
/** @param key - Chat Node identity. @returns whether the visible Node belongs to the last Turn in the loaded timeline. */
/**
* @param key - Chat Node identity.
* @returns whether the visible Node belongs to the latest loaded exchange (see `ChatConversationViewNode.groupTree`).
*/
bottomSource(key: string): ObservableSnapshot<boolean>
/**
* Observe one Turn's data for a single Node kind, including hidden Nodes, in anchor order.
@@ -24,8 +24,8 @@
en: 91b35cbebe8c2a1d
zh: a4fee6d7102bc874
/chat-conversation-node-rules/whole-turn-folding:
en: 6feeb1527191633f
zh: 401656a7b9a4a4ed
en: 8e0c0b62da18a24f
zh: f740809d0b7ff2f5
/chat-conversation-node-rules/display-modes:
en: ada5a60d2c46f6a8
zh: f3eaeb5cb4d9b3de
@@ -33,8 +33,8 @@
en: b25bf00ce3a6a119
zh: 0ab47a4014ea5e1e
/chat-conversation-node-rules/display-modes/collapse-timing:
en: d87667e4d1729e51
zh: 014a14ebb16ac798
en: 03d744dd1c92bdd3
zh: b26e0fca325b47c5
/chat-conversation-node-rules/display-modes/group-title-rules:
en: 2392290662bc69e8
zh: 8872d651c07a8f4c
@@ -70,7 +70,7 @@ If steering, a User message, or a trigger notice follows process output, the Tur
Whole-Turn folding controls the loaded process range, independently of secondary groups, in Compact, Standard, and Detailed. Verbose keeps that range visible and retains the duration/status header without a collapse action. A recorded Turn end makes that range eligible even when paging has not loaded the Turn start.
The rules below use the default completion-time folding. The [collapse-timing preference](#collapse-timing) can defer the latest Turn's historical presentation without changing whole-Turn eligibility.
The rules below use the default completion-time folding. The [collapse-timing preference](#collapse-timing) can defer the latest exchange's historical presentation without changing whole-Turn eligibility.
The Turn control follows all its opening inputs, including human steering and non-human trigger notices, while waiting for the first Assistant output and after that output arrives. Consecutive inputs before the first process evidence are opening inputs, anchored by the last one. Later inputs retain their positions: even when paging has not loaded their inbox insertions and they temporarily appear as ordinary User messages, preceding process content must not move after them.
@@ -138,7 +138,7 @@ Reading a default does not automatically save configuration. When the user chang
Settings → General → Collapse work details appears directly below Work details and is independent of the display mode. On completion is the default and retains immediate folding and tail positioning. On next message enables delayed folding and its coordinated transitions. The choice is saved as `ui-chat.collapseTiming` through the same Host profile settings as Work details and is restored after reload or reopening the app. Profiles without a saved value use On completion without writing that default. It does not write browser storage or Session data.
With On next message, the latest Turn keeps its running presentation after completion: Compact and Standard retain their process headers, and Detailed retains its flat process rows. A pending ordinary transcript submission or a newer Turn ends this exemption without clearing the reader's manual whole-Turn choice. Inner disclosures reset only when their outer process actually hides. Queued input and steering alone do not end it. The latest Turn of a reopened Session also receives the exemption; manual whole-Turn choices still take precedence. Switching back to On completion ends the exemption immediately.
With On next message, the latest exchange keeps its running presentation after completion: Compact and Standard retain their process headers, and Detailed retains its flat process rows. An exchange starts with the Turn whose first Step admits human input and includes every later Turn until the next such Turn, so a Turn woken by a notice (a settled subagent, goal round, schedule, or job) leaves the preceding Turns of its exchange expanded. A pending ordinary transcript submission or a Turn that opens a newer exchange ends this exemption without clearing the reader's manual whole-Turn choice; a bare Turn start does not, because the host claims the waking input before it logs Step 1. Inner disclosures reset only when their outer process actually hides. Queued input and steering alone do not end it. The latest exchange of a reopened Session also receives the exemption, starting at the first loaded Turn when the loaded window holds no opening Turn; manual whole-Turn choices still take precedence. Switching back to On completion ends the exemption immediately.
When a new message triggers a fold, process details close and the transcript follows the new message in one upward movement, without a second scroll after the fold. Off-screen steps do not add bottom space; only the space needed to preserve the visible movement remains when the retained answer and new message cannot fill it. Content added during the fold updates its remaining movement. Reopening every closing row cancels that movement and releases obsolete bottom space. A reading gesture interrupts automatic following; finishing the fold does not resume it.
@@ -70,7 +70,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
简洁、标准、详细模式的整轮折叠控制已加载的过程范围,独立于二级组。完全展开模式保持该范围可见,保留时长或状态抬头,但不支持收起。已经记录轮次结束时,即使向前分页尚未加载到轮次起点,也可以对该范围折叠。
以下规则采用默认的完成时收起。[收起时机设置](#collapse-timing) 可以推迟最新轮次恢复历史呈现的时间,不改变整轮折叠资格。
以下规则采用默认的完成时收起。[收起时机设置](#collapse-timing) 可以推迟最新交互段恢复历史呈现的时间,不改变整轮折叠资格。
整轮控件位于该轮所有起始输入之后,包括人工 steering 和非人工触发通知;等待首个 Assistant 输出及输出到达后均保持这一顺序。首个过程证据之前的连续输入都属于开场,以最后一条为准;过程证据之后的输入留在原位,即使分页尚未加载其入队记录、消息暂时归为普通 User,也不能把前面的过程移到它后面。
@@ -138,7 +138,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
设置 → 通用设置 → 过程收起时机位于工作步骤展示正下方,与展示模式独立。「完成时收起」是默认值,保留立即折叠和即时跟尾定位;「下次发送时收起」启用延后折叠及其协调过渡。选择以 `ui-chat.collapseTiming` 保存到与工作步骤展示相同的 Host profile 设置,刷新或重新打开应用后会恢复。未保存此值的 profile 使用「完成时收起」,不会自动写入默认值。此偏好不写入浏览器存储或 Session 数据。
选择「下次发送时收起」后,最新轮次完成时保留运行中的呈现:简洁和标准保留过程组头,详细保留平铺过程行。普通 transcript 输入进入待发送状态,或出现更新轮次时,取消这项例外,但保留用户对整轮的手动开合选择。只有外层过程实际隐藏时,才重置内部开合。排队输入和单独的 steering 不会取消例外。重新打开 Session 时,其最新轮次也享有例外;整轮的手动开合选择仍优先。切回「完成时收起」会立即取消例外。
选择「下次发送时收起」后,最新交互段完成时保留运行中的呈现:简洁和标准保留过程组头,详细保留平铺过程行。交互段从首个 Step 接收真人输入的轮次开始,包含之后的所有轮次,直到下一个这样的轮次,因此由通知(子 agent 完成、goal 轮次、定时任务或后台作业)唤醒的轮次不会收起同一交互段里更早的轮次。普通 transcript 输入进入待发送状态,或出现开启更新交互段的轮次时,取消这项例外,但保留用户对整轮的手动开合选择;仅出现新轮次的开始不会取消,因为 Host 先认领唤醒输入,再记录 Step 1。只有外层过程实际隐藏时,才重置内部开合。排队输入和单独的 steering 不会取消例外。重新打开 Session 时,其最新交互段也享有例外,已加载窗口中没有开启轮次时从第一个已加载轮次算起;整轮的手动开合选择仍优先。切回「完成时收起」会立即取消例外。
新消息触发折叠时,过程收起与跟随新消息合为一次向上运动,折叠结束后不再追加滚动。屏幕外的步骤不增加底部补白;只有保留的正文和新消息无法填满可见内容移动所需空间时,才保留必要差额。折叠期间新增的内容会更新剩余运动;重新展开全部正在收起的行时,取消该运动并释放失效的底部补白。阅读手势会中断自动跟随,折叠结束不会重新接管。
@@ -13,6 +13,7 @@ import type {
} from '../contract/snapshot.ts'
import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts'
import { sessionRecallLabels, skillInvocationName } from './event-projection.ts'
import { GroupTreeSegments } from './group-tree-segments.ts'
import { sameTurnNavigationItem, turnNavigationItem } from './turn-navigation.ts'
import { ChatTurnProcessProjector } from './turn-process-presentation.ts'
@@ -108,7 +109,8 @@ class MutableChatNodeStore implements ChatNodeStore {
private readonly sources = new Map<string, MutableChatSource<ChatConversationViewNode | undefined>>()
private readonly processSources = new Map<string, MutableChatSource<ChatTurnProcessPresentation | undefined>>()
private readonly bottomSources = new Map<string, MutableChatSource<boolean>>()
private bottomTurn: number | undefined
/** Bottom membership: the latest loaded exchange. */
private readonly exchanges = new GroupTreeSegments('exchange')
private readonly dirtyKeys = new Set<string>()
private readonly dirtyBottomKeys = new Set<string>()
private readonly dirtyProcessKeys = new Set<string>()
@@ -131,19 +133,21 @@ class MutableChatNodeStore implements ChatNodeStore {
bottomSource(key: string): ObservableSnapshot<boolean> {
return cachedSource(this.bottomSources, key, () => new MutableChatSource(() => {
const node = this.get(key) as ChatNode | undefined
return this.bottomTurn !== undefined && node !== undefined && isVisibleChatNode(node)
&& locationCoordinates(node.location).turn === this.bottomTurn
return node !== undefined && isVisibleChatNode(node)
&& this.exchanges.inLatest(locationCoordinates(node.location).turn)
}, `[ui-chat] node bottom source ${key}`))
}
setBottomTurn(turn: number | undefined, locations: ChatLocationNodeIndex): void {
if (turn === this.bottomTurn) return
for (const changed of [this.bottomTurn, turn]) {
if (changed !== undefined) {
for (const key of locations.getTurn(changed)) this.dirtyBottomKeys.add(key)
}
/**
* Settle the latest exchange for the loaded Turns and mark the Nodes of
* Turns that entered or left it.
* @param turnOrder - loaded Turns in ascending timeline order.
* @param locations - current Chat Location index.
*/
settleBottom(turnOrder: readonly number[], locations: ChatLocationNodeIndex): void {
for (const turn of this.exchanges.settle(turnOrder)) {
for (const key of locations.getTurn(turn)) this.dirtyBottomKeys.add(key)
}
this.bottomTurn = turn
}
turnDataSource<Kind extends ChatNodeKind>(turn: number, kind: Kind): ObservableSnapshot<readonly ChatNodeDataMap[Kind][]> {
@@ -196,6 +200,7 @@ class MutableChatNodeStore implements ChatNodeStore {
this.byKey.set(node.key, node)
if (previous.get(node.key) !== node) {
this.updateTurnKind(previous.get(node.key), node)
this.exchanges.track(previous.get(node.key), node)
this.dirtyKeys.add(node.key)
this.dirtyProcessKeys.add(node.key)
}
@@ -203,6 +208,7 @@ class MutableChatNodeStore implements ChatNodeStore {
}
for (const key of previous.keys()) {
this.updateTurnKind(previous.get(key), undefined)
this.exchanges.track(previous.get(key), undefined)
this.dirtyKeys.add(key)
this.dirtyProcessKeys.add(key)
}
@@ -215,6 +221,7 @@ class MutableChatNodeStore implements ChatNodeStore {
for (const node of nodes) {
if (this.byKey.get(node.key) === node) continue
this.updateTurnKind(this.byKey.get(node.key), node)
this.exchanges.track(this.byKey.get(node.key), node)
this.byKey.set(node.key, node)
this.dirtyKeys.add(node.key)
this.dirtyProcessKeys.add(node.key)
@@ -1114,7 +1121,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
this.order = orderedVisibleChatNodes(nodes).map(node => node.key)
this.locations.rebuild(this.order, this.store)
this.store.replaceProcesses(this.order, this.locations)
this.store.setBottomTurn(input.timeline.turnOrder.at(-1), this.locations)
this.store.settleBottom(input.timeline.turnOrder, this.locations)
this.navigation.rebuild(input.timeline, this.locations, this.store)
this.timeline = input.timeline
this.latestGroupInput = {
@@ -1165,7 +1172,7 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder<ChatConversa
}
this.locations.touch(contentOnly)
this.store.updateProcesses(processTurns, this.locations)
this.store.setBottomTurn(input.timeline.turnOrder.at(-1), this.locations)
this.store.settleBottom(input.timeline.turnOrder, this.locations)
if (structural || input.timeline !== this.timeline) {
this.navigation.rebuild(input.timeline, this.locations, this.store)
} else {
@@ -4,6 +4,7 @@ import type {
import type {
ChatNode, ChatNodeDataMap, ChatNodeKind,
} from '../contract/chat-nodes.ts'
import type { ChatGroupTree } from '../contract/group-tree.ts'
/**
* Relative positions in one durable event's seq neighborhood: interrupted
@@ -34,7 +35,7 @@ export function contextLocation(context: ConversationNodeContext): ConversationL
* @param kind - Chat renderer dispatch key.
* @param anchorSeq - sortable render position.
* @param data - renderer-owned payload.
* @param options - optional Location and visibility overrides.
* @param options - optional Location, visibility, and group-tree overrides.
* @returns final Chat view Node.
*/
export function chatNode<Kind extends ChatNodeKind>(
@@ -45,6 +46,7 @@ export function chatNode<Kind extends ChatNodeKind>(
options: {
readonly location?: ConversationLocation
readonly visibility?: 'visible' | 'hidden'
readonly groupTree?: ChatGroupTree | undefined
} = {},
): ChatNode<Kind> {
return {
@@ -55,6 +57,7 @@ export function chatNode<Kind extends ChatNodeKind>(
anchorSeq,
location: options.location ?? contextLocation(context),
visibility: options.visibility ?? 'visible',
groupTree: options.groupTree,
data,
}
}
@@ -0,0 +1,82 @@
import type { ChatConversationViewNode } from '../contract/chat-nodes.ts'
import { isAtOrOutside, type ChatGroupTree } from '../contract/group-tree.ts'
const NO_TURNS: readonly number[] = []
/**
* Loaded Turns split into consecutive segments at the Turns holding a Node at
* one group-tree position, visible or hidden. The latest segment starts at the
* last such Turn, or at the first loaded Turn when none is loaded. Node changes
* update per-Turn counts, so no operation scans the loaded Nodes.
*/
export class GroupTreeSegments {
/** Opening Node count by owning Turn. */
private readonly openings = new Map<number, number>()
private latestOpening: number | undefined
private latestStart: number | undefined
/** @param position - group-tree position whose Nodes open a segment. */
constructor(private readonly position: ChatGroupTree) {}
/**
* Account one Node change.
* @param previous - replaced Node; absent for an addition.
* @param next - replacing Node; absent for a removal.
*/
track(previous: ChatConversationViewNode | undefined, next: ChatConversationViewNode | undefined): void {
const before = this.openingTurn(previous)
const after = this.openingTurn(next)
if (before === after) return
if (before !== undefined && this.adjust(before, -1) === 0 && before === this.latestOpening) {
this.latestOpening = this.openings.size === 0 ? undefined : Math.max(...this.openings.keys())
}
if (after !== undefined) {
this.adjust(after, 1)
if (this.latestOpening === undefined || after > this.latestOpening) this.latestOpening = after
}
}
/**
* Test latest-segment membership as of the last `settle`.
* @param turn - owning Turn, when resolved.
* @returns whether the Turn belongs to the latest segment.
*/
inLatest(turn: number | undefined): boolean {
return turn !== undefined && this.latestStart !== undefined && turn >= this.latestStart
}
/**
* Move the latest segment start for the currently loaded Turns.
* @param turnOrder - loaded Turns in ascending timeline order.
* @returns loaded Turns that entered or left the latest segment, in ascending order.
*/
settle(turnOrder: readonly number[]): readonly number[] {
const previous = this.latestStart
const start = this.latestOpening ?? turnOrder[0]
if (start === previous) return NO_TURNS
this.latestStart = start
const bounded = previous !== undefined && start !== undefined
const low = bounded ? Math.min(previous, start) : Number.NEGATIVE_INFINITY
const high = bounded ? Math.max(previous, start) : Number.POSITIVE_INFINITY
const changed: number[] = []
for (const turn of turnOrder.slice(turnOrder.findLastIndex(turn => turn < low) + 1)) {
if (turn >= high) break
changed.push(turn)
}
return changed
}
private openingTurn(node: ChatConversationViewNode | undefined): number | undefined {
if (node === undefined || !isAtOrOutside(node, this.position)) return undefined
const location = node.location
return location.kind === 'turn' || location.kind === 'step' ? location.turn.turn : undefined
}
/** @returns the Turn's opening count after the change; zero removes the Turn. */
private adjust(turn: number, delta: number): number {
const next = (this.openings.get(turn) ?? 0) + delta
if (next > 0) this.openings.set(turn, next)
else this.openings.delete(turn)
return next
}
}
@@ -4,7 +4,7 @@ import type {
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session/surface'
import type { InboxState } from './inbox.ts'
import { chatNode } from './common.ts'
import { chatNode, contextLocation } from './common.ts'
import { contextForm, contextProducer } from './event-projection.ts'
interface ReferencedUserMessageNode extends UserMessageNode {
@@ -115,7 +115,18 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
const waking = context.state.kind === 'context'
&& context.start?.event.type === 'user/message'
&& context.state.waking === true
return chatNode(context, waking ? 'turn-trigger' : context.state.kind, context.state.seq, context.state)
// Human input claimed for Step 1 opens an exchange; Notice-woken Turns and
// later steering stay in the current exchange, so they never fold earlier Turns.
const location = contextLocation(context)
const opensExchange = (context.state.kind === 'user' || context.state.kind === 'steering')
&& location.kind === 'step' && location.step.step === 1
return chatNode(
context,
waking ? 'turn-trigger' : context.state.kind,
context.state.seq,
context.state,
{ groupTree: opensExchange ? 'exchange' : undefined },
)
},
}
@@ -27,6 +27,7 @@ export type {
FinalAssistantChatData, ManualCompactionChatData, RetryChatData, ToolChatData,
TurnProcessChatData, TurnTailChatData,
} from './contract/chat-nodes.ts'
export type { ChatGroupTree } from './contract/group-tree.ts'
export type { ChatStoreState, ToolCallId, TurnProcessViewEntry } from './contract/store.ts'
export type { TranscriptViewRowInjected, TranscriptViewRowProps } from './settings/TranscriptViewRow.tsx'
export type { TranscriptViewMode } from '../chat-settings.ts'
@@ -18,15 +18,20 @@ function node(index: number, owner = turn(index), text = ''): ChatConversationVi
}
}
/** A Node whose Turn opens an exchange. */
function opening(index: number, owner = turn(index), text = ''): ChatConversationViewNode {
return { ...node(index, owner, text), groupTree: 'exchange' }
}
function timeline(turns: readonly TurnLocation[]): ConversationTimelineSnapshot {
return { turnOrder: turns.map(value => value.turn), turns: new Map(turns.map(value => [value.turn, value])) }
}
describe('assembler-owned Chat bottom membership', () => {
it('notifies only old and new bottom keys among 4,000 observed historical Nodes', () => {
it('notifies only the Turns leaving or entering the latest exchange among 4,000 observed historical Nodes', () => {
const builder = new ChatSnapshotBuilder()
const turns = Array.from({ length: 4_000 }, (_, index) => turn(index + 1))
const nodes = turns.map(value => node(value.turn, value))
const nodes = turns.map(value => opening(value.turn, value))
const snapshot = builder.replace({ nodes, timeline: timeline(turns) })
builder.publish()
const sources = nodes.map(value => snapshot.nodes.bottomSource(value.key))
@@ -36,7 +41,7 @@ describe('assembler-owned Chat bottom membership', () => {
return listener
})
const nextTurn = turn(4_001)
const nextNode = node(4_001, nextTurn)
const nextNode = opening(4_001, nextTurn)
const nextSource = snapshot.nodes.bottomSource(nextNode.key)
const nextChanged = vi.fn()
nextSource.subscribe(nextChanged)
@@ -55,17 +60,17 @@ describe('assembler-owned Chat bottom membership', () => {
expect(snapshot.nodes.bottomSource(nextNode.key)).toBe(nextSource)
nextChanged.mockClear()
builder.apply({ upserts: [node(4_001, nextTurn, 'updated')], timeline: timeline([...turns, nextTurn]) })
builder.apply({ upserts: [opening(4_001, nextTurn, 'updated')], timeline: timeline([...turns, nextTurn]) })
builder.publish()
expect(nextChanged).not.toHaveBeenCalled()
expect(listeners.at(-1)).toHaveBeenCalledOnce()
})
it('advances on a Turn boundary before its Node materializes and retains source identity across replacement', () => {
it('keeps a bare Turn boundary and continuation Turns in the latest exchange until an opening Node arrives', () => {
const builder = new ChatSnapshotBuilder()
const first = turn(1)
const second = turn(2)
const initial = builder.replace({ nodes: [node(1, first)], timeline: timeline([first]) })
const initial = builder.replace({ nodes: [opening(1, first)], timeline: timeline([first]) })
builder.publish()
const oldBottom = initial.nodes.bottomSource('user:1')
const nextBottom = initial.nodes.bottomSource('user:2')
@@ -73,26 +78,92 @@ describe('assembler-owned Chat bottom membership', () => {
const unsubscribe = oldBottom.subscribe(changed)
builder.apply({ upserts: [], timeline: timeline([first, second]) })
builder.publish()
expect(changed).toHaveBeenCalledOnce()
expect(oldBottom.getSnapshot()).toBe(false)
expect(nextBottom.getSnapshot()).toBe(false)
expect(changed).not.toHaveBeenCalled()
expect(oldBottom.getSnapshot()).toBe(true)
builder.apply({ upserts: [node(2, second)], timeline: timeline([first, second]) })
builder.publish()
expect(nextBottom.getSnapshot()).toBe(true)
expect(changed).not.toHaveBeenCalled()
expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([true, true])
builder.apply({ upserts: [opening(2, second)], timeline: timeline([first, second]) })
builder.publish()
expect(changed).toHaveBeenCalledOnce()
expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([false, true])
unsubscribe()
const replaced = builder.replace({ nodes: [node(1, first)], timeline: timeline([first]) })
const replaced = builder.replace({ nodes: [opening(1, first)], timeline: timeline([first]) })
builder.publish()
expect(replaced.nodes.bottomSource('user:1')).toBe(oldBottom)
expect(oldBottom.getSnapshot()).toBe(true)
expect(nextBottom.getSnapshot()).toBe(false)
expect(changed).toHaveBeenCalledOnce()
expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([true, false])
builder.replace({ nodes: [node(1, first), node(2, second)], timeline: timeline([first, second]) })
builder.publish()
expect([oldBottom.getSnapshot(), nextBottom.getSnapshot()]).toEqual([true, true])
})
it('starts the latest exchange at the first loaded Turn while no opening Node is loaded', () => {
const builder = new ChatSnapshotBuilder()
const turns = [turn(3), turn(4), turn(5)]
const initial = builder.replace({ nodes: turns.map(value => node(value.turn, value)), timeline: timeline(turns) })
builder.publish()
const bottoms = ['user:3', 'user:4', 'user:5'].map(key => initial.nodes.bottomSource(key))
expect(bottoms.map(source => source.getSnapshot())).toEqual([true, true, true])
const older = [turn(1), turn(2), ...turns]
const loaded = builder.replace({ nodes: older.map(value => node(value.turn, value)), timeline: timeline(older) })
builder.publish()
expect(['user:1', 'user:2'].map(key => loaded.nodes.bottomSource(key).getSnapshot())).toEqual([true, true])
builder.replace({ nodes: [], timeline: timeline([]) })
builder.publish()
expect(bottoms.map(source => source.getSnapshot())).toEqual([false, false, false])
})
it('follows the latest remaining opening Turn when opening Nodes leave, move, or repeat in one Turn', () => {
const builder = new ChatSnapshotBuilder()
const turns = [turn(1), turn(2), turn(3), turn(4)]
const second: ChatConversationViewNode = { ...opening(22, turns[1]), key: 'user:2b' }
const nodes = [opening(1, turns[0]), opening(2, turns[1]), second, node(3, turns[2]), opening(4, turns[3])]
const initial = builder.replace({ nodes, timeline: timeline(turns) })
builder.publish()
const bottom = (key: string): boolean => initial.nodes.bottomSource(key).getSnapshot()
const membership = (): boolean[] => ['user:1', 'user:2', 'user:3', 'user:4'].map(bottom)
expect(membership()).toEqual([false, false, false, true])
const before = vi.fn()
const unchanged = vi.fn()
initial.nodes.bottomSource('user:2').subscribe(before)
initial.nodes.bottomSource('user:1').subscribe(unchanged)
builder.apply({ upserts: [node(4, turns[3])], timeline: initial.timeline })
builder.publish()
expect(membership()).toEqual([false, true, true, true])
expect(before).toHaveBeenCalledOnce()
expect(unchanged).not.toHaveBeenCalled()
builder.apply({ upserts: [node(2, turns[1])], timeline: initial.timeline })
builder.publish()
expect(membership()).toEqual([false, true, true, true])
builder.apply({ upserts: [{ ...second, location: { kind: 'session' } }], timeline: initial.timeline })
builder.publish()
expect(membership()).toEqual([true, true, true, true])
builder.apply({ upserts: [{ ...opening(3, turns[2]), visibility: 'hidden' }], timeline: initial.timeline })
builder.publish()
expect(membership()).toEqual([false, false, false, true])
expect(bottom('user:2b')).toBe(false)
builder.replace({ nodes: [opening(1, turns[0]), node(2, turns[1]), node(4, turns[3])], timeline: initial.timeline })
builder.publish()
expect(membership()).toEqual([true, true, false, true])
})
it('updates same-key Turn moves without changing root order and excludes unplaced or hidden Nodes', () => {
const builder = new ChatSnapshotBuilder()
const first = turn(1)
const second = turn(2)
const initial = builder.replace({ nodes: [node(1, first)], timeline: timeline([first, second]) })
const initial = builder.replace({ nodes: [node(1, first), opening(2, second)], timeline: timeline([first, second]) })
builder.publish()
const bottom = initial.nodes.bottomSource('user:1')
expect(bottom.getSnapshot()).toBe(false)
@@ -19,6 +19,7 @@ import {
isSubagentDelegationTool, sameTurnProcessSpec, TURN_PROCESS_INDEPENDENT_KINDS,
type TurnProcessSpec,
} from '../src/client/contract/turn-process.ts'
import { isAtOrOutside } from '../src/client/contract/group-tree.ts'
const EMPTY: readonly never[] = []
@@ -106,7 +107,8 @@ class FixtureNodeStore implements ChatNodeStore {
private readonly sources = new Map<string, FixtureSource<ChatConversationViewNode | undefined>>()
private readonly processSources = new Map<string, FixtureSource<ChatTurnProcessPresentation | undefined>>()
private readonly bottomSources = new Map<string, FixtureSource<boolean>>()
bottomTurn: number | undefined
/** First Turn of the latest loaded exchange. */
bottomStart: number | undefined
private readonly dirtyKeys = new Set<string>()
private readonly dirtyProcessKeys = new Set<string>()
private list: readonly ChatConversationViewNode[] = EMPTY
@@ -122,8 +124,8 @@ class FixtureNodeStore implements ChatNodeStore {
bottomSource(key: string): ReturnType<ChatNodeStore['bottomSource']> {
return cachedSource(this.bottomSources, key, () => new FixtureSource(() => {
const location = this.get(key)?.location
return this.bottomTurn !== undefined && (location?.kind === 'turn' || location?.kind === 'step')
&& location.turn.turn === this.bottomTurn
return this.bottomStart !== undefined && (location?.kind === 'turn' || location?.kind === 'step')
&& location.turn.turn >= this.bottomStart
}))
}
@@ -270,6 +272,8 @@ function settledNode(
? { kind: 'session' as const }
: { kind: 'turn' as const, turn },
visibility: 'visible' as const,
// A fixture user message inside a Turn stands for its opening human input.
groupTree: node.kind === 'user' && turn !== undefined ? 'exchange' as const : undefined,
}
switch (node.kind) {
case 'assistant':
@@ -527,7 +531,11 @@ export function chatSnapshotFixture(input: {
&& previous.legacy.turnEnds === legacy.turnEnds
? previous.timeline
: { turnOrder: [...turns.keys()], turns }
store.bottomTurn = timeline.turnOrder.at(-1)
const openingTurns = store.values().flatMap((node) => {
const location = node.location
return isAtOrOutside(node, 'exchange') && (location.kind === 'turn' || location.kind === 'step') ? [location.turn.turn] : []
})
store.bottomStart = openingTurns.length === 0 ? timeline.turnOrder[0] : Math.max(...openingTurns)
const derived = timeline.turnOrder
.map(turn => turnNavigationItem(turn, locations, store))
.filter((item): item is TurnNavigationItem => item !== undefined)
@@ -659,6 +659,62 @@ describe('built-in conversation node Definitions', () => {
}
})
it('opens exchanges only for human input claimed for Step 1', () => {
const human = textMessage('block-human', 'first task')
const steer = textMessage('block-steer', 'adjust course')
const notice = { ...textMessage('block-notice', 'background work finished'), source: { kind: 'schedule' } }
const idle = textMessage('block-idle', 'next task')
const bottomTurns = (current: ChatSnapshot): number[] => [...new Set(current.order.flatMap((key) => {
const location = current.nodes.get(key)?.location
return current.nodes.bottomSource(key).getSnapshot() && (location?.kind === 'turn' || location?.kind === 'step')
? [location.turn.turn]
: []
}))]
const inputs = (current: ChatSnapshot) => current.order
.map(key => current.nodes.get(key))
.filter(candidate => candidate?.kind === 'user' || candidate?.kind === 'steering' || candidate?.kind === 'turn-trigger')
.map(candidate => [candidate?.kind, candidate?.groupTree ?? null])
const value = assembler([
at(1, 'agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [human] }),
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', human, { surfaceOp: 'append' }),
at(6, 'step/end', { turn: 1, step: 1 }),
at(7, 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [steer] }),
at(8, 'agent/inbox/spliced', { target: 'next-step', start: 0, removedCount: 1, inserted: [] }),
at(9, 'step/start', { turn: 1, step: 2 }),
at(10, 'user/message', steer, { surfaceOp: 'append' }),
at(11, 'assistant/message', { turn: 1, step: 2, message: assistantMessage('block-answer-1', 'done') }, { surfaceOp: 'append' }),
at(12, 'step/end', { turn: 1, step: 2 }),
at(13, 'turn/end', { turn: 1, reason: { kind: 'completed' } }),
// The host assembles the system prompt between the waking claim and Step 1.
at(14, 'agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [notice] }),
at(15, 'turn/start', { turn: 2 }),
at(16, 'agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }),
])
expect(inputs(snapshot(value))).toEqual([['user', 'exchange'], ['steering', null]])
expect(bottomTurns(snapshot(value))).toEqual([1, 2])
value.append(at(17, 'step/start', { turn: 2, step: 1 }))
value.append(at(18, 'user/message', notice, { surfaceOp: 'append' }))
value.append(at(19, 'assistant/message', { turn: 2, step: 1, message: assistantMessage('block-answer-2', 'noted') }, { surfaceOp: 'append' }))
value.append(at(20, 'step/end', { turn: 2, step: 1 }))
value.append(at(21, 'turn/end', { turn: 2, reason: { kind: 'completed' } }))
value.flush()
expect(inputs(snapshot(value))).toEqual([['user', 'exchange'], ['steering', null], ['turn-trigger', null]])
expect(bottomTurns(snapshot(value))).toEqual([1, 2])
value.append(at(22, 'agent/inbox/spliced', { target: 'next-step', start: 0, inserted: [idle] }))
value.append(at(23, 'turn/start', { turn: 3 }))
value.append(at(24, 'agent/inbox/spliced', { target: 'next-step', start: 0, removedCount: 1, inserted: [] }))
value.append(at(25, 'step/start', { turn: 3, step: 1 }))
value.append(at(26, 'user/message', idle, { surfaceOp: 'append' }))
value.flush()
expect(inputs(snapshot(value)).at(-1)).toEqual(['steering', 'exchange'])
expect(bottomTurns(snapshot(value))).toEqual([3])
})
it('replays pending splice chains and scopes steering to the current claim', () => {
const first = textMessage('claim-first', 'first')
const second = textMessage('claim-second', 'second')
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import type { TurnLocation } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatConversationViewNode } from '../src/client/contract/chat-nodes.ts'
import { isAtOrOutside, type ChatGroupTree } from '../src/client/contract/group-tree.ts'
import { GroupTreeSegments } from '../src/client/conversation-nodes/group-tree-segments.ts'
function turn(index: number): TurnLocation {
return {
turn: index, start: undefined, end: undefined, status: 'closed', steps: [],
data: { get: () => undefined, source: () => ({ getSnapshot: () => undefined, subscribe: () => () => {} }) },
}
}
function node(key: string, owner: number | undefined, groupTree?: ChatGroupTree): ChatConversationViewNode {
return {
key, id: key, kind: 'user', target: 'chat', anchorSeq: 0, visibility: 'hidden', groupTree,
location: owner === undefined ? { kind: 'session' } : { kind: 'turn', turn: turn(owner) },
data: null,
}
}
function members(segments: GroupTreeSegments, turns: readonly number[]): number[] {
return turns.filter(value => segments.inLatest(value))
}
describe('Chat group tree', () => {
it('orders positions from the outermost exchange inward', () => {
const at = (groupTree?: ChatGroupTree) => (['exchange', 'turn', 'process'] as const)
.map(position => isAtOrOutside({ groupTree }, position))
expect(at('exchange')).toEqual([true, true, true])
expect(at('turn')).toEqual([false, true, true])
expect(at('process')).toEqual([false, false, true])
expect(at(undefined)).toEqual([false, false, false])
})
it('starts the latest segment at the last opening Turn and reports only the Turns that moved', () => {
const segments = new GroupTreeSegments('exchange')
const order = [1, 2, 3, 4, 5]
expect(segments.inLatest(1)).toBe(false)
expect(segments.settle(order)).toEqual(order)
expect(members(segments, order)).toEqual(order)
segments.track(undefined, node('a', 2, 'exchange'))
expect(segments.settle(order)).toEqual([1])
expect(members(segments, order)).toEqual([2, 3, 4, 5])
expect(segments.settle(order)).toEqual([])
segments.track(undefined, node('b', 4, 'exchange'))
expect(segments.settle(order)).toEqual([2, 3])
expect(members(segments, order)).toEqual([4, 5])
expect(segments.inLatest(undefined)).toBe(false)
segments.track(node('b', 4, 'exchange'), undefined)
expect(segments.settle(order)).toEqual([2, 3])
expect(members(segments, order)).toEqual([2, 3, 4, 5])
})
it('counts every opening Node of a Turn and falls back as openings leave', () => {
const segments = new GroupTreeSegments('exchange')
const order = [1, 2, 3]
const first = node('first', 1, 'exchange')
const twin = node('twin', 3, 'exchange')
const late = node('late', 3, 'exchange')
for (const value of [late, first, twin]) segments.track(undefined, value)
segments.settle(order)
expect(members(segments, order)).toEqual([3])
segments.track(late, node('late', 3))
segments.settle(order)
expect(members(segments, order)).toEqual([3])
segments.track(first, undefined)
segments.settle(order)
expect(members(segments, order)).toEqual([3])
segments.track(twin, { ...twin, location: { kind: 'session' } })
expect(segments.settle(order)).toEqual([1, 2])
expect(members(segments, order)).toEqual(order)
})
it('opens segments at Nodes of the configured position or an outer one', () => {
const segments = new GroupTreeSegments('turn')
const order = [1, 2, 3]
segments.track(undefined, node('process', 3, 'process'))
segments.track(undefined, node('turn', 2, 'turn'))
segments.track(undefined, node('exchange', 1, 'exchange'))
segments.track(node('none', 3), node('none', 3))
segments.settle(order)
expect(members(segments, order)).toEqual([2, 3])
})
it('clears the latest segment when no Turn is loaded', () => {
const segments = new GroupTreeSegments('exchange')
segments.track(undefined, node('a', 1, 'exchange'))
segments.settle([1])
segments.track(node('a', 1, 'exchange'), undefined)
expect(segments.settle([])).toEqual([])
expect(segments.inLatest(1)).toBe(false)
})
})
+1
View File
@@ -9,6 +9,7 @@
},
"include": [
"apps/web/tests/collapse-timing.e2e.ts",
"apps/web/tests/notice-wake-tail.e2e.ts",
"apps/web/tests/chat-flow-layout.e2e.ts",
"apps/web/product-isolation.ts",
"apps/web/katex-fonts.ts",