fix(ui-chat): address review on notice-woken exchanges
Share locationCoordinates between the Chat Builder and GroupTreeSegments, find the last opening Turn with a loop instead of spreading every key, and pass the resolved Location through chatNode so message Nodes resolve it once. The notice e2e now waits for observable barriers (each Step's row, the notice Turn's start, the notice row) instead of fixed sleeps, asserts the settled tail through polling plus a later-frame recheck instead of one absolute blank threshold, and fails on console warnings. Record exchange in the terminology table and keep Goal Round in English.
This commit is contained in:
1 parent
c62f26aaa1
commit
453a79206c
9 files changed
+58
-39
No files matched your search
@@ -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<Tail> {
|
||||
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"]')]
|
||||
@@ -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<HTMLElement>('[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<void>((resolve) => {
|
||||
requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) })
|
||||
}))
|
||||
expect(await readTail(page)).toMatchObject(settled)
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
} finally {
|
||||
await page.context().close()
|
||||
|
||||
@@ -127,6 +127,7 @@
|
||||
| event log | 事件日志 | | | |
|
||||
| event stream | 事件流 | | | |
|
||||
| event-sourced | 事件溯源 | | | 沿用 DDD 社区通行译法 |
|
||||
| exchange | 交互段 | | 回合、交换 | Chat 中从真人输入开启的轮次起、到下一个这样的轮次为止的连续轮次;见术语表 |
|
||||
| Executive summary | 摘要 | | | 事故复盘标题用语 |
|
||||
| executor | 执行器 | | | |
|
||||
| expected output | 预期输出 | | 金标 | 指 snapshot 比较产物;翻译语料的人工校准样例不在此列 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 时,其最新交互段也享有例外,已加载窗口中没有开启轮次时从第一个已加载轮次算起;整轮的手动开合选择仍优先。切回「完成时收起」会立即取消例外。
|
||||
|
||||
新消息触发折叠时,过程收起与跟随新消息合为一次向上运动,折叠结束后不再追加滚动。屏幕外的步骤不增加底部补白;只有保留的正文和新消息无法填满可见内容移动所需空间时,才保留必要差额。折叠期间新增的内容会更新剩余运动;重新展开全部正在收起的行时,取消该运动并释放失效的底部补白。阅读手势会中断自动跟随,折叠结束不会重新接管。
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -125,7 +125,7 @@ export const messageDefinition: ConversationNodeDefinition<MessageNode> = {
|
||||
waking ? 'turn-trigger' : context.state.kind,
|
||||
context.state.seq,
|
||||
context.state,
|
||||
{ groupTree: opensExchange ? 'exchange' : undefined },
|
||||
{ location, groupTree: opensExchange ? 'exchange' : undefined },
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user