From 5f34f782fc9d1c76927e7bae4d1b02398dab8906 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 5 Aug 2026 17:08:51 +0800 Subject: [PATCH] fix(web): address the late review batch on context bodies - Content blocks render in the order the model received them. Partitioning hoisted every unknown block past the text around it, so an interleaved log read back in an order the model never saw. - A delta distinguishes a newly reconciled file from a rewritten one; `set` and `replace` already separate them at the producer, and collapsing both to "updated" misread a new file. - The superseded note states current fact in its consequences and testing rather than keeping claims the implementation now contradicts, per implemented/AGENTS.md; the decision itself stays as the record of that change, with the supersession pointer above it. --- ...eer-entry-or-interjection-chrome.i18n.yaml | 4 +- ...i-no-steer-entry-or-interjection-chrome.md | 12 +-- ...o-steer-entry-or-interjection-chrome.zh.md | 12 +-- .../src/client/chat/ContextBody.tsx | 74 ++++++++++++++----- .../ui-conversation/src/client/locales.ts | 2 + .../tests/chat-branch-tails.spec.tsx | 46 ++++++++++++ 6 files changed, 119 insertions(+), 31 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml index 7081a0d6a5..9d11640e8f 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.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 .agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md -2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: dfd79a1b1b44778275b4326cf5f51f6e69ce2a7c -2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: 9b470dda8de45ca754a04900ffac0535ebfabd96 +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md: b7d4d4fa1e8189d8c5b0c275b15c332cba1b4e6a +2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md: 9ce5ac32797a08b4b1f95cd8b350d3f1a7148f80 diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md index dfd79a1b1b..b7d4d4fa1e 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md @@ -28,12 +28,12 @@ Keep host and runtime steering intact. Remove only the Web UI entry and chrome: ## Consequences -- **Superseded in part.** Every clause below about the steer ENTRY, the `queue`/`steer` mode union, the interjection caption, its locale strings, and the goldens that pinned their absence no longer describes master — in the Decision that is bullets 1 and 3 through 5, and in the consequences bullets 1 and 3. Composer steering shipped afterwards, and the [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) then supplied the product decision this note's reintroduction clause required. What still holds: host steering ownership, `ConversationService.send`'s queue-only contract, and the rejection of hiding `steering/message` from the transcript. -- Web users cannot steer from the composer or `ctx.conversation.send`; stop/cancel and Queue remain the only mid-turn controls. -- Host-wire and non-Web clients can still steer; the Web client shows those messages without labeling them as interjections. -- Reintroducing a dedicated steer UI would need a new product decision; do not revive the mode union or badge without one. +- **Superseded in part.** Every clause in the Decision about the steer ENTRY, the `queue`/`steer` mode union, the interjection caption, its locale strings, and the goldens that pinned their absence no longer describes master — that is bullets 1 and 3 through 5. Composer steering shipped afterwards, and the [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) then supplied the product decision this note's reintroduction clause required and owns the caption. The consequences and testing below state current fact. +- Host steering ownership is unchanged: agent-loop drain, session events, and the wire mode remain load-bearing for ACP, automation, and any non-Web client. +- `ConversationService.send(text)` still takes no mode and always queues; the composer's Steer gesture goes through `session.prompt(mode: 'steer')` instead. +- `steering/message` still folds into the durable transcript, so an externally submitted steer stays truthful on replay. It now carries the interjection caption rather than rendering as a bare bubble. ## Testing -- `packages/client/ui-conversation` unit/jsdom coverage: input machine enter/sink, ConversationService routing, MessageItem steering arm (no 「插话」), InputBar submit. -- `apps/web/tests/steering.e2e.ts` keyless replay plus updated `settled.expected.md` (steer text without badge). +- `packages/client/ui-conversation` unit/jsdom coverage: input machine enter/sink, ConversationService routing, the MessageItem steering arm, InputBar submit. +- `apps/web/tests/steering.e2e.ts` keyless replay plus its goldens, which now pin the caption. diff --git a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md index 9b470dda8d..9ce5ac3279 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md @@ -28,12 +28,12 @@ Status: implemented ## 后果 -- **部分被取代。** 下文中所有关于 steer **入口**、`queue`/`steer` mode 联合类型、插话标注、其 locale 字符串、以及钉住这些「不存在」的黄金基线的子句,均已不再描述 master——在 Decision 中是第 1 条与第 3 至 5 条,在后果中是第 1 条与第 3 条。composer steering 在此之后落地,随后[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)提供了本 note 重新引入条款所要求的产品决策。仍然成立的是:host 侧 steering 的归属、`ConversationService.send` 仅排队的契约,以及不在 transcript 中隐藏 `steering/message` 的取舍。 -- Web 用户无法从 composer 或 `ctx.conversation.send` steer;中途控制只剩停止/取消与 Queue。 -- Host 线缆与非 Web 客户端仍可 steer;Web 客户端展示这些消息时不再标成插话。 -- 若要重新引入专用 steer UI,需要新的产品决策;没有决策就不要复活 mode 联合类型或徽章。 +- **部分被取代。** Decision 中所有关于 steer **入口**、`queue`/`steer` mode 联合类型、插话标注、其 locale 字符串、以及钉住这些「不存在」的黄金基线的子句,均已不再描述 master——即第 1 条与第 3 至 5 条。composer steering 在此之后落地,随后[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)提供了本 note 重新引入条款所要求的产品决策,并成为该标注的归属者。下面的后果与测试陈述的是当前事实。 +- host 侧 steering 的归属未变:agent-loop 排空、session 事件与线缆 mode 对 ACP、自动化以及任何非 Web 客户端仍是承重能力。 +- `ConversationService.send(text)` 仍然不接 mode、始终排队;composer 的 Steer 手势改走 `session.prompt(mode: 'steer')`。 +- `steering/message` 仍然折叠进持久 transcript,因此从外部提交的 steer 在回放时依旧如实;只是它现在带上了插话标注,而不再是一个无标识的气泡。 ## 测试 -- `packages/client/ui-conversation` unit/jsdom 覆盖:input machine enter/sink、ConversationService 路由、MessageItem steering 分支(无「插话」)、InputBar submit。 -- `apps/web/tests/steering.e2e.ts` 无密钥回放,以及更新后的 `settled.expected.md`(有 steer 正文、无徽章)。 +- `packages/client/ui-conversation` unit/jsdom 覆盖:input machine enter/sink、ConversationService 路由、MessageItem 的 steering 分支、InputBar submit。 +- `apps/web/tests/steering.e2e.ts` 无密钥回放及其黄金基线,后者现在钉住该标注。 diff --git a/packages/client/ui-conversation/src/client/chat/ContextBody.tsx b/packages/client/ui-conversation/src/client/chat/ContextBody.tsx index 60bd38f2cf..1e770af57a 100644 --- a/packages/client/ui-conversation/src/client/chat/ContextBody.tsx +++ b/packages/client/ui-conversation/src/client/chat/ContextBody.tsx @@ -25,23 +25,36 @@ function asRecord(value: unknown): Record | null { : null } +/** One run of the model-facing content: adjacent text, or one unknown block. */ +type ContentRun = { text: string } | { block: unknown } + /** - * Concatenated text of the content blocks, with the non-text blocks kept aside. - * Context is text in practice (every producer injects one text block), but the - * block union is merge-extensible, so an unknown block keeps its own fallback - * rather than vanishing. + * The content blocks as runs, IN THE ORDER the model received them. * - * Blocks join with no separator, matching how provider adapters flatten them: - * inserting a line break here would show the reader a line the model never saw. + * Adjacent text blocks join with no separator, matching how provider adapters + * flatten them — inserting a line break would show the reader a line the model + * never saw. An unknown block breaks the run and keeps its own fallback rather + * than being hoisted past the text around it or vanishing; the block union is + * merge-extensible, so a foreign log may interleave shapes this build does not + * know. */ -function partitionContent(content: ContextMessageNode['content']): { text: string; rest: unknown[] } { - const texts: string[] = [] - const rest: unknown[] = [] +function contentRuns(content: ContextMessageNode['content']): ContentRun[] { + const runs: ContentRun[] = [] for (const block of content) { - if (block.type === 'text') texts.push(block.text) - else rest.push(block) + if (block.type !== 'text') { + runs.push({ block }) + continue + } + const last = runs[runs.length - 1] + if (last !== undefined && 'text' in last) last.text += block.text + else runs.push({ text: block.text }) } - return { text: texts.join(''), rest } + return runs +} + +/** Only the blocks this UI version does not know, for bodies that replace the text. */ +function unknownBlocks(content: ContextMessageNode['content']): unknown[] { + return contentRuns(content).flatMap(run => 'block' in run ? [run.block] : []) } /** The model-facing text, truncated to the display bound. */ @@ -126,11 +139,20 @@ function ModelFacingContent({ content, t }: { content: ContextMessageNode['content'] t: Translate }): ReactNode { - const { text, rest } = partitionContent(content) return ( <> - {text !== '' &&
{boundedText(text, t)}
} - + {contentRuns(content).map((run, index) => ('text' in run + ? run.text !== '' && ( +
{boundedText(run.text, t)}
+ ) + : ( + t('json.truncated', { total })} + /> + )))} ) } @@ -194,6 +216,24 @@ function instructionChanges(source: unknown): InstructionChange[] | null { return changes.length === 0 ? null : changes } +/** + * Locale key for one reconciled file. The baseline loads a file; a later delta + * distinguishes a newly reconciled path from a rewritten one, which `set` and + * `replace` already separate at the producer. + * @param action - the durable change action. + * @param baseline - whether this context is the startup/resume baseline. + * @returns the key naming what happened to that file. + */ +function instructionAction( + action: InstructionChange['action'], + baseline: boolean, +): 'message.context.instructions.removed' | 'message.context.instructions.loaded' + | 'message.context.instructions.added' | 'message.context.instructions.updated' { + if (action === 'remove') return 'message.context.instructions.removed' + if (baseline) return 'message.context.instructions.loaded' + return action === 'set' ? 'message.context.instructions.added' : 'message.context.instructions.updated' +} + /** * `instructions` form: the files this context reconciled, then their text. * @@ -218,7 +258,7 @@ export function InstructionsBody({ content, source, t }: {
  • {change.path} - {t(`message.context.instructions.${change.action === 'remove' ? 'removed' : baseline ? 'loaded' : 'updated'}`)} + {t(instructionAction(change.action, baseline))}
  • ))} @@ -279,7 +319,7 @@ export function CatalogBody({ content, source, t }: { // Entry count is unbounded (a provider may publish any number of skills), and // the scrollport bounds height, not node count — so the list bounds itself. const shown = entries.slice(0, MAX_ENTRIES) - const { rest } = partitionContent(content) + const rest = unknownBlocks(content) return ( <> {update &&

    {t('message.context.catalog.replaced')}

    } diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index eceb5b2514..9b7d24480f 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -56,6 +56,7 @@ export const zh = { 'message.contextInjection': '上下文注入', 'message.contextRecall': '跨会话召回', 'message.context.instructions.loaded': '已载入', + 'message.context.instructions.added': '已新增', 'message.context.instructions.updated': '已更新', 'message.context.instructions.removed': '已移除', 'message.context.catalog.replaced': '替换目录', @@ -176,6 +177,7 @@ export const en = { 'message.contextInjection': 'Context injection', 'message.contextRecall': 'Session recall', 'message.context.instructions.loaded': 'loaded', + 'message.context.instructions.added': 'added', 'message.context.instructions.updated': 'updated', 'message.context.instructions.removed': 'removed', 'message.context.catalog.replaced': 'Replacement catalog', diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index c2d8f3acf1..327265d870 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -288,6 +288,52 @@ describe('MessageItem arms', () => { .toContain('') }) + it('a delta distinguishes a newly reconciled file from a rewritten one', () => { + const view = render( + , + ) + fireEvent.click(view.getByRole('button', { name: /^上下文注入\s*new\/AGENTS\.md, old\/AGENTS\.md$/ })) + const files = [...view.container.querySelectorAll('[data-context-files] li')].map(node => node.textContent) + expect(files).toEqual(['new/AGENTS.md已新增', 'old/AGENTS.md已更新']) + }) + + it('keeps an interleaved unknown block in the order the model received it', () => { + const view = render( + , + ) + fireEvent.click(view.getByRole('button', { name: '上下文注入' })) + const texts = [...view.container.querySelectorAll('[data-context-text]')].map(node => node.textContent) + expect(texts).toEqual(['before', 'after']) + expect(view.getByText(/未知内容块/)).toBeTruthy() + }) + it('the catalog form lists its durable entries instead of the model-facing prose', () => { const view = render(