diff --git a/apps/web/tests/notice-wake-tail.e2e.ts b/apps/web/tests/notice-wake-tail.e2e.ts index 085e6e8a4b..884adcb9d3 100644 --- a/apps/web/tests/notice-wake-tail.e2e.ts +++ b/apps/web/tests/notice-wake-tail.e2e.ts @@ -21,15 +21,11 @@ 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 @@ -49,22 +45,8 @@ function readTail(page: Page): Promise { const rect = element.getBoundingClientRect() return rect.height > 0.5 && rect.bottom > viewTop && rect.top < viewBottom } - const spans = [...host.querySelectorAll('[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('[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"]')] @@ -159,7 +141,7 @@ describe('web e2e: notice Turn after a long Detailed Turn', () => { 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) + await page.locator(`[data-chat-node-key$="${callId}"]`).waitFor({ state: 'attached', timeout: 15_000 }) } session.append('assistant/message', { stream: [], @@ -177,7 +159,7 @@ describe('web e2e: notice Turn after a long Detailed Turn', () => { const host = document.querySelector('[data-conversation-scroll]') if (host !== null) host.scrollTop = host.scrollHeight }) - await page.waitForTimeout(600) + await page.locator('[data-chat-following-tail]').waitFor({ timeout: 10_000 }) // 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' } }) @@ -199,12 +181,14 @@ describe('web e2e: notice Turn after a long Detailed Turn', () => { session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }) setRunning(true) await scaffold.ctx.sessions.flush(session) + // The browser has applied the notice Turn's start before the gap is held and sampled. + await page.locator(`[data-chat-turn="${String(NOTICE_TURN)}"]`).first().waitFor({ state: 'attached', timeout: 15_000 }) 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) + await page.locator('[data-turn-trigger]').first().waitFor({ state: 'attached', timeout: 15_000 }) session.append('assistant/message', { stream: [], turn: NOTICE_TURN, @@ -216,11 +200,15 @@ describe('web e2e: notice Turn after a long Detailed Turn', () => { }, { 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) + const settled = { noticeVisible: true, replyVisible: true, longTurnOpen: true, spacer: 0 } + await expect.poll(() => readTail(page), { timeout: 15_000 }).toMatchObject(settled) + // The settled tail survives later frames instead of passing through on the way to a fold. + await page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) }) + })) + expect(await readTail(page)).toMatchObject(settled) + expect(tripwire.warnings).toEqual([]) expect(tripwire.pageErrors).toEqual([]) } finally { await page.context().close() diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index cf7009ff7f..5971f88600 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -127,6 +127,7 @@ | event log | 事件日志 | | | | | event stream | 事件流 | | | | | event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 | +| exchange | 交互段 | | 回合、交换 | Chat 中从真人输入开启的轮次起、到下一个这样的轮次为止的连续轮次;见术语表 | | Executive summary | 摘要 | | | 事故复盘标题用语 | | executor | 执行器 | | | | | expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 | diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml index 922219671d..64ed556c17 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.i18n.yaml @@ -34,7 +34,7 @@ zh: 0ab47a4014ea5e1e /chat-conversation-node-rules/display-modes/collapse-timing: en: 03d744dd1c92bdd3 - zh: b26e0fca325b47c5 + zh: 19804eaf3234632a /chat-conversation-node-rules/display-modes/group-title-rules: en: 2392290662bc69e8 zh: 8872d651c07a8f4c diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md index 72ba992f79..089a0b886a 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md +++ b/packages/client/ui-chat/src/client/conversation-nodes/README.zh.md @@ -138,7 +138,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。" 设置 → 通用设置 → 过程收起时机位于工作步骤展示正下方,与展示模式独立。「完成时收起」是默认值,保留立即折叠和即时跟尾定位;「下次发送时收起」启用延后折叠及其协调过渡。选择以 `ui-chat.collapseTiming` 保存到与工作步骤展示相同的 Host profile 设置,刷新或重新打开应用后会恢复。未保存此值的 profile 使用「完成时收起」,不会自动写入默认值。此偏好不写入浏览器存储或 Session 数据。 -选择「下次发送时收起」后,最新交互段完成时保留运行中的呈现:简洁和标准保留过程组头,详细保留平铺过程行。交互段从首个 Step 接收真人输入的轮次开始,包含之后的所有轮次,直到下一个这样的轮次,因此由通知(子 agent 完成、goal 轮次、定时任务或后台作业)唤醒的轮次不会收起同一交互段里更早的轮次。普通 transcript 输入进入待发送状态,或出现开启更新交互段的轮次时,取消这项例外,但保留用户对整轮的手动开合选择;仅出现新轮次的开始不会取消,因为 Host 先认领唤醒输入,再记录 Step 1。只有外层过程实际隐藏时,才重置内部开合。排队输入和单独的 steering 不会取消例外。重新打开 Session 时,其最新交互段也享有例外,已加载窗口中没有开启轮次时从第一个已加载轮次算起;整轮的手动开合选择仍优先。切回「完成时收起」会立即取消例外。 +选择「下次发送时收起」后,最新交互段完成时保留运行中的呈现:简洁和标准保留过程组头,详细保留平铺过程行。交互段从首个 Step 接收真人输入的轮次开始,包含之后的所有轮次,直到下一个这样的轮次,因此由通知(子 agent 完成、Goal Round、定时任务或后台作业)唤醒的轮次不会收起同一交互段里更早的轮次。普通 transcript 输入进入待发送状态,或出现开启更新交互段的轮次时,取消这项例外,但保留用户对整轮的手动开合选择;仅出现新轮次的开始不会取消,因为 Host 先认领唤醒输入,再记录 Step 1。只有外层过程实际隐藏时,才重置内部开合。排队输入和单独的 steering 不会取消例外。重新打开 Session 时,其最新交互段也享有例外,已加载窗口中没有开启轮次时从第一个已加载轮次算起;整轮的手动开合选择仍优先。切回「完成时收起」会立即取消例外。 新消息触发折叠时,过程收起与跟随新消息合为一次向上运动,折叠结束后不再追加滚动。屏幕外的步骤不增加底部补白;只有保留的正文和新消息无法填满可见内容移动所需空间时,才保留必要差额。折叠期间新增的内容会更新剩余运动;重新展开全部正在收起的行时,取消该运动并释放失效的底部补白。阅读手势会中断自动跟随,折叠结束不会重新接管。 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index 4bd8ce2704..dad0b6e51e 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -12,6 +12,7 @@ import type { ChatTurnNavigationIndex, ChatTurnProcessPresentation, LegacyConversationSlice, TurnNavigationItem, } from '../contract/snapshot.ts' import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts' +import { locationCoordinates } from './common.ts' import { sessionRecallLabels, skillInvocationName } from './event-projection.ts' import { GroupTreeSegments } from './group-tree-segments.ts' import { sameTurnNavigationItem, turnNavigationItem } from './turn-navigation.ts' @@ -415,12 +416,6 @@ function stepKey(turn: number, step: number): string { return `${turn}:${step}` } -function locationCoordinates(location: ConversationLocation): { turn?: number; step?: number } { - if (location.kind === 'step') return { turn: location.turn.turn, step: location.step.step } - if (location.kind === 'turn') return { turn: location.turn.turn } - return {} -} - function locationTurnStatus(location: ConversationLocation): string | undefined { return location.kind === 'turn' || location.kind === 'step' ? location.turn.status : undefined } diff --git a/packages/client/ui-chat/src/client/conversation-nodes/common.ts b/packages/client/ui-chat/src/client/conversation-nodes/common.ts index 55e2bb6a8b..ac7859271b 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/common.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/common.ts @@ -29,6 +29,17 @@ export function contextLocation(context: ConversationNodeContext): ConversationL return context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' } } +/** + * Read the Turn and Step a Location resolves to. + * @param location - engine-owned Location. + * @returns resolved coordinates; Session-level and unresolved Locations have none. + */ +export function locationCoordinates(location: ConversationLocation): { turn?: number; step?: number } { + if (location.kind === 'step') return { turn: location.turn.turn, step: location.step.step } + if (location.kind === 'turn') return { turn: location.turn.turn } + return {} +} + /** * Build one final Chat target Node with the engine-owned stable key. * @param context - assembled business Context. diff --git a/packages/client/ui-chat/src/client/conversation-nodes/group-tree-segments.ts b/packages/client/ui-chat/src/client/conversation-nodes/group-tree-segments.ts index 2628bb3f6c..738b79ef29 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/group-tree-segments.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/group-tree-segments.ts @@ -1,5 +1,6 @@ import type { ChatConversationViewNode } from '../contract/chat-nodes.ts' import { isAtOrOutside, type ChatGroupTree } from '../contract/group-tree.ts' +import { locationCoordinates } from './common.ts' const NO_TURNS: readonly number[] = [] @@ -7,7 +8,9 @@ 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. + * update per-Turn counts without scanning the loaded Nodes; `settle` returns at + * once while the start holds and otherwise reads loaded Turns only from the + * earlier of the previous and new starts. */ export class GroupTreeSegments { /** Opening Node count by owning Turn. */ @@ -28,7 +31,7 @@ export class GroupTreeSegments { 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()) + this.latestOpening = this.lastOpeningTurn() } if (after !== undefined) { this.adjust(after, 1) @@ -67,9 +70,15 @@ export class GroupTreeSegments { } 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 + return node !== undefined && isAtOrOutside(node, this.position) ? locationCoordinates(node.location).turn : undefined + } + + private lastOpeningTurn(): number | undefined { + let last: number | undefined + for (const turn of this.openings.keys()) { + if (last === undefined || turn > last) last = turn + } + return last } /** @returns the Turn's opening count after the change; zero removes the Turn. */ diff --git a/packages/client/ui-chat/src/client/conversation-nodes/message.ts b/packages/client/ui-chat/src/client/conversation-nodes/message.ts index 27ad1c07e2..d5ef45f93c 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/message.ts @@ -125,7 +125,7 @@ export const messageDefinition: ConversationNodeDefinition = { waking ? 'turn-trigger' : context.state.kind, context.state.seq, context.state, - { groupTree: opensExchange ? 'exchange' : undefined }, + { location, groupTree: opensExchange ? 'exchange' : undefined }, ) }, } diff --git a/packages/client/ui-chat/tests/group-tree-segments.client.spec.ts b/packages/client/ui-chat/tests/group-tree-segments.client.spec.ts index 8e092e7633..377ca9c7d2 100644 --- a/packages/client/ui-chat/tests/group-tree-segments.client.spec.ts +++ b/packages/client/ui-chat/tests/group-tree-segments.client.spec.ts @@ -78,6 +78,21 @@ describe('Chat group tree', () => { expect(members(segments, order)).toEqual(order) }) + it('falls back to the latest remaining opening Turn whatever order the openings arrived in', () => { + const segments = new GroupTreeSegments('exchange') + const order = [1, 2, 3, 4, 5] + const latest = node('latest', 5, 'exchange') + for (const value of [node('middle', 3, 'exchange'), node('earliest', 1, 'exchange'), latest]) { + segments.track(undefined, value) + } + segments.settle(order) + expect(members(segments, order)).toEqual([5]) + + segments.track(latest, undefined) + expect(segments.settle(order)).toEqual([3, 4]) + expect(members(segments, order)).toEqual([3, 4, 5]) + }) + it('opens segments at Nodes of the configured position or an outer one', () => { const segments = new GroupTreeSegments('turn') const order = [1, 2, 3]