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(