feat(web): keep the composer read-only while a message is sent
The composer returns to a read-only sending phase. Enter captures the draft, its reference chips, and its attachments; the editor and the attachment rail stay read-only while Send shows progress; and the Host's acceptance clears the draft and its attachments in one transaction before the echo renders. A refusal or failure returns the draft unchanged. A running Session still shows Stop beside the sending control, and Workspace selection still leaves a draft whose message is in flight in its Session. This reverts the editable-while-sending behavior of a7af0509ab5 and the in-flight attachment accounting of e36c3f9a395, which only that behavior needed.
This commit is contained in:
1 parent
4804ce4de4
commit
2c2574ade6
20 files changed
+181
-355
No files matched your search
@@ -1,8 +1,7 @@
|
||||
// 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 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
|
||||
// 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
|
||||
// composer, announced by a toast unless the refusing plugin presents it.
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser } from 'playwright'
|
||||
@@ -64,22 +63,19 @@ 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('true')
|
||||
expect(await input.getAttribute('contenteditable')).toBe('false')
|
||||
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
|
||||
// Only the admitted text leaves the composer; the line typed meanwhile stays, and so does the keyboard.
|
||||
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)
|
||||
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()
|
||||
|
||||
@@ -124,14 +124,12 @@ describe('web e2e: queue row actions', () => {
|
||||
await input.fill(REMOVE)
|
||||
await input.press('Enter')
|
||||
await received.promise
|
||||
// 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.
|
||||
// The composer keeps the message, read-only, until the Host accepts it; 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('true')
|
||||
expect(await input.getAttribute('contenteditable')).toBe('false')
|
||||
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)
|
||||
|
||||
@@ -124,11 +124,8 @@ describe('web e2e: queued image submission', () => {
|
||||
try {
|
||||
await input.fill(QUEUED_TEXT)
|
||||
await input.press('Enter')
|
||||
// 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)
|
||||
// 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')
|
||||
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)
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
// Submission echo over the BUILT client graph (keyless RemoteMock transport):
|
||||
// a text-plus-image send stays in the editable composer, with its image rail
|
||||
// a text-plus-image send stays in the read-only composer, with its image rail
|
||||
// and a busy send control, until the fixture host accepts the prompt; only then
|
||||
// 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.
|
||||
// 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.
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { expect, it } from 'vitest'
|
||||
import { createAssembledSessionComposer, installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts'
|
||||
@@ -35,22 +34,21 @@ 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
|
||||
// editable composer behind a busy send control, and the conversation shows
|
||||
// nothing yet. Text pasted meanwhile belongs to the next message.
|
||||
// read-only composer behind a busy send control, and the conversation shows
|
||||
// nothing yet.
|
||||
expect(document.querySelector('[data-submission-echo]')).toBeNull()
|
||||
expect(composer.textContent).toBe('回显这条消息')
|
||||
expect(composer.getAttribute('contenteditable')).toBe('true')
|
||||
expect(composer.getAttribute('contenteditable')).toBe('false')
|
||||
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: 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.
|
||||
// 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.
|
||||
await waitFor(() => {
|
||||
expect(composer.textContent).toBe('下一条')
|
||||
expect(composer.textContent).toBe('')
|
||||
expect(composer.getAttribute('contenteditable')).toBe('true')
|
||||
expect(document.querySelector('[role="group"][aria-label="Pending attachments"]')).toBeNull()
|
||||
}, { timeout: 10_000 })
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 9912b286384c41d7
|
||||
zh: 3e994887c1948677
|
||||
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
|
||||
en: 6a7acd0e22f73db0
|
||||
zh: 3947b71dd81323ac
|
||||
en: d0b526d0c45d9fd6
|
||||
zh: 7453dcbedf20408d
|
||||
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
|
||||
en: bb1244310f5c0382
|
||||
zh: 824b84077432b559
|
||||
|
||||
@@ -104,11 +104,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 Send showing progress until the Host accepts the prompt. Meanwhile the editor stays editable and the rail takes new attachments for the next message, whose image limits do not count the attachments of the message being sent; Enter and Send wait, and those attachments stay in the rail without a remove action, so one message is in flight at a time. Once the Host accepts the prompt, as its receipt or an Inbox row carrying its request id shows, whichever arrives first, its attachments leave the draft and its text leaves the editor before the echo renders, 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`.
|
||||
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, as its receipt or an Inbox row carrying its request id shows, whichever arrives first. Only then do the draft, its attachments, the occurrence table, and the undo history clear in one transaction, before the echo renders; 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. 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. 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)).
|
||||
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, 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.
|
||||
|
||||
|
||||
@@ -104,11 +104,11 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染
|
||||
|
||||
每次输入框提交都携带原始发生时间、操作来源、投递意图以及会话模型和运行状态快照,贯穿异步命令裁决。普通消息通知在引用序列化前发出一次,纯附件提交也遵循这一规则;已处理或认领的命令不发出通知。快照读取与通知消费异常不能阻断发送。
|
||||
|
||||
默认发送等待 Host 接收:Enter 把草稿文字、引用胶囊和附件一起记入本次 attempt,composer 进入 `sending`,Send 显示发送中,直到 Host 接收该提示词。期间编辑器保持可编辑,附件栏可以为下一条消息添加新附件,下一条消息的图片上限不计入正在发送的消息的附件;Enter 和 Send 需要等待,正在发送的消息的附件留在附件栏中且没有移除按钮,因此同一时刻只有一条消息在途。Host 接收提示词后(以回执或带有该请求 id 的 Inbox 行为准,先到者生效),它的附件离开草稿,它的文字在回显渲染之前、在同一事务里离开编辑器并截断撤销历史:草稿仍等于已发送文字时整段清空,用户只在其后追加了文字时只删除已发送文字及紧随其后的空白,用户改动了已发送文字本身时不做删除。被拒绝或失败时 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 接收该提示词(以回执或带有该请求 id 的 Inbox 行为准,先到者生效)。之后才在回显渲染之前、在同一事务里清空草稿、其附件、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 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。被拒绝或失败的提交不会公开回显,草稿留在 composer 中并显示错误提示。Agent 接纳阶段的拒绝(`session/prompt-rejected`)显示它自带的文字;若 `presented` 表明拒绝它的插件会在自己的界面里说明,composer 不再提示。
|
||||
|
||||
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))。
|
||||
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 引用共用覆盖整个引用的悬停背景,并跟随输入框的行高与文字基线。首次点击立即由已注册的引用来源负责打开预览,包括双击序列的第一次点击。后续点击保留原生文本选择行为;已有非折叠选区时,指针点击不打开预览。预览不改变草稿、剪贴板文本或提交内容。
|
||||
|
||||
|
||||
@@ -497,7 +497,8 @@ 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')
|
||||
if (shell.snapshot.phase === 'adjudicating' || shell.snapshot.phase === 'submitting') {
|
||||
const { phase } = shell.snapshot
|
||||
if (phase === 'adjudicating' || phase === 'submitting' || phase === 'sending') {
|
||||
return t('attachment.dropBlocked')
|
||||
}
|
||||
const uploads: File[] = []
|
||||
|
||||
@@ -298,9 +298,7 @@ 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 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.
|
||||
* attachments are read-only in all three.
|
||||
*/
|
||||
readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting' | 'sending'
|
||||
/** Present exactly while claimed/submitting (claim snapshot during flight; submit closure withheld). */
|
||||
@@ -315,10 +313,9 @@ 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 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.
|
||||
* 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.
|
||||
*/
|
||||
export interface SubmitAttempt {
|
||||
readonly seq: number
|
||||
@@ -365,19 +362,8 @@ export type InputEvent =
|
||||
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
|
||||
}
|
||||
/** 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 }
|
||||
| { readonly type: 'release' }
|
||||
|
||||
/**
|
||||
@@ -396,11 +382,10 @@ export type InputEffect =
|
||||
}
|
||||
| { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string }
|
||||
/**
|
||||
* 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.
|
||||
* 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).
|
||||
*/
|
||||
| { readonly type: 'commit-draft'; readonly submitted: string }
|
||||
| { readonly type: 'commit-draft'; readonly retainSuffixOf: string | null }
|
||||
@@ -16,19 +16,13 @@ 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 { ComposerLayout, EditorProjection } from './projection.ts'
|
||||
import type { EditorProjection } from './projection.ts'
|
||||
import { $composerLayout, $projectComposer, detectOffsetOfClipboardOffset } from './projection.ts'
|
||||
import { $replaceDetectSpanWithNodes, $replaceDetectSpanWithText, $selectDetectSpan } from './span-map.ts'
|
||||
import { $replaceDetectSpanWithNodes, $replaceDetectSpanWithText } 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
|
||||
@@ -339,43 +333,23 @@ export class DraftEditorRuntime {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
removeSubmittedDraft(submitted: string): boolean {
|
||||
let removed = false
|
||||
clearCommittedDraft(prefixLength: (clipboardText: string) => number | null): void {
|
||||
this.editor.update(() => {
|
||||
const layout = $composerLayout()
|
||||
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 length = prefixLength(layout.clipboardText)
|
||||
if (length !== null) {
|
||||
$replaceDetectSpanWithText(
|
||||
{ start: 0, end: detectOffsetOfClipboardOffset(layout, length) }, '',
|
||||
)
|
||||
return
|
||||
}
|
||||
const root = $getRoot()
|
||||
if (end === clip.length) {
|
||||
root.clear()
|
||||
root.selectEnd()
|
||||
return
|
||||
}
|
||||
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),
|
||||
})
|
||||
root.clear()
|
||||
root.selectEnd()
|
||||
}, { discrete: true, tag: HISTORY_MERGE_TAG })
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,9 +9,6 @@ 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
|
||||
@@ -114,7 +111,7 @@ export function installDraftKeymap(
|
||||
return registerComposerKeymap(editor, {
|
||||
arbitrate: (key, composing) => keyboard.arbitrate(key, composing),
|
||||
space: () => {
|
||||
if (gate.current.commandBusy || gate.current.locked) return false
|
||||
if (gate.current.machineBusy || gate.current.locked) return false
|
||||
return keyboard.space()
|
||||
},
|
||||
dismissPopup: () => { keyboard.dismissPopup() },
|
||||
@@ -141,7 +138,7 @@ export function installDraftKeymap(
|
||||
},
|
||||
intakeFiles: (files, directories) => { gate.current.intakeFiles(files, directories) },
|
||||
pasteText: (text) => {
|
||||
if (gate.current.commandBusy || gate.current.locked) return
|
||||
if (gate.current.machineBusy || gate.current.locked) return
|
||||
keyboard.paste(text)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -80,22 +80,18 @@ export interface SessionInputDeps {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' {
|
||||
/** Guard tier from the machine phase. */
|
||||
function guardOf(phase: InputState['phase']): 'plain' | 'claimed' | 'frozen' {
|
||||
switch (phase) {
|
||||
case 'plain': return 'plain'
|
||||
case 'claimed': return 'claimed'
|
||||
case 'sending': return command === undefined ? 'plain' : 'claimed'
|
||||
default: return 'frozen' // adjudicating / submitting
|
||||
default: return 'frozen' // adjudicating / submitting / 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 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 two projections differ in content (selection and caret excluded). */
|
||||
@@ -137,7 +133,7 @@ export class SessionInputShell implements SessionInput {
|
||||
readonly actions: InputActions = {
|
||||
captureInsertion: () => ({ ...this.caretSpan(), draftRev: this.rev }),
|
||||
insertText: (text, span) => {
|
||||
if (locked(this.snapshot.phase) || this.disposed) return false
|
||||
if (frozen(this.snapshot.phase) || this.disposed) return false
|
||||
if (span.draftRev !== this.rev) return false
|
||||
return this.draftEditor.insertAsyncText(span, text)
|
||||
},
|
||||
@@ -200,7 +196,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.projection.command) }, this.rev,
|
||||
this.projection.detectText, caret, { tier: guardOf(this.core.state.phase) }, this.rev,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -301,13 +297,12 @@ export class SessionInputShell implements SessionInput {
|
||||
}
|
||||
|
||||
private draftInitializationBlocked(): boolean {
|
||||
const { phase } = this.core.state
|
||||
return this.disposed || (phase !== 'plain' && phase !== 'claimed')
|
||||
return this.disposed || frozen(this.core.state.phase)
|
||||
}
|
||||
|
||||
/** Append ordered attachment ids unless a command attempt holds the draft. */
|
||||
/** Append ordered attachment ids unless an admission transaction is locked. */
|
||||
addAttachments(ids: readonly DraftAttachmentId[]): boolean {
|
||||
if (locked(this.snapshot.phase)) return false
|
||||
if (frozen(this.snapshot.phase)) return false
|
||||
if (ids.length === 0) return true
|
||||
this.attachmentIds = [...this.attachmentIds, ...ids]
|
||||
this.publish()
|
||||
@@ -315,13 +310,13 @@ export class SessionInputShell implements SessionInput {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add validated file references and attachment ids while the draft is editable.
|
||||
* Add validated file references and attachment ids while admission is editable.
|
||||
* @param references - reference chips in source order.
|
||||
* @param ids - newly allocated attachment ids.
|
||||
* @returns false when a command attempt holds the draft or the editor refuses the insertion.
|
||||
* @returns false when admission is locked or the editor refuses the insertion.
|
||||
*/
|
||||
addFiles(references: readonly ReferenceInsert[], ids: readonly DraftAttachmentId[]): boolean {
|
||||
if (locked(this.snapshot.phase)) return false
|
||||
if (frozen(this.snapshot.phase)) return false
|
||||
if (!this.draftEditor.insertFileReferences(references)) return false
|
||||
this.attachmentIds = [...this.attachmentIds, ...ids]
|
||||
this.publish()
|
||||
@@ -329,13 +324,12 @@ export class SessionInputShell implements SessionInput {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
removeAttachment(id: DraftAttachmentId): boolean {
|
||||
if (locked(this.snapshot.phase) || this.core.sendingAttempt?.attachmentIds.includes(id) === true) return false
|
||||
if (frozen(this.snapshot.phase)) return false
|
||||
const next = this.attachmentIds.filter(candidate => candidate !== id)
|
||||
if (next.length === this.attachmentIds.length) return false
|
||||
this.attachmentIds = next
|
||||
@@ -370,9 +364,8 @@ 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) force-closes the transient layers: the popup
|
||||
* dismisses and the menu tracks frozen. A message being sent leaves them to
|
||||
* the editable draft.
|
||||
* (adjudicating/submitting/sending) force-closes the transient layers: the
|
||||
* popup dismisses and the menu tracks frozen.
|
||||
*/
|
||||
submit(mode: InputSubmitMode = 'queue', source?: 'click' | 'enter'): void {
|
||||
if (this.disposed) return
|
||||
@@ -401,7 +394,7 @@ export class SessionInputShell implements SessionInput {
|
||||
attachmentIds: [...this.attachmentIds],
|
||||
submission,
|
||||
})
|
||||
if (locked(this.snapshot.phase)) {
|
||||
if (frozen(this.snapshot.phase)) {
|
||||
this.deps.popup?.()?.dismiss()
|
||||
this.deps.inputTriggers?.()?.track(this.projection.detectText, 0, { tier: 'frozen' }, this.rev)
|
||||
}
|
||||
@@ -485,7 +478,8 @@ export class SessionInputShell implements SessionInput {
|
||||
* @returns whether the edit applied.
|
||||
*/
|
||||
private applyClaim(claim: CommandClaim, span: TokenSpan, mergeHistory: boolean): boolean {
|
||||
if (locked(this.core.state.phase)) return false
|
||||
const phase = this.core.state.phase
|
||||
if (phase !== 'plain' && phase !== 'claimed') 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.
|
||||
@@ -506,7 +500,8 @@ export class SessionInputShell implements SessionInput {
|
||||
* @returns whether the edit applied.
|
||||
*/
|
||||
insertReference(ref: ReferenceInsert, span: TokenSpan): boolean {
|
||||
if (locked(this.core.state.phase)) return false
|
||||
const phase = this.core.state.phase
|
||||
if (phase !== 'plain' && phase !== 'claimed') 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)
|
||||
@@ -666,19 +661,25 @@ export class SessionInputShell implements SessionInput {
|
||||
return
|
||||
}
|
||||
case 'commit-draft': {
|
||||
this.commitDraft(fx.submitted)
|
||||
this.commitDraft(fx.retainSuffixOf)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
private commitDraft(submitted: string): void {
|
||||
if (this.draftEditor.removeSubmittedDraft(submitted)) this.draftEditor.clearHistory()
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -733,10 +734,9 @@ export class SessionInputShell implements SessionInput {
|
||||
|
||||
/**
|
||||
* Settle the message being sent. Once the Host accepts it, its attachments
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
private settleSink(
|
||||
attempt: SubmitAttempt,
|
||||
@@ -760,15 +760,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, command: this.projection.command, outcome }))
|
||||
this.dispatchRun(({ type: 'sink-settled', attempt, ok: accepted, outcome }))
|
||||
}
|
||||
|
||||
/** Settle a send that ended without a Host outcome; the draft stays for correction. */
|
||||
private failSink(attempt: SubmitAttempt, error: unknown): void {
|
||||
if (this.dead(attempt)) return
|
||||
this.dispatchRun(({
|
||||
type: 'sink-settled', attempt, ok: false, command: this.projection.command, message: errorText(error),
|
||||
}))
|
||||
this.dispatchRun(({ type: 'sink-settled', attempt, ok: false, message: errorText(error) }))
|
||||
}
|
||||
|
||||
/** Enter adjudication: poll the session controller; failure = notice + draft retained (never a silent downgrade). */
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
* 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 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.
|
||||
* 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.
|
||||
*/
|
||||
import type { InputSubmitMode, MessageSubmission } from '../contract/composer-submission.ts'
|
||||
import type { Occurrence } from '../contract/draft-editor.ts'
|
||||
@@ -95,8 +94,7 @@ 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, and the settlement of a message being sent applies the chip
|
||||
* the user left meanwhile.
|
||||
* refuse both.
|
||||
*/
|
||||
private followCommand(command: CommandClaim | undefined): readonly InputEffect[] {
|
||||
if (this.phase !== 'plain' && this.phase !== 'claimed') return []
|
||||
@@ -105,7 +103,7 @@ export class SubmitMachine {
|
||||
return []
|
||||
}
|
||||
|
||||
/** Occupy the in-flight slot with the draft, references, and attachments the user sent. */
|
||||
/** Occupy the frozen slot with the draft, references, and attachments the user sent. */
|
||||
private beginAttempt(
|
||||
mode: InputSubmitMode,
|
||||
draft: string,
|
||||
@@ -128,7 +126,7 @@ export class SubmitMachine {
|
||||
return attempt
|
||||
}
|
||||
|
||||
/** Send the captured message; another send waits until the Host accepts or refuses it. */
|
||||
/** Send the frozen message; the editor stays read-only until the Host accepts it. */
|
||||
private send(attempt: SubmitAttempt): readonly InputEffect[] {
|
||||
this.claim = undefined
|
||||
this.phase = 'sending'
|
||||
@@ -190,7 +188,7 @@ export class SubmitMachine {
|
||||
if (ev.ok) {
|
||||
this.phase = 'plain'
|
||||
this.claim = undefined
|
||||
const effects: InputEffect[] = [{ type: 'commit-draft', submitted: flight.attempt.draftSnapshot }]
|
||||
const effects: InputEffect[] = [{ type: 'commit-draft', retainSuffixOf: flight.attempt.draftSnapshot }]
|
||||
if (ev.outcome?.text !== undefined) {
|
||||
effects.push({ type: 'notice', level: ev.outcome.kind === 'error' ? 'error' : 'info', text: ev.outcome.text })
|
||||
}
|
||||
@@ -202,18 +200,13 @@ export class SubmitMachine {
|
||||
return text === undefined ? [] : [{ type: 'notice', level: 'error', text }]
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** An accepted message clears the editor; a refused or failed one returns to editing unchanged. */
|
||||
private onSinkSettled(ev: Extract<InputEvent, { type: 'sink-settled' }>): readonly InputEffect[] {
|
||||
const flight = this.inflight
|
||||
if (this.phase !== 'sending' || flight === undefined || flight.attempt.seq !== ev.attempt.seq) return []
|
||||
this.inflight = undefined
|
||||
this.claim = ev.command
|
||||
this.phase = ev.command === undefined ? 'plain' : 'claimed'
|
||||
const effects: InputEffect[] = ev.ok ? [{ type: 'commit-draft', submitted: flight.attempt.draftSnapshot }] : []
|
||||
this.phase = 'plain'
|
||||
const effects: InputEffect[] = ev.ok ? [{ type: 'commit-draft', retainSuffixOf: null }] : []
|
||||
const text = ev.message ?? ev.outcome?.text
|
||||
if (text !== undefined) {
|
||||
effects.push({ type: 'notice', level: ev.ok && ev.outcome?.kind !== 'error' ? 'info' : 'error', text })
|
||||
|
||||
@@ -149,19 +149,16 @@ 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
|
||||
// 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 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.
|
||||
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 && !commandBusy
|
||||
const editable = live && !locked && !machineBusy
|
||||
const steeringAvailable = subagent === null || subagent.address.mode === 'continuable'
|
||||
const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && steeringAvailable
|
||||
&& input.queue.length > 0
|
||||
@@ -192,13 +189,13 @@ export const InputBar = memo(function InputBar({
|
||||
focusDraftEditor(editor, revealSelection)
|
||||
}, [locked, sessionId, editor])
|
||||
|
||||
// A command phase leaves the box read-only, and the browser then moves the
|
||||
// A busy 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 typing can continue as soon as the command settles; a
|
||||
// control the user moved to meanwhile keeps the keyboard.
|
||||
// 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.
|
||||
const busyPhaseSeen = useRef(false)
|
||||
useEffect(() => {
|
||||
if (commandBusy) {
|
||||
if (machineBusy) {
|
||||
busyPhaseSeen.current = true
|
||||
return
|
||||
}
|
||||
@@ -206,7 +203,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)
|
||||
}, [commandBusy])
|
||||
}, [machineBusy])
|
||||
|
||||
// A persisted draft arrives AFTER the unlock effect: DefaultConversationViews
|
||||
// adopts it in its own mount effect, and a parent's mount effect runs after
|
||||
@@ -236,7 +233,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<File>): void => {
|
||||
if (locked || commandBusy || (subagent !== null && !continuable) || addFiles === undefined || files.length === 0) return
|
||||
if (locked || machineBusy || (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.
|
||||
@@ -262,9 +259,9 @@ export const InputBar = memo(function InputBar({
|
||||
return addFiles(files, directories)
|
||||
})()
|
||||
if (rejected !== null) showToast(rejected)
|
||||
}, [locked, commandBusy, subagent, continuable, addFiles, attachments, imageLimits, showToast, t])
|
||||
}, [locked, machineBusy, subagent, continuable, addFiles, attachments, imageLimits, showToast, t])
|
||||
|
||||
const canAcceptDrop = (subagent === null || continuable) && !locked && !commandBusy && addFiles !== undefined
|
||||
const canAcceptDrop = (subagent === null || continuable) && !locked && !machineBusy && addFiles !== undefined
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const onPickFiles = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
@@ -277,11 +274,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, commandBusy, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter,
|
||||
locked, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter,
|
||||
intakeFiles, uploadsPending, showToast, t, canAcceptDrop,
|
||||
})
|
||||
gate.current = {
|
||||
locked, commandBusy, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter,
|
||||
locked, machineBusy, canSteerQueue, running, steeringAvailable, busyEnter,
|
||||
intakeFiles, uploadsPending, showToast, t, canAcceptDrop,
|
||||
}
|
||||
|
||||
@@ -346,7 +343,7 @@ export const InputBar = memo(function InputBar({
|
||||
// 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, and so does a running session while a
|
||||
// message waits for the Host, whatever the draft holds meanwhile.
|
||||
// message waits for the Host.
|
||||
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
|
||||
|
||||
@@ -42,11 +42,6 @@ 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<SessionInputDeps> = {}): SessionInputShell {
|
||||
const ctx = new Context()
|
||||
onTestFinished(() => ctx.fiber.dispose())
|
||||
@@ -598,33 +593,29 @@ describe('draft documents in the input shell', () => {
|
||||
})
|
||||
|
||||
it.each(['message', 'attachment-only message'] as const)(
|
||||
'keeps a %s and its attachments while it waits for the Host and takes new attachments', async (kind) => {
|
||||
'refuses draft initialization and attachments while a %s waits for the Host', async (kind) => {
|
||||
const outcome = Promise.withResolvers<SubmitOutcome>()
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const sink = vi.fn<SessionInputDeps['defaultSink']>(() => { 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'
|
||||
shell.addAttachments([sentAttachment])
|
||||
if (!attachmentOnly) shell.setDraft('outbound message')
|
||||
if (attachmentOnly) shell.addAttachments([sentAttachment])
|
||||
else shell.setDraft('outbound message')
|
||||
shell.submit()
|
||||
await started.promise
|
||||
expect(sink).toHaveBeenCalledOnce()
|
||||
expect(sink.mock.calls[0]?.[1]).toEqual([sentAttachment])
|
||||
expect(sink.mock.calls[0]?.[1]).toEqual(attachmentOnly ? [sentAttachment] : [])
|
||||
expect(shell.snapshot.phase).toBe('sending')
|
||||
expectInitializationBlocked(shell)
|
||||
expect(shell.addAttachments(['next-image' as DraftAttachmentId])).toBe(false)
|
||||
expect(shell.removeAttachment(sentAttachment)).toBe(false)
|
||||
expect(shell.addAttachments([nextAttachment])).toBe(true)
|
||||
expect(shell.removeAttachment(nextAttachment)).toBe(true)
|
||||
expect(shell.addAttachments([nextAttachment])).toBe(true)
|
||||
expectInitializationBlocked(shell)
|
||||
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: [sentAttachment, nextAttachment],
|
||||
phase: 'plain', draft: attachmentOnly ? '' : 'outbound message', attachmentIds: attachmentOnly ? [sentAttachment] : [],
|
||||
})
|
||||
expect(shell.removeAttachment(sentAttachment)).toBe(true)
|
||||
expect(shell.requestDraftInitialization({ prompt: 'after settlement', clearPreviousDraft: true })).toBe('applied')
|
||||
} finally {
|
||||
shell.dispose()
|
||||
@@ -651,7 +642,7 @@ describe('draft documents in the input shell', () => {
|
||||
expect(shell.snapshot.phase).toBe('sending')
|
||||
accept?.()
|
||||
// In the same call, so the editor has shrunk before the Session renders the echo.
|
||||
expect(shell.snapshot).toMatchObject({ phase: 'plain', draft: '', attachmentIds: [], sendingAttachmentIds: [] })
|
||||
expect(shell.snapshot).toMatchObject({ phase: 'plain', draft: '', attachmentIds: [] })
|
||||
outcome.resolve({ kind: 'success' })
|
||||
await outcome.promise
|
||||
await Promise.resolve()
|
||||
@@ -663,58 +654,6 @@ 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<SubmitOutcome>()
|
||||
const started = Promise.withResolvers<undefined>()
|
||||
const sink = vi.fn<SessionInputDeps['defaultSink']>(() => { 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<ReadonlyMap<'/' | '@', readonly string[]>>(new Map())
|
||||
const provider = triggerProvider(lexicon)
|
||||
|
||||
@@ -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 { CommandClaim, SubmitOutcome } from '../src/client/contract/input.ts'
|
||||
import type { 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,41 +383,19 @@ describe('composer focus handoff', () => {
|
||||
it.each<[string, SubmitOutcome]>([
|
||||
['the Host accepts it', { kind: 'success' }],
|
||||
['it fails', { kind: 'error', text: 'send failed' }],
|
||||
])('keeps the editor editable and focused while a message is sent until %s', async (_label, outcome) => {
|
||||
])('returns the keyboard to the editor once a sent message settles because %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<SubmitOutcome>((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<SubmitOutcome>()
|
||||
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 () => { result.resolve({ kind: 'success' }) })
|
||||
await act(async () => { settle(outcome) })
|
||||
|
||||
expect(textarea.getAttribute('contenteditable')).toBe('true')
|
||||
expect(document.activeElement).toBe(textarea)
|
||||
@@ -717,10 +695,8 @@ describe('image draft rail', () => {
|
||||
sink.mockImplementationOnce(() => new Promise<SubmitOutcome>((resolve) => { settle = resolve }))
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue', expect.any(AbortSignal), expect.any(Function))
|
||||
// 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.
|
||||
// The rail keeps the sent image, and Send shows progress, until the Host accepts the message.
|
||||
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')
|
||||
@@ -1106,7 +1082,7 @@ describe('running and lock semantics', () => {
|
||||
})
|
||||
|
||||
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: '排队消息' })
|
||||
const { view, textarea, stop, sink } = bench({ running: true, draft: '排队消息' })
|
||||
let settle!: (outcome: SubmitOutcome) => void
|
||||
sink.mockImplementationOnce(() => new Promise<SubmitOutcome>((resolve) => { settle = resolve }))
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
@@ -1114,9 +1090,6 @@ describe('running and lock semantics', () => {
|
||||
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' }) })
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('submit-machine: plain × enter', () => {
|
||||
expect(m.state.phase).toBe('plain')
|
||||
})
|
||||
|
||||
it('sends non-command text with the draft and mode and holds another send until the Host accepts it', () => {
|
||||
it('non-command text is sent with the draft and mode and stays 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,48 +70,29 @@ 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, command: undefined, outcome: { kind: 'success' },
|
||||
})
|
||||
expect(effectAt(accepted, 0, 'commit-draft').submitted).toBe('hello')
|
||||
const accepted = m.dispatch({ type: 'sink-settled', attempt: sink.attempt, ok: true, outcome: { kind: 'success' } })
|
||||
expect(effectAt(accepted, 0, 'commit-draft').retainSuffixOf).toBeNull()
|
||||
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, command: undefined, outcome: { kind: 'error' },
|
||||
})).toEqual([])
|
||||
expect(m.dispatch({ type: 'sink-settled', attempt: refused.attempt, ok: false, 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, command: undefined, message: 'offline' }))
|
||||
expect(m.dispatch({ type: 'sink-settled', attempt: failed.attempt, ok: false, 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, 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 } })
|
||||
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([])
|
||||
})
|
||||
|
||||
it('sends an attachment-only draft as a message', () => {
|
||||
@@ -323,7 +304,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').submitted).toBe('/goal x')
|
||||
expect(effectAt(fx, 0, 'commit-draft').retainSuffixOf).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()
|
||||
@@ -393,7 +374,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, command: undefined })).toEqual([])
|
||||
expect(m.dispatch({ type: 'sink-settled', attempt: sink.attempt, ok: true })).toEqual([])
|
||||
expect(m.state.phase).toBe('plain')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -607,7 +607,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:264',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:262',
|
||||
},
|
||||
{
|
||||
key: 'conversation.composer.bar',
|
||||
@@ -649,7 +649,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:286',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:284',
|
||||
},
|
||||
{
|
||||
key: 'conversation.composer.dock',
|
||||
@@ -709,7 +709,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:278',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:276',
|
||||
},
|
||||
{
|
||||
key: 'conversation.header',
|
||||
@@ -747,7 +747,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.header\', () => ctx.slots.register(\n { name: \'conversation.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:214',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:212',
|
||||
},
|
||||
{
|
||||
key: 'conversation.header.leading',
|
||||
@@ -778,7 +778,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
occupants: [],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.header.leading\', () => ctx.slots.register(\n { name: \'conversation.header.leading\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:243',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:241',
|
||||
},
|
||||
{
|
||||
key: 'conversation.hero.agentPreset',
|
||||
@@ -818,7 +818,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:270',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:268',
|
||||
},
|
||||
{
|
||||
key: 'conversation.hero.brand.mark',
|
||||
@@ -849,7 +849,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
occupants: [],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:268',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:266',
|
||||
},
|
||||
{
|
||||
key: 'conversation.hero.options',
|
||||
@@ -906,7 +906,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
occupants: [],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.options\', () => ctx.slots.register(\n { name: \'conversation.hero.options\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:272',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:270',
|
||||
},
|
||||
{
|
||||
key: 'conversation.hero.workspace',
|
||||
@@ -941,7 +941,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:266',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:264',
|
||||
},
|
||||
{
|
||||
key: 'conversation.hero.workspace.directoryFlow',
|
||||
@@ -1019,7 +1019,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.activity\', () => ctx.slots.register(\n { name: \'conversation.input.activity\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:284',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:282',
|
||||
},
|
||||
{
|
||||
key: 'conversation.input.attachments',
|
||||
@@ -1029,7 +1029,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
doc: 'Optional draft-attachment rail and drop target.',
|
||||
registerOptions: [],
|
||||
ownerProps: [
|
||||
'/** Input state handed to the optional attachment presentation plugin. */\nexport interface ComposerAttachmentsOwnerProps {\n /** Browser-owned draft attachments in input order. */\n attachments: readonly ComposerAttachment[]\n /** Whether a document-level file drop may add attachments now. */\n canAcceptDrop: boolean\n /**\n * Add one dropped batch through the composer\'s validation path.\n * @param files - dropped, pasted, or picked browser files in source order.\n * @param directories - members of `files` the drop source identified as directories.\n */\n onAddFiles: (files: readonly File[], directories?: ReadonlySet<File>) => void\n /** Remove one draft attachment through the Conversation service. */\n onRemoveAttachment: (id: DraftAttachmentId) => void\n /** Attachments of the message being sent, which the rail shows without a remove action. */\n sendingAttachmentIds: readonly DraftAttachmentId[]\n /** Current per-draft upload states for file-kind attachments. */\n uploads: DraftFileUploads\n /** Restart one failed file upload. */\n onRetryFile: (id: DraftAttachmentId) => void\n /** Display-ready limits for the drop invitation. */\n dropLimits?: { readonly count: number; read /* …truncated — full shape in source */',
|
||||
'/** Input state handed to the optional attachment presentation plugin. */\nexport interface ComposerAttachmentsOwnerProps {\n /** Browser-owned draft attachments in input order. */\n attachments: readonly ComposerAttachment[]\n /** Whether a document-level file drop may add attachments now. */\n canAcceptDrop: boolean\n /**\n * Add one dropped batch through the composer\'s validation path.\n * @param files - dropped, pasted, or picked browser files in source order.\n * @param directories - members of `files` the drop source identified as directories.\n */\n onAddFiles: (files: readonly File[], directories?: ReadonlySet<File>) => void\n /** Remove one draft attachment through the Conversation service. */\n onRemoveAttachment: (id: DraftAttachmentId) => void\n /** Current per-draft upload states for file-kind attachments. */\n uploads: DraftFileUploads\n /** Restart one failed file upload. */\n onRetryFile: (id: DraftAttachmentId) => void\n /** Display-ready limits for the drop invitation. */\n dropLimits?: { readonly count: number; readonly size: string } | undefined\n}',
|
||||
],
|
||||
ownerPropsReferences: [
|
||||
'ComposerAttachment',
|
||||
@@ -1063,7 +1063,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:288',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:286',
|
||||
},
|
||||
{
|
||||
key: 'conversation.input.dock',
|
||||
@@ -1130,7 +1130,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:274',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:272',
|
||||
},
|
||||
{
|
||||
key: 'conversation.input.left',
|
||||
@@ -1187,7 +1187,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
occupants: [],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:280',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:278',
|
||||
},
|
||||
{
|
||||
key: 'conversation.input.model',
|
||||
@@ -1229,7 +1229,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:302',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:300',
|
||||
},
|
||||
{
|
||||
key: 'conversation.input.model.label',
|
||||
@@ -1335,7 +1335,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:276',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:274',
|
||||
},
|
||||
{
|
||||
key: 'conversation.input.permission',
|
||||
@@ -1377,7 +1377,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.permission\', () => ctx.slots.register(\n { name: \'conversation.input.permission\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:296',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:294',
|
||||
},
|
||||
{
|
||||
key: 'conversation.input.plan',
|
||||
@@ -1419,7 +1419,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:294',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:292',
|
||||
},
|
||||
{
|
||||
key: 'conversation.input.right',
|
||||
@@ -1476,7 +1476,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
occupants: [],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:282',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:280',
|
||||
},
|
||||
{
|
||||
key: 'conversation.plan-review.actions',
|
||||
@@ -1577,7 +1577,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:208',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:206',
|
||||
},
|
||||
{
|
||||
key: 'conversation.session.header',
|
||||
@@ -1617,7 +1617,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:216',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:214',
|
||||
},
|
||||
{
|
||||
key: 'conversation.session.header.actions',
|
||||
@@ -1681,7 +1681,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:231',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:229',
|
||||
},
|
||||
{
|
||||
key: 'conversation.session.header.corner',
|
||||
@@ -1723,7 +1723,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.corner\', () => ctx.slots.register(\n { name: \'conversation.session.header.corner\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:254',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:252',
|
||||
},
|
||||
{
|
||||
key: 'conversation.session.header.lineage',
|
||||
@@ -1767,7 +1767,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:225',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:223',
|
||||
},
|
||||
{
|
||||
key: 'conversation.session.header.utilities',
|
||||
@@ -1830,7 +1830,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:237',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:235',
|
||||
},
|
||||
{
|
||||
key: 'conversation.session.header.views.actions',
|
||||
@@ -1884,7 +1884,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.views.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.views.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:260',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:258',
|
||||
},
|
||||
{
|
||||
key: 'conversation.trajectory.images',
|
||||
@@ -1985,7 +1985,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.user.text.node\', () => ctx.slots.register(\n { name: \'conversation.user.text.node\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:198',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:196',
|
||||
},
|
||||
{
|
||||
key: 'conversation.view',
|
||||
@@ -2049,7 +2049,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:262',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:260',
|
||||
},
|
||||
{
|
||||
key: 'deliverables.file.actions',
|
||||
@@ -2252,7 +2252,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
],
|
||||
replaceRisk: 'shadows-shipped-ui',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'main.conversation\', () => ctx.slots.register(\n { name: \'main.conversation\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:206',
|
||||
source: 'packages/client/ui-conversation/src/client/contract/slots.ts:204',
|
||||
},
|
||||
{
|
||||
key: 'plugins.add.actions',
|
||||
|
||||
@@ -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: true
|
||||
contenteditable: false
|
||||
@@ -17,5 +17,4 @@
|
||||
- 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]
|
||||
Reference in new issue
Block a user