diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml
index 98d357296c..cad48f6e6a 100644
--- a/packages/client/ui-chat/README.i18n.yaml
+++ b/packages/client/ui-chat/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
-README.md: 34ac2d6e79b6ec67583b7a0f85d48e71f7fb27b1
-README.zh.md: bcb521100ff11b59e1e1c2b5a47b45cabb1ce57d
+README.md: 67ef87a7ffdcf669d39e5d7caff983049e08063c
+README.zh.md: d42505e2a0dedc502476b00ce4d4416947d793dd
diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md
index 34ac2d6e79..67ef87a7ff 100644
--- a/packages/client/ui-chat/README.md
+++ b/packages/client/ui-chat/README.md
@@ -58,6 +58,8 @@ Settings → General → Performance & usage stores `ui-chat.performanceUsage` a
On non-loopback browsers, the preference remains process-local because the settings scope cannot persist writes. Explicit selections update every consumer immediately; accepted Host settings reconcile the live value on loopback browsers.
+Preference menus restore focus to their trigger without scrolling before publishing a new selection.
+
## Completed-turn footer
@@ -70,8 +72,6 @@ The completed-turn action footer follows the recorded Turn end. Its action row s
## Turn Process Folding
-Preference menus restore focus to their trigger without scrolling before publishing a new selection.
-
Local steering echoes remain mounted through Inbox acceptance and claim until the durable message arrives. Matching Host-pending rows stay hidden during this handoff, and the same request does not trigger tail following twice.
Work-details modes control process-group display and reasoning previews; eligible completed Turns fold their process without hiding the final answer. The [business-rule reference](src/client/conversation-nodes/README.md#display-modes) contains the mode table, title behavior, whole-Turn eligibility, clocks, and disclosure resets.
@@ -109,7 +109,7 @@ While the pointer is outside the rail, automatic follow keeps the rail still whe
`useChatViewport` owns turn-aware DOM reads, clamped writes, native events, and one retained paging anchor. For Load older, Node and Group seats mark eligible anchors from their existing disclosure state. The viewport selects the first nonempty, unhidden marker in transcript order without hit testing or geometry-based search, then measures that element and its scroll containers. It compensates the anchor's capped group first, then gives the remaining displacement to the transcript scrollport. Commits and later content resizes reuse that anchor; a remounted row is resolved by the same semantic key. Compensation stays within the actual scroll ranges without adding bottom space.
-`useChatReading` owns follow policy, sampled reader input, and semantic memory; `useChatNavigation` owns turn jumps and requests preservation from the viewport. Reader input releases the paging anchor; while a page is still loading, `scrollend` captures the reader's new position. `useChatScroll` coordinates their committed inputs. Explicit navigation carries its measured landing into reading policy, so it does not rediscover the known target with a hit test.
+`useChatReading` owns follow policy, sampled reader input, and semantic memory; `useChatNavigation` owns turn jumps and requests preservation from the viewport. Reading gestures release the paging anchor, but composer clicks, typing, and non-scrolling keys retain it; while a page is still loading, `scrollend` captures the reader's new position. `useChatScroll` coordinates their committed inputs. Explicit navigation carries its measured landing into reading policy, so it does not rediscover the known target with a hit test.
Active-Turn highlighting is approximate: `readVisibleTurn` binary-searches the content column's direct Node/Group boxes and retains the preceding candidate in gaps. It neither hit-tests the document nor searches Group members or all Turn markers. Empty Seats retain zero-height in-flow boxes so outer positions remain ordered without extra spacing. This lookup does not change semantic position capture or paging compensation.
diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md
index bcb521100f..d42505e2a0 100644
--- a/packages/client/ui-chat/README.zh.md
+++ b/packages/client/ui-chat/README.zh.md
@@ -58,6 +58,8 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,
在非回环地址浏览器中,设置作用域无法持久化写入,因此该偏好仅在当前进程内生效。明确选择会立即更新所有使用方;回环地址浏览器收到 Host 已接受的设置后会同步当前值。
+偏好菜单在发布新选择之前,先将焦点还给触发按钮,且不引起滚动。
+
## 已完成轮次的页脚
@@ -70,8 +72,6 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,
## 轮次过程折叠
-偏好菜单在发布新选择之前,先将焦点还给触发按钮,且不引起滚动。
-
本地 steering 回显在 Inbox 接受与领取期间保持挂载,直到持久消息到达。交接期间隐藏同请求的 Host 待处理行,同一请求不会重复触发跟随底部。
工作过程展示模式控制过程组显示与推理预览;符合条件的已完成轮次折叠过程,但不隐藏最终答案。[业务规则明细](src/client/conversation-nodes/README.zh.md#display-modes) 统一说明模式表、组头行为、整轮折叠资格、时钟与开合重置。
@@ -109,7 +109,7 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且
`useChatViewport` 负责识别轮次的 DOM 读取、限制范围后的写入、原生事件及一个持续保留的分页锚点。加载更早时,Node 与 Group 容器根据已有开合状态标记可选锚点。视口按正文顺序选择第一个非空、未隐藏的标记,不做命中测试或按几何位置搜索,再测量该元素及其滚动容器。先补偿锚点所在的限高组,再把剩余位移交给整段文本的滚动区域。提交和后续内容尺寸变化复用该锚点;消息行重挂载时按同一语义 key 重新定位。补偿限于实际可滚动范围,不增加底部空白。
-`useChatReading` 负责跟随策略、读者输入采样和语义位置记忆;`useChatNavigation` 负责轮次跳转,并向视口请求位置保持。读者输入释放分页锚点;分页仍在加载时,`scrollend` 会记录读者的新位置。`useChatScroll` 协调已提交的输入。显式导航把测得的落点交给阅读策略,因此无需通过命中测试重新寻找已知目标。
+`useChatReading` 负责跟随策略、读者输入采样和语义位置记忆;`useChatNavigation` 负责轮次跳转,并向视口请求位置保持。阅读手势释放分页锚点,composer 内的点击、打字和非滚动按键则保留锚点;分页仍在加载时,`scrollend` 会记录读者的新位置。`useChatScroll` 协调已提交的输入。显式导航把测得的落点交给阅读策略,因此无需通过命中测试重新寻找已知目标。
活跃轮次高亮采用近似定位:`readVisibleTurn` 对正文容器的直属 Node/Group 布局盒二分,落在间隙时保留前方候选。它不做文档命中测试,也不查询 Group 成员或全部 Turn 标记。空 Seat 保留零高度的流内布局盒,使外层位置有序且不增加间距。这种定位不改变语义位置捕获或分页补偿。
diff --git a/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx
index 5d5e6802c6..7df6e718d0 100644
--- a/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx
+++ b/packages/client/ui-chat/src/client/chat/TurnTriggerNodeView.tsx
@@ -26,7 +26,7 @@ const TRIGGER_ICONS: Record> = {
}
/** Render recorded trigger attribution above the whole-Turn disclosure. */
-export function TurnTriggerNodeView({ node, t }: ChatNodeViewProps<'turn-trigger'>) {
+export function TurnTriggerNodeView({ node, t }: Pick, 'node' | 't'>) {
const [open, setOpen] = useState(false)
const bodyId = useId()
const details = turnTriggerDetails(node.data)
diff --git a/packages/client/ui-chat/src/client/chat/turn-trigger.ts b/packages/client/ui-chat/src/client/chat/turn-trigger.ts
index 6555796e98..0e560572e2 100644
--- a/packages/client/ui-chat/src/client/chat/turn-trigger.ts
+++ b/packages/client/ui-chat/src/client/chat/turn-trigger.ts
@@ -24,7 +24,6 @@ export function turnTriggerDetails(node: ContextMessageNode): {
} {
const source = record(node.source)
const kind = field(source, 'kind')
- const plugin = field(source, 'plugin')
let title: ChatKey = 'message.trigger.request'
let icon: TurnTriggerIcon = 'request'
switch (kind) {
@@ -52,17 +51,17 @@ export function turnTriggerDetails(node: ContextMessageNode): {
icon = github ? 'github' : 'webhook'
break
}
- case 'plugin':
- if (plugin === 'schedule') {
- title = 'message.trigger.schedule'
- icon = 'schedule'
- } else if (plugin === 'tool-jobs') {
- title = 'message.trigger.job'
- icon = 'job'
- } else if (plugin === 'cordis-host-runner') {
- title = 'message.trigger.plugin'
- icon = 'plugin'
- }
+ case 'schedule':
+ title = 'message.trigger.schedule'
+ icon = 'schedule'
+ break
+ case 'tool-jobs':
+ title = 'message.trigger.job'
+ icon = 'job'
+ break
+ case 'cordis-host-runner':
+ title = 'message.trigger.plugin'
+ icon = 'plugin'
break
default:
// Custom sources remain visible without attributing unrecorded identity or success.
diff --git a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts
index efb06b7942..42bee3ebe4 100644
--- a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts
+++ b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts
@@ -44,6 +44,7 @@ interface PagingPosition {
}
const READING_INTENTS = ['wheel', 'touchstart', 'pointerdown', 'keydown', 'beforematch'] as const
+const SCROLL_KEYS = new Set(['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End', ' '])
/** Owns one Chat scrollport's DOM operations, event listeners, and size observer. */
export class ChatViewport {
@@ -153,12 +154,12 @@ export class ChatViewport {
return { top: scroller.scrollTop, height, floor: Math.max(0, scroller.scrollHeight - height) }
}
- private anchor(key: string): HTMLElement | null {
+ private anchor(key: string, identity: 'position' | 'node' = 'position'): HTMLElement | null {
if (this.elements === null) return null
// Reading anchors name exact parts; Turn navigation names the original Node.
let nodePart: HTMLElement | null = null
for (const row of this.elements.list.querySelectorAll('[data-chat-anchor-key]:not([hidden]):not([hidden] *)')) {
- if (row.dataset.chatAnchorKey === key) return row
+ if (row.dataset.chatAnchorKey === key || (identity === 'node' && row.dataset.chatNodeKey === key)) return row
if (nodePart === null && row.dataset.chatNodeKey === key) nodePart = row
}
return nodePart
@@ -253,7 +254,7 @@ export class ChatViewport {
scrollToTurn(turn: number): ViewportLanding | null {
const item = this.turns.find(candidate => candidate.turn === turn)
if (item === undefined) return null
- const row = this.anchor(item.anchorKey)
+ const row = this.anchor(item.anchorKey, 'node')
return row === null ? null : this.align(row, 24, turn)
}
@@ -430,7 +431,11 @@ export class ChatViewport {
|| (event.target instanceof HTMLElement && event.target.hasAttribute('data-step-process-body'))) this.events?.scrollEnd()
}
- private readonly onIntent = (): void => {
+ private readonly onIntent = (event: Event): void => {
+ if (event.type === 'keydown' || event.type === 'pointerdown') {
+ if (event.target instanceof Element && event.target.closest('[data-composer-seat]') !== null) return
+ if (event.type === 'keydown' && (!(event instanceof KeyboardEvent) || !SCROLL_KEYS.has(event.key))) return
+ }
if (this.paging === null) return
this.stopPreserving()
this.events?.interact()
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 19bd9d1ba4..d99cfb74f4 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
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-chat/src/client/conversation-nodes/README.md
-README.md: a176d067705b3ae9c16df9121ce1815682fa0215
-README.zh.md: 3ac6a9681c9b04ca05f0fbfef24293cfaa57ad65
+README.md: 0e71eaf3f5a201b999e60763af73b3c9064bed10
+README.zh.md: 9d41a549d07da430485ff20298be1f556f4fb507
diff --git a/packages/client/ui-chat/src/client/conversation-nodes/README.md b/packages/client/ui-chat/src/client/conversation-nodes/README.md
index a176d06770..0e71eaf3f5 100644
--- a/packages/client/ui-chat/src/client/conversation-nodes/README.md
+++ b/packages/client/ui-chat/src/client/conversation-nodes/README.md
@@ -67,7 +67,16 @@ Visibility applies from outside inward: whole Turn → secondary group → indiv
Whole-Turn folding controls the loaded process range, independently of secondary groups. A recorded Turn end makes that range eligible even when paging has not loaded the Turn start.
-The Turn control follows its opening human input, including while waiting for the first Assistant output.
+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.
+
+| Admitted input | Chat presentation |
+|---|---|
+| Human message claimed from `next-step` | Steering message, outside process folding. |
+| 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. |
+
+Trigger titles and icons use the recorded `source.kind`: `schedule`, `tool-jobs`, and `cordis-host-runner` identify scheduled work, background work, and plugin updates. Goal, agent, team, subagent, and webhook sources have their own titles; a webhook with `provider: github` uses the GitHub title. Unknown sources use the generic execution-request title. Expanding the notice shows its recorded body; it does not imply successful execution.
| Turn condition | Current behavior |
|---|---|
@@ -85,7 +94,7 @@ Loading an older page does not itself change the reader's opening choice. Newly
Clicking Load older anchors the first visible content item below that button in transcript order, regardless of its position in the viewport. A collapsed group anchors its header; an expanded group skips its header and anchors its first visible member. The whole-Turn process control is excluded because paging can move it ahead of newly loaded work and steering. When a whole Turn is collapsed, the anchor is its first remaining visible message, such as steering or the final answer. Ordinary messages and steering anchor themselves; hidden and empty rows are skipped. Loading more content inside a collapsed group keeps its header stationary; adding earlier groups or rows preserves that same group header while they appear above it.
-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 reader input or explicit navigation releases it. If the reader scrolls while a page is loading, scrolling takes priority and its settled reading position becomes the new paging anchor.
+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.
A running clock updates in whole seconds, starts at one second, and uses hours from 60 minutes. Completion fixes the duration; cancellation and failure replace it with their status. Lifecycle changes have a polite announcement; clock ticks do not. This control is Chat's only Turn-level running indicator.
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 3ac6a9681c..9d41a549d0 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
@@ -67,7 +67,16 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
整轮折叠控制已加载的过程范围,独立于二级分组。已经记录轮次结束时,即使向前分页尚未加载到轮次起点,也可以对该范围折叠。
-整轮控件位于该轮的起始人工输入之后,等待首个 Assistant 输出时也保持这一顺序。
+整轮控件位于该轮所有起始输入之后,包括人工 steering 和非人工触发通知;等待首个 Assistant 输出及输出到达后均保持这一顺序。
+
+| 已进入请求的输入 | Chat 展示 |
+|---|---|
+| 从 `next-step` 领取的人工消息 | Steering 消息,不参与过程折叠。 |
+| 当前 `next-turn` 领取批次中的非人工消息 | 独立、默认收起的轮次触发通知。 |
+| 空闲时从 `next-step` 领取的非人工消息 | 仅在 Step 1、已加载轮次起点、领取发生于起点之后、本轮没有 `next-turn` 领取、同一 `next-step` 批次没有人工消息时,作为轮次触发通知。 |
+| 其他非人工输入 | 普通 Context,保留在 Node Store,但不显示在 Chat 中。未领取、已取消或重新入队的消息不构成唤醒领取。 |
+
+触发通知的标题与图标使用记录中的 `source.kind`:`schedule`、`tool-jobs`、`cordis-host-runner` 分别表示定时任务、后台任务和插件更新。目标、Agent、团队、子任务和 webhook 来源有各自的标题;`provider: github` 的 webhook 使用 GitHub 标题。未知来源显示通用执行请求标题。展开通知显示记录的正文,不代表执行成功。
| 轮次条件 | 当前行为 |
|---|---|
@@ -85,7 +94,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
点击加载更早时,按正文顺序锚定按钮下的第一个可见内容项,不根据它在视口中的位置选择。组收起时选组 header;展开时跳过组 header,选内部第一个可见成员。整轮过程控件不作为锚点,因为分页可能将它移到新加载的过程及 steering 前面。整轮收起时,锚定第一个仍可见的消息,例如 steering 或最终答案。普通消息与 steering 选择自身,跳过隐藏和空行。新增内容全进入收起的组时,组 header 保持原位;前面出现更早的组或节点时,仍保持该组 header 的位置,让新增内容出现在它上方。
-分页把更早内容加到保留的锚点上方,不主动跳到新内容顶部。限高组先在自己的滚动范围内吸收位移,剩余部分由外层文本滚动补偿,包括组正文刚达到限高的情况。可滚动范围不足时,补偿停在实际边界,不增加底部空白。后续内容撑高沿用同一个锚点,直到读者输入或显式导航释放它。分页请求期间若读者滚动,优先服从滚动,停止后以新的阅读位置作为分页锚点。
+分页把更早内容加到保留的锚点上方,不主动跳到新内容顶部。限高组先在自己的滚动范围内吸收位移,剩余部分由外层文本滚动补偿,包括组正文刚达到限高的情况。可滚动范围不足时,补偿停在实际边界,不增加底部空白。后续内容撑高沿用同一个锚点,直到阅读手势或显式导航释放它。在 composer 中打字或点击,以及 transcript 内的非滚动按键,保留锚点。分页请求期间若读者滚动,优先服从滚动,停止后以新的阅读位置作为分页锚点。
运行时钟按整秒更新,最少显示一秒,满 60 分钟使用小时。完成后固定时长,取消与失败使用对应状态替代。生命周期变化有礼貌播报,时钟跳动不播报。该控件是 Chat 唯一的轮次级运行指示。
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 2e934a1fd6..9a7a72d553 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
@@ -411,7 +411,7 @@ function processPresentationInputChanged(
interface TurnProcessPresentation {
readonly control?: ChatNode<'turn-process'>
- readonly openingHumanAnchor?: number
+ readonly openingInputAnchor?: number
readonly earliestProcessAnchor?: number
}
@@ -430,11 +430,13 @@ function turnProcessPresentations(
const location = node.location
if (location.kind !== 'turn' && location.kind !== 'step') continue
const current: TurnProcessPresentation = presentations.get(location.turn.turn) ?? {}
- if (node.kind === 'user' || (node.kind === 'steering' && current.control !== undefined
- && node.anchorSeq < current.control.data.controlAnchorSeq)) {
+ const controlAnchor = current.control?.data.controlAnchorSeq
+ if (node.kind === 'user' || node.kind === 'turn-trigger'
+ || (node.kind === 'steering' && controlAnchor !== undefined
+ && (controlAnchor === location.turn.start?.seq || node.anchorSeq < controlAnchor))) {
presentations.set(location.turn.turn, {
...current,
- openingHumanAnchor: Math.min(current.openingHumanAnchor ?? node.anchorSeq, node.anchorSeq),
+ openingInputAnchor: Math.max(current.openingInputAnchor ?? node.anchorSeq, node.anchorSeq),
})
continue
}
@@ -466,27 +468,27 @@ function presentationPosition(
if (presentation === undefined) {
return { anchor: node.anchorSeq, rank: 0, originalAnchor: node.anchorSeq }
}
- const openingHumanAnchor = presentation.openingHumanAnchor
- if (openingHumanAnchor !== undefined
- && node.anchorSeq < openingHumanAnchor
+ const openingInputAnchor = presentation.openingInputAnchor
+ if (openingInputAnchor !== undefined
+ && node.anchorSeq < openingInputAnchor
&& !TURN_PROCESS_INDEPENDENT_KINDS.has(node.kind)) {
- return { anchor: openingHumanAnchor, rank: 2, originalAnchor: node.anchorSeq }
+ return { anchor: openingInputAnchor, rank: 2, originalAnchor: node.anchorSeq }
}
if (presentation.control !== undefined && node.key === presentation.control.key) {
- return openingHumanAnchor === undefined
+ return openingInputAnchor === undefined
? {
anchor: presentation.earliestProcessAnchor ?? node.anchorSeq,
rank: -1,
originalAnchor: node.anchorSeq,
}
- : { anchor: openingHumanAnchor, rank: 1, originalAnchor: node.anchorSeq }
+ : { anchor: openingInputAnchor, rank: 1, originalAnchor: node.anchorSeq }
}
return { anchor: node.anchorSeq, rank: 0, originalAnchor: node.anchorSeq }
}
/**
* Order visible Chat Nodes without changing existing relative order as process
- * eligibility changes. Opening human input precedes process candidates, while
+ * eligibility changes. Opening input precedes process candidates, while
* each synthetic process control sits between them.
* @param nodes - currently materialized Chat Nodes.
* @returns visible Nodes in presentation order.
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 f3c8fa5866..4181a40bcb 100644
--- a/packages/client/ui-chat/src/client/conversation-nodes/message.ts
+++ b/packages/client/ui-chat/src/client/conversation-nodes/message.ts
@@ -64,6 +64,8 @@ export const messageDefinition: ConversationNodeDefinition = {
const nextStep = reader.previous('inbox-next-step')?.state
const location = match.location
const turnStart = location.kind === 'step' ? location.turn.start?.seq : undefined
+ // An idle steer opens Step 1 without a next-turn claim in this Turn.
+ // 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
diff --git a/packages/client/ui-chat/tests/chat-viewport.client.spec.ts b/packages/client/ui-chat/tests/chat-viewport.client.spec.ts
index 7c5e90a07b..a7e42c0f33 100644
--- a/packages/client/ui-chat/tests/chat-viewport.client.spec.ts
+++ b/packages/client/ui-chat/tests/chat-viewport.client.spec.ts
@@ -35,7 +35,7 @@ function fixture() {
const element = document.createElement('div')
element.textContent = nodeKey
element.dataset.chatNodeKey = nodeKey
- element.dataset.chatAnchorKey = part === undefined ? nodeKey : JSON.stringify([nodeKey, part])
+ element.dataset.chatAnchorKey = part === undefined || part === 'response' ? nodeKey : JSON.stringify([nodeKey, part])
element.dataset.chatFlowKey = element.dataset.chatAnchorKey
element.dataset.chatPagingAnchor = ''
element.dataset.chatTurn = '1'
@@ -441,7 +441,9 @@ it.each(['wheel', 'touchstart', 'pointerdown', 'keydown', 'beforematch'])(
h.resize()
expect(h.row.getBoundingClientRect().top).toBe(top)
}
- h.body.dispatchEvent(new Event(intent, { bubbles: true }))
+ h.body.dispatchEvent(intent === 'keydown'
+ ? new KeyboardEvent('keydown', { key: 'PageUp', bubbles: true })
+ : new Event(intent, { bubbles: true }))
expect(h.viewport.preserving).toBe(false)
expect(h.observed.has(h.content)).toBe(false)
const previousTop = h.scroller.scrollTop
@@ -450,6 +452,40 @@ it.each(['wheel', 'touchstart', 'pointerdown', 'keydown', 'beforematch'])(
expect(h.scroller.scrollTop).toBe(previousTop)
})
+it.each(['typing', 'space', 'arrow', 'pointer'])(
+ 'keeps the paging anchor when the composer receives %s', (interaction) => {
+ const h = nestedFixture(200, 400)
+ const composer = document.createElement('div')
+ composer.dataset.composerSeat = ''
+ const input = document.createElement('textarea')
+ composer.append(input)
+ h.scroller.append(composer)
+ h.scroller.scrollTop = 80
+ const top = h.begin()
+ const interact = vi.fn()
+ h.viewport.connect({ scroll: () => {}, scrollEnd: () => {}, resize: () => { h.viewport.preserve() }, interact })
+ input.dispatchEvent(interaction === 'pointer' ? new Event('pointerdown', { bubbles: true })
+ : new KeyboardEvent('keydown', { key: interaction === 'space' ? ' ' : interaction === 'arrow' ? 'ArrowUp' : 'a', bubbles: true }))
+ expect(h.viewport.preserving).toBe(true)
+ expect(interact).not.toHaveBeenCalled()
+ h.prepend(200)
+ h.resize()
+ expect(h.row.getBoundingClientRect().top).toBe(top)
+ },
+)
+
+it.each(['a', 'Tab', 'Escape', 'Enter'])(
+ 'keeps the paging anchor for the non-scrolling key %s in the transcript', (key) => {
+ const h = nestedFixture(200, 400)
+ const top = h.begin()
+ h.body.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }))
+ expect(h.viewport.preserving).toBe(true)
+ h.prepend(100)
+ h.resize()
+ expect(h.row.getBoundingClientRect().top).toBe(top)
+ },
+)
+
it('does not compensate twice when native inner anchoring already held the old row', () => {
const h = nestedFixture(600, 400)
h.scroller.scrollTop = 80
diff --git a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
index f0af3e2746..2a3b5fea1d 100644
--- a/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
+++ b/packages/client/ui-chat/tests/conversation-node-definitions.client.spec.ts
@@ -21,7 +21,7 @@ import { chatViewDefinition } from '../src/client/conversation-nodes/chat-snapsh
import { commandDefinition } from '../src/client/conversation-nodes/command.ts'
import { compactionDefinition } from '../src/client/conversation-nodes/compaction.ts'
import { unknownFallbackDefinition } from '../src/client/conversation-nodes/fallback.ts'
-import { nextStepInboxDefinition } from '../src/client/conversation-nodes/inbox.ts'
+import { nextStepInboxDefinition, nextTurnInboxDefinition } from '../src/client/conversation-nodes/inbox.ts'
import { messageDefinition } from '../src/client/conversation-nodes/message.ts'
import { inspectRequestPrompt } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { requestPromptDefinition, systemMessageDefinition } from '../src/client/conversation-nodes/request-prompt.ts'
@@ -37,6 +37,7 @@ import type {
const DEFINITIONS: readonly ConversationNodeDefinition[] = [
nextStepInboxDefinition,
+ nextTurnInboxDefinition,
messageDefinition,
systemMessageDefinition(inspectSystemPrompt),
requestPromptDefinition(inspectRequestPrompt),
@@ -545,6 +546,78 @@ describe('built-in conversation node Definitions', () => {
])
})
+ it.each(['next-turn', 'idle-notice', 'idle-human'] as const)(
+ 'keeps the %s opening input before its control from admission through first output', (route) => {
+ const target = route === 'next-turn' ? 'next-turn' : 'next-step'
+ const input = {
+ ...textMessage('opening-input', 'start this work'),
+ source: { kind: route === 'idle-human' ? 'user' : 'schedule' },
+ }
+ const kind = route === 'idle-human' ? 'steering' : 'turn-trigger'
+ const value = assembler([
+ at(1, 'agent/inbox/spliced', { target, start: 0, inserted: [input] }),
+ at(2, 'turn/start', { turn: 1 }),
+ at(3, 'agent/inbox/spliced', { target, start: 0, removedCount: 1, inserted: [] }),
+ at(4, 'step/start', { turn: 1, step: 1 }),
+ at(5, 'user/message', input, { surfaceOp: 'append' }),
+ ])
+ const opening = snapshot(value)
+ expect(opening.order.map(key => opening.nodes.get(key)?.kind)).toEqual([kind, 'turn-process'])
+ value.append(at(6, 'assistant/live-chunk', {
+ turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' },
+ }))
+ value.flush()
+ const running = snapshot(value)
+ expect(running.order.slice(0, 2)).toEqual(opening.order)
+ expect(running.order.map(key => running.nodes.get(key)?.kind)).toEqual([kind, 'turn-process', 'assistant-step'])
+ },
+ )
+
+ it.each(['replay', 'live', 'prepend'] as const)('classifies waking Inbox messages through %s', (mode) => {
+ const notice = { ...textMessage('notice', 'scheduled work'), source: { kind: 'schedule' } }
+ const human = textMessage('human', 'user task')
+ const insertion = (target: 'next-step' | 'next-turn', messages = [notice]) =>
+ at(1, 'agent/inbox/spliced', { target, start: 0, inserted: messages })
+ const claim = (target: 'next-step' | 'next-turn', count = 1) =>
+ at(3, 'agent/inbox/spliced', { target, start: 0, removedCount: count, inserted: [] })
+ const start = at(2, 'turn/start', { turn: 1 })
+ const step = at(8, 'step/start', { turn: 1, step: 1 })
+ const cases: { name: string; before: SessionLiveEventEntry[]; waking: boolean }[] = [
+ { name: 'next-turn claim', before: [insertion('next-turn'), start, claim('next-turn'), step], waking: true },
+ { name: 'idle non-human steer', before: [insertion('next-step'), start, claim('next-step'), step], waking: true },
+ { name: 'unclaimed input', before: [insertion('next-turn'), start, step], waking: false },
+ { name: 'canceled input', before: [insertion('next-turn'), start, at(3, 'agent/inbox/spliced', {
+ target: 'next-turn', start: 0, removedCount: 1, inserted: [], outcome: 'canceled',
+ }), step], waking: false },
+ { name: 'requeued claim', before: [insertion('next-turn'), start, claim('next-turn'),
+ at(4, 'agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [notice] }), step], waking: false },
+ { name: 'human steer in the same batch', before: [insertion('next-step', [notice, human]),
+ start, claim('next-step', 2), step], waking: false },
+ { name: 'a queued message starts this Turn', before: [insertion('next-step'), start, claim('next-step'),
+ at(4, 'agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [human] }),
+ at(5, 'agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }), step], waking: false },
+ { name: 'later step', before: [insertion('next-step'), start, claim('next-step'), step,
+ at(9, 'step/end', { turn: 1, step: 1 }), at(10, 'step/start', { turn: 1, step: 2 })], waking: false },
+ { name: 'missing Turn start', before: [insertion('next-step'), claim('next-step'), step], waking: false },
+ { name: 'missing Step', before: [insertion('next-step'), start, claim('next-step')], waking: false },
+ { name: 'claim before the Turn', before: [insertion('next-step'), claim('next-step'),
+ at(4, 'turn/start', { turn: 1 }), step], waking: false },
+ { name: 'not a member of the claim', before: [insertion('next-step', [{ ...notice, id: 'other' }]),
+ start, claim('next-step'), step], waking: false },
+ ]
+ for (const test of cases) {
+ const message = at(11, 'user/message', notice, { surfaceOp: 'append' })
+ const value = assembler(mode === 'replay' ? [...test.before, message] : mode === 'prepend' ? [message] : [], mode === 'prepend')
+ if (mode === 'live') for (const entry of [...test.before, message]) value.append(entry)
+ if (mode === 'prepend') value.prepend(test.before, false)
+ value.flush()
+ const current = snapshot(value)
+ const input = node(current, test.waking ? 'turn-trigger' : 'context')
+ expect(input, test.name).toMatchObject({ data: { waking: test.waking, source: { kind: 'schedule' } } })
+ expect(current.order.includes(input!.key), test.name).toBe(test.waking)
+ }
+ })
+
it('replays pending splice chains and scopes steering to the current claim', () => {
const first = textMessage('claim-first', 'first')
const second = textMessage('claim-second', 'second')
diff --git a/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx b/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx
new file mode 100644
index 0000000000..851111fd14
--- /dev/null
+++ b/packages/client/ui-chat/tests/turn-trigger.client.spec.tsx
@@ -0,0 +1,72 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it } from 'vitest'
+import { cleanup, fireEvent, render } from '@testing-library/react'
+import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
+import type { ChatNode } from '../src/client/contract/chat-nodes.ts'
+import { TurnTriggerNodeView } from '../src/client/chat/TurnTriggerNodeView.tsx'
+import { turnTriggerDetails } from '../src/client/chat/turn-trigger.ts'
+import { contextForm, contextProducer } from '../src/client/conversation-nodes/event-projection.ts'
+import { en } from '../src/client/locale.ts'
+
+afterEach(cleanup)
+
+function trigger(source: unknown): ChatNode<'turn-trigger'> {
+ return {
+ key: 'input-message:notice', kind: 'turn-trigger', id: 'notice', target: 'chat',
+ anchorSeq: 4, location: { kind: 'unresolved' }, visibility: 'visible',
+ data: {
+ kind: 'context', seq: 4, time: 1_700_000_000_000,
+ content: [{ type: 'text', text: 'Recorded notification\nwith its original body.' }],
+ source, form: contextForm(source), producer: contextProducer(source),
+ },
+ }
+}
+
+describe('Turn trigger notices', () => {
+ it.each([
+ [{ kind: 'schedule' }, 'schedule', 'Scheduled task'],
+ [{ kind: 'tool-jobs' }, 'job', 'Background task updated'],
+ [{ kind: 'cordis-host-runner' }, 'plugin', 'Plugin status updated'],
+ [{ kind: 'goal' }, 'goal', 'Continuing goal'],
+ [{ kind: 'agent-message' }, 'agent', 'Task message received'],
+ [{ kind: 'team-message' }, 'team', 'Team message received'],
+ [{ kind: 'subagent-settled' }, 'subagent', 'Subtask status updated'],
+ [{ kind: 'webhook', provider: 'github' }, 'github', 'GitHub event received'],
+ [{ kind: 'webhook', provider: 'custom' }, 'webhook', 'External event received'],
+ [{ kind: 'custom-extension' }, 'request', 'Execution requested'],
+ ] as const)('presents the recorded %j source', (source, icon, title) => {
+ const node = trigger(source)
+ const details = turnTriggerDetails(node.data)
+ expect(details).toEqual({ title: `message.trigger.${icon}`, icon })
+ const t = makeTranslate(en)
+ const view = render()
+ expect(view.getByRole('button').textContent).toContain(title)
+ expect(view.getByRole('button').querySelector('svg')).not.toBeNull()
+ expect(view.container.querySelector('time')?.dateTime).toBe('2023-11-14T22:13:20.000Z')
+ })
+
+ it('opens the recorded notice body and closes it independently of the Turn', () => {
+ const view = render()
+ const button = view.getByRole('button')
+ expect(button.getAttribute('aria-expanded')).toBe('false')
+ expect(view.container.querySelector('[data-context-text]')).toBeNull()
+ fireEvent.click(button)
+ expect(button.getAttribute('aria-expanded')).toBe('true')
+ expect(document.getElementById(button.getAttribute('aria-controls')!)).not.toBeNull()
+ expect(view.container.querySelector('[data-context-text]')?.textContent)
+ .toBe('Recorded notification\nwith its original body.')
+ fireEvent.click(button)
+ expect(button.getAttribute('aria-expanded')).toBe('false')
+ expect(view.container.querySelector('[data-context-text]')).toBeNull()
+ })
+
+ it.each([null, [], 'external-source', {}, { kind: 5 }, { kind: 'webhook', provider: 5 }])(
+ 'keeps an opaque source %j readable without invented attribution', (source) => {
+ const details = turnTriggerDetails(trigger(source).data)
+ const webhook = typeof source === 'object' && source !== null && 'kind' in source && source.kind === 'webhook'
+ expect(details).toEqual(webhook
+ ? { title: 'message.trigger.webhook', icon: 'webhook' }
+ : { title: 'message.trigger.request', icon: 'request' })
+ },
+ )
+})
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index 8a01cbc6bb..192a0da89b 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: e67533ea47184d4a25563aad657919eed4a372b3
-README.zh.md: d2d87ea49b0e9fd214b2bc39b88c852b5d9b27cb
+README.md: a58ae14a450a18ae4bc89dc0dc5cc6fb664d48e0
+README.zh.md: 20d6cab01e7b5fc4c9b075a993783c9df8121413
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index e67533ea47..a58ae14a45 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -66,7 +66,7 @@ The shell reads the persisted View preference before rendering when a Session fi
Active transcripts in the main occurrence expose content-width drag handles in their uncovered side gutters; embedded occurrences omit them. A View that paints into a gutter raises only its concrete painted element above the handle; transparent full-width wrappers stay below so they do not claim empty gutter. This requires the path between that element and the Conversation body to remain outside an intermediate stacking context; the shipped Chromium behavior is pinned by the browser scenario. Chat applies the rule to table elements, while its column-bounded tool cards need no raise. Wheel motion over a handle still scrolls the transcript, while Ctrl+wheel remains a browser zoom gesture. The sticky composer intentionally owns its full footer band, which is not a resize target; an already-captured drag lifts its indicator until release ([handle styles](src/client/skeleton/ConversationRoot.module.css)).
-Width-handle pointer moves first check a synchronous dragging ref, then pointer capture, before reading layout, writing styles, or scheduling animation frames. Pointer handlers update the drag indicator directly without a React state update. The indicator follows the pointer only during a captured drag; ordinary hover leaves its position unchanged.
+The width-handle indicator follows the pointer only during a captured drag; ordinary hover leaves its position unchanged.
The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start.
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index d2d87ea49b..20d6cab01e 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -66,7 +66,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
主 occurrence 的活跃 transcript 只在未被内容覆盖的两侧沟槽中提供正文宽度拖拽条;嵌入式 occurrence 省略这些拖拽条。View 如果绘制进沟槽,只将具体的可见元素提到拖拽条上方;透明的全宽包装层保持在下方,不会占用空白沟槽。该规则要求此元素与 Conversation body 之间不能引入中间堆叠上下文;浏览器场景固定了交付 Chromium 的行为。Chat 将该规则用于表格元素,其限定在阅读列内的工具卡片无需提高层级。指针位于拖拽条上时,滚轮仍会滚动 transcript,Ctrl+滚轮则保留为浏览器缩放手势。粘滞 composer 刻意拥有完整的底部区带,该区域不是宽度调整目标;已捕获的拖拽会将指示线提高到松开为止([拖动手柄样式](src/client/skeleton/ConversationRoot.module.css))。
-宽度拖拽条处理指针移动时,先检查同步的 dragging ref,再确认指针捕获,之后才读取布局、写入样式或调度动画帧。指针处理函数直接更新拖拽指示,不触发 React 状态更新。指示线只在已捕获指针的拖拽期间跟随指针,普通悬停不改变其位置。
+宽度拖拽条的指示线只在已捕获指针的拖拽期间跟随指针,普通悬停不改变其位置。
常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。