From 7cf67a2dbf72050a5b3fa499a73445c1f08d928d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 19 Oct 2026 16:08:53 +0800 Subject: [PATCH] feat(web): keep the composer editable while a message is sent Enter still sends one message at a time and waits for the Host, but the editor stays editable meanwhile: typing, paste, completion, and new attachments go to the next message, and only another send waits. Once the Host accepts the message, its attachments leave the draft and its text leaves the editor: the whole draft while it still equals the sent text, only the sent text and the whitespace after it when the user added text after it, and nothing when the user changed the sent text itself. A running Session keeps Stop beside the sending control, and Workspace selection leaves a draft with a submission in flight in its Session. --- ...gent-controller-message-delivery.i18n.yaml | 8 +- ...10-17-agent-controller-message-delivery.md | 4 +- ...17-agent-controller-message-delivery.zh.md | 4 +- apps/web/tests/prompt-admission.e2e.ts | 20 +++-- apps/web/tests/queue-actions.e2e.ts | 6 +- apps/web/tests/queue-image.e2e.ts | 7 +- apps/web/tests/submission-echo.e2e.ts | 28 +++--- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 6 +- packages/client/ui-conversation/README.zh.md | 6 +- .../ui-conversation/src/client/apply.ts | 5 +- .../src/client/contract/input.ts | 39 +++++--- .../src/client/input/editor/runtime.ts | 52 ++++++++--- .../src/client/input/editor/view-binding.ts | 7 +- .../src/client/input/facade.ts | 90 ++++++++++--------- .../src/client/input/machine.ts | 34 ++++--- .../src/client/skeleton/InputBar.tsx | 36 ++++---- .../tests/apply-inject.client.spec.tsx | 21 +++++ .../tests/draft-document.client.spec.ts | 75 ++++++++++++++-- .../tests/input-bar.client.spec.tsx | 51 ++++++++++- .../tests/submit-machine.client.spec.ts | 39 +++++--- .../submission-echo.expected.md | 2 +- .../web/queue-actions/sending.expected.md | 1 + 23 files changed, 383 insertions(+), 162 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.i18n.yaml index 6f625a356c..938198ae47 100644 --- a/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.i18n.yaml @@ -9,11 +9,11 @@ en: 5071232d9743096c zh: b553a626538b3466 /agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/decision: - en: 203f783e7e2903eb - zh: 0a73055c1e52ac1e + en: 3a8ac7102fdc9ae8 + zh: 762184146694a0ae /agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/alternatives-considered: en: acc465cfe02b624c zh: db1d06574f647130 /agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/consequences: - en: 9420fde397cde65f - zh: a76ecf4a926e930a + en: 985b872f3b36459c + zh: 8bb507ce60b163e3 diff --git a/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md b/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md index 9d1399cfad..669392f809 100644 --- a/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md +++ b/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md @@ -29,7 +29,7 @@ The Web Session Remote adopts the controller first. `session/prompt` resolves or Request deduplication stays in the Session Remote. It reads the Session history synchronously, and the [synchronous-read decision](2026-09-09-deprecate-synchronous-session-event-reads.md) prohibits copying such a read into new production code; the controller can take over deduplication once a projection records accepted request ids. -The Web client shows a message only after the Host accepts it. The composer keeps the sent draft, references, and attachments read-only in a `sending` phase, with Send showing progress, until `session/prompt` resolves. Then it clears, and the Session publishes the message's echo and leaves the blank New Session layout. A refusal or failure returns the message to editing unchanged; the composer no longer clears on Enter and restores failed drafts. +The Web client shows a message only after the Host accepts it. While the Host decides, the composer is in a `sending` phase: Send shows progress and another send waits, but the editor stays editable for the next message and a running Turn keeps its Stop. When `session/prompt` resolves, the sent text and attachments leave the composer while text added after them stays, and the Session publishes the message's echo and leaves the blank New Session layout. A refusal or failure leaves the draft as it is; the composer no longer clears on Enter and restores failed drafts. ## Alternatives considered @@ -45,7 +45,7 @@ The liveshare proposal in PR #3854 also introduced an admission hook, for caller ## Consequences - A listener that holds one message keeps later messages to the same Agent waiting; other Agents proceed. -- A Web composer sends one message at a time. Editing waits for the Host's answer, which normally takes one round-trip and lasts as long as a listener holds the message. +- A Web composer sends one message at a time. The next send waits for the Host's answer, which normally takes one round-trip and lasts as long as a listener holds the message; typing does not wait. - `prepare` stores attachments before the decision, so a refused prompt can leave a stored attachment unreferenced. - Image prompts deliver inside the Session Remote's image admission chain, so a model switch waits while a listener holds an image prompt. - Until every outside sender adopts `deliver()`, the stage is not a complete enforcement point. diff --git a/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.zh.md b/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.zh.md index 107aba83cc..767e38e842 100644 --- a/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.zh.md @@ -29,7 +29,7 @@ Web Session Remote 首先接入控制器。`session/prompt` 自己解析或恢 请求去重留在 Session Remote。它同步读取 Session 历史,而[同步读取决策](2026-09-09-deprecate-synchronous-session-event-reads.zh.md)禁止把这类读取复制到新的生产代码;等有投影记录已接收的请求 id 后,控制器才能接管去重。 -Web 客户端只在 Host 接收消息后才显示它。composer 在 `sending` 阶段把已发送的草稿、引用和附件保持为只读,Send 显示发送中,直到 `session/prompt` 返回。之后 composer 清空,Session 公开该消息的回显并离开空白的新会话布局。被拒绝或失败时消息原样回到编辑状态;composer 不再在 Enter 时清空并回填失败草稿。 +Web 客户端只在 Host 接收消息后才显示它。Host 做决定期间 composer 处于 `sending` 阶段:Send 显示发送中,下一次发送需要等待,但编辑器保持可编辑,可以接着写下一条消息,运行中的轮次也保留 Stop。`session/prompt` 返回后,已发送的文字和附件离开 composer,期间在其后追加的文字保留,Session 公开该消息的回显并离开空白的新会话布局。被拒绝或失败时草稿保持当时的样子;composer 不再在 Enter 时清空并回填失败草稿。 ## 备选方案 @@ -45,7 +45,7 @@ PR #3854 中的 liveshare 方案也引入过一个接纳钩子,用于调用方 ## 影响 - 监听器暂留一条消息时,同一 Agent 之后的消息都要等待;其他 Agent 不受影响。 -- Web composer 同一时刻只发送一条消息。编辑要等 Host 答复,通常只需一次往返,监听器暂留消息时则一直等待。 +- Web composer 同一时刻只发送一条消息。下一次发送要等 Host 答复,通常只需一次往返,监听器暂留消息时则一直等待;输入不需要等待。 - `prepare` 在决定之前存储附件,因此被拒绝的提示词可能留下一个无人引用的已存附件。 - 图片提示词在 Session Remote 的图片接纳链内投递,因此监听器暂留图片提示词期间,切换模型要等待。 - 在所有外部发送方都改用 `deliver()` 之前,这个阶段还不是完整的强制执行点。 diff --git a/apps/web/tests/prompt-admission.e2e.ts b/apps/web/tests/prompt-admission.e2e.ts index f922d4d21d..e67477acf4 100644 --- a/apps/web/tests/prompt-admission.e2e.ts +++ b/apps/web/tests/prompt-admission.e2e.ts @@ -1,7 +1,8 @@ // Keyless browser coverage for Agent admission through the shipped Web -// composition and the real wire: a Host `agent-controller/admission` listener holds a -// sent message, so the composer stays read-only behind the sending control -// until the listener admits it, and a refusal leaves the message in the +// composition and the real wire: a Host `agent-controller/admission` listener +// holds a sent message, so the composer keeps it behind the sending control +// and stays editable for the next message; once the listener admits it, only +// the admitted text leaves the composer. A refusal leaves the message in the // composer, announced by a toast unless the refusing plugin presents it. import { fileURLToPath } from 'node:url' import type { Browser } from 'playwright' @@ -63,19 +64,22 @@ describe('web e2e: prompt admission', () => { await held.promise const sending = page.getByRole('button', { name: 'Sending message', exact: true }) await expect.poll(() => sending.getAttribute('aria-busy')).toBe('true') - expect(await input.getAttribute('contenteditable')).toBe('false') + expect(await input.getAttribute('contenteditable')).toBe('true') expect(await input.textContent()).toBe(HELD) + // The next message can be typed at once; Enter waits for the Host instead of sending it. + await page.keyboard.press('Shift+Enter') + await page.keyboard.type(FOLLOW_UP) + await expect.poll(() => input.textContent()).toBe(`${HELD}${FOLLOW_UP}`) + await page.keyboard.press('Enter') expect(await page.locator('[data-submission-echo]').count()).toBe(0) expect(userMessages()).toBe(0) const settled = host.whenTurnSettled() gate.resolve(undefined) await settled - await expect.poll(() => input.textContent()).toBe('') - expect(await input.getAttribute('contenteditable')).toBe('true') - // The keyboard returns to the composer, so the next message can be typed at once. - await page.keyboard.type(FOLLOW_UP) + // Only the admitted text leaves the composer; the line typed meanwhile stays, and so does the keyboard. await expect.poll(() => input.textContent()).toBe(FOLLOW_UP) + expect(await input.evaluate(element => element === document.activeElement)).toBe(true) await page.locator('[data-chat-flow-kind="user"]', { hasText: HELD }).waitFor() expect(userMessages()).toBe(1) disposeHold() diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index f4267ced83..fb2b79c57d 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -124,12 +124,14 @@ describe('web e2e: queue row actions', () => { await input.fill(REMOVE) await input.press('Enter') await received.promise - // The composer keeps the message, read-only, until the Host accepts it; the queue shows nothing yet. + // The composer keeps the message, still editable, until the Host accepts it; Stop stays + // beside the sending control for the running Turn, and the queue shows nothing yet. const sendingButton = page.getByRole('button', { name: 'Sending message', exact: true }) await sendingButton.waitFor() expect(await sendingButton.getAttribute('aria-busy')).toBe('true') + expect(await page.getByRole('button', { name: 'Stop generating', exact: true }).isEnabled()).toBe(true) expect(await input.textContent()).toBe(REMOVE) - expect(await input.getAttribute('contenteditable')).toBe('false') + expect(await input.getAttribute('contenteditable')).toBe('true') expect(await page.locator('[data-queue-dock] [data-submission-echo]').count()).toBe(0) const sending = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(SENDING_EXPECTED, sending, MODE) diff --git a/apps/web/tests/queue-image.e2e.ts b/apps/web/tests/queue-image.e2e.ts index c147e05630..075662ea2c 100644 --- a/apps/web/tests/queue-image.e2e.ts +++ b/apps/web/tests/queue-image.e2e.ts @@ -123,8 +123,11 @@ describe('web e2e: queued image submission', () => { try { await input.fill(QUEUED_TEXT) await input.press('Enter') - // The composer keeps the text and image, read-only, until the Host accepts the prompt. - await expect.poll(() => input.getAttribute('contenteditable'), { timeout: 15_000 }).toBe('false') + // The composer keeps the text and image, still editable, until the Host accepts the prompt. + const sending = page.getByRole('button', { name: 'Sending message', exact: true }) + await expect.poll(() => sending.getAttribute('aria-busy'), { timeout: 15_000 }).toBe('true') + expect(await input.getAttribute('contenteditable')).toBe('true') + expect(await input.textContent()).toBe(QUEUED_TEXT) expect(await page.getByRole('img', { name: 'queued.png' }).count()).toBe(1) expect(await page.locator('[data-queue-dock] [data-submission-echo]').count()).toBe(0) releasePrompt.resolve(undefined) diff --git a/apps/web/tests/submission-echo.e2e.ts b/apps/web/tests/submission-echo.e2e.ts index a1401e9e9a..8937145f6e 100644 --- a/apps/web/tests/submission-echo.e2e.ts +++ b/apps/web/tests/submission-echo.e2e.ts @@ -1,10 +1,11 @@ // @vitest-environment jsdom // Submission echo over the BUILT client graph (keyless RemoteMock transport): -// a text-plus-image send stays in the read-only composer, with its image rail +// a text-plus-image send stays in the editable composer, with its image rail // and a busy send control, until the fixture host accepts the prompt; only then -// does the composer clear, and the conversation shows the message once. The -// fixture host echoes the prompt requestId as the durable source's rpcId, so -// the reconciliation here is the production correlation, not a test hook. +// does its text leave the composer, keeping what was typed meanwhile, and the +// conversation shows the message once. The fixture host echoes the prompt +// requestId as the durable source's rpcId, so the reconciliation here is the +// production correlation, not a test hook. import { fireEvent, screen, waitFor } from '@testing-library/react' import { expect, it } from 'vitest' import { createAssembledSessionComposer, installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' @@ -34,21 +35,22 @@ it('keeps a send in the composer until the Host accepts it and then shows the me // Synchronously after the keystroke, before the prompt is even serialized // (it starts after a paint yield): the draft and its image stay in the - // read-only composer behind a busy send control, and the conversation shows - // nothing yet. + // editable composer behind a busy send control, and the conversation shows + // nothing yet. Text pasted meanwhile belongs to the next message. expect(document.querySelector('[data-submission-echo]')).toBeNull() expect(composer.textContent).toBe('回显这条消息') - expect(composer.getAttribute('contenteditable')).toBe('false') + expect(composer.getAttribute('contenteditable')).toBe('true') expect(document.querySelector('[role="group"][aria-label="Pending attachments"] img')).not.toBeNull() expect(screen.getByRole('button', { name: 'Sending message' }).getAttribute('aria-busy')).toBe('true') + fireEvent.paste(composer, { + clipboardData: { items: [], getData: () => ' 下一条' }, + }) - // The Host accepts the prompt: the composer clears and becomes editable - // again, its rail is gone, and the durable user/message (source.rpcId - // echoes the prompt requestId) is the one bubble, its image rendering from - // the durable gallery. + // The Host accepts the prompt: only its text leaves the composer, its rail + // is gone, and the durable user/message (source.rpcId echoes the prompt + // requestId) is the one bubble, its image rendering from the durable gallery. await waitFor(() => { - expect(composer.textContent).toBe('') - expect(composer.getAttribute('contenteditable')).toBe('true') + expect(composer.textContent).toBe('下一条') expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull() }, { timeout: 10_000 }) await waitFor(() => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index ce26358971..2b490b8a19 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -21,8 +21,8 @@ en: 9912b286384c41d7 zh: 3e994887c1948677 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: f55957dfb2d48d88 - zh: 7cfb08eca106f067 + en: aa78b2ab677206b8 + zh: 1799bf4654eb5447 /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: en: bb1244310f5c0382 zh: 824b84077432b559 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 13cf4b96f1..b5adff9a1b 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -80,7 +80,7 @@ Drafts retain plain text and file, folder, and Session reference chips across Se A claimed command is one atomic chip at the start of the draft, and the claim lasts exactly as long as that chip. Deleting the arguments and separator keeps the claim. At the chip's edge, the first Backspace or Delete selects the whole chip and the next one removes it and releases the claim, leaving the arguments as plain text; any other key, a click, or a caret move clears the selection. Text or a line break inserted before the chip turns it back into its literal token and releases the claim, and undo restores a deleted chip together with its claim. The persisted draft records the chip's token, so a reload rebuilds the chip once the command catalog resolves the claim; editing the draft first keeps the literal token, which Enter still submits as the command. The same rules apply to every command and locale, including `/goal`, `/目标`, `/plan`, and `/计划`. Command hints and ordinary placeholders remain hidden throughout IME composition and reappear only after the editor commits the final text and the corresponding input is empty. -Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft content, including structured references, and attachments move in its synchronous preparation callback only while that request is current; later navigation or owner disposal leaves the original draft intact. +Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and commit navigation. Draft content, including structured references, and attachments move in its synchronous preparation callback only while that request is current and no submission is in flight; later navigation, owner disposal, or a submission in flight leaves the original draft in its Session. The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the resident `conversation.header` outside the optional-Session `conversation.content` Component Factory. The header hosts root-scoped navigation without a selected Session; titles, actions, and View tabs remain in its strict Session child. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. The shared body and Composer register the queue and Todo docks. The Todo dock uses the shared panel elevation above the composer; its rows use the shared idle, ongoing, and done markers for pending, in-progress, and completed items. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop. @@ -102,11 +102,11 @@ Clicking unused space inside an editable composer preserves its current caret or Each composer attempt carries its original occurrence time, gesture, delivery intent, and Session model/run-state snapshot through asynchronous command arbitration. The ordinary-message notification fires once before reference serialization, including attachment-only submissions; handled or claimed commands do not notify. Snapshot and notification failures cannot block sending. -A default send waits for the Host: Enter captures the draft text, its reference chips, and its attachments in the attempt, and the composer enters `sending`, with the editor and attachment rail read-only and Send showing progress, until the Host accepts the prompt. Only then do the draft, occurrence table, and undo history clear in one transaction; a refusal or failure returns the composer to `plain` with the draft and attachments unchanged, so one message is in flight at a time. The browser moves the keyboard to the document body when the editor turns read-only; if it is still there when a read-only phase ends, the editor takes it back, while a control the user focused meanwhile keeps it. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order; Session publishes it in `pendingSubmissions` once the Host accepts the prompt and derives the placement from that mode and its running state at registration, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; Session retires the echo once at its display handoff, correlated by that `rpcId`. Command submissions keep the frozen `submitting` phase. Accepted attachments leave the draft. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards; an accepted prompt whose echo retires unobserved releases its attachments, and Session scope disposal releases the attachments of a message the Host has not accepted. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents accept pasted and dropped PNG, JPEG, WebP, and GIF images with the same projected limits; a batch containing a generic file, unsupported image format, or directory is refused before intake. Their sends register the same local echo; the continuation request carries the echo's `requestId`, which the Host records as the child message's `rpcId`. +A default send waits for the Host: Enter captures the draft text, its reference chips, and its attachments in the attempt, and the composer enters `sending`, with Send showing progress until the Host accepts the prompt. Meanwhile the editor stays editable and the rail takes new attachments; Enter and Send wait, and the attachments of the message being sent stay attached, so one message is in flight at a time. Once the Host accepts the prompt, its attachments leave the draft and its text leaves the editor in one transaction that also cuts the undo history: the whole draft while it still equals the sent text, only the sent text and the whitespace after it when the user added text after it, and nothing when the user changed the sent text itself. A refusal or failure returns the composer to `plain` with the draft and attachments as they are. Command arbitration and command submission keep the editor read-only; the browser then moves the keyboard to the document body, and if it is still there when the read-only phase ends, the editor takes it back, while a control the user focused meanwhile keeps it. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order; Session publishes it in `pendingSubmissions` once the Host accepts the prompt and derives the placement from that mode and its running state at registration, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; Session retires the echo once at its display handoff, correlated by that `rpcId`. Command submissions keep the frozen `submitting` phase. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards; an accepted prompt whose echo retires unobserved releases its attachments, and Session scope disposal releases the attachments of a message the Host has not accepted. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents accept pasted and dropped PNG, JPEG, WebP, and GIF images with the same projected limits; a batch containing a generic file, unsupported image format, or directory is refused before intake. Their sends register the same local echo; the continuation request carries the echo's `requestId`, which the Host records as the child message's `rpcId`. Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A refused or failed submission publishes no echo and leaves the draft in the composer with an error notice. A refusal from the Agent admission stage (`session/prompt-rejected`) shows its own text, unless `presented` says the refusing plugin explains it in its own interface, in which case the composer adds no notice. -Disabled Send and Stop buttons suppress their tooltips, including a Stop button that becomes a disabled Send button when the turn ends. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label. Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions and accept pasted or dropped images, but expose no File row; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../subagent/subagent/README.md)). +Disabled Send and Stop buttons suppress their tooltips, including a Stop button that becomes a disabled Send button when the turn ends. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. While a message waits for the Host, the seat shows its progress whatever the draft holds, and a separate Stop stays beside it. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label. Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions and accept pasted or dropped images, but expose no File row; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../subagent/subagent/README.md)). File chips and editable skill references share a whole-reference hover background and follow the composer's line height and text baseline. The first click delegates preview opening to the registered reference source immediately, including the first click of a double-click sequence. Subsequent clicks retain native text selection; an existing noncollapsed selection suppresses pointer preview activation. Previewing does not change the draft, its clipboard projection, or submission. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 6b8da75222..496bc2dd8a 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -80,7 +80,7 @@ Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CS 已认领的命令是草稿开头的一个原子 chip,认领与该 chip 同生共灭。删除参数和分隔空格不影响认领。光标到达 chip 边缘时,第一次 Backspace 或 Delete 选中整个 chip,再按一次才删除它并释放认领,参数保留为普通文字;按其他键、点击或移动光标会取消选中。在 chip 前插入文字或换行会把它还原为字面 token 并释放认领,撤销会连同认领一起恢复被删除的 chip。持久化的草稿记录 chip 的 token,刷新后命令目录解析出认领即重建 chip;在此之前先编辑了草稿则保留字面 token,Enter 仍会把它作为命令提交。所有命令和语言使用相同规则,包括 `/goal`、`/目标`、`/plan` 和 `/计划`。输入法组合输入期间,命令提示和普通占位文字持续隐藏,直到编辑器提交最终文字且对应输入为空时才重新显示。 -工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿内容(包括结构化引用)和附件仅在该请求仍为当前请求时,通过它的同步准备回调搬移;后续导航或所有者释放会保留原草稿。 +工作区选择使用 `uiWorkspace.openWorkspace` 准备目标并提交导航。草稿内容(包括结构化引用)和附件仅在该请求仍为当前请求、且没有在途提交时,通过它的同步准备回调搬移;后续导航、所有者释放或在途提交都会把原草稿留在它的 Session 中。 本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` 外壳将常驻的 `conversation.header` 放在可选 Session 的 `conversation.content` Component Factory 外。未选中 Session 时,头部仍承载根作用域导航;标题、操作和 View 标签保留在严格 Session 子组件中。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。共享正文与 Composer 注册 queue dock 和 Todo dock。Todo dock 在 composer 上方使用共享面板 elevation;其中的行分别以共享 idle、ongoing 与 done 标记表示待处理、进行中与已完成。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。 @@ -102,11 +102,11 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 每次输入框提交都携带原始发生时间、操作来源、投递意图以及会话模型和运行状态快照,贯穿异步命令裁决。普通消息通知在引用序列化前发出一次,纯附件提交也遵循这一规则;已处理或认领的命令不发出通知。快照读取与通知消费异常不能阻断发送。 -默认发送等待 Host 接收:Enter 把草稿文字、引用胶囊和附件一起记入本次 attempt,composer 进入 `sending`,编辑器和附件栏只读,Send 显示发送中,直到 Host 接收该提示词。之后才在同一事务里清空草稿、occurrence 表和撤销历史;被拒绝或失败时 composer 回到 `plain`,草稿和附件保持不变,因此同一时刻只有一条消息在途。编辑器变为只读时,浏览器会把键盘焦点移到页面 body 上;只读阶段结束时焦点若仍在那里,编辑器会把它取回,用户期间聚焦的其他控件则保留焦点。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),保留图片与文件的选择顺序;Host 接收提示词后,Session 才在 `pendingSubmissions` 中公开它,并按注册时的模式与运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;Session 按同一 `rpcId` 关联展示接管,并仅退休回显一次。命令提交保持冻结的 `submitting` 阶段。被接收的附件离开草稿。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡;已被接收但回显未被观察到就退休的提示词会释放其附件,Session scope 销毁时会释放尚未被 Host 接收的消息的附件。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理允许粘贴和拖入 PNG、JPEG、WebP、GIF 图片,并沿用相同的投影限制;包含通用文件、不支持的图片格式或目录的批次会在进入草稿前整批拒绝。它们的发送同样注册本地回显;continuation 请求携带回显的 `requestId`,Host 把它记录为子会话消息的 `rpcId`。 +默认发送等待 Host 接收:Enter 把草稿文字、引用胶囊和附件一起记入本次 attempt,composer 进入 `sending`,Send 显示发送中,直到 Host 接收该提示词。期间编辑器保持可编辑,附件栏可以添加新附件;Enter 和 Send 需要等待,正在发送的消息的附件不能移除,因此同一时刻只有一条消息在途。Host 接收提示词后,它的附件离开草稿,它的文字在同一事务里离开编辑器并截断撤销历史:草稿仍等于已发送文字时整段清空,用户只在其后追加了文字时只删除已发送文字及紧随其后的空白,用户改动了已发送文字本身时不做删除。被拒绝或失败时 composer 回到 `plain`,草稿和附件保持当时的样子。命令判定和命令提交期间编辑器只读;此时浏览器会把键盘焦点移到页面 body 上,只读阶段结束时焦点若仍在那里,编辑器会把它取回,用户期间聚焦的其他控件则保留焦点。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),保留图片与文件的选择顺序;Host 接收提示词后,Session 才在 `pendingSubmissions` 中公开它,并按注册时的模式与运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;Session 按同一 `rpcId` 关联展示接管,并仅退休回显一次。命令提交保持冻结的 `submitting` 阶段。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡;已被接收但回显未被观察到就退休的提示词会释放其附件,Session scope 销毁时会释放尚未被 Host 接收的消息的附件。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理允许粘贴和拖入 PNG、JPEG、WebP、GIF 图片,并沿用相同的投影限制;包含通用文件、不支持的图片格式或目录的批次会在进入草稿前整批拒绝。它们的发送同样注册本地回显;continuation 请求携带回显的 `requestId`,Host 把它记录为子会话消息的 `rpcId`。 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。被拒绝或失败的提交不会公开回显,草稿留在 composer 中并显示错误提示。Agent 接纳阶段的拒绝(`session/prompt-rejected`)显示它自带的文字;若 `presented` 表明拒绝它的插件会在自己的界面里说明,composer 不再提示。 -Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切换成禁用 Send 的按钮也遵循此规则。普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,允许粘贴或拖入图片,但不提供「文件」菜单项;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../subagent/subagent/README.zh.md))。 +Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切换成禁用 Send 的按钮也遵循此规则。普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。消息等待 Host 接收期间,无论草稿内容如何,该位置都显示发送进度,旁边另有独立的 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,允许粘贴或拖入图片,但不提供「文件」菜单项;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../subagent/subagent/README.zh.md))。 文件标签和可编辑的 skill 引用共用覆盖整个引用的悬停背景,并跟随输入框的行高与文字基线。首次点击立即由已注册的引用来源负责打开预览,包括双击序列的第一次点击。后续点击保留原生文本选择行为;已有非折叠选区时,指针点击不打开预览。预览不改变草稿、剪贴板文本或提交内容。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index c555e2b5d9..d857d9e7a5 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -358,6 +358,8 @@ export function apply(ctx: Context, config: Config = Config({})): void { selectWorkspace: workspaceId => workspaceNavigation.openWorkspace(workspaceId, (nextId) => { if (sessionId !== undefined && nextId !== sessionId) { const from = inputHub.shell(sessionId) + // A draft with a submission in flight stays with the Session that is admitting it. + if (from.snapshot.phase !== 'plain' && from.snapshot.phase !== 'claimed') return const draft = from.draftSnapshot const attachmentIds = from.snapshot.attachmentIds const next = inputHub.shell(nextId) @@ -487,8 +489,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { keyboard: shell, addFiles: (files, directories = new Set()) => { if (sessions.binding(sessionId) === undefined) return t('file.sessionUnavailable') - const { phase } = shell.snapshot - if (phase === 'adjudicating' || phase === 'submitting' || phase === 'sending') { + if (shell.snapshot.phase === 'adjudicating' || shell.snapshot.phase === 'submitting') { return t('attachment.dropBlocked') } const uploads: File[] = [] diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index 31f3620345..f6ee273e28 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -290,7 +290,9 @@ export interface InputState { * Submit plane: `adjudicating` while a `/` line is checked against the * commands, `submitting` while a claimed command runs, and `sending` while * an ordinary message waits for the Host to accept it. The editor and the - * attachments are read-only in all three. + * attachments are read-only while adjudicating or submitting. While + * sending, the draft stays editable and accepts new attachments; another + * send waits, and the attachments of the message being sent stay attached. */ readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting' | 'sending' /** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */ @@ -305,9 +307,10 @@ export interface InputState { * One in-flight submission attempt: the ONLY id concept in the submit plane. * Created on enter; carried by adjudicated/submit-settled/sink-settled * events; stale attempts are dropped (anti-backwash). Command and message - * attempts hold the single frozen in-flight slot, so the draft they carry is - * the one the user sent. release/session teardown aborts the attempt, keeping - * every promise bounded. + * attempts hold the single in-flight slot and carry the draft, references, + * and attachments captured at Enter, so edits made while a message is sending + * do not change what it sends. release/session teardown aborts the attempt, + * keeping every promise bounded. */ export interface SubmitAttempt { readonly seq: number @@ -354,8 +357,19 @@ export type InputEvent = readonly outcome?: SubmitOutcome readonly message?: string } - /** Settlement of the message being sent: the Host accepted it, or refused it or failed. */ - | { readonly type: 'sink-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string } + /** + * Settlement of the message being sent: the Host accepted it, or refused it + * or failed. It carries the leading command chip's claim, which the claim + * follows afterwards, because the draft stays editable while a message is sent. + */ + | { + readonly type: 'sink-settled' + readonly attempt: SubmitAttempt + readonly ok: boolean + readonly command: CommandClaim | undefined + readonly outcome?: SubmitOutcome + readonly message?: string + } | { readonly type: 'release' } /** @@ -374,10 +388,11 @@ export type InputEffect = } | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string } /** - * Clear the committed draft in the editor and cut undo history. A string - * snapshot keeps a pure suffix typed during the Host round-trip (content - * appended after the sent snapshot survives; interleaved edits cannot be - * separated and clear whole); null clears unconditionally (an accepted - * message froze the editor, so nothing was typed after it). + * Remove the submitted draft from the editor after its command succeeded or + * the Host accepted its message: the whole draft while it equals the + * submitted text, only the submitted text and the whitespace after it when + * the user added text after it, and nothing when the user changed the + * submitted text itself. A removal also cuts the undo history, so the + * submitted text cannot come back. */ - | { readonly type: 'commit-draft'; readonly retainSuffixOf: string | null } + | { readonly type: 'commit-draft'; readonly submitted: string } diff --git a/packages/client/ui-conversation/src/client/input/editor/runtime.ts b/packages/client/ui-conversation/src/client/input/editor/runtime.ts index 7cd85c0506..611d5e4021 100644 --- a/packages/client/ui-conversation/src/client/input/editor/runtime.ts +++ b/packages/client/ui-conversation/src/client/input/editor/runtime.ts @@ -16,13 +16,19 @@ import { ReferenceChipNode, $createReferenceChipNode } from './chip-node.tsx' import { CommandChipNode, $createCommandChipNode, registerCommandChipSeat } from './command-chip-node.tsx' import { registerCommandChipSelection } from './command-chip-selection.ts' import { registerTextRefDecoration, rescanTextRefs, TextRefNode } from './text-ref.ts' -import type { EditorProjection } from './projection.ts' +import type { ComposerLayout, EditorProjection } from './projection.ts' import { $composerLayout, $projectComposer, detectOffsetOfClipboardOffset } from './projection.ts' -import { $replaceDetectSpanWithNodes, $replaceDetectSpanWithText } from './span-map.ts' +import { $replaceDetectSpanWithNodes, $replaceDetectSpanWithText, $selectDetectSpan } from './span-map.ts' import type { DetectSpan } from './span-map.ts' type Lexicon = ReadonlyMap<'/' | '@', readonly string[]> +/** Whether a clipboard offset falls strictly inside a chip's clipboard text. */ +function splitsChip(layout: ComposerLayout, offset: number): boolean { + return layout.segments.some(segment => segment.kind === 'chip' + && segment.clipboardStart < offset && offset < segment.clipboardStart + segment.clipboardLength) +} + /** Model callbacks read at the same editor registration and update points. */ interface DraftEditorRuntimeDeps { readonly onUpdate: () => void @@ -307,23 +313,43 @@ export class DraftEditorRuntime { } /** - * Clear committed content using the model's suffix decision inside the editor update. - * @param prefixLength - returns the clipboard-prefix length to remove, or null to clear the root. + * Remove submitted text from the start of the draft. The whole draft goes + * while it equals the submitted text. When the user added text after it, + * the submitted text and the whitespace that follows it go, and the + * selection keeps its place in the remaining text. When the user changed + * the submitted text itself, including turning its end into a chip, the + * draft stays. + * @param submitted - clipboard text of the submitted draft. + * @returns whether the draft changed. */ - clearCommittedDraft(prefixLength: (clipboardText: string) => number | null): void { + removeSubmittedDraft(submitted: string): boolean { + let removed = false this.editor.update(() => { const layout = $composerLayout() - const length = prefixLength(layout.clipboardText) - if (length !== null) { - $replaceDetectSpanWithText( - { start: 0, end: detectOffsetOfClipboardOffset(layout, length) }, '', - ) + const clip = layout.clipboardText + if (submitted === '' || !clip.startsWith(submitted) || splitsChip(layout, submitted.length)) return + let end = submitted.length + while (end < clip.length && /\s/u.test(clip.charAt(end))) end += 1 + removed = true + const root = $getRoot() + if (end === clip.length) { + root.clear() + root.selectEnd() return } - const root = $getRoot() - root.clear() - root.selectEnd() + const selection = $projectComposer(key => this.occurrenceIdOf(key)).selection + const removedEnd = detectOffsetOfClipboardOffset(layout, end) + $replaceDetectSpanWithText({ start: 0, end: removedEnd }, '') + if (selection === null) { + root.selectEnd() + return + } + $selectDetectSpan({ + start: Math.max(0, selection.start - removedEnd), + end: Math.max(0, selection.end - removedEnd), + }) }, { discrete: true, tag: HISTORY_MERGE_TAG }) + return removed } /** diff --git a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts index dacb1796b8..6aaef26bdb 100644 --- a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts +++ b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts @@ -9,6 +9,9 @@ import { registerComposerKeymap } from './keymap.ts' interface DraftViewGate { locked: boolean + /** A command holds the draft read-only. */ + commandBusy: boolean + /** A command or a message is in flight, so another send waits. */ machineBusy: boolean canSteerQueue: boolean running: boolean @@ -111,7 +114,7 @@ export function installDraftKeymap( return registerComposerKeymap(editor, { arbitrate: (key, composing) => keyboard.arbitrate(key, composing), space: () => { - if (gate.current.machineBusy || gate.current.locked) return false + if (gate.current.commandBusy || gate.current.locked) return false return keyboard.space() }, dismissPopup: () => { keyboard.dismissPopup() }, @@ -138,7 +141,7 @@ export function installDraftKeymap( }, intakeFiles: (files, directories) => { gate.current.intakeFiles(files, directories) }, pasteText: (text) => { - if (gate.current.machineBusy || gate.current.locked) return + if (gate.current.commandBusy || gate.current.locked) return keyboard.paste(text) }, }) diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 8c8e246b68..cbbfc42800 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -74,18 +74,22 @@ export interface SessionInputDeps { } } -/** Guard tier from the machine phase. */ -function guardOf(phase: InputState['phase']): 'plain' | 'claimed' | 'frozen' { +/** + * Guard tier from the machine phase. A message being sent leaves the draft + * editable, so its tier follows the leading command chip like an idle draft. + */ +function guardOf(phase: InputState['phase'], command: CommandClaim | undefined): 'plain' | 'claimed' | 'frozen' { switch (phase) { case 'plain': return 'plain' case 'claimed': return 'claimed' - default: return 'frozen' // adjudicating / submitting / sending + case 'sending': return command === undefined ? 'plain' : 'claimed' + default: return 'frozen' // adjudicating / submitting } } -/** Whether an attempt owns the draft: the editor and attachments are read-only until it settles. */ -function frozen(phase: InputState['phase']): boolean { - return phase === 'adjudicating' || phase === 'submitting' || phase === 'sending' +/** Whether a command attempt owns the draft: the editor and attachments are read-only until it settles. */ +function locked(phase: InputState['phase']): boolean { + return phase === 'adjudicating' || phase === 'submitting' } /** Whether two projections differ in content (selection and caret excluded). */ @@ -127,7 +131,7 @@ export class SessionInputShell implements SessionInput { readonly actions: InputActions = { captureInsertion: () => ({ ...this.caretSpan(), draftRev: this.rev }), insertText: (text, span) => { - if (frozen(this.snapshot.phase) || this.disposed) return false + if (locked(this.snapshot.phase) || this.disposed) return false if (span.draftRev !== this.rev) return false return this.draftEditor.insertAsyncText(span, text) }, @@ -190,7 +194,7 @@ export class SessionInputShell implements SessionInput { const caret = this.projection.caret if (caret !== null) { this.deps.inputTriggers?.()?.track( - this.projection.detectText, caret, { tier: guardOf(this.core.state.phase) }, this.rev, + this.projection.detectText, caret, { tier: guardOf(this.core.state.phase, this.projection.command) }, this.rev, ) } } @@ -284,12 +288,13 @@ export class SessionInputShell implements SessionInput { } private draftInitializationBlocked(): boolean { - return this.disposed || frozen(this.core.state.phase) + const { phase } = this.core.state + return this.disposed || (phase !== 'plain' && phase !== 'claimed') } - /** Append ordered attachment ids unless an admission transaction is locked. */ + /** Append ordered attachment ids unless a command attempt holds the draft. */ addAttachments(ids: readonly DraftAttachmentId[]): boolean { - if (frozen(this.snapshot.phase)) return false + if (locked(this.snapshot.phase)) return false if (ids.length === 0) return true this.attachmentIds = [...this.attachmentIds, ...ids] this.publish() @@ -297,13 +302,13 @@ export class SessionInputShell implements SessionInput { } /** - * Add validated file references and attachment ids while admission is editable. + * Add validated file references and attachment ids while the draft is editable. * @param references - reference chips in source order. * @param ids - newly allocated attachment ids. - * @returns false when admission is locked or the editor refuses the insertion. + * @returns false when a command attempt holds the draft or the editor refuses the insertion. */ addFiles(references: readonly ReferenceInsert[], ids: readonly DraftAttachmentId[]): boolean { - if (frozen(this.snapshot.phase)) return false + if (locked(this.snapshot.phase)) return false if (!this.draftEditor.insertFileReferences(references)) return false this.attachmentIds = [...this.attachmentIds, ...ids] this.publish() @@ -311,12 +316,13 @@ export class SessionInputShell implements SessionInput { } /** - * Remove one attachment id from this draft. Busy admission phases refuse, like - * {@link addAttachments}: a removal landing while a send serializes would - * otherwise vanish from the rail yet still ride the in-flight send. + * Remove one attachment id from this draft. A command attempt refuses every + * removal, and a message being sent refuses the removal of its own + * attachments: such a removal would vanish from the rail while the + * attachment still rides the in-flight send. */ removeAttachment(id: DraftAttachmentId): boolean { - if (frozen(this.snapshot.phase)) return false + if (locked(this.snapshot.phase) || this.core.sendingAttempt?.attachmentIds.includes(id) === true) return false const next = this.attachmentIds.filter(candidate => candidate !== id) if (next.length === this.attachmentIds.length) return false this.attachmentIds = next @@ -351,8 +357,9 @@ export class SessionInputShell implements SessionInput { * Enter adjudication + submit transaction + default sink with the draft, its * reference chips, and its attachments as the user sent them. Effects fan * out from the machine; this method only feeds the event. Lock entry - * (adjudicating/submitting/sending) force-closes the transient layers: the - * popup dismisses and the menu tracks frozen. + * (adjudicating/submitting) force-closes the transient layers: the popup + * dismisses and the menu tracks frozen. A message being sent leaves them to + * the editable draft. */ submit(mode: InputSubmitMode = 'queue', source?: 'click' | 'enter'): void { if (this.disposed) return @@ -381,7 +388,7 @@ export class SessionInputShell implements SessionInput { attachmentIds: [...this.attachmentIds], submission, }) - if (frozen(this.snapshot.phase)) { + if (locked(this.snapshot.phase)) { this.deps.popup?.()?.dismiss() this.deps.inputTriggers?.()?.track(this.projection.detectText, 0, { tier: 'frozen' }, this.rev) } @@ -465,8 +472,7 @@ export class SessionInputShell implements SessionInput { * @returns whether the edit applied. */ private applyClaim(claim: CommandClaim, span: TokenSpan, mergeHistory: boolean): boolean { - const phase = this.core.state.phase - if (phase !== 'plain' && phase !== 'claimed') return false + if (locked(this.core.state.phase)) return false if (span.draftRev !== this.rev) return false // Leading-trigger contract: only whitespace may precede the span; the // whitespace prefix is dropped so the chip takes the leading seat. @@ -487,8 +493,7 @@ export class SessionInputShell implements SessionInput { * @returns whether the edit applied. */ insertReference(ref: ReferenceInsert, span: TokenSpan): boolean { - const phase = this.core.state.phase - if (phase !== 'plain' && phase !== 'claimed') return false + if (locked(this.core.state.phase)) return false if (span.draftRev !== this.rev) return false const tail = this.projection.detectText.slice(span.end, span.end + 1) return this.draftEditor.insertReference(span, ref, tail) @@ -648,25 +653,19 @@ export class SessionInputShell implements SessionInput { return } case 'commit-draft': { - this.commitDraft(fx.retainSuffixOf) + this.commitDraft(fx.submitted) return } } } /** - * Execute the commit-draft effect: clear the committed content (retaining - * a pure typed-during-flight suffix when the snapshot allows) and cut the - * undo history so sent content cannot resurrect. + * Execute the commit-draft effect: remove the submitted text the editor + * still holds, and cut the undo history after a removal so submitted + * content cannot come back. */ - private commitDraft(retainSuffixOf: string | null): void { - this.draftEditor.clearCommittedDraft((clip) => { - if (retainSuffixOf !== null && clip !== retainSuffixOf && clip.startsWith(retainSuffixOf)) { - return retainSuffixOf.length - } - return null - }) - this.draftEditor.clearHistory() + private commitDraft(submitted: string): void { + if (this.draftEditor.removeSubmittedDraft(submitted)) this.draftEditor.clearHistory() } /** @@ -712,7 +711,9 @@ export class SessionInputShell implements SessionInput { }, (error: unknown) => { if (this.dead(attempt)) return - this.dispatchRun(({ type: 'sink-settled', attempt, ok: false, message: errorText(error) })) + this.dispatchRun(({ + type: 'sink-settled', attempt, ok: false, command: this.projection.command, message: errorText(error), + })) }, ) } @@ -724,9 +725,10 @@ export class SessionInputShell implements SessionInput { /** * Settle the message being sent. Once the Host accepts it, its attachments - * leave the draft before the editor clears; ConversationController releases - * them when the message's echo retires. Any other outcome keeps the draft - * and its attachments for correction. + * leave the draft before its text leaves the editor; ConversationController + * releases them when the message's echo retires. Any other outcome keeps + * the draft and its attachments for correction. Both carry the leading + * command chip the user may have inserted while the message was sent. */ private settleSink( attempt: SubmitAttempt, @@ -740,11 +742,13 @@ export class SessionInputShell implements SessionInput { const sent = new Set(attempt.attachmentIds) this.attachmentIds = this.attachmentIds.filter(id => !sent.has(id)) } - this.dispatchRun(({ type: 'sink-settled', attempt, ok: accepted, outcome })) + this.dispatchRun(({ type: 'sink-settled', attempt, ok: accepted, command: this.projection.command, outcome })) }, (error: unknown) => { if (this.dead(attempt)) return - this.dispatchRun(({ type: 'sink-settled', attempt, ok: false, message: errorText(error) })) + this.dispatchRun(({ + type: 'sink-settled', attempt, ok: false, command: this.projection.command, message: errorText(error), + })) }, ) } diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index 62586302c1..1e5592247d 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -3,10 +3,11 @@ * Events in, effects out; zero React / DOM / cordis. Package-private; the * SessionInput shell owns editor state and executes the returned effects. * - * Claimed commands and ordinary messages occupy the one frozen in-flight - * slot. A message stays in the read-only editor until the Host accepts it; - * only then does the editor clear, and a refusal or failure returns it to - * editing unchanged. + * Claimed commands and ordinary messages occupy the one in-flight slot. A + * command keeps the editor read-only until it settles. A message keeps the + * editor editable while the Host decides and blocks only another send; once + * the Host accepts it, the sent text leaves the editor, and a refusal or + * failure leaves the draft as the user left it. */ import type { InputSubmitMode, MessageSubmission } from '../contract/composer-submission.ts' import type { Occurrence } from '../contract/draft-editor.ts' @@ -65,6 +66,11 @@ export class SubmitMachine { } } + /** The attempt of the message being sent; undefined outside the sending phase. */ + get sendingAttempt(): SubmitAttempt | undefined { + return this.phase === 'sending' ? this.inflight?.attempt : undefined + } + /** * Feed one event through the machine. * @param ev - submit-plane event. @@ -89,7 +95,8 @@ export class SubmitMachine { * chip: deleting or displacing the chip releases it, and a history restore * of the chip re-enters claimed. The claim event applies the same rule to a * chip the editor just inserted, before its update commits; busy phases - * refuse both. + * refuse both, and the settlement of a message being sent applies the chip + * the user left meanwhile. */ private followCommand(command: CommandClaim | undefined): readonly InputEffect[] { if (this.phase !== 'plain' && this.phase !== 'claimed') return [] @@ -98,7 +105,7 @@ export class SubmitMachine { return [] } - /** Occupy the frozen slot with the draft, references, and attachments the user sent. */ + /** Occupy the in-flight slot with the draft, references, and attachments the user sent. */ private beginAttempt( mode: InputSubmitMode, draft: string, @@ -121,7 +128,7 @@ export class SubmitMachine { return attempt } - /** Send the frozen message; the editor stays read-only until the Host accepts it. */ + /** Send the captured message; another send waits until the Host accepts or refuses it. */ private send(attempt: SubmitAttempt): readonly InputEffect[] { this.claim = undefined this.phase = 'sending' @@ -183,7 +190,7 @@ export class SubmitMachine { if (ev.ok) { this.phase = 'plain' this.claim = undefined - const effects: InputEffect[] = [{ type: 'commit-draft', retainSuffixOf: flight.attempt.draftSnapshot }] + const effects: InputEffect[] = [{ type: 'commit-draft', submitted: flight.attempt.draftSnapshot }] if (ev.outcome?.text !== undefined) { effects.push({ type: 'notice', level: ev.outcome.kind === 'error' ? 'error' : 'info', text: ev.outcome.text }) } @@ -195,13 +202,18 @@ export class SubmitMachine { return text === undefined ? [] : [{ type: 'notice', level: 'error', text }] } - /** An accepted message clears the editor; a refused or failed one returns to editing unchanged. */ + /** + * Settle the message being sent. The claim follows the leading command chip + * the draft holds now; an accepted message's text leaves the editor, and a + * refused or failed one leaves the draft as it is. + */ private onSinkSettled(ev: Extract): readonly InputEffect[] { const flight = this.inflight if (this.phase !== 'sending' || flight === undefined || flight.attempt.seq !== ev.attempt.seq) return [] this.inflight = undefined - this.phase = 'plain' - const effects: InputEffect[] = ev.ok ? [{ type: 'commit-draft', retainSuffixOf: null }] : [] + this.claim = ev.command + this.phase = ev.command === undefined ? 'plain' : 'claimed' + const effects: InputEffect[] = ev.ok ? [{ type: 'commit-draft', submitted: flight.attempt.draftSnapshot }] : [] const text = ev.message ?? ev.outcome?.text if (text !== undefined) { effects.push({ type: 'notice', level: ev.ok && ev.outcome?.kind !== 'error' ? 'info' : 'error', text }) diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index b4fd35fddc..3d4a00b3f0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -149,16 +149,19 @@ export const InputBar = memo(function InputBar({ // the composer asking for the only thing it prevents. The other reasons to // be disabled do lock it — there is no session to choose a model for. const modelSeatLocked = removed || inert || !live - const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' || input?.phase === 'sending' - // The message waits for the Host to accept it; Send shows progress instead of the arrow. + // A command being checked or run holds the draft read-only. + const commandBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' + // The message waits for the Host to accept it; Send shows progress instead + // of the arrow, and the box stays editable for the next message. const sending = input?.phase === 'sending' + const machineBusy = commandBusy || sending // The no-workspace surface remains the resident DOM node but acts as the // existing picker trigger. Message controls stay locked until a Session // exists; the trigger itself is read-only rather than disabled so pointer // and keyboard users can reach the recovery action. const workspaceTrigger = inert && !removed && onRequestWorkspace !== undefined const editorDisabled = removed || (locked && !workspaceTrigger) - const editable = live && !locked && !machineBusy + const editable = live && !locked && !commandBusy const steeringAvailable = subagent === null || subagent.address.mode === 'continuable' const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && steeringAvailable && input.queue.length > 0 @@ -189,13 +192,13 @@ export const InputBar = memo(function InputBar({ focusDraftEditor(editor, revealSelection) }, [locked, sessionId, editor]) - // A busy phase leaves the box read-only, and the browser then moves the + // A command phase leaves the box read-only, and the browser then moves the // keyboard to the document body. When the phase ends with the keyboard still - // there, return it, so the next message can be typed as soon as a send - // settles; a control the user moved to meanwhile keeps the keyboard. + // there, return it, so typing can continue as soon as the command settles; a + // control the user moved to meanwhile keeps the keyboard. const busyPhaseSeen = useRef(false) useEffect(() => { - if (machineBusy) { + if (commandBusy) { busyPhaseSeen.current = true return } @@ -203,7 +206,7 @@ export const InputBar = memo(function InputBar({ busyPhaseSeen.current = false if (locked || editor === null) return if (document.activeElement === null || document.activeElement === document.body) focusDraftEditor(editor, revealSelection) - }, [machineBusy]) + }, [commandBusy]) // A persisted draft arrives AFTER the unlock effect: DefaultConversationViews // adopts it in its own mount effect, and a parent's mount effect runs after @@ -233,7 +236,7 @@ export const InputBar = memo(function InputBar({ // The host enforces the same image limits at submit for callers that bypass // this composer. const intakeFiles = useCallback((files: readonly File[], directories?: ReadonlySet): void => { - if (locked || machineBusy || (subagent !== null && !continuable) || addFiles === undefined || files.length === 0) return + if (locked || commandBusy || (subagent !== null && !continuable) || addFiles === undefined || files.length === 0) return const rejected = ((): string | null => { // Child prompts support images, but not generic file receipts or the // Desktop path-reference fallback. Refuse mixed batches before intake. @@ -259,9 +262,9 @@ export const InputBar = memo(function InputBar({ return addFiles(files, directories) })() if (rejected !== null) showToast(rejected) - }, [locked, machineBusy, subagent, continuable, addFiles, attachments, imageLimits, showToast, t]) + }, [locked, commandBusy, subagent, continuable, addFiles, attachments, imageLimits, showToast, t]) - const canAcceptDrop = (subagent === null || continuable) && !locked && !machineBusy && addFiles !== undefined + const canAcceptDrop = (subagent === null || continuable) && !locked && !commandBusy && addFiles !== undefined const fileInputRef = useRef(null) const onPickFiles = (e: ChangeEvent): void => { @@ -274,11 +277,11 @@ export const InputBar = memo(function InputBar({ // The keymap handlers read live bar state through this ref so the editor // registration survives re-renders without re-arming per keystroke. const gate = useRef({ - locked, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter, + locked, commandBusy, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter, intakeFiles, uploadsPending, showToast, t, canAcceptDrop, }) gate.current = { - locked, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter, + locked, commandBusy, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter, intakeFiles, uploadsPending, showToast, t, canAcceptDrop, } @@ -342,11 +345,12 @@ export const InputBar = memo(function InputBar({ // claimed command nor a `/` line headed for adjudication — so it never // describes a delivery the click cannot or does not perform; every other // state keeps plain Send. A continuable child keeps Send primary and - // exposes Stop independently. - const primaryStops = running && subagent === null && (empty || blocked !== undefined) + // exposes Stop independently, and so does a running session while a + // message waits for the Host, whatever the draft holds meanwhile. + const primaryStops = running && subagent === null && !sending && (empty || blocked !== undefined) // Disabled native buttons may omit mouseleave; their tooltip must close from state. const primaryDisabled = primaryStops ? stop === undefined : empty || disabled || machineBusy || uploadsPending - const interruptible = running && continuable + const interruptible = running && (continuable || (sending && subagent === null)) const primarySubmitMode = resolveSubmitMode(busyEnter, running, 'enter', steeringAvailable) const plainMessageDraft = !empty && input?.phase === 'plain' && !draft.trimStart().startsWith('/') const primaryLabel = primaryStops diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index 98e8f325dc..8ecf9897fc 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -547,6 +547,27 @@ describe('Conversation inject API', () => { await b.runtime.dispose() }) + it('leaves a draft whose message is still being sent with its Session', async () => { + const b = await bench() + const pending = Promise.withResolvers>>() + b.sessionFake.prompt.mockReturnValueOnce(pending.promise) + const { state, actions } = b.inputApi(ROOT) + actions.setDraft('in flight') + actions.submit() + await vi.waitFor(() => { expect(b.sessionFake.prompt).toHaveBeenCalledOnce() }) + expect(state.getSnapshot().phase).toBe('sending') + const other = 'sending-target' as SessionId + await b.runtime.sessions.add({ id: other, session: {} }) + b.connectWorkspace.mockResolvedValueOnce(other) + await b.residentApi(ROOT).selectWorkspace('workspace-sending' as WorkspaceId) + expect(b.opened).toHaveBeenCalledWith(other) + expect(b.inputApi(other).state.getSnapshot().draft).toBe('') + expect(state.getSnapshot().draft).toBe('in flight') + pending.resolve({ ok: true, value: { accepted: true } }) + await vi.waitFor(() => { expect(state.getSnapshot()).toMatchObject({ phase: 'plain', draft: '' }) }) + await b.runtime.dispose() + }) + it('carries structured references through Workspace selection without flattening them', async () => { const b = await bench() const draft = { diff --git a/packages/client/ui-conversation/tests/draft-document.client.spec.ts b/packages/client/ui-conversation/tests/draft-document.client.spec.ts index 77aed44300..9a51aa6719 100644 --- a/packages/client/ui-conversation/tests/draft-document.client.spec.ts +++ b/packages/client/ui-conversation/tests/draft-document.client.spec.ts @@ -42,6 +42,11 @@ function documentOf(...parts: readonly (string | ReferenceContent)[]): DraftSnap return { text, references } } +/** A collapsed insertion span at one detect offset of the current draft revision. */ +function atOffset(shell: SessionInputShell, offset: number): { start: number; end: number; draftRev: number } { + return { start: offset, end: offset, draftRev: shell.snapshot.draftRev } +} + function makeShell(overrides: Partial = {}): SessionInputShell { const ctx = new Context() onTestFinished(() => ctx.fiber.dispose()) @@ -568,29 +573,33 @@ describe('draft documents in the input shell', () => { }) it.each(['message', 'attachment-only message'] as const)( - 'refuses draft initialization and attachments while a %s waits for the Host', async (kind) => { + 'keeps a %s and its attachments while it waits for the Host and takes new attachments', async (kind) => { const outcome = Promise.withResolvers() const started = Promise.withResolvers() const sink = vi.fn(() => { started.resolve(undefined); return outcome.promise }) const shell = makeShell({ defaultSink: sink }) try { const sentAttachment = 'sent-image' as DraftAttachmentId + const nextAttachment = 'next-image' as DraftAttachmentId const attachmentOnly = kind === 'attachment-only message' - if (attachmentOnly) shell.addAttachments([sentAttachment]) - else shell.setDraft('outbound message') + shell.addAttachments([sentAttachment]) + if (!attachmentOnly) shell.setDraft('outbound message') shell.submit() await started.promise expect(sink).toHaveBeenCalledOnce() - expect(sink.mock.calls[0]?.[1]).toEqual(attachmentOnly ? [sentAttachment] : []) + expect(sink.mock.calls[0]?.[1]).toEqual([sentAttachment]) expect(shell.snapshot.phase).toBe('sending') - expect(shell.addAttachments(['next-image' as DraftAttachmentId])).toBe(false) - expect(shell.removeAttachment(sentAttachment)).toBe(false) expectInitializationBlocked(shell) + expect(shell.removeAttachment(sentAttachment)).toBe(false) + expect(shell.addAttachments([nextAttachment])).toBe(true) + expect(shell.removeAttachment(nextAttachment)).toBe(true) + expect(shell.addAttachments([nextAttachment])).toBe(true) outcome.resolve({ kind: 'error', text: 'flight rejected' }) await vi.waitFor(() => { expect(shell.notices.getSnapshot()?.text).toBe('flight rejected') }) expect(shell.snapshot).toMatchObject({ - phase: 'plain', draft: attachmentOnly ? '' : 'outbound message', attachmentIds: attachmentOnly ? [sentAttachment] : [], + phase: 'plain', draft: attachmentOnly ? '' : 'outbound message', attachmentIds: [sentAttachment, nextAttachment], }) + expect(shell.removeAttachment(sentAttachment)).toBe(true) expect(shell.requestDraftInitialization({ prompt: 'after settlement', clearPreviousDraft: true })).toBe('applied') } finally { shell.dispose() @@ -600,6 +609,58 @@ describe('draft documents in the input shell', () => { }, ) + it.each([ + { + change: 'adds text after it', + sent: 'first message', + edit: (shell: SessionInputShell) => shell.actions.insertText(' second', atOffset(shell, 'first message'.length)), + // The accepted text and the space after it leave, the caret stays after the typed text, and undo cannot restore them. + draft: 'second', caret: 'second'.length, afterUndo: 'second', + }, + { + change: 'changes the accepted text itself', + sent: 'first message', + edit: (shell: SessionInputShell) => shell.actions.insertText('my ', atOffset(shell, 'first '.length)), + draft: 'first my message', caret: 'first my '.length, afterUndo: 'first message', + }, + { + change: 'turns the end of the accepted text into a reference', + sent: 'see @src/ma', + edit: (shell: SessionInputShell) => shell.insertReference( + { source: file.source, ref: file.ref, label: file.label, appearance: 'file', clipboardText: file.clipboardText }, + { ...atOffset(shell, 'see @src/ma'.length), start: 'see '.length }, + ), + // Detect coordinates: the chip counts as one character, followed by its separating space. + draft: 'see @src/main.ts ', caret: 'see '.length + 2, afterUndo: 'see @src/ma', + }, + ])('removes only what the Host accepted when the user $change while it was sent', async ({ sent, edit, draft, caret, afterUndo }) => { + const outcome = Promise.withResolvers() + const started = Promise.withResolvers() + const sink = vi.fn(() => { started.resolve(undefined); return outcome.promise }) + const shell = makeShell({ defaultSink: sink }) + const sentAttachment = 'sent-image' as DraftAttachmentId + const nextAttachment = 'next-image' as DraftAttachmentId + try { + shell.addAttachments([sentAttachment]) + shell.setDraft(sent) + shell.submit() + await started.promise + expect(edit(shell)).toBe(true) + expect(shell.addAttachments([nextAttachment])).toBe(true) + outcome.resolve({ kind: 'success' }) + await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') }) + expect(shell.snapshot).toMatchObject({ draft, attachmentIds: [nextAttachment] }) + expect(shell.caretSpan()).toEqual({ start: caret, end: caret }) + shell.editor.dispatchCommand(UNDO_COMMAND, undefined) + shell.editor.update(() => {}, { discrete: true }) + await vi.waitFor(() => { expect(shell.snapshot.draft).toBe(afterUndo) }) + } finally { + shell.dispose() + outcome.resolve({ kind: 'error' }) + await outcome.promise + } + }) + it('matches a delayed skill lexicon against the current editable document without changing references', async () => { const lexicon = createSnapshotStore>(new Map()) const provider = triggerProvider(lexicon) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index af78686ed1..4ac145f5bf 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -25,7 +25,7 @@ import type { ContextPressureProjection } from '@deepseek-ai/dsh-token-meter/cli import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session/types' -import type { SubmitOutcome } from '../src/client/contract/input.ts' +import type { CommandClaim, SubmitOutcome } from '../src/client/contract/input.ts' import { SessionInputShell } from '../src/client/input/facade.ts' import { $replaceDetectSpanWithText, $selectDetectSpan } from '../src/client/input/editor/span-map.ts' import type { @@ -383,19 +383,41 @@ describe('composer focus handoff', () => { it.each<[string, SubmitOutcome]>([ ['the Host accepts it', { kind: 'success' }], ['it fails', { kind: 'error', text: 'send failed' }], - ])('returns the keyboard to the editor once a sent message settles because %s', async (_label, outcome) => { + ])('keeps the editor editable and focused while a message is sent until %s', async (_label, outcome) => { const { shell, textarea, sink } = bench() writeDraft(shell, 'draft text') act(() => { shell.focus() }) let settle!: (outcome: SubmitOutcome) => void sink.mockImplementationOnce(() => new Promise((resolve) => { settle = resolve })) fireEvent.keyDown(textarea, { key: 'Enter' }) + expect(shell.snapshot.phase).toBe('sending') + expect(textarea.getAttribute('contenteditable')).toBe('true') + expect(document.activeElement).toBe(textarea) + // Enter waits for the Host instead of sending again. + fireEvent.keyDown(textarea, { key: 'Enter' }) + expect(sink).toHaveBeenCalledOnce() + + await act(async () => { settle(outcome) }) + + expect(textarea.getAttribute('contenteditable')).toBe('true') + expect(document.activeElement).toBe(textarea) + }) + + it('returns the keyboard to the editor once a command leaves the box read-only', async () => { + const { shell, textarea } = bench() + const result = Promise.withResolvers() + const claim: CommandClaim = { name: 'goal', token: '/goal ', submit: () => result.promise } + writeDraft(shell, '/goal') + act(() => { shell.beginCommand(claim, { start: 0, end: '/goal'.length, draftRev: shell.snapshot.draftRev }) }) + act(() => { shell.focus() }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + expect(shell.snapshot.phase).toBe('submitting') expect(textarea.getAttribute('contenteditable')).toBe('false') // A browser moves the keyboard to the body once the focused box stops being editable; jsdom does not. act(() => { textarea.blur() }) expect(document.activeElement).toBe(document.body) - await act(async () => { settle(outcome) }) + await act(async () => { result.resolve({ kind: 'success' }) }) expect(textarea.getAttribute('contenteditable')).toBe('true') expect(document.activeElement).toBe(textarea) @@ -695,8 +717,10 @@ describe('image draft rail', () => { sink.mockImplementationOnce(() => new Promise((resolve) => { settle = resolve })) fireEvent.keyDown(textarea, { key: 'Enter' }) expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue', expect.any(AbortSignal)) - // The rail keeps the sent image, and Send shows progress, until the Host accepts the message. + // The rail keeps the sent image, and Send shows progress, until the Host accepts the message; + // the rail still takes files for the next message meanwhile. expect(attachmentOwner(result.slotCalls).attachments).toEqual([attachments[0]]) + expect(attachmentOwner(result.slotCalls).canAcceptDrop).toBe(true) const sending = view.getByRole('button', { name: '正在发送消息' }) as HTMLButtonElement expect(sending.disabled).toBe(true) expect(sending.getAttribute('aria-busy')).toBe('true') @@ -1081,6 +1105,25 @@ describe('running and lock semantics', () => { expect(stop).toHaveBeenCalledTimes(1) }) + it('running keeps Stop beside the sending control until the Host accepts the message', async () => { + const { view, textarea, stop, sink, shell } = bench({ running: true, draft: '排队消息' }) + let settle!: (outcome: SubmitOutcome) => void + sink.mockImplementationOnce(() => new Promise((resolve) => { settle = resolve })) + fireEvent.keyDown(textarea, { key: 'Enter' }) + const sending = view.getByRole('button', { name: '正在发送消息' }) as HTMLButtonElement + expect(sending.disabled).toBe(true) + fireEvent.click(view.getByRole('button', { name: '停止生成' })) + expect(stop).toHaveBeenCalledTimes(1) + // An emptied draft keeps the sending control while the message waits. + writeDraft(shell, '') + expect(view.getByRole('button', { name: '正在发送消息' })).toBe(sending) + + await act(async () => { settle({ kind: 'success' }) }) + + expect(view.queryByRole('button', { name: '正在发送消息' })).toBeNull() + expect(view.getAllByRole('button', { name: '停止生成' })).toHaveLength(1) + }) + it('running Send follows the busy-state Steer preference and labels the delivery', () => { const { button, sink } = bench({ running: true, busyEnter: 'steer', draft: '按钮插话' }) expect(button.getAttribute('aria-label')).toBe('插话发送') diff --git a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts index 43d7f0ef58..602d6477f7 100644 --- a/packages/client/ui-conversation/tests/submit-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/submit-machine.client.spec.ts @@ -60,7 +60,7 @@ describe('submit-machine: plain × enter', () => { expect(m.state.phase).toBe('plain') }) - it('non-command text is sent with the draft and mode and stays until the Host accepts it', () => { + it('sends non-command text with the draft and mode and holds another send until the Host accepts it', () => { const m = new SubmitMachine() const attachmentIds = [attachmentId('image-1')] const fx = m.dispatch({ type: 'enter', mode: 'queue', draft: 'hello', occurrences: [], attachmentIds }) @@ -70,29 +70,48 @@ describe('submit-machine: plain × enter', () => { expect(sink.mode).toBe('queue') expect(sink.attempt).toMatchObject({ draftSnapshot: 'hello', attachmentIds }) expect(m.state.phase).toBe('sending') + expect(m.sendingAttempt).toBe(sink.attempt) expect(m.dispatch({ type: 'enter', mode: 'queue', draft: 'again', occurrences: [], attachmentIds: [] })).toEqual([]) - const accepted = m.dispatch({ type: 'sink-settled', attempt: sink.attempt, ok: true, outcome: { kind: 'success' } }) - expect(effectAt(accepted, 0, 'commit-draft').retainSuffixOf).toBeNull() + const accepted = m.dispatch({ + type: 'sink-settled', attempt: sink.attempt, ok: true, command: undefined, outcome: { kind: 'success' }, + }) + expect(effectAt(accepted, 0, 'commit-draft').submitted).toBe('hello') expect(m.state.phase).toBe('plain') + expect(m.sendingAttempt).toBeUndefined() }) it('returns a refused or failed message to editing unchanged with its notice', () => { const m = new SubmitMachine() const refused = effectAt(m.dispatch({ type: 'enter', mode: 'queue', draft: 'kept', occurrences: [], attachmentIds: [] }), 0, 'default-sink') - expect(m.dispatch({ type: 'sink-settled', attempt: refused.attempt, ok: false, outcome: { kind: 'error' } })).toEqual([]) + expect(m.dispatch({ + type: 'sink-settled', attempt: refused.attempt, ok: false, command: undefined, outcome: { kind: 'error' }, + })).toEqual([]) expect(m.state.phase).toBe('plain') const failed = effectAt(m.dispatch({ type: 'enter', mode: 'queue', draft: 'kept', occurrences: [], attachmentIds: [] }), 0, 'default-sink') - expect(m.dispatch({ type: 'sink-settled', attempt: failed.attempt, ok: false, message: 'offline' })) + expect(m.dispatch({ type: 'sink-settled', attempt: failed.attempt, ok: false, command: undefined, message: 'offline' })) .toEqual([{ type: 'notice', level: 'error', text: 'offline' }]) expect(m.state.phase).toBe('plain') const informed = effectAt(m.dispatch({ type: 'enter', mode: 'queue', draft: 'sent', occurrences: [], attachmentIds: [] }), 0, 'default-sink') expect(m.dispatch({ - type: 'sink-settled', attempt: informed.attempt, ok: true, outcome: { kind: 'success', text: 'queued' }, - })).toEqual([{ type: 'commit-draft', retainSuffixOf: null }, { type: 'notice', level: 'info', text: 'queued' }]) - expect(m.dispatch({ type: 'sink-settled', attempt: informed.attempt, ok: true })).toEqual([]) + type: 'sink-settled', attempt: informed.attempt, ok: true, command: undefined, outcome: { kind: 'success', text: 'queued' }, + })).toEqual([{ type: 'commit-draft', submitted: 'sent' }, { type: 'notice', level: 'info', text: 'queued' }]) + expect(m.dispatch({ type: 'sink-settled', attempt: informed.attempt, ok: true, command: undefined })).toEqual([]) + }) + + it.each([true, false])('applies the command chip the user left while a message was sent (accepted: %s)', (ok) => { + const m = new SubmitMachine() + const goal = claimOf('goal') + const sink = effectAt(m.dispatch({ type: 'enter', mode: 'queue', draft: '', occurrences: [], attachmentIds: [attachmentId('image')] }), 0, 'default-sink') + // The draft stays editable, but the claim waits for the settlement. + expect(m.dispatch({ type: 'claim', claim: goal })).toEqual([]) + expect(m.dispatch({ type: 'draft-changed', command: goal })).toEqual([]) + expect(m.state).toEqual({ phase: 'sending' }) + expect(m.dispatch({ type: 'sink-settled', attempt: sink.attempt, ok, command: goal })) + .toEqual(ok ? [{ type: 'commit-draft', submitted: '' }] : []) + expect(m.state).toMatchObject({ phase: 'claimed', claim: { name: 'goal', token: goal.token } }) }) it('sends an attachment-only draft as a message', () => { @@ -304,7 +323,7 @@ describe('submit-machine: submitting transaction', () => { type: 'submit-settled', attempt, ok: true, command: undefined, outcome: { kind: 'success', text: 'goal saved' }, }) - expect(effectAt(fx, 0, 'commit-draft').retainSuffixOf).toBe('/goal x') + expect(effectAt(fx, 0, 'commit-draft').submitted).toBe('/goal x') expect(effectAt(fx, 1, 'notice')).toMatchObject({ level: 'info', text: 'goal saved' }) expect(m.state.phase).toBe('plain') expect(m.state.claim).toBeUndefined() @@ -374,7 +393,7 @@ describe('submit-machine: submitting transaction', () => { const sink = effectAt(m.dispatch({ type: 'enter', mode: 'queue', draft: 'gone', occurrences: [], attachmentIds: [] }), 0, 'default-sink') m.dispatch({ type: 'release' }) expect(sink.attempt.signal.aborted).toBe(true) - expect(m.dispatch({ type: 'sink-settled', attempt: sink.attempt, ok: true })).toEqual([]) + expect(m.dispatch({ type: 'sink-settled', attempt: sink.attempt, ok: true, command: undefined })).toEqual([]) expect(m.state.phase).toBe('plain') }) }) diff --git a/snapshots/web/fresh-round-trip/submission-echo.expected.md b/snapshots/web/fresh-round-trip/submission-echo.expected.md index 0cda940af9..882860af0a 100644 --- a/snapshots/web/fresh-round-trip/submission-echo.expected.md +++ b/snapshots/web/fresh-round-trip/submission-echo.expected.md @@ -1,3 +1,3 @@ echo: (missing) composer: "Use the bash tool to run exactly: echo WEB_E2E_OK. Then reply with the single word DONE and stop." -contenteditable: false +contenteditable: true diff --git a/snapshots/web/queue-actions/sending.expected.md b/snapshots/web/queue-actions/sending.expected.md index 29210760bb..7d10fcee98 100644 --- a/snapshots/web/queue-actions/sending.expected.md +++ b/snapshots/web/queue-actions/sending.expected.md @@ -16,4 +16,5 @@ - button "Add files or run commands" - 'button "Access mode, current: Workspace Write"': Workspace Write - button "Select model, current DeepSeek-V4-Flash": DeepSeek-V4-Flash +- button "Stop generating" - button "Sending message" [disabled]