From 98da332260aea9ac881422b4358172335a46affd Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 26 Aug 2026 11:48:33 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(session-controller):=20=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E6=9C=AC=E5=9C=B0=E6=8F=90=E4=BA=A4=E5=9B=9E?= =?UTF-8?q?=E6=98=BE=E4=B8=8E=20rpcId=20=E5=85=B3=E8=81=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit beginSubmission 在 prompt 之前同步把本地提交回显写入 SessionSnapshot.pendingSubmissions; durable user/message(source.rpcId)或队列投影(SessionQueuedItem.rpcId)到达后延迟一帧退休, prompt 失败与放弃立即退休并回调 onRetire。fixture 的 prompt 同步回显 requestId。 --- .../src/client/contract/session.ts | 45 +++- .../src/client/contract/snapshot.ts | 34 +++ .../session-controller/src/client/index.ts | 11 +- .../src/client/sessions/queue-mirror.ts | 1 + .../src/client/sessions/session.ts | 134 +++++++++- .../api/session-controller/src/control.ts | 8 + packages/api/session-controller/src/types.ts | 2 + ...session-pending-submissions.client.spec.ts | 238 ++++++++++++++++++ .../client/connection/src/client/fixture.ts | 9 +- .../tests/ui-session.client.spec.ts | 1 + .../ui-trajectory/tests/views.client.spec.tsx | 1 + .../user-questions-composer.client.spec.tsx | 1 + .../client-runtime/src/fixtures.ts | 1 + .../client-runtime/src/sessions.ts | 19 +- 14 files changed, 496 insertions(+), 9 deletions(-) create mode 100644 packages/api/session-controller/tests/session-pending-submissions.client.spec.ts diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 6ac4182bb9..9b8ed3f7ec 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -12,9 +12,37 @@ import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' -import type { PromptContentPart, QueueAction } from '../../types.ts' +import type { PromptContentPart, QueueAction, SessionRequestId } from '../../types.ts' import type { ClientResult } from './result.ts' -import type { SessionSnapshot } from './snapshot.ts' +import type { PendingSubmissionImage, SessionSnapshot } from './snapshot.ts' + +/** + * Why a local submission echo left the snapshot: `observed` when its durable + * `user/message` event or host queue occurrence arrived (with the admitted + * image references in prompt order), `failed` when the prompt was rejected, + * threw, or was aborted before acceptance. + */ +export type PendingSubmissionRetirement = + | { readonly reason: 'observed'; readonly attachments: readonly ImageAttachmentRef[] } + | { readonly reason: 'failed' } + +/** Input registering one local submission echo ahead of its prompt call. */ +export interface BeginSubmissionInput { + /** Prompt text exactly as the upcoming prompt will send it. */ + readonly text: string + /** Ordered image previews matching the upcoming prompt's image parts. */ + readonly images: readonly PendingSubmissionImage[] + /** Settlement callback fired exactly once when the echo retires. */ + readonly onRetire?: (retirement: PendingSubmissionRetirement) => void +} + +/** One registered submission echo: the identity its prompt must carry, and the pre-prompt escape hatch. */ +export interface SubmissionHandle { + /** The prompt RPC identity; pass it to {@link ISession.prompt}. */ + readonly requestId: SessionRequestId + /** Retire the echo as failed when the caller cannot reach prompt() (serialization failure); no-op after any other settlement. */ + abandon(): void +} /** Key-addressed projection read face (the useProjection resolution path; see ProjectionValueStore). */ export interface ProjectionsFace { @@ -33,16 +61,29 @@ export interface ISession { readonly sessionId: SessionId /** Host-computed projection values by key (the useProjection seat). */ readonly projections: ProjectionsFace + /** + * Register one local submission echo in `snapshot.pendingSubmissions`, + * synchronously, before the caller serializes and sends the prompt. The + * echo retires when a durable `user/message` event or queue occurrence + * carrying the returned identity arrives, or when the identified prompt + * call fails. + * @param input - echo content and the optional settlement callback. + * @returns the minted identity for {@link prompt} plus the pre-prompt abandon path. + */ + beginSubmission(input: BeginSubmissionInput): SubmissionHandle /** * Send a prompt into the session. * @param content - text plus browser-owned temporary image uploads. * @param mode - 'queue' appends a turn; 'steer' interrupts the running one. + * @param signal - optional caller cancellation for the complete admission round-trip. + * @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo. * @returns acceptance, or the business error (also mirrored into snapshot.promptError). */ prompt( content: PromptContentPart[], mode: 'queue' | 'steer', signal?: AbortSignal, + requestId?: SessionRequestId, ): Promise> /** * Resolve one durable image referenced by this session. diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts index dff2317359..85b5a3013e 100644 --- a/packages/api/session-controller/src/client/contract/snapshot.ts +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -3,6 +3,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionRequestId } from '../../types.ts' import type { ClientFailure } from './result.ts' /** One transient inbox occurrence from the authoritative queue snapshot. */ @@ -10,11 +11,42 @@ export interface QueuedMessage { readonly id: MessageId readonly messageId: MessageId readonly placement: 'queued' | 'steering' | 'context' + /** Prompt-RPC identity of a browser-submitted occurrence; correlates the local submission echo. */ + readonly rpcId?: SessionRequestId readonly content: readonly ContentBlock[] readonly preview: string readonly text: string | null } +/** One image displayed by a local submission echo before durable admission. */ +export interface PendingSubmissionImage { + /** Browser-owned preview URL; its lifecycle belongs to the submitter, never this snapshot. */ + readonly previewUrl: string + /** Browser file name, when the file had one. */ + readonly name?: string + /** Intrinsic pixel width, when the submitter has probed it. */ + readonly width?: number + /** Intrinsic pixel height, when the submitter has probed it. */ + readonly height?: number +} + +/** + * One local prompt-submission echo: inserted synchronously when a submission + * begins, so the conversation can show the message before serialization, + * transport, and durable admission complete. Client-memory only — reload and + * reconnect rebuild the conversation from durable events alone. + */ +export interface PendingSubmission { + /** The prompt RPC identity; the durable `user/message` source echoes it as `rpcId`. */ + readonly requestId: SessionRequestId + /** Client wall-clock ms when the submission began. */ + readonly time: number + /** Prompt text exactly as it will be sent (one text block). */ + readonly text: string + /** Ordered image previews matching the prompt's image parts. */ + readonly images: readonly PendingSubmissionImage[] +} + /** History-open lifecycle of a Session event window. */ export type OpenState = 'cold' | 'loading' | 'open' | 'error' @@ -28,6 +60,8 @@ export interface PromptError { export interface SessionSnapshot { readonly sessionId: SessionId readonly queue: readonly QueuedMessage[] + /** Local prompt-submission echoes not yet observed as durable events or queue occurrences. */ + readonly pendingSubmissions: readonly PendingSubmission[] readonly running: boolean readonly subagent: { readonly address: SubagentAddress diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts index 7fa6ede0d0..965b5e2bb8 100644 --- a/packages/api/session-controller/src/client/index.ts +++ b/packages/api/session-controller/src/client/index.ts @@ -40,7 +40,14 @@ export type { SessionProjectionMap, UseProjection, } from './sessions/projection-store.ts' -export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts' +export type { + BeginSubmissionInput, + ISession, + PendingSubmissionRetirement, + ProjectionsFace, + SessionFace, + SubmissionHandle, +} from './contract/session.ts' export type { ISessions } from './contract/sessions.ts' export { MutableSessionEventSource } from './contract/events.ts' export type { @@ -53,6 +60,8 @@ export type { } from './contract/events.ts' export type { OpenState, + PendingSubmission, + PendingSubmissionImage, PromptError, QueuedMessage, SessionSnapshot, diff --git a/packages/api/session-controller/src/client/sessions/queue-mirror.ts b/packages/api/session-controller/src/client/sessions/queue-mirror.ts index 209349af2c..2a9f274b28 100644 --- a/packages/api/session-controller/src/client/sessions/queue-mirror.ts +++ b/packages/api/session-controller/src/client/sessions/queue-mirror.ts @@ -43,6 +43,7 @@ export class SessionQueueMirror { id: item.id, messageId: item.message.id, placement: item.placement, + ...(item.rpcId === undefined ? {} : { rpcId: item.rpcId }), content, preview: previewOf(content), text: textOf(content), diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index 182d8514d9..acf671b9e9 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -24,9 +24,11 @@ import type { } from '../../types.ts' import type { ClientFailure, ClientResult } from '../contract/result.ts' import { transportResult } from '../contract/result.ts' -import type { SessionFace } from '../contract/session.ts' import type { - OpenState, PromptError, SessionSnapshot, + BeginSubmissionInput, PendingSubmissionRetirement, SessionFace, SubmissionHandle, +} from '../contract/session.ts' +import type { + OpenState, PendingSubmission, PromptError, SessionSnapshot, } from '../contract/snapshot.ts' import { MutableSessionEventSource } from '../contract/events.ts' import type { @@ -101,6 +103,14 @@ export class Session implements SessionFace { private removed = false private promptError: PromptError | null = null private lastAgentError: string | null = null + /** Local submission echoes, insertion-ordered (see SessionSnapshot.pendingSubmissions). */ + private pendingSubmissions: readonly PendingSubmission[] = [] + /** Per-echo settlement state; `retiring` latches the first observation so a + * queue frame and its durable event cannot both retire one echo. */ + private readonly submissionSettlements = new Map void) | undefined + retiring: boolean + }>() /** Owns the addressed page/follow lifecycle while this Session is open. */ private events: SessionEventStream | undefined @@ -172,16 +182,42 @@ export class Session implements SessionFace { // ---- Operations ---- + /** + * Register one local submission echo (see the ISession declaration). + * Synchronous through markDirty: the echo is in the very next snapshot, so + * the conversation can paint it before the caller starts serializing. + * @param input - echo content and the optional settlement callback. + * @returns the minted identity for {@link prompt} plus the pre-prompt abandon path. + */ + beginSubmission(input: BeginSubmissionInput): SubmissionHandle { + const requestId = randomUUID() as SessionRequestId + this.pendingSubmissions = [...this.pendingSubmissions, { + requestId, + time: Date.now(), + text: input.text, + images: input.images, + }] + this.submissionSettlements.set(requestId, { onRetire: input.onRetire, retiring: false }) + // The blank → engaging edge flips here, ahead of prompt(): the composer + // docks and the echo renders on the click's own frame. + this.promptAttempted = true + this.notifier.markDirty() + return { requestId, abandon: () => { this.retireFailedSubmission(requestId) } } + } + /** * Send (queue/steer passed through 1:1); failures land in the snapshot's promptError. * @param content - text plus browser-owned temporary image uploads. * @param mode - queue appends after the current turn; steer interrupts it. + * @param signal - optional caller cancellation for the complete admission round-trip. + * @param requestId - identity from {@link beginSubmission}; a failed identified prompt retires its echo. * @returns the prompt result (also mirrored into promptError on failure). */ async prompt( content: PromptContentPart[], mode: 'queue' | 'steer', signal?: AbortSignal, + requestId?: SessionRequestId, ): Promise> { this.promptError = null this.lastAgentError = null @@ -196,7 +232,7 @@ export class Session implements SessionFace { if (this.address === undefined) { const clientTimeZone = resolvedClientTimeZone() result = toSessionResult(await this.remote.session.prompt({ - requestId: randomUUID() as SessionRequestId, + requestId: requestId ?? randomUUID() as SessionRequestId, sessionId: this.sessionId, mode, content, @@ -236,6 +272,7 @@ export class Session implements SessionFace { result = transportResult(error) } if (!result.ok) { + if (requestId !== undefined) this.retireFailedSubmission(requestId) this.promptError = { op: 'send', error: result.error } this.notifier.markDirty() return result @@ -434,6 +471,7 @@ export class Session implements SessionFace { */ replaceControl(queue: readonly SessionQueuedItem[]): void { this.queueMirror.replace(queue) + this.observeSubmissionQueue(queue) this.notifier.markDirty() } @@ -443,6 +481,7 @@ export class Session implements SessionFace { */ handleControlFrame(frame: Extract): void { this.queueMirror.replace(frame.items) + this.observeSubmissionQueue(frame.items) this.notifier.markDirty() } @@ -523,6 +562,12 @@ export class Session implements SessionFace { * @returns when the Remote iterator has completed teardown. */ async dispose(): Promise { + // Unsettled echoes retire as failed so their owners can restore or + // release browser resources; echoes already scheduled as observed keep + // that settlement. + for (const requestId of [...this.submissionSettlements.keys()]) { + this.retireFailedSubmission(requestId) + } this.openGeneration++ const events = this.events this.events = undefined @@ -581,6 +626,7 @@ export class Session implements SessionFace { if (entries.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false if (projections !== undefined) this.projections.seed(projections) this.eventSource.replace(entries, hasMore) + for (const entry of entries) this.observeSubmissionEvent(entry.event) this.notifier.markDirty() } @@ -598,9 +644,70 @@ export class Session implements SessionFace { if (event.type === 'turn/start') this.firstPromptPendingTurn = false const queueChanged = this.queueMirror.acceptDurable(event) this.eventSource.append(entry) + // After the feed append: the conversation assembly's animation frame is + // registered by the feed subscribers above, so the echo-retirement frame + // scheduled here always runs after the durable node became renderable. + this.observeSubmissionEvent(event) return queueChanged || awaitingFirstTurn !== this.firstPromptPendingTurn } + /** Retire the matching echo when a durable browser-prompt `user/message` becomes visible. */ + private observeSubmissionEvent(event: { readonly type: string; readonly data?: unknown }): void { + if (this.submissionSettlements.size === 0 || event.type !== 'user/message') return + // Structural read: window entries may be compact history records, so the + // fields are narrowed rather than trusted (same posture as Conversation + // assembly matchers). + const data = event.data as { readonly source?: unknown; readonly content?: unknown } | undefined + const source = data?.source as { readonly kind?: unknown; readonly rpcId?: unknown } | undefined + if (source?.kind !== 'user' || typeof source.rpcId !== 'string') return + this.scheduleObservedRetirement(source.rpcId as SessionRequestId, imageRefsIn(data?.content)) + } + + /** Retire echoes whose prompts landed in the host inbox instead of the log (running-turn submissions). */ + private observeSubmissionQueue(items: readonly SessionQueuedItem[]): void { + if (this.submissionSettlements.size === 0) return + for (const item of items) { + if (item.rpcId !== undefined) { + this.scheduleObservedRetirement(item.rpcId, imageRefsIn(item.message.content)) + } + } + } + + /** + * Latch one observed settlement and remove the echo an animation frame + * later. The delay keeps the echo in the snapshot until the frame in which + * the durable node (whose assembly frame was registered first) is + * renderable; the render-time rpcId dedupe hides the one-frame overlap. + */ + private scheduleObservedRetirement( + requestId: SessionRequestId, + attachments: readonly ImageAttachmentRef[], + ): void { + const settlement = this.submissionSettlements.get(requestId) + if (settlement === undefined || settlement.retiring) return + settlement.retiring = true + scheduleFrame(() => { this.finishSubmission(requestId, { reason: 'observed', attachments }) }) + } + + /** Remove one unsettled echo immediately (prompt rejection, abort, or disposal). */ + private retireFailedSubmission(requestId: SessionRequestId): void { + const settlement = this.submissionSettlements.get(requestId) + if (settlement === undefined || settlement.retiring) return + settlement.retiring = true + this.finishSubmission(requestId, { reason: 'failed' }) + } + + /** Single removal point: drop the echo, publish, then notify the owner. */ + private finishSubmission(requestId: SessionRequestId, retirement: PendingSubmissionRetirement): void { + const settlement = this.submissionSettlements.get(requestId) + /* v8 ignore next -- retiring latches before every schedule, so one settlement never finishes twice. */ + if (settlement === undefined) return + this.submissionSettlements.delete(requestId) + this.pendingSubmissions = this.pendingSubmissions.filter(echo => echo.requestId !== requestId) + this.notifier.markDirty() + settlement.onRetire?.(retirement) + } + /** Publish a terminal background failure only while this stream still owns the Session. */ private failEventStream(events: SessionEventStream, generation: number, error: unknown): void { if (generation !== this.openGeneration || this.events !== events) return @@ -617,6 +724,7 @@ export class Session implements SessionFace { return { sessionId: this.sessionId, queue: this.queueMirror.snapshot(), + pendingSubmissions: this.pendingSubmissions, running: this.running, subagent: this.address === undefined ? null @@ -644,6 +752,26 @@ export class Session implements SessionFace { } } +/** Run one callback on the next animation frame, or a macrotask where no frame clock exists. */ +function scheduleFrame(fn: () => void): void { + if (typeof requestAnimationFrame === 'function') requestAnimationFrame(() => { fn() }) + else setTimeout(fn, 0) +} + +/** Image attachment references in one structurally-read content block list, in block order. */ +function imageRefsIn(content: unknown): readonly ImageAttachmentRef[] { + if (!Array.isArray(content)) return [] + const refs: ImageAttachmentRef[] = [] + for (const block of content) { + if (typeof block !== 'object' || block === null) continue + const candidate = block as { readonly type?: unknown; readonly attachment?: unknown } + if (candidate.type === 'image' && typeof candidate.attachment === 'object' && candidate.attachment !== null) { + refs.push(candidate.attachment as ImageAttachmentRef) + } + } + return refs +} + /** Convert a terminal Session stream failure to the Client error vocabulary. */ function openFailure(error: unknown): ClientFailure { const failure = sessionStreamFailure(error) diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts index 4068b5536d..624c9a9493 100644 --- a/packages/api/session-controller/src/control.ts +++ b/packages/api/session-controller/src/control.ts @@ -188,16 +188,24 @@ function queueItems( ...project('next-turn').map(message => ({ id: message.id, placement: 'queued' as const, + ...promptRpcId(message), message: { id: message.id, content: message.content as unknown as JsonValue[] }, })), ...project('next-step').map(message => ({ id: message.id, placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const, + ...promptRpcId(message), message: { id: message.id, content: message.content as unknown as JsonValue[] }, })), ] } +/** Prompt-RPC identity carried by a browser-submitted message's user source. */ +function promptRpcId(message: UserMessage): Pick { + const source = message.source + return source.kind === 'user' && 'rpcId' in source ? { rpcId: source.rpcId } : {} +} + function jobView(job: JobSnapshot): SessionJob { return { id: job.id, diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index c045e00707..e9e416777c 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -432,6 +432,8 @@ export type SessionFollowFrame = export interface SessionQueuedItem { readonly id: MessageId readonly placement: 'queued' | 'steering' | 'context' + /** Prompt-RPC identity from the queued message's user source; clients retire the matching local submission echo on it. */ + readonly rpcId?: SessionRequestId /** JSON-safe message fields consumed by pending-queue presentation. */ readonly message: { readonly id: MessageId diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts new file mode 100644 index 0000000000..fa0e69d89a --- /dev/null +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -0,0 +1,238 @@ +/** Local submission echoes: synchronous insertion, observed/failed retirement, and settlement callbacks. */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import type { MessageSource } from '@deepseek-ai/dsh-llm' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' +import { Session } from '../src/client/sessions/session.ts' +import type { PendingSubmissionRetirement } from '../src/client/contract/session.ts' +import type { SessionQueuedItem, SessionRequestId } from '../src/types.ts' +import { FakeApiClient, err, fakeRemote, ok } from './fake-api.client.ts' +import { historyValue } from './event-script.client.ts' + +const SID = 'fk-s1' as SessionId + +afterEach(() => { + vi.unstubAllGlobals() +}) + +function makeSession(api = new FakeApiClient()): { api: FakeApiClient; session: Session } { + return { api, session: new Session(SID, api, fakeRemote(api)) } +} + +function imageRef(id: string): ImageAttachmentRef { + return { + attachmentId: id, + mediaType: 'image/png', + bytes: 1, + width: 2, + height: 2, + } as unknown as ImageAttachmentRef +} + +/** A durable browser-prompt user/message whose source echoes `rpcId`. */ +function promptEvent(seq: number, rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionEvent { + return { + seq, + time: 1_700_000_000_000 + seq, + type: 'user/message', + surfaceOp: 'append', + data: createUserMessage({ + content: [ + ...refs.map(attachment => ({ type: 'image' as const, attachment })), + { type: 'text' as const, text: '发送' }, + ], + source: { kind: 'user', rpcId } as MessageSource, + }), + } as unknown as SessionEvent +} + +function queuedItem(rpcId: SessionRequestId, refs: readonly ImageAttachmentRef[] = []): SessionQueuedItem { + return { + id: 'm-queued' as SessionQueuedItem['id'], + placement: 'queued', + rpcId, + message: { + id: 'm-queued' as SessionQueuedItem['id'], + content: refs.map(attachment => ({ type: 'image', attachment })) as unknown as SessionQueuedItem['message']['content'], + }, + } +} + +/** Let the frame-delayed retirement (setTimeout fallback in this node environment) run. */ +function settleFrames(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)) +} + +describe('beginSubmission', () => { + it('inserts the echo synchronously and flips the engaging edge before any prompt call', () => { + const { session } = makeSession() + expect(session.getSnapshot()).toMatchObject({ pendingSubmissions: [], promptAttempted: false }) + const handle = session.beginSubmission({ + text: '你好', + images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }], + }) + expect(session.getSnapshot().promptAttempted).toBe(true) + expect(session.getSnapshot().pendingSubmissions).toMatchObject([{ + requestId: handle.requestId, + text: '你好', + images: [{ previewUrl: 'blob:p1', name: 'a.png', width: 4, height: 3 }], + }]) + }) + + it('abandon retires the echo as failed exactly once', () => { + const { session } = makeSession() + const retirements: PendingSubmissionRetirement[] = [] + const handle = session.beginSubmission({ + text: '放弃', + images: [], + onRetire: retirement => retirements.push(retirement), + }) + handle.abandon() + handle.abandon() + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + expect(retirements).toEqual([{ reason: 'failed' }]) + }) +}) + +describe('prompt-coupled retirement', () => { + it('a rejected identified prompt retires its echo immediately alongside promptError', async () => { + const { api, session } = makeSession() + api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: {} })) + const retirements: PendingSubmissionRetirement[] = [] + const handle = session.beginSubmission({ + text: '失败的', + images: [], + onRetire: retirement => retirements.push(retirement), + }) + const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue', undefined, handle.requestId) + expect(result.ok).toBe(false) + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + expect(session.getSnapshot().promptError).toMatchObject({ op: 'send' }) + expect(retirements).toEqual([{ reason: 'failed' }]) + }) + + it('sends the echo identity as the prompt requestId', async () => { + const { api, session } = makeSession() + const handle = session.beginSubmission({ text: '带 id', images: [] }) + await session.prompt([{ type: 'text', text: '带 id' }], 'queue', undefined, handle.requestId) + expect(api.callsOf('session.prompt')).toMatchObject([{ requestId: handle.requestId }]) + }) + + it('an unidentified prompt failure leaves registered echoes alone', async () => { + const { api, session } = makeSession() + api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: {} })) + session.beginSubmission({ text: '还在', images: [] }) + await session.prompt([{ type: 'text', text: '另一个' }], 'queue') + expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) + }) +}) + +describe('observed retirement', () => { + it('a live durable event carrying the rpcId retires the echo one frame later with the admitted refs', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const retirements: PendingSubmissionRetirement[] = [] + const handle = session.beginSubmission({ + text: '发送', + images: [{ previewUrl: 'blob:p1' }], + onRetire: retirement => retirements.push(retirement), + }) + const refs = [imageRef('att-1')] + await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId, refs) as never }) + // Synchronously after the append the echo is still in the snapshot; the + // render-time dedupe owns the overlap frame. + expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) + await settleFrames() + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + expect(retirements).toEqual([{ reason: 'observed', attachments: refs }]) + }) + + it('a queue occurrence carrying the rpcId retires the echo (running-turn submissions)', async () => { + const { session } = makeSession() + const retirements: PendingSubmissionRetirement[] = [] + const handle = session.beginSubmission({ + text: '排队', + images: [{ previewUrl: 'blob:p1' }], + onRetire: retirement => retirements.push(retirement), + }) + const refs = [imageRef('att-q')] + session.handleControlFrame({ type: 'queue', sessionId: SID, items: [queuedItem(handle.requestId, refs)] }) + await settleFrames() + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + expect(retirements).toEqual([{ reason: 'observed', attachments: refs }]) + // The queue projection keeps the correlation id for render-time dedupe. + expect(session.getSnapshot().queue).toMatchObject([{ rpcId: handle.requestId }]) + }) + + it('a full-window install (reconnect resync) retires echoes observed in the window', async () => { + const { api, session } = makeSession() + const handle = session.beginSubmission({ text: '重连', images: [] }) + api.onHistory = () => Promise.resolve(ok(historyValue([promptEvent(12, handle.requestId)]))) + await session.open() + await settleFrames() + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + }) + + it('the first observation wins: a later prompt failure cannot re-retire an observed echo', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const retirements: PendingSubmissionRetirement[] = [] + const handle = session.beginSubmission({ + text: '先观察', + images: [], + onRetire: retirement => retirements.push(retirement), + }) + await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never }) + handle.abandon() + await settleFrames() + expect(retirements).toEqual([{ reason: 'observed', attachments: [] }]) + }) + + it('uses requestAnimationFrame for the retirement delay when the runtime provides one', async () => { + const frames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => { + frames.push(fn) + return frames.length + }) + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const handle = session.beginSubmission({ text: '帧', images: [] }) + await api.pushFollow(SID, { type: 'event', event: promptEvent(0, handle.requestId) as never }) + expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) + expect(frames).toHaveLength(1) + frames[0]?.(0) + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + }) +}) + +describe('disposal', () => { + it('retires unsettled echoes as failed and preserves an already-observed settlement', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const retirements: { text: string; retirement: PendingSubmissionRetirement }[] = [] + const observed = session.beginSubmission({ + text: '已观察', + images: [], + onRetire: retirement => retirements.push({ text: '已观察', retirement }), + }) + session.beginSubmission({ + text: '未settle', + images: [], + onRetire: retirement => retirements.push({ text: '未settle', retirement }), + }) + await api.pushFollow(SID, { type: 'event', event: promptEvent(0, observed.requestId) as never }) + await session.dispose() + await settleFrames() + expect(retirements).toEqual([ + { text: '未settle', retirement: { reason: 'failed' } }, + { text: '已观察', retirement: { reason: 'observed', attachments: [] } }, + ]) + expect(session.getSnapshot().pendingSubmissions).toEqual([]) + }) +}) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index ce2fcdae6a..d3be8087a6 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2757,9 +2757,14 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { attachments.set(String(attachment.attachmentId), { attachment, data: block.data }) return { type: 'image', attachment } }) + // The host echoes the prompt's requestId as the user source's rpcId; + // the Session object retires its local submission echo on it. The + // user-rpc source member is declared by dsh-api-session-controller, + // which this standalone fixture does not import — hence the assertion. + const promptSource = { kind: 'user', rpcId: request.requestId } as MessageSource if (mode === 'steer' && replays.has(id)) { // Steering: the durable user/message lands inside the current turn; the replay continues. - append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) }) + append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable, promptSource) }) return sessionOk({ accepted: true as const }) } const turn = nextTurn.get(id) ?? 0 @@ -2772,7 +2777,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { if (plan.wanted !== null && plan.wanted !== plan.active) { append(id, { type: 'plan/mode', data: { active: plan.wanted } }) } - append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) }) + append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable, promptSource) }) // Capacity parallel of the host token-meter's request/context record: // log-only, appended inside the open turn, and deduplicated against the // route already recorded (the fixture never varies contextWindow). diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts index 97946f4c1d..ce6d154c0c 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -76,6 +76,7 @@ function createSessionsBench(_ctx: Context): SessionsBench { const snapshot = createSnapshotStore({ sessionId: id, queue: [], + pendingSubmissions: [], running: false, subagent: null, removed: false, diff --git a/packages/client/ui-trajectory/tests/views.client.spec.tsx b/packages/client/ui-trajectory/tests/views.client.spec.tsx index 4aa01e9ca6..4bf4793de0 100644 --- a/packages/client/ui-trajectory/tests/views.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.client.spec.tsx @@ -111,6 +111,7 @@ function sessionSnapshot(nodes: LegacyConversationSlice['nodes']): SessionSnapsh return { sessionId: SID, queue: [], + pendingSubmissions: [], running: false, subagent: null, removed: false, diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index a650dd6a5b..c2ee66b3b0 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -25,6 +25,7 @@ type AttentionState = Parameters {}, + } + } + + private submissionSeq = 0 + /** * Fail-loud stub; supply `readAttachment` on the fixture's session face to exercise it. * @param _attachmentId - opaque durable attachment id. From 390dad6138d1ddbbc129cf5ba4a7c49305f7dccf Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 26 Aug 2026 11:49:16 +0800 Subject: [PATCH 02/10] =?UTF-8?q?feat(ui-conversation):=20=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=8F=91=E9=80=81=E6=94=B9=E4=B8=BA=E4=B9=90=E8=A7=82?= =?UTF-8?q?=E6=8F=90=E4=BA=A4=E5=B9=B6=E6=8E=A5=E5=85=A5=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E5=9B=9E=E6=98=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit enter 即清空草稿并解冻输入框,默认发送作为 detached attempt 并发运行; sink-settled 失败时仅还原未被覆盖的空草稿与图片;sendSession 在序列化前注册 提交回显并在绘制让步后再编码(FileReader 原生 base64);观察退休时把预览 URL 移交 HistoricalImageCache,正式消息节点零往返显示。 --- .../src/client/contract/input.ts | 26 ++++- .../src/client/contract/slots.ts | 30 ++++- .../src/client/conversation/assembly.ts | 13 +++ .../client/conversation/historical-images.ts | 23 ++++ .../ui-conversation/src/client/index.ts | 3 +- .../src/client/input/facade.ts | 73 +++++++----- .../src/client/input/machine.ts | 74 ++++++++++-- .../ui-conversation/src/client/service.ts | 110 ++++++++++++++++-- .../tests/apply-inject.client.spec.tsx | 12 +- .../conversation-registry.client.spec.ts | 1 + .../tests/input-bar.client.spec.tsx | 21 +++- .../tests/input-machine.client.spec.ts | 43 +++++-- .../tests/input-matrix.client.spec.tsx | 5 +- .../input-reference-submit.client.spec.ts | 20 ++-- 14 files changed, 370 insertions(+), 84 deletions(-) diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index 7d50fa8124..0bc412e4dd 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -375,9 +375,11 @@ export interface InputState { /** * One in-flight submission attempt: the ONLY id concept in the submit plane. - * Created on enter; carried by adjudicated/submit-settled events; stale - * attempts are dropped (anti-backwash). release/session teardown aborts the - * current attempt, keeping the promise bounded. + * Created on enter; carried by adjudicated/submit-settled/sink-settled + * events; stale attempts are dropped (anti-backwash). Command attempts hold + * the single frozen in-flight slot; default-sink attempts run detached and + * concurrently. release/session teardown aborts them all, keeping every + * promise bounded. */ export interface SubmitAttempt { readonly seq: number @@ -421,6 +423,13 @@ export type InputEvent = | { readonly type: 'adjudicated'; readonly attempt: SubmitAttempt; readonly outcome: PickOutcome } | { readonly type: 'adjudication-failed'; readonly attempt: SubmitAttempt; readonly message: string } | { readonly type: 'submit-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string } + /** + * Settlement of one detached default-sink send. Independent of phase and of + * the command-plane in-flight slot: the composer committed optimistically at + * enter, so failure restores the enter-time draft and occurrences only while + * the composer is still untouched (empty plain draft). + */ + | { readonly type: 'sink-settled'; readonly attempt: SubmitAttempt; readonly ok: boolean; readonly outcome?: SubmitOutcome; readonly message?: string } /** Commit an image-only send whose empty draft did not need an attempt. */ | { readonly type: 'send-committed' } | { readonly type: 'release' } @@ -433,5 +442,14 @@ export type InputEvent = export type InputEffect = | { readonly type: 'adjudicate'; readonly attempt: SubmitAttempt; readonly draft: string } | { readonly type: 'begin-submit'; readonly attempt: SubmitAttempt; readonly claim: CommandClaim; readonly args: string } - | { readonly type: 'default-sink'; readonly attempt: SubmitAttempt; readonly draft: string; readonly mode: InputSubmitMode } + /** Detached default send. The machine committed the composer clear at enter; + * `occurrences` snapshots the reference table serialization needs (the live + * table was cleared with the draft). */ + | { + readonly type: 'default-sink' + readonly attempt: SubmitAttempt + readonly draft: string + readonly occurrences: readonly Occurrence[] + readonly mode: InputSubmitMode + } | { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string } diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a7865d5906..d79cdc9de1 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -28,6 +28,10 @@ export interface ComposerAttachment { id: DraftAttachmentId file: File previewUrl: string + /** Intrinsic pixel width, filled asynchronously by the intake header probe. */ + width?: number + /** Intrinsic pixel height, filled asynchronously by the intake header probe. */ + height?: number } /** Input state handed to the optional attachment presentation plugin. */ @@ -44,11 +48,29 @@ export interface ComposerAttachmentsOwnerProps { dropLimits?: { readonly count: number; readonly size: string } | undefined } -/** Durable image group handed to the optional attachment presentation plugin. */ +/** + * One image inside a message record: a durable admitted reference, or the + * local preview of a submission echo whose admission is still in flight. + */ +export type MessageImageSource = + | { readonly attachment: ImageAttachmentRef } + | { + readonly preview: { + /** Browser-owned preview URL (lifecycle stays with the submitter). */ + readonly url: string + readonly name?: string + /** Intrinsic pixel width, when the intake probe has resolved it. */ + readonly width?: number + /** Intrinsic pixel height, when the intake probe has resolved it. */ + readonly height?: number + } + } + +/** Message image group handed to the optional attachment presentation plugin. */ export interface MessageImagesOwnerProps { - /** Durable image references in source order. */ - images: readonly { readonly attachment: ImageAttachmentRef }[] - /** Session-authorized image URL loader. */ + /** Durable references or submission-echo previews in source order. */ + images: readonly MessageImageSource[] + /** Session-authorized image URL loader for the durable arm. */ loadImage: (attachment: ImageAttachmentRef) => Promise /** Horizontal placement inside the owning record. */ align: 'start' | 'end' diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index ffb132cd92..a279cc8409 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -214,6 +214,19 @@ export class UiConversation extends Service { return this.images.resolve(sessionId, attachment) } + /** + * Adopt an already-displayable URL for one durable reference (see + * HistoricalImageCache.seed): the transcript node then renders it without a + * byte round-trip. + * @param sessionId - Session authorization and lifetime scope. + * @param attachment - Durable image reference the URL displays. + * @param url - browser URL to adopt. + * @returns whether the cache took URL ownership. + */ + seedImageUrl(sessionId: SessionId, attachment: ImageAttachmentRef, url: string): boolean { + return this.images.seed(sessionId, attachment, url) + } + /** * Canonicalize one `request/header` event against the previous prompt state. * diff --git a/packages/client/ui-conversation/src/client/conversation/historical-images.ts b/packages/client/ui-conversation/src/client/conversation/historical-images.ts index 602b104c1d..58216febab 100644 --- a/packages/client/ui-conversation/src/client/conversation/historical-images.ts +++ b/packages/client/ui-conversation/src/client/conversation/historical-images.ts @@ -67,6 +67,29 @@ export class HistoricalImageCache { return pending } + /** + * Adopt an already-displayable URL for one durable reference (a submission + * echo's preview whose bytes are the just-admitted image). Ownership moves + * to this cache: the URL is revoked with the Session scope like a fetched + * one, and later resolve() calls reuse it without a byte round-trip. + * @param sessionId - Session authorization and lifetime scope. + * @param attachment - Durable image reference the URL displays. + * @param url - browser URL to adopt. + * @returns whether the cache took ownership (false: entry already present or unknown session — the caller keeps the URL). + */ + seed(sessionId: SessionId, attachment: ImageAttachmentRef, url: string): boolean { + if (this.disposed) return false + const key = `${sessionId}:${attachment.attachmentId}` + if (this.entries.has(key)) return false + const binding = this.sessions.binding(sessionId) + if (binding === undefined) return false + this.bindScope(sessionId, binding.ctx) + const generation = this.generations.get(sessionId) ?? 0 + this.urls.add(url) + this.entries.set(key, { sessionId, generation, pending: Promise.resolve(url) }) + return true + } + private bindScope(sessionId: SessionId, scope: Context): void { if (this.scopeDisposers.has(sessionId)) return const dispose = scope.effect(() => () => { diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 67e2f28611..83a8839976 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -53,7 +53,8 @@ export type { ConversationSessionInjected, ConversationSessionSlotProps, ConversationSlotProps, ConversationStore, ConvViewOwnerProps, ConvViewProps, EmptyWorkspaceOwnerProps, HeroAgentPresetOwnerProps, HeroBrandMarkOwnerProps, InputControlOwnerProps, InputZone, - MessageImagesOwnerProps, RenderMessageImages, UseConversation, UseConversationViews, + MessageImageSource, MessageImagesOwnerProps, RenderMessageImages, UseConversation, + UseConversationViews, } from './contract/slots.ts' export type { ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest, diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 6d48be9371..28a9985a01 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -13,7 +13,7 @@ import { import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, DraftAttachmentId, EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, - InputTriggerController, PasteComponent, PickOutcome, QueuedMessage, ReferenceInsert, + InputTriggerController, Occurrence, PasteComponent, PickOutcome, QueuedMessage, ReferenceInsert, SessionInput, SubmitAttempt, SubmitImageAttachment, SubmitOutcome, TokenSpan, } from '../contract/input.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' @@ -100,8 +100,6 @@ export class SessionInputShell implements SessionInput { private noticeSeq = 0 private lastMirroredDraft = '' private imageIds: readonly DraftAttachmentId[] = [] - /** One image-only send at a time: Enter during the Host round-trip is a no-op. */ - private imageSendInFlight = false private disposed = false /** Draft persistence mirror (Conversation store write; receives the clipboard projection, never display-only ranges). */ private mirrorFn: ((text: string) => void) | undefined @@ -208,17 +206,19 @@ export class SessionInputShell implements SessionInput { */ submit(mode: InputSubmitMode = 'queue'): void { if (this.snapshot.draft.trim() === '' && this.imageIds.length > 0) { - if (this.snapshot.phase === 'plain' && !this.imageSendInFlight) { + if (this.snapshot.phase === 'plain') { + // Optimistic image-only send: the rail clears now; a failed admission + // restores the same ids (they stay registered until release). const imageIds = [...this.imageIds] - this.imageSendInFlight = true + this.commitSend(imageIds) void this.deps.defaultSink('', imageIds, mode, new AbortController().signal).then((outcome) => { - this.imageSendInFlight = false - if (this.disposed) return - if (outcome.kind === 'success') this.commitSend(imageIds) - else if (outcome.text !== undefined) this.notify('error', outcome.text) + if (this.disposed || outcome.kind === 'success') return + this.restoreImages(imageIds) + if (outcome.text !== undefined) this.notify('error', outcome.text) }, (error: unknown) => { - this.imageSendInFlight = false - if (!this.disposed) this.notify('error', error instanceof Error ? error.message : String(error)) + if (this.disposed) return + this.restoreImages(imageIds) + this.notify('error', error instanceof Error ? error.message : String(error)) }) } return @@ -438,7 +438,7 @@ export class SessionInputShell implements SessionInput { return } case 'default-sink': { - this.sinkSerialized(fx.attempt, fx.draft, fx.mode) + this.sinkSerialized(fx.attempt, fx.draft, fx.occurrences, fx.mode) return } default: @@ -449,15 +449,21 @@ export class SessionInputShell implements SessionInput { /** * Prompt serialization before the sink: expand each * inline reference range to its owner's model form via the session controller's - * codec routing. Owner missing / serialize failure / disposal blocks the - * send — notice + draft and chips retained, never a silent downgrade to - * the clipboard text. Chip-free drafts skip the async detour. + * codec routing. The composer committed at enter, so the draft images clear + * here (captured for the send) and a failure — owner missing, serialize + * rejection, transport, or admission — restores them beside the machine's + * untouched-draft restore. Chip-free drafts skip the async detour. */ - private sinkSerialized(attempt: SubmitAttempt, draft: string, mode: InputSubmitMode): void { + private sinkSerialized( + attempt: SubmitAttempt, + draft: string, + occurrences: readonly Occurrence[], + mode: InputSubmitMode, + ): void { const imageIds = [...this.imageIds] - const occurrences = this.core.state.occurrences + this.imageIds = [] if (occurrences.length === 0) { - this.settleSubmit(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal), imageIds) + this.settleSink(attempt, this.deps.defaultSink(draft.trim(), imageIds, mode, attempt.signal), imageIds) return } const inputTriggers = this.deps.inputTriggers?.() @@ -481,32 +487,30 @@ export class SessionInputShell implements SessionInput { cursor = part.offset + part.length } out += draft.slice(cursor) - this.settleSubmit(attempt, this.deps.defaultSink(out.trim(), imageIds, mode, attempt.signal), imageIds) + this.settleSink(attempt, this.deps.defaultSink(out.trim(), imageIds, mode, attempt.signal), imageIds) }, (error: unknown) => { controller.abort() if (this.dead(attempt)) return + this.restoreImages(imageIds) const message = error instanceof Error ? error.message : String(error) - this.run(this.core.dispatch({ type: 'submit-settled', attempt, ok: false, message })) + this.run(this.core.dispatch({ type: 'sink-settled', attempt, ok: false, message })) }, ) } - /** Settle one admission attempt; successful sends consume only their captured images. */ - private settleSubmit( + /** Settle one detached default send; a failure returns its captured images to the rail. */ + private settleSink( attempt: SubmitAttempt, pending: Promise, - imageIds: readonly DraftAttachmentId[] = [], + imageIds: readonly DraftAttachmentId[], ): void { pending.then( (outcome) => { if (this.dead(attempt)) return - if (outcome.kind === 'success' && imageIds.length > 0) { - const submitted = new Set(imageIds) - this.imageIds = this.imageIds.filter(id => !submitted.has(id)) - } + if (outcome.kind !== 'success') this.restoreImages(imageIds) this.run(this.core.dispatch({ - type: 'submit-settled', + type: 'sink-settled', attempt, ok: outcome.kind === 'success', outcome, @@ -514,8 +518,9 @@ export class SessionInputShell implements SessionInput { }, (error: unknown) => { if (this.dead(attempt)) return + this.restoreImages(imageIds) this.run(this.core.dispatch({ - type: 'submit-settled', + type: 'sink-settled', attempt, ok: false, message: error instanceof Error ? error.message : String(error), @@ -524,6 +529,16 @@ export class SessionInputShell implements SessionInput { ) } + /** Return failed-send images to the head of the rail (ids still resolve — release happens only after success). */ + private restoreImages(imageIds: readonly DraftAttachmentId[]): void { + if (imageIds.length === 0) return + const current = new Set(this.imageIds) + const restored = imageIds.filter(id => !current.has(id)) + if (restored.length === 0) return + this.imageIds = [...restored, ...this.imageIds] + this.publish() + } + /** Enter adjudication: poll the session controller; failure = notice + draft retained (never a silent downgrade). */ private adjudicate(attempt: SubmitAttempt, draft: string): void { const inputTriggers = this.deps.inputTriggers?.() diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index 75ae348d8f..4078563685 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -125,6 +125,12 @@ export class InputMachine { readonly attempt: SubmitAttempt readonly controller: AbortController } | undefined + /** Detached default-sink sends by attempt seq: the composer already committed; settlement only restores on failure. */ + private readonly detached = new Map() private log: Transaction[] = [] private redoStack: Transaction[] = [] /** Open single-char typing run: the next contiguous char within the window coalesces. */ @@ -186,6 +192,7 @@ export class InputMachine { case 'adjudicated': return this.onAdjudicated(ev.attempt, ev.outcome) case 'adjudication-failed': return this.onAdjudicationFailed(ev.attempt, ev.message) case 'submit-settled': return this.onSubmitSettled(ev) + case 'sink-settled': return this.onSinkSettled(ev) case 'send-committed': return this.onSendCommitted() case 'release': return this.onRelease() default: return unreachable(ev) @@ -480,6 +487,30 @@ export class InputMachine { return attempt } + /** + * Detach one default send and commit the composer clear in the same + * transaction: the draft, occurrence table, and undo history go now (a sent + * draft must not resurrect through Ctrl/Cmd-Z), while the snapshots ride + * the detached record so a failed settlement can restore an untouched + * composer. The phase stays 'plain' — typing and further sends continue + * during the flight. + */ + private detachSink(attempt: SubmitAttempt, controller: AbortController): InputEffect { + const occurrences = this.occurrences + this.detached.set(attempt.seq, { controller, draftSnapshot: attempt.draftSnapshot, occurrences }) + this.phase = 'plain' + this.claim = undefined + if (this.draft === attempt.draftSnapshot) { + this.occurrences = [] + this.adopt('') + this.log = [] + this.redoStack = [] + } + this.typingRun = undefined + this.paste = undefined + return { type: 'default-sink', attempt, draft: attempt.draftSnapshot, occurrences, mode: attempt.mode } + } + private onEnter(mode: InputSubmitMode): InputEffect[] { if (this.phase === 'adjudicating' || this.phase === 'submitting') return [] if (this.phase === 'claimed' && this.claim !== undefined) { @@ -496,9 +527,10 @@ export class InputMachine { this.phase = 'adjudicating' return [{ type: 'adjudicate', attempt, draft: this.draft }] } - const attempt = this.beginAttempt(mode) - this.phase = 'submitting' - return [{ type: 'default-sink', attempt, draft: this.draft, mode }] + const controller = new AbortController() + this.seq += 1 + const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft, mode } + return [this.detachSink(attempt, controller)] } private onAdjudicated(attempt: SubmitAttempt, outcome: Extract['outcome']): InputEffect[] { @@ -517,13 +549,8 @@ export class InputMachine { // 'handled' (source dealt internally), {insert} (no enter-time span // semantics), or a miss: all land plain; only the miss flows to the sink. if (outcome === undefined) { - this.phase = 'submitting' - return [{ - type: 'default-sink', - attempt, - draft: attempt.draftSnapshot, - mode: attempt.mode, - }] + this.inflight = undefined + return [this.detachSink(attempt, flight.controller)] } this.inflight = undefined this.phase = 'plain' @@ -577,6 +604,31 @@ export class InputMachine { return text === undefined ? [] : [{ type: 'notice', level: 'error', text }] } + /** + * Settle one detached default send. Success has nothing left to commit (the + * clear happened at enter); failure restores the enter-time draft and + * occurrence table, but only into a still-untouched composer — an empty + * plain draft — so content typed during the flight always wins. + */ + private onSinkSettled(ev: Extract): InputEffect[] { + const record = this.detached.get(ev.attempt.seq) + if (record === undefined) return [] + this.detached.delete(ev.attempt.seq) + if (ev.ok) { + return ev.outcome?.text !== undefined + ? [{ type: 'notice', level: ev.outcome.kind === 'error' ? 'error' : 'info', text: ev.outcome.text }] + : [] + } + if (this.phase === 'plain' && this.draft === '') { + this.occurrences = record.occurrences + this.adopt(record.draftSnapshot) + this.typingRun = undefined + this.paste = undefined + } + const text = ev.message ?? ev.outcome?.text + return text === undefined ? [] : [{ type: 'notice', level: 'error', text }] + } + /** Cut undo state after an accepted image-only send. */ private onSendCommitted(): InputEffect[] { if (this.phase !== 'plain') return [] @@ -595,6 +647,8 @@ export class InputMachine { this.inflight.controller.abort() this.inflight = undefined } + for (const record of this.detached.values()) record.controller.abort() + this.detached.clear() this.phase = 'plain' this.claim = undefined this.typingRun = undefined diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 6b7d94829f..8879be7740 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -9,11 +9,13 @@ */ import { Service } from '@deepseek-ai/cordis' import type { Context } from '@deepseek-ai/cordis' -import { bytesToBase64, randomUUID } from '@deepseek-ai/dsh-util-crypto' +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' // Type-only imports: a plugin-to-plugin value import is a bundle purity // error, so scope resolution goes through the sessions service (scopeOf // method) instead of the standalone helper. -import type { ISessions, SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' +import type { + ISessions, PendingSubmissionRetirement, SessionFace, +} from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ComposerAttachment } from './contract/slots.ts' @@ -72,6 +74,48 @@ function browserDraftAttachment(file: File): ComposerAttachment { } } +/** + * Fill the draft's intrinsic dimensions once the browser parses the image + * header (a metadata read off the preview URL, not a full decode). Failures + * and non-browser runtimes leave them absent — consumers size those images + * from CSS constraints instead. + */ +function probeDimensions(attachment: ComposerAttachment): void { + if (typeof Image !== 'function') return + const probe = new Image() + probe.onload = () => { + attachment.width = probe.naturalWidth + attachment.height = probe.naturalHeight + } + probe.src = attachment.previewUrl +} + +/** Resolve after the browser paints the frame in which a just-published submission echo renders. */ +function nextPaint(): Promise { + return new Promise((resolve) => { + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(() => { setTimeout(resolve, 0) }) + } else { + setTimeout(resolve, 0) + } + }) +} + +/** Native canonical base64 of one browser file (FileReader data-URL encode; no main-thread byte loop). */ +function base64Of(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => { + const url = reader.result as string + resolve(url.slice(url.indexOf(',') + 1)) + } + reader.onerror = () => { + reject(reader.error ?? new Error('conversation: image read failed')) + } + reader.readAsDataURL(file) + }) +} + /** Unsupported browser-declared image type, localized by the UI boundary. */ export class UnsupportedImageMediaTypeError extends Error { /** Browser-declared MIME value, possibly empty. */ @@ -125,7 +169,12 @@ export class ConversationController extends Service implements IConversation { } /** - * Submit ordered draft images with text through one host admission. + * Submit ordered draft images with text through one host admission. A local + * submission echo enters the session snapshot synchronously; serialization + * and the prompt round-trip start after the browser can paint it. On the + * echo's observed retirement the draft images hand their preview URLs to + * the durable image cache and leave the registry; on failure they stay + * registered so the composer can restore them. * @param session - target session. * @param text - serialized prompt text. * @param imageIds - ordered draft-local attachment ids. @@ -144,12 +193,27 @@ export class ConversationController extends Service implements IConversation { if (attachments.length !== imageIds.length) { throw new Error('conversation.sendSession: one or more draft images are no longer available') } - const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file)) - const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] - const result = await session.prompt(content, mode, signal) - if (!result.ok) return { kind: 'error' } - this.releaseDraftImages(attachments) - return { kind: 'success' } + const submission = session.beginSubmission({ + text, + images: attachments.map(attachment => ({ + previewUrl: attachment.previewUrl, + ...(attachment.file.name === '' ? {} : { name: attachment.file.name }), + ...(attachment.width === undefined ? {} : { width: attachment.width }), + ...(attachment.height === undefined ? {} : { height: attachment.height }), + })), + onRetire: (retirement) => { this.settleSubmittedImages(session.sessionId, attachments, retirement) }, + }) + let content: Parameters[0] + try { + await nextPaint() + const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file)) + content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] + } catch (error) { + submission.abandon() + throw error + } + const result = await session.prompt(content, mode, signal, submission.requestId) + return result.ok ? { kind: 'success' } : { kind: 'error' } } /** @@ -162,6 +226,7 @@ export class ConversationController extends Service implements IConversation { return files.map((file) => { const attachment = browserDraftAttachment(file) this.draftAttachments.set(attachment.id, attachment) + probeDimensions(attachment) return attachment }) } @@ -264,6 +329,31 @@ export class ConversationController extends Service implements IConversation { return sessions } + /** + * Settle one submission's draft images when its echo retires. Observed: + * each image leaves the registry, handing its preview URL to the durable + * image cache (seeded under the admitted reference so the transcript node + * renders without a byte round-trip) or revoking it when the cache already + * holds that reference. Failed: nothing changes — the ids stay registered + * for the composer's rail restore. + */ + private settleSubmittedImages( + sessionId: SessionId, + attachments: readonly ComposerAttachment[], + retirement: PendingSubmissionRetirement, + ): void { + if (retirement.reason !== 'observed') return + const uiConversation = this.ctx.get('uiConversation') + attachments.forEach((attachment, index) => { + const live = this.draftAttachments.get(attachment.id) + if (live === undefined) return + this.draftAttachments.delete(attachment.id) + const ref = retirement.attachments[index] + if (ref !== undefined && uiConversation?.seedImageUrl(sessionId, ref, attachment.previewUrl) === true) return + revokePreview(attachment.previewUrl) + }) + } + /** Convert browser files to canonical base64 prompt parts. */ private serializeImages(images: readonly File[]): Promise[0]> { return Promise.all(images.map(async file => ({ type: 'image' as const, ...await this.encodeImage(file) }))) @@ -273,7 +363,7 @@ export class ConversationController extends Service implements IConversation { private async encodeImage(file: File): Promise { return { mediaType: imageMediaType(file.type), - data: bytesToBase64(new Uint8Array(await file.arrayBuffer())), + data: await base64Of(file), ...(file.name === '' ? {} : { name: file.name }), } } diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index db5bf29baa..b4103dbda4 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -102,10 +102,14 @@ describe('Conversation inject API', () => { actions.setDraft('hello') actions.submit() - await vi.waitFor(() => { expect(state.getSnapshot().draft).toBe('') }) - expect(b.sessionFake.prompt).toHaveBeenCalledWith( - [{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal), - ) + // Optimistic commit clears the draft at enter; the prompt lands after the + // paint-yield inside the send pipeline. + expect(state.getSnapshot().draft).toBe('') + await vi.waitFor(() => { + expect(b.sessionFake.prompt).toHaveBeenCalledWith( + [{ type: 'text', text: 'hello' }], 'queue', expect.any(AbortSignal), expect.any(String), + ) + }) b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'busy', details: { reason: 'busy' } }, diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts index 20a7a0f7aa..f75e04523e 100644 --- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -21,6 +21,7 @@ function sessionSnapshot(): SessionSnapshot { return { sessionId: SESSION_ID, queue: [], + pendingSubmissions: [], running: false, subagent: null, removed: false, diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 17064a97a5..2ca9b22cf4 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -367,11 +367,28 @@ describe('image draft rail', () => { sink.mockImplementationOnce(() => new Promise((resolve) => { settle = resolve })) fireEvent.keyDown(textarea, { key: 'Enter' }) expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue', expect.any(AbortSignal)) - expect(attachmentOwner(result.slotCalls).attachments).toEqual([attachments[0]]) + // Optimistic commit: the rail clears at submit, before the admission settles. + expect(attachmentOwner(result.slotCalls).attachments).toEqual([]) await act(async () => { settle({ kind: 'success' }) }) + expect(attachmentOwner(result.slotCalls).attachments).toEqual([]) + }) + + it('returns an image-only draft to the rail when its admission fails', async () => { + const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }) + const attachments = [ + { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' }, + ] + const result = bench({ attachments }) + const { textarea, sink } = result + let fail!: (outcome: SubmitOutcome) => void + sink.mockImplementationOnce(() => new Promise((resolve) => { fail = resolve })) + fireEvent.keyDown(textarea, { key: 'Enter' }) + expect(attachmentOwner(result.slotCalls).attachments).toEqual([]) + await act(async () => { fail({ kind: 'error', text: '图片发送失败' }) }) await vi.waitFor(() => { - expect(attachmentOwner(result.slotCalls).attachments).toEqual([]) + expect(attachmentOwner(result.slotCalls).attachments).toEqual([attachments[0]]) }) + expect(result.view.getByRole('alert').textContent).toContain('图片发送失败') }) it('announces an image-intake rejection as a fading toast, repeatable for the same reason', () => { diff --git a/packages/client/ui-conversation/tests/input-machine.client.spec.ts b/packages/client/ui-conversation/tests/input-machine.client.spec.ts index 28e7cc4430..c9938c6c10 100644 --- a/packages/client/ui-conversation/tests/input-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-machine.client.spec.ts @@ -71,13 +71,18 @@ describe('input-machine: plain × enter', () => { expect(m.state.phase).toBe('plain') }) - it('non-command text falls to the default sink', () => { + it('non-command text falls to the default sink and commits the composer clear at enter', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: 'hello world' }) const effect = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') expect(effect).toMatchObject({ draft: 'hello world', mode: 'queue' }) expect(effect.attempt.draftSnapshot).toBe('hello world') - expect(m.state.phase).toBe('submitting') + // Optimistic commit: the send is detached — the composer is already + // cleared, unlocked, and un-undoable while the flight runs. + expect(m.state.phase).toBe('plain') + expect(m.state.draft).toBe('') + expect(m.dispatch({ type: 'undo' })).toEqual([]) + expect(m.state.draft).toBe('') }) it('retains an explicit steer mode on the default sink effect', () => { @@ -134,7 +139,7 @@ describe('input-machine: adjudication outcomes', () => { expect(effectAt(b.dispatch({ type: 'adjudicated', attempt: attemptB, outcome: { claim: claimOf('goal') } }), 0, 'begin-submit').args).toBe('x') }) - it('undefined outcome falls back to the default sink', () => { + it('undefined outcome falls back to the default sink and commits the clear', () => { const m = new InputMachine() const attempt = enterAdjudicating(m, '/unknown thing', 'steer') expect(effectAt( @@ -142,7 +147,8 @@ describe('input-machine: adjudication outcomes', () => { 0, 'default-sink', )).toMatchObject({ attempt, draft: '/unknown thing', mode: 'steer' }) - expect(m.state.phase).toBe('submitting') + expect(m.state.phase).toBe('plain') + expect(m.state.draft).toBe('') }) it("'handled' lands plain with zero effects (popup shell path)", () => { @@ -525,20 +531,35 @@ describe('input-machine: undo / redo', () => { expect(m.state.draft).toBe('') }) - it('keeps a suffix typed during the round-trip and drops interleaved edits with the commit', () => { + it('text typed during the detached flight is the next draft and survives both settlements', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: 'hello' }) const effect = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') - m.dispatch({ type: 'draft-changed', draft: 'hello world' }) - m.dispatch({ type: 'submit-settled', attempt: effect.attempt, ok: true }) - expect(m.state.draft).toBe(' world') + expect(m.state.draft).toBe('') + m.dispatch({ type: 'draft-changed', draft: 'world' }) + m.dispatch({ type: 'sink-settled', attempt: effect.attempt, ok: true }) + expect(m.state.draft).toBe('world') + // Failure with a non-empty composer keeps the typed content: the sent + // draft is NOT restored over it. const n = new InputMachine() n.dispatch({ type: 'draft-changed', draft: 'hello' }) const second = effectAt(n.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') - n.dispatch({ type: 'draft-changed', draft: 'hXello' }) - n.dispatch({ type: 'submit-settled', attempt: second.attempt, ok: true }) - expect(n.state.draft).toBe('') + n.dispatch({ type: 'draft-changed', draft: 'typed during flight' }) + n.dispatch({ type: 'sink-settled', attempt: second.attempt, ok: false, message: 'boom' }) + expect(n.state.draft).toBe('typed during flight') + }) + + it('a failed detached flight restores the sent draft and occurrences into an untouched composer', () => { + const m = new InputMachine() + m.dispatch({ type: 'draft-changed', draft: 'restore me' }) + const effect = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') + expect(m.state.draft).toBe('') + const fx = m.dispatch({ type: 'sink-settled', attempt: effect.attempt, ok: false, message: 'boom' }) + expect(fx).toEqual([{ type: 'notice', level: 'error', text: 'boom' }]) + expect(m.state.draft).toBe('restore me') + // A second settlement of the same attempt is a dropped stale event. + expect(m.dispatch({ type: 'sink-settled', attempt: effect.attempt, ok: false, message: 'again' })).toEqual([]) }) }) diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index fd00297bee..94e4b81976 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -114,8 +114,9 @@ describe('matrix row: plain', () => { expect(shell.snapshot.claim).toBeUndefined() fireEvent.keyDown(textarea, { key: 'Enter' }) expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue', expect.any(AbortSignal)) - expect(shell.snapshot.phase).toBe('submitting') - await vi.waitFor(() => { expect(shell.snapshot.phase).toBe('plain') }) + // The detached default send never freezes the composer. + expect(shell.snapshot.phase).toBe('plain') + expect(shell.snapshot.draft).toBe('') expect(shell.snapshot.claim).toBeUndefined() }) }) diff --git a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts index b2d1d34335..3a6b886695 100644 --- a/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-reference-submit.client.spec.ts @@ -96,9 +96,12 @@ describe('reference submission', () => { }) shell.submit('queue') - expect(shell.snapshot.phase).toBe('submitting') + // Optimistic commit: the composer clears at enter and stays unlocked + // while the detached flight runs. + expect(shell.snapshot.phase).toBe('plain') + expect(shell.snapshot.draft).toBe('') await vi.waitFor(() => { - expect(shell.snapshot.phase).toBe('plain') + expect(shell.snapshot.draft).toBe('@Research ') }) expect(sink).toHaveBeenNthCalledWith(1, mention, [], 'queue', expect.any(AbortSignal)) expect(shell.snapshot).toMatchObject({ @@ -111,10 +114,10 @@ describe('reference submission', () => { }) shell.submit('queue') + expect(shell.snapshot.draft).toBe('') await vi.waitFor(() => { - expect(shell.snapshot.draft).toBe('') + expect(sink).toHaveBeenNthCalledWith(2, mention, [], 'queue', expect.any(AbortSignal)) }) - expect(sink).toHaveBeenNthCalledWith(2, mention, [], 'queue', expect.any(AbortSignal)) expect(shell.snapshot.occurrences).toEqual([]) expect(serializeReference).toHaveBeenCalledTimes(2) }) @@ -133,11 +136,12 @@ describe('reference submission', () => { }) chip(shell) shell.submit() + // The serializer rejection restores the committed draft and chip into the + // still-untouched composer. await vi.waitFor(() => { - expect(shell.snapshot.phase).toBe('plain') + expect(shell.snapshot.draft).toBe('@Research ') }) expect(sink).not.toHaveBeenCalled() - expect(shell.snapshot.draft).toBe('@Research ') expect(shell.snapshot.occurrences).toHaveLength(1) expect(shell.notices.getSnapshot()).toMatchObject({ level: 'error', @@ -161,7 +165,9 @@ describe('reference submission', () => { shell.dispose() expect(signal?.aborted).toBe(true) expect(shell.snapshot.phase).toBe('plain') - expect(shell.snapshot.draft).toBe('send this') + // The optimistic commit stands: disposal drops the settlement, so the + // sent draft is not restored into the dying composer. + expect(shell.snapshot.draft).toBe('') }) it('retains a rejected default message without duplicating its prompt error notice', async () => { From cf47b7e05995924a65ed0ac6683e8ecdff6bd781 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 26 Aug 2026 11:49:56 +0800 Subject: [PATCH 03/10] =?UTF-8?q?feat(web):=20=E6=8F=90=E4=BA=A4=E5=9B=9E?= =?UTF-8?q?=E6=98=BE=E5=9C=A8=20Chat=20=E6=B5=81=E5=B0=BE=E5=8D=B3?= =?UTF-8?q?=E6=97=B6=E6=B8=B2=E6=9F=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChatView 渲染 pendingSubmissions 为用户气泡,按 rpcId 对正式节点与队列行做 渲染期去重,替换原子无闪烁;新增回显跟随滚动;MessageImage/ImageGallery 增加 本地预览 arm,回显图片直接显示 object URL。 --- .../client/ui-attachment/src/MessageImage.tsx | 73 +++++++++++++++---- .../tests/message-image.client.spec.tsx | 22 +++--- .../ui-chat/src/client/chat/ChatView.tsx | 57 ++++++++++++++- .../ui-chat/src/client/chat/MessageItem.tsx | 56 +++++++++++++- .../ui-chat/tests/chat-view.client.spec.tsx | 1 + .../tests/gate-branch-tails.client.spec.tsx | 1 + .../tests/image-labels.client.spec.tsx | 8 +- 7 files changed, 183 insertions(+), 35 deletions(-) diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index c4de8f73a7..43a43c7a48 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -7,6 +7,18 @@ import css from './MessageImage.module.css' /** Loads a session-authorized durable image URL. */ export type ImageLoader = (attachment: ImageAttachmentRef) => Promise +/** One gallery entry: a durable admitted reference, or a submission echo's local preview. */ +export type MessageImageSpec = + | { readonly attachment: ImageAttachmentRef } + | { + readonly preview: { + readonly url: string + readonly name?: string + readonly width?: number + readonly height?: number + } + } + /** Message-image strings the owner resolves from its own locale namespace. */ export interface MessageImageLabels { /** Fallback display name for an unnamed image. */ @@ -28,11 +40,13 @@ export interface MessageImageLabels { * `object-fit: cover` — and never upscaled past the image's natural size. The * crop anchor keeps the top of very tall images and the left of very wide * ones, where the informative content usually starts. */ -function singleFit(attachment: ImageAttachmentRef): { width: number; height: number; objectPosition: string } { - const natural = attachment.width / attachment.height +function singleFit( + dimensions: { readonly width: number; readonly height: number }, +): { width: number; height: number; objectPosition: string } { + const natural = dimensions.width / dimensions.height const ratio = Math.min(4, Math.max(0.25, natural)) const box = ratio >= 1 ? { width: 240, height: 240 / ratio } : { width: 240 * ratio, height: 240 } - const scale = Math.min(1, attachment.width / box.width, attachment.height / box.height) + const scale = Math.min(1, dimensions.width / box.width, dimensions.height / box.height) return { width: Math.max(1, Math.round(box.width * scale)), height: Math.max(1, Math.round(box.height * scale)), @@ -40,24 +54,35 @@ function singleFit(attachment: ImageAttachmentRef): { width: number; height: num } } +/** Intrinsic dimensions of one gallery entry; a preview's stay unknown until its intake probe resolved. */ +function dimensionsOf(image: MessageImageSpec): { readonly width: number; readonly height: number } | undefined { + if ('attachment' in image) return image.attachment + return image.preview.width !== undefined && image.preview.height !== undefined + ? { width: image.preview.width, height: image.preview.height } + : undefined +} + /** * Compact history renderer with retryable loading and click-to-open original * preview. A lone image renders at its `singleFit` size; an image among - * several renders as a fixed 64px square tile. + * several renders as a fixed 64px square tile. The preview arm displays its + * local URL directly — no loader round-trip, no failure/retry surface. * - * @param props.attachment - the durable image reference to load and bound. - * @param props.load - session-authorized URL loader. + * @param props.image - the durable reference to load, or the local preview to display. + * @param props.load - session-authorized URL loader for the durable arm. * @param props.variant - `single` for a message's lone image, `tile` otherwise. * @param props.labels - resolved strings (tooltip, loading, retry, lightbox). * @returns the bounded thumbnail button, or the retry control on failure. */ -export function MessageImage({ attachment, load, variant, labels }: { - attachment: ImageAttachmentRef +export function MessageImage({ image, load, variant, labels }: { + image: MessageImageSpec load: ImageLoader variant: 'single' | 'tile' labels: MessageImageLabels }) { - const [src, setSrc] = useState(null) + const preview = 'preview' in image ? image.preview : undefined + const attachment = 'attachment' in image ? image.attachment : undefined + const [loaded, setLoaded] = useState(null) const [error, setError] = useState(false) const [open, setOpen] = useState(false) // Retry re-arms the one load effect below, so every attempt — first load or @@ -65,20 +90,30 @@ export function MessageImage({ attachment, load, variant, labels }: { const [attempt, setAttempt] = useState(0) const request = useCallback(() => { setAttempt(a => a + 1) }, []) const close = useCallback(() => { setOpen(false) }, []) + const dimensions = useMemo(() => dimensionsOf(image), [image]) const fit = useMemo( - () => (variant === 'single' ? singleFit(attachment) : undefined), - [attachment, variant], + () => { + if (variant !== 'single') return undefined + // A preview whose intake probe has not resolved sizes as a square crop; + // the durable replacement restores the exact fit. + return dimensions === undefined + ? { width: 240, height: 240, objectPosition: 'center' } + : singleFit(dimensions) + }, + [dimensions, variant], ) useEffect(() => { + if (attachment === undefined) return let live = true setError(false) - setSrc(null) - void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) }) + setLoaded(null) + void load(attachment).then((url) => { if (live) setLoaded(url) }).catch(() => { if (live) setError(true) }) return () => { live = false } }, [attachment, load, attempt]) - const label = attachment.name ?? labels.image + const src = preview?.url ?? loaded + const label = (preview?.name ?? attachment?.name) ?? labels.image if (error) return return ( <> @@ -103,7 +138,7 @@ export function MessageImage({ attachment, load, variant, labels }: { /** Wrapping image group shared by user and assistant history: a lone image * renders large, several render as 64px square tiles (DeepSeek Chat rule). */ export function ImageGallery({ images, load, align, labels }: { - images: readonly { attachment: ImageAttachmentRef }[] + images: readonly MessageImageSpec[] load: ImageLoader align: 'start' | 'end' labels: MessageImageLabels @@ -113,7 +148,13 @@ export function ImageGallery({ images, load, align, labels }: { return (
{images.map((image, index) => ( - + ))}
) diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index d7d37bfd97..5972beacdb 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -49,7 +49,7 @@ const useTrajectory: MessageImagesProps['useTrajectory'] = selector => selector( describe('MessageImage', () => { it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => { const load = vi.fn().mockResolvedValue('blob:history') - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(frame.getAttribute('style')).toContain('width: 240px') expect(frame.getAttribute('style')).toContain('height: 120px') @@ -64,7 +64,7 @@ describe('MessageImage', () => { it('ignores a click while the thumbnail is still loading', () => { const load = vi.fn(() => new Promise(() => {})) - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(view.getByText('图片加载中…')).toBeTruthy() fireEvent.click(frame) @@ -74,7 +74,7 @@ describe('MessageImage', () => { it('falls back to the image label for an unnamed attachment', async () => { const { name: _named, ...unnamed } = attachment const load = vi.fn().mockResolvedValue('blob:unnamed') - const view = render() + const view = render() await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() }) expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy() }) @@ -84,7 +84,7 @@ describe('MessageImage', () => { .mockRejectedValueOnce(new Error('offline')) .mockRejectedValueOnce(new Error('still offline')) .mockResolvedValueOnce('blob:retry') - const view = render() + const view = render() const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) fireEvent.click(retry) const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' }) @@ -96,7 +96,7 @@ describe('MessageImage', () => { it('clamps extreme aspect ratios and anchors the crop toward the informative edge', async () => { const load = vi.fn().mockResolvedValue('blob:ratio') const tall = render( - , + , ) const tallFrame = tall.getByRole('button', { name: 'history.png,点击查看原图' }) expect(tallFrame.getAttribute('style')).toContain('width: 60px') @@ -105,7 +105,7 @@ describe('MessageImage', () => { expect(tall.getByAltText('history.png').style.objectPosition).toBe('center top') tall.unmount() const wide = render( - , + , ) const wideFrame = wide.getByRole('button', { name: 'history.png,点击查看原图' }) expect(wideFrame.getAttribute('style')).toContain('width: 240px') @@ -114,7 +114,7 @@ describe('MessageImage', () => { expect(wide.getByAltText('history.png').style.objectPosition).toBe('left center') wide.unmount() const small = render( - , + , ) const smallFrame = small.getByRole('button', { name: 'history.png,点击查看原图' }) expect(smallFrame.getAttribute('style')).toContain('width: 100px') @@ -123,7 +123,7 @@ describe('MessageImage', () => { it('renders a tile at the fixed square without inline sizing', () => { const load = vi.fn(() => new Promise(() => {})) - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(frame.getAttribute('data-variant')).toBe('tile') expect(frame.getAttribute('style')).toBeNull() @@ -131,7 +131,7 @@ describe('MessageImage', () => { it('keeps the tile variant on the failed-load retry control', async () => { const load = vi.fn().mockRejectedValue(new Error('offline')) - const view = render() + const view = render() const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) expect(retry.getAttribute('data-variant')).toBe('tile') }) @@ -139,13 +139,13 @@ describe('MessageImage', () => { it('ignores a load settling after unmount', async () => { let resolve: ((url: string) => void) | undefined const load = vi.fn(() => new Promise((r) => { resolve = r })) - const view = render() + const view = render() view.unmount() resolve?.('blob:late') await Promise.resolve() let reject: ((error: Error) => void) | undefined const failing = vi.fn(() => new Promise((_r, rej) => { reject = rej })) - const second = render() + const second = render() second.unmount() reject?.(new Error('late failure')) await Promise.resolve() diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 0b8591b205..8cf3d2d276 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -7,7 +7,8 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { PendingSteeringBubble } from './MessageItem.tsx' +import type { ChatSnapshot } from '../contract/snapshot.ts' +import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx' import { ChatNodeSeat } from './ChatNodeSeat.tsx' import { formatRunDuration } from './message-chrome.ts' import css from './ChatView.module.css' @@ -96,6 +97,33 @@ function isFolderOpenPath(path: string): boolean { return path === '.' } +/** + * Prompt-RPC identities already rendered by durable material: user/steering + * node sources plus queue occurrences. A submission echo whose identity + * appears here is hidden in the same render, so the echo→durable swap is + * atomic — no duplicate, no gap — regardless of when the echo leaves the + * session snapshot. + */ +function observedRpcIds( + order: readonly string[], + nodes: ChatSnapshot['nodes'], + queue: readonly { readonly rpcId?: string }[], +): ReadonlySet { + const observed = new Set() + for (const key of order) { + const node = nodes.get(key) + if (node === undefined || (node.kind !== 'user' && node.kind !== 'steering')) continue + const source = (node.data as { readonly source?: unknown }).source as + | { readonly kind?: unknown; readonly rpcId?: unknown } + | undefined + if (source?.kind === 'user' && typeof source.rpcId === 'string') observed.add(source.rpcId) + } + for (const item of queue) { + if (item.rpcId !== undefined) observed.add(item.rpcId) + } + return observed +} + function runningTurnStartTime(timeline: ConversationTimelineSnapshot): number | null { let latest: number | null = null for (const turn of timeline.turns.values()) { @@ -202,6 +230,15 @@ export function ChatView({ () => inbox.filter(item => item.placement === 'steering'), [inbox], ) + const pendingSubmissions = useSession(s => s.pendingSubmissions) + // Submission echoes still awaiting their durable counterpart. `order` is the + // recompute trigger: durable user material always arrives as an append, and + // every append replaces the order array. + const visibleSubmissions = useMemo(() => { + if (pendingSubmissions.length === 0) return pendingSubmissions + const observed = observedRpcIds(order, nodeStore, inbox) + return pendingSubmissions.filter(submission => !observed.has(submission.requestId)) + }, [pendingSubmissions, order, nodeStore, inbox]) const renderMessageImages = useCallback( owner => renderSlot('conversation.message.images', { ...owner, loadImage }), [loadImage, renderSlot], @@ -221,6 +258,7 @@ export function ChatView({ const openedRef = useRef(false) const lastKeyRef = useRef(null) const lastSteeringIdRef = useRef(null) + const lastSubmissionIdRef = useRef(null) /** Flow tip signature — follow-scroll only when this moves, never on a * scroll-driven at-bottom chrome re-render (which would snap inertial * scrolls the rest of the way to the floor). */ @@ -231,7 +269,8 @@ export function ChatView({ const lastKey = order.at(-1) ?? null const lastNode = lastKey === null ? undefined : nodeStore.get(lastKey) const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null - const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}` + const lastSubmissionId = visibleSubmissions[visibleSubmissions.length - 1]?.requestId ?? null + const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ''}:${lastSubmissionId ?? ''}` const toBottom = (el: HTMLElement): void => { anchorRef.current = null @@ -270,6 +309,7 @@ export function ChatView({ firstSeqRef.current = firstSeq lastKeyRef.current = lastKey lastSteeringIdRef.current = lastSteeringId + lastSubmissionIdRef.current = lastSubmissionId followSigRef.current = followSig return } @@ -286,6 +326,7 @@ export function ChatView({ /* v8 ignore next -- ?? arm: a prepend adds nodes, so the flow list here is never empty. */ lastKeyRef.current = lastKey lastSteeringIdRef.current = lastSteeringId + lastSubmissionIdRef.current = lastSubmissionId followSigRef.current = followSig return } @@ -294,13 +335,15 @@ export function ChatView({ // (send lives in the composer, so arrival is detected here, not armed there). const appendedUser = lastKey !== lastKeyRef.current && lastNode?.kind === 'user' const appendedSteering = lastSteeringId !== null && lastSteeringId !== lastSteeringIdRef.current + const appendedSubmission = lastSubmissionId !== null && lastSubmissionId !== lastSubmissionIdRef.current const tipMoved = followSigRef.current !== followSig lastKeyRef.current = lastKey lastSteeringIdRef.current = lastSteeringId + lastSubmissionIdRef.current = lastSubmissionId followSigRef.current = followSig // Follow new flow content while pinned; do NOT re-pin on every render // merely because atBottomRef is true (scroll threshold → setState → snap). - if (appendedUser || appendedSteering || (tipMoved && atBottomRef.current)) toBottom(el) + if (appendedUser || appendedSteering || appendedSubmission || (tipMoved && atBottomRef.current)) toBottom(el) }) const onScrollRef = useRef(() => {}) @@ -451,6 +494,14 @@ export function ChatView({ t={t} /> ))} + {visibleSubmissions.map(submission => ( + + ))} {!atBottom && (
diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index c7368dc24d..6a3e26ff69 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -1,5 +1,7 @@ import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' +import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client' +import type { MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client' import { JsonBlock, MessageText, ReferenceIcon, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts' @@ -214,7 +216,7 @@ function projectUserText(text: string, sessionLabels: readonly string[]): ReactN /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, renderMessageImages, actions, pending = false, referenceLabels = [], t, + content, renderMessageImages, actions, pending = false, referenceLabels = [], previewImages, t, }: { content: readonly unknown[] renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] @@ -224,9 +226,12 @@ function UserStyleBubble({ pending?: boolean /** Exact session mention labels associated by the adjacent recall node. */ referenceLabels?: readonly string[] + /** Local submission-echo previews replacing the content-derived image group. */ + previewImages?: readonly MessageImageSource[] t: ChatViewSlotProps['t'] }): ReactNode { - const { text, images, rest } = contentParts(content) + const { text, images: contentImages, rest } = contentParts(content) + const images = previewImages ?? contentImages const truncated = (total: number): string => t('json.truncated', { total }) const showBubble = text !== '' || rest.length > 0 return ( @@ -277,6 +282,53 @@ export function PendingSteeringBubble({ content, renderMessageImages, t }: { ) } +/** + * Render one local submission echo with the exact visual language of the + * durable user node that replaces it: draft text plus object-URL previews, + * visible from the submit click until the durable `user/message` (or its + * queue occurrence) renders. + * @param props - the session snapshot's pending submission and render seats. + * @returns the echoed user bubble. + */ +export function PendingSubmissionBubble({ submission, renderMessageImages, t }: { + submission: PendingSubmission + renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] + t: ChatViewSlotProps['t'] +}): ReactNode { + const content = useMemo( + () => (submission.text === '' ? [] : [{ type: 'text', text: submission.text }]), + [submission.text], + ) + const previewImages = useMemo( + () => submission.images.map(image => ({ + preview: { + url: image.previewUrl, + ...(image.name === undefined ? {} : { name: image.name }), + ...(image.width === undefined ? {} : { width: image.width }), + ...(image.height === undefined ? {} : { height: image.height }), + }, + })), + [submission.images], + ) + return ( + ( + + )} + /> + ) +} + /** User and admitted-steering keyed Chat renderer. */ export const UserMessageNodeView = memo(function UserMessageNodeView({ node, renderMessageImages, t, diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 1e9d71a523..094eeea06e 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -51,6 +51,7 @@ function sessionSnapshot(overrides: Partial = {}): SessionSnaps return { sessionId: SID, queue: [], + pendingSubmissions: [], running: false, removed: false, openState: 'open', diff --git a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx index d4e4022e1c..cc1cf29745 100644 --- a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx @@ -57,6 +57,7 @@ function sessionSnapshot(): SessionSnapshot { return { sessionId: SID, queue: [], + pendingSubmissions: [], running: false, removed: false, openState: 'open', diff --git a/packages/client/ui-chat/tests/image-labels.client.spec.tsx b/packages/client/ui-chat/tests/image-labels.client.spec.tsx index bb5d339f0a..ffbde6bb29 100644 --- a/packages/client/ui-chat/tests/image-labels.client.spec.tsx +++ b/packages/client/ui-chat/tests/image-labels.client.spec.tsx @@ -29,9 +29,11 @@ function imageRenderer(calls: MessageImagesRenderOwner[]): RenderMessageImages { calls.push(owner) return (
- {owner.images.map(({ attachment: image }, index) => ( - {image.name} - ))} + {owner.images.map((entry, index) => { + if (!('attachment' in entry)) throw new Error('assistant flow images are always durable references') + const image = entry.attachment + return {image.name} + })}
) } From 5657066b1d76f6a8ac509aff82ae8bd7219081f0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 26 Aug 2026 11:51:35 +0800 Subject: [PATCH 04/10] =?UTF-8?q?test:=20=E4=BF=AE=E5=A4=8D=E5=9B=9E?= =?UTF-8?q?=E6=98=BE=E5=A5=91=E7=BA=A6=E6=89=A9=E6=95=A3=E5=88=B0=E7=9A=84?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E5=8C=96=20fake=20=E4=B8=8E=E6=96=AD?= =?UTF-8?q?=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/session-pending-submissions.client.spec.ts | 4 ++-- .../tests/conversation-registry.client.spec.ts | 1 + .../client/ui-conversation/tests/queue-dock.client.spec.tsx | 1 + packages/client/ui-trajectory/tests/table.client.spec.tsx | 5 ++++- .../tests/plan-review-panel.client.spec.tsx | 1 + 5 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts index fa0e69d89a..0ab7073c3f 100644 --- a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -99,7 +99,7 @@ describe('beginSubmission', () => { describe('prompt-coupled retirement', () => { it('a rejected identified prompt retires its echo immediately alongside promptError', async () => { const { api, session } = makeSession() - api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: {} })) + api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } })) const retirements: PendingSubmissionRetirement[] = [] const handle = session.beginSubmission({ text: '失败的', @@ -122,7 +122,7 @@ describe('prompt-coupled retirement', () => { it('an unidentified prompt failure leaves registered echoes alone', async () => { const { api, session } = makeSession() - api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: {} })) + api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: '忙', details: { reason: 'busy' } })) session.beginSubmission({ text: '还在', images: [] }) await session.prompt([{ type: 'text', text: '另一个' }], 'queue') expect(session.getSnapshot().pendingSubmissions).toHaveLength(1) diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts index f75e04523e..9ef8c333dd 100644 --- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -44,6 +44,7 @@ function fakeSession(): SessionFace { projections: { faceOf: () => createSnapshotStore(undefined) }, getSnapshot: () => snapshot.getSnapshot(), subscribe: listener => snapshot.subscribe(listener), + beginSubmission: () => ({ requestId: 'test-req' as never, abandon: () => {} }), prompt: () => Promise.reject(new Error('unused fake Session operation')), readAttachment: () => Promise.reject(new Error('unused fake Session operation')), updateQueue: () => Promise.reject(new Error('unused fake Session operation')), diff --git a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx index 6a30ed4d8a..3cd61a0b5b 100644 --- a/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.client.spec.tsx @@ -39,6 +39,7 @@ function snapshotWith(queue: QueuedMessage[]): SessionSnapshot { return { sessionId: SID, queue, running: true, removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, + pendingSubmissions: [], lastAgentError: null, promptAttempted: true, awaitingFirstTurn: false, } } diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index 9bd8de74f0..89a8165a29 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -13,7 +13,10 @@ import { t, tZh } from './locale.client.ts' const renderImagesStub: RenderMessageImages = ({ images }) => (
{images.map((image, index) => ( - + ))}
) diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index 4faba04c00..c6f4180ed5 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -27,6 +27,7 @@ type AttentionState = Parameters Date: Wed, 26 Aug 2026 12:01:27 +0800 Subject: [PATCH 05/10] =?UTF-8?q?test+docs:=20=E5=9B=9E=E6=98=BE=E7=94=9F?= =?UTF-8?q?=E5=91=BD=E5=91=A8=E6=9C=9F=E3=80=81=E5=8E=BB=E9=87=8D=E4=B8=8E?= =?UTF-8?q?=E9=A2=84=E8=A7=88=E7=A7=BB=E4=BA=A4=E7=9A=84=E8=A6=86=E7=9B=96?= =?UTF-8?q?=EF=BC=8CREADME=20=E4=B8=8E=20Agent=20Note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 sendSession 回显编排、ChatView 回显渲染与 rpcId 去重、control 队列 rpcId 投影、HistoricalImageCache.seed、MessageImage 预览 arm 的测试;四个包 README 双语更新;Agent Note 记录 rpcId 关联与延帧退休决策。 --- ...26-08-26-local-submission-echoes.i18n.yaml | 6 + .../2026-08-26-local-submission-echoes.md | 37 ++++ .../2026-08-26-local-submission-echoes.zh.md | 37 ++++ .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 2 + packages/api/session-controller/README.zh.md | 2 + .../tests/control-queue.host.spec.ts | 24 +++ .../client/ui-attachment/README.i18n.yaml | 4 +- packages/client/ui-attachment/README.md | 2 +- packages/client/ui-attachment/README.zh.md | 2 +- .../tests/message-image.client.spec.tsx | 39 ++++ packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- .../ui-chat/tests/chat-view.client.spec.tsx | 92 +++++++++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + .../tests/historical-images.client.spec.ts | 37 ++++ .../service-orchestration.client.spec.ts | 177 ++++++++++++++++++ 20 files changed, 469 insertions(+), 12 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md create mode 100644 .agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.zh.md diff --git a/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.i18n.yaml new file mode 100644 index 0000000000..7a3d710fe7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md +2026-08-26-local-submission-echoes.md: 2151ebeb44f096e343aba88133495fbc1e743eb4 +2026-08-26-local-submission-echoes.zh.md: 052ca219164724d2bf687c3c728a7ea1581401cf diff --git a/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md new file mode 100644 index 0000000000..2151ebeb44 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.md @@ -0,0 +1,37 @@ +# Agent Note: Local submission echoes over the prompt rpcId + +Status: implemented + +English | [中文](2026-08-26-local-submission-echoes.zh.md) + +## Problem + +A multi-image prompt spent seconds in client serialization plus host admission before its durable `user/message` existed, and the conversation showed nothing until then: the composer froze read-only, the message appeared only after the full pipeline, and the user could not tell whether the submission had started (#3003). The durable event cannot move earlier — Model-visible ⟺ logged requires the `user/message` to land only after every attachment persists — so the visible submission had to decouple from the durable one. + +## Decision + +**The Session object owns a client-local submission echo, correlated by the prompt's existing `requestId`/`rpcId`.** `session.beginSubmission` synchronously inserts `{requestId, text, images: previews}` into `SessionSnapshot.pendingSubmissions` and flips `promptAttempted`, before the caller serializes anything; the same `requestId` rides the prompt RPC. No new correlation id, no wire-type change, and no session-log change: the host already stamps the prompt's `requestId` into the durable user source as `rpcId`, and the queue projection now carries it as `SessionQueuedItem.rpcId` for prompts that land in the inbox instead of the log (running-turn submissions). + +**Retirement is observation-driven with a one-frame delay; display dedupe is render-time and declarative.** The Session marks an echo observed when a durable `user/message` or queue occurrence with its rpcId arrives (append, window install, or control frame) and removes it one animation frame later — after the conversation assembly's frame, which was scheduled first. ChatView independently hides any echo whose rpcId appears among rendered user/steering nodes or queue rows, so within every render exactly one of echo/durable is visible regardless of store update order. An identified prompt failure, `abandon()`, or disposal retires the echo immediately as failed; the first settlement wins. + +**The composer commits optimistically.** Enter clears the draft, occurrence table, and undo history in one machine transaction and keeps phase `plain`; the send runs as a detached attempt (concurrent sends allowed; the single frozen in-flight slot remains command-only). A failed settlement restores the sent draft, occurrences, and image ids only into a still-empty plain composer — content typed during the flight always wins. Draft images stay registered until the echo retires: failed → available for rail restore; observed → each hands its object URL to `HistoricalImageCache.seed` under the admitted reference (URL ownership and scope-bound revocation move to the cache) so the durable node renders without a byte round-trip or loading flash. + +Client image encoding switched from the synchronous chunked-`btoa` loop to `FileReader.readAsDataURL` (native encode). The browser→host transport still ships one base64 JSON envelope; that remaining #2885 transport work is out of scope here. + +## Consequences + +The submit click paints its message and docks the composer on the same frame, for text and image prompts alike, while admission timing is unchanged. The composer never freezes for default sends, so drafts can be typed and sent during a flight; the machine's `submitting` phase now occurs only for command submissions. A prompt whose RPC response is lost but whose admission succeeded converges through observation instead of double-posting. Echo previews pin the original image blobs until the durable bytes would be fetched anyway; seeded cache entries keep the original (not the normalized) rendition for the session scope's lifetime, which trades some memory for zero-flash replacement. + +## Verification + +Session client specs pin synchronous insertion, requestId threading, event/queue/window observation, frame-delayed removal, first-settlement-wins, abandon, and disposal. Machine and shell specs pin the optimistic commit, detached settlement, untouched-composer restore, and image-only rail restore. ChatView specs pin flow-tail rendering, node- and queue-keyed dedupe with the echo still in the snapshot, and preview handoff through the message-image slot. Host control specs pin the queue rpcId projection; cache specs pin seed adoption, exclusivity, and scope revocation. The connection fixture echoes `requestId`, so assembled web replays exercise the same retirement. + +## Alternatives considered + +**A new `clientSubmissionId` threaded through the wire and the user source.** Rejected: `requestId` already exists end-to-end (`user-rpc` source member), so a second id would duplicate the correlation and touch wire validation for nothing. + +**Retire the echo synchronously on event ingestion.** Rejected: the chat assembly publishes on an animation frame, so synchronous removal blanks the message for a frame. The steering queue mirror historically accepted that race; the echo path removes it via render-time dedupe plus the delayed retirement. + +**Render echoes through the conversation assembler as synthetic nodes.** Rejected: the assembler is driven by durable session events only, and a client-only node kind would widen the closed `ConversationNode` union into every target's `assertNever`; the `PartialAssistant`-style side-channel state matches the existing precedent. + +**Keep the composer frozen and only add the echo.** Rejected: the issue's acceptance requires consecutive and concurrent submissions, and a frozen composer reintroduces the perceived hang the echo exists to remove. diff --git a/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.zh.md b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.zh.md new file mode 100644 index 0000000000..052ca21916 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-26-local-submission-echoes.zh.md @@ -0,0 +1,37 @@ +# Agent Note:基于 prompt rpcId 的本地提交回显 + +状态:implemented + +[English](2026-08-26-local-submission-echoes.md) | 中文 + +## 问题 + +多图 prompt 在客户端序列化加 host admission 上要花数秒,durable `user/message` 在此之前不存在,会话在此期间什么都不显示:composer 冻结为只读,消息在整条流水线结束后才出现,用户无法判断提交是否已经开始(#3003)。durable event 无法提前,Model-visible ⟺ logged 要求 `user/message` 只能在全部附件持久化后落盘,因此可见的提交必须与 durable 的提交解耦。 + +## 决定 + +**Session 对象持有客户端本地的提交回显,用 prompt 现有的 `requestId`/`rpcId` 关联。**`session.beginSubmission` 在调用方序列化任何内容之前,同步把 `{requestId, text, images: previews}` 写入 `SessionSnapshot.pendingSubmissions` 并翻转 `promptAttempted`;同一个 `requestId` 随 prompt RPC 发出。没有新关联 id,没有 wire 类型改动,也没有 session log 改动:host 本就把 prompt 的 `requestId` 写进 durable user source 的 `rpcId`,queue 投影现在把它作为 `SessionQueuedItem.rpcId` 携带,覆盖落进 inbox 而非 log 的 prompt(运行中 turn 的提交)。 + +**退休由观察驱动并延迟一帧;显示去重是渲染期的声明式规则。**Session 在带其 rpcId 的 durable `user/message` 或 queue occurrence 到达时(append、窗口安装或 control frame)标记回显为已观察,并在一个动画帧之后移除,晚于先注册的会话组装帧。ChatView 独立地隐藏 rpcId 出现在已渲染 user/steering 节点或 queue 行中的回显,因此无论 store 更新顺序如何,每一次渲染中回显与 durable 恰有一个可见。带标识的 prompt 失败、`abandon()` 或销毁使回显立即按 failed 退休;先到的 settlement 生效。 + +**Composer 乐观提交。**Enter 在一个 machine 事务里清空草稿、occurrence 表和撤销历史,phase 保持 `plain`;发送作为 detached attempt 运行(允许并发发送,唯一的冻结 in-flight 槽只留给命令)。失败的 settlement 只把已发送的草稿、occurrence 和图片 id 还原进仍为空的 plain composer,飞行期间输入的内容始终优先。草稿图片保持注册直到回显退休:failed 时可供 rail 还原;observed 时逐张把 object URL 通过 `HistoricalImageCache.seed` 挂到 admitted 引用名下(URL 所有权与随 scope 的回收移交缓存),durable 节点因此无需字节往返即可渲染,没有加载闪烁。 + +客户端图片编码从同步分块 `btoa` 循环换成 `FileReader.readAsDataURL`(原生编码)。browser→host 传输仍是一个 base64 JSON 整包;#2885 剩余的传输改造不在本决定范围内。 + +## 后果 + +点击提交在当帧画出消息并让 composer 落底,文本与图片 prompt 一致,admission 时机不变。默认发送不再冻结 composer,飞行期间可以继续输入和发送;machine 的 `submitting` 阶段只在命令提交时出现。RPC 响应丢失但 admission 已成功的 prompt 通过观察收敛,不会重复发送。回显预览会固定原始图片 blob,直到 durable 字节本来也要被拉取为止;seed 进缓存的条目在 session scope 生命周期内保留原图而非归一化版本,用一些内存换零闪烁替换。 + +## 验证 + +Session client spec 钉住同步插入、requestId 透传、event/queue/窗口观察、延帧移除、先到 settlement 生效、abandon 与销毁。Machine 与 shell spec 钉住乐观提交、detached settlement、未触碰 composer 的还原和图片纯发送的 rail 还原。ChatView spec 钉住流尾渲染、回显仍在 snapshot 时按节点与队列的去重,以及经 message-image slot 的预览移交。Host control spec 钉住 queue rpcId 投影;缓存 spec 钉住 seed 的接管、排他与 scope 回收。connection fixture 回显 `requestId`,组装 web 回放具备同样的退休语义。 + +## 考虑过的替代方案 + +**新增 `clientSubmissionId` 贯穿 wire 与 user source。**否决:`requestId` 已端到端存在(`user-rpc` source 成员),第二个 id 会重复关联并平白触碰 wire 校验。 + +**事件入库时同步移除回显。**否决:会话组装按动画帧发布,同步移除会让消息空一帧。steering 队列镜像历史上接受了这个竞态;回显路径用渲染期去重加延帧退休消除它。 + +**把回显作为合成节点走会话 assembler。**否决:assembler 只由 durable session event 驱动,客户端专属的节点 kind 会把闭合的 `ConversationNode` 联合扩进每个 target 的 `assertNever`;`PartialAssistant` 式的旁路状态符合现有先例。 + +**保持 composer 冻结,只加回显。**否决:issue 验收要求连续与并发提交,冻结的 composer 会重新引入回显本要消除的卡顿感。 diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 3325cc86a4..4f66278d0d 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: 815276847f538f99352e0f0e55fc19af5da67470 -README.zh.md: 51bf5a98c62aaa3fcb2156c029416a4d26515f0b +README.md: dc04e595c9c4bf029ed427d5a1214802041c11c0 +README.zh.md: b34c8ad018e4e2c0d95ac0b8aa1954742f9ecfa4 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 815276847f..dc04e595c9 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -29,6 +29,8 @@ Each endpoint states its activation policy. List, search, attachment, history pa The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. +The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The prompt's `requestId` is the correlation identity — the Host already echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the transcript node is), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only — reload and reconnect rebuild the conversation from durable events alone. + ----- diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 51bf5a98c6..b34c8ad018 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -29,6 +29,8 @@ kind: "package-reference" Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 +Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。prompt 的 `requestId` 就是关联标识,Host 本就把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休(该延迟保证 transcript 节点可渲染之前回显仍在),带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存,刷新与重连只从 durable event 重建会话。 + ----- diff --git a/packages/api/session-controller/tests/control-queue.host.spec.ts b/packages/api/session-controller/tests/control-queue.host.spec.ts index 9ce0c453a0..e3fe868c89 100644 --- a/packages/api/session-controller/tests/control-queue.host.spec.ts +++ b/packages/api/session-controller/tests/control-queue.host.spec.ts @@ -69,6 +69,30 @@ describe('Session control queue projection', () => { await iterator.next() }) + it('projects the prompt rpcId from a user-rpc source and omits it elsewhere', async () => { + const { control, inbox } = await harness() + const identified = createUserMessage({ + content: [{ type: 'text', text: 'browser prompt' }], + source: { kind: 'user', rpcId: 'req-42' as never }, + }) + inbox.append('next-turn', identified) + inbox.append('next-step', message('plain steering')) + + const abort = new AbortController() + const iterator = control.control(abort.signal)[Symbol.asyncIterator]() + const opened = await iterator.next() + if (opened.done || opened.value.type !== 'baseline') throw new Error('missing baseline') + const items = opened.value.value.queues['queue-session' as SessionId] ?? [] + expect(items).toMatchObject([ + { id: identified.id, placement: 'queued', rpcId: 'req-42' }, + { id: expect.anything(), placement: 'steering' }, + ]) + expect('rpcId' in (items[1] ?? {})).toBe(false) + + abort.abort() + await iterator.next() + }) + it('ignores inbox events without the exact live Agent session', async () => { const { ctx, control, agent, inbox } = await harness() const abort = new AbortController() diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 7378631ca9..5e0e50e06a 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-attachment/README.md -README.md: 3e9240f5f2f68dbc9f709ee7e720bb48053ba3b5 -README.zh.md: 0a450d2bc78f183847b945c73107c1466fb3ba0e +README.md: 9fa03432b43686dd55af494640708a8bf0983f9a +README.zh.md: 48e467280bfb83b2f4341f0e4c833b0b44cdce18 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 3e9240f5f2..9fa03432b4 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -54,7 +54,7 @@ The plugin waits for `conversation.input.attachments`, `conversation.message.ima | [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | Draft-image rail + drop overlay assembly | | [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | Scrolling thumbnail rail, wheel translation, edge arrows | | [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | Per-message gallery + lightbox assembly | -| [`src/MessageImage.tsx`](src/MessageImage.tsx) | Single image sizing, load/retry, click-to-open | +| [`src/MessageImage.tsx`](src/MessageImage.tsx) | Single image sizing, load/retry, click-to-open; local submission-echo previews render their object URL directly | | [`src/ImageLightbox.tsx`](src/ImageLightbox.tsx) | Document-level modal preview over the shared mask | | [`src/DropOverlay.tsx`](src/DropOverlay.tsx) | Pointer-inert drag invitation portal | diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 0a450d2bc7..48e467280b 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -54,7 +54,7 @@ kind: "package-reference" | [`src/client/ComposerAttachments.tsx`](src/client/ComposerAttachments.tsx) | 草稿图片栏+拖放遮罩的组装 | | [`src/AttachmentRail.tsx`](src/AttachmentRail.tsx) | 滚动缩略图栏、滚轮转换、边缘箭头 | | [`src/client/MessageImages.tsx`](src/client/MessageImages.tsx) | 每消息画廊+灯箱的组装 | -| [`src/MessageImage.tsx`](src/MessageImage.tsx) | 单图尺寸、加载/重试、点击打开 | +| [`src/MessageImage.tsx`](src/MessageImage.tsx) | 单图尺寸、加载/重试、点击打开;本地提交回显预览直接显示其 object URL | | [`src/ImageLightbox.tsx`](src/ImageLightbox.tsx) | 铺在共享遮罩上的文档级模态预览 | | [`src/DropOverlay.tsx`](src/DropOverlay.tsx) | 不接收指针事件的拖拽邀请 portal | diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index 5972beacdb..64d1eea37e 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -152,6 +152,45 @@ describe('MessageImage', () => { }) }) +describe('MessageImage preview arm', () => { + it('displays a local preview immediately, without the loader, sized by its probed dimensions', () => { + const load = vi.fn() + const view = render( + , + ) + expect(load).not.toHaveBeenCalled() + const img = view.getByAltText('echo.png') as HTMLImageElement + expect(img.src).toContain('blob:echo') + const frame = img.closest('button') as HTMLButtonElement + expect(frame.style.width).toBe('240px') + expect(frame.style.height).toBe('120px') + }) + + it('sizes an unprobed lone preview as a square crop and falls back to the image label', () => { + const load = vi.fn() + const view = render( + , + ) + const img = view.getByAltText('图片') as HTMLImageElement + const frame = img.closest('button') as HTMLButtonElement + expect(frame.style.width).toBe('240px') + expect(frame.style.height).toBe('240px') + }) + + it('opens the lightbox from a preview thumbnail', () => { + const view = render( + , + ) + fireEvent.click(view.getByRole('button', { name: '图片,点击查看原图' })) + expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() + }) +}) + describe('ImageGallery', () => { it('renders nothing without images and an aligned wrapping group with them', async () => { const load = vi.fn().mockResolvedValue('blob:gallery') diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index dbcc134300..9146293925 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: b44c061aa9af8f58dbbae212c72fa55e186e4564 -README.zh.md: b339fea3835f0be0e6e07ec93577c4f11c80c1b7 +README.md: a48413b7d6ef95d23ff854db125cb2757afd93da +README.zh.md: e64d55f754392867820fd9b4d8fa2593aa84fc23 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index b44c061aa9..a48413b7d6 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. +The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. The flow tail renders the session's local submission echoes (`SessionSnapshot.pendingSubmissions`) with the same bubble as their eventual durable user nodes, hidden per render once a user/steering node or queue occurrence carries the echo's prompt `rpcId`, so the echo-to-durable swap is atomic. ## Table of Contents diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index b339fea383..e64d55f754 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。 +Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。消息流尾部渲染 session 的本地提交回显(`SessionSnapshot.pendingSubmissions`),气泡与其最终的 durable user 节点一致;一旦某个 user/steering 节点或 queue occurrence 携带回显的 prompt `rpcId`,该回显即在同一渲染中隐藏,因此回显到 durable 的替换是原子的。 ## 目录 diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 094eeea06e..a0720a90fa 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -661,6 +661,98 @@ describe('ChatView', () => { expect(view.container.querySelectorAll('[data-pending-steering]')).toHaveLength(1) }) + it('renders local submission echoes at the flow tail and swaps atomically with the durable node', () => { + const h = makeHarness( + { nodes: [assistant(1, 'working')] }, + { + pendingSubmissions: [ + { requestId: 'req-1' as never, time: 5_000, text: '即发即显', images: [] }, + ], + }, + ) + const view = render() + expect(view.getByText('即发即显')).toBeTruthy() + + // The durable node arrives while the echo is STILL in the session + // snapshot: the render-time rpcId dedupe keeps exactly one bubble. + act(() => { + h.setChat({ + nodes: [ + assistant(1, 'working'), + { + kind: 'user', seq: 2, time: 2_000, + content: [{ type: 'text', text: '即发即显' }] as never, + source: { kind: 'user', rpcId: 'req-1' } as never, + }, + ], + }) + }) + expect(view.getAllByText('即发即显')).toHaveLength(1) + + // The delayed snapshot retirement changes nothing visible. + act(() => { h.setSession({ pendingSubmissions: [] }) }) + expect(view.getAllByText('即发即显')).toHaveLength(1) + }) + + it('hides an echo once its queue occurrence carries the rpcId (running-turn submission)', () => { + const h = makeHarness( + { nodes: [assistant(1, 'working')] }, + { + running: true, + pendingSubmissions: [ + { requestId: 'req-q' as never, time: 6_000, text: '排队中', images: [] }, + ], + }, + ) + const view = render() + expect(view.getByText('排队中')).toBeTruthy() + act(() => { + h.setSession({ + queue: [{ + id: 'q-occurrence' as never, + messageId: 'q-message' as never, + placement: 'queued' as const, + rpcId: 'req-q' as never, + content: [{ type: 'text' as const, text: '排队中' }], + preview: '排队中', + text: '排队中', + }], + }) + }) + // The queued occurrence renders in the queue dock, not the flow; the + // flow-tail echo yields to it in the same snapshot. + expect(view.queryByText('排队中')).toBeNull() + }) + + it('an image echo renders its previews through the message-image slot', () => { + const h = makeHarness( + { nodes: [] }, + { + pendingSubmissions: [{ + requestId: 'req-img' as never, + time: 7_000, + text: '', + images: [ + { previewUrl: 'blob:echo-a', name: 'a.png', width: 4, height: 3 }, + { previewUrl: 'blob:echo-b' }, + ], + }], + }, + ) + const baseRenderSlot = h.props.renderSlot + const renderSlot = ((key: string, owner: object, opts?: { fallback?: React.ReactNode }) => { + if (key !== 'conversation.message.images') return baseRenderSlot(key as never, owner as never, opts as never) + const images = (owner as { images: readonly unknown[] }).images + return
+ }) as unknown as ChatViewSlotProps['renderSlot'] + const view = render() + const gallery = view.getByTestId('echo-images') + expect(gallery.getAttribute('data-count')).toBe('2') + expect(JSON.parse(gallery.getAttribute('data-first') ?? '{}')).toEqual({ + preview: { url: 'blob:echo-a', name: 'a.png', width: 4, height: 3 }, + }) + }) + it('animates only the latest unresolved model retry', () => { const retryNode = retry(2) const nextRetry = { ...retry(3), turn: 2, retry: 2 } diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 122fcc3d5d..bf29a9bd0f 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 276f4126328ab26f610e8a59bc18e43283290296 -README.zh.md: 3b3e2135625e521bc4362de657d259222cb3d09b +README.md: f0669403f90d2283a0ae521b569955978cdaf94a +README.zh.md: 9341772e1148880a3caa4fa9443fa8a2cf7e9880 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 276f412632..f0669403f9 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -40,6 +40,8 @@ View selection is deterministic: a registered persisted selection wins, otherwis The resident composer survives no-Session and Session transitions. The no-Session state keeps the same textarea mounted but inert while the Workspace picker connects a blank Session. Draft text is mirrored into the per-Session Conversation store. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. +Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) before serializing, yields one paint so the echo renders on the click's own frame, and encodes images through the browser's native `FileReader` data-URL path. A failed send restores the sent draft, references, and image ids only into a still-untouched empty composer; command submissions keep the frozen `submitting` phase. When an echo retires as observed, its draft previews hand their object URLs to the durable image cache (`seedImageUrl`) so the transcript node displays without a byte round-trip. + 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 Queue Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting continues to select the Queue or Steer keyboard action. Continuable subagents keep separate Send and Stop actions ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 3b3e213562..9341772e11 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -40,6 +40,8 @@ View 选择规则固定:有效且已注册的持久化选择优先,其次是 常驻 composer 在无 Session 与有 Session 之间保持挂载。无 Session 时,同一个 textarea 保持 inert,Workspace picker 连接 blank Session;草稿文本镜像到逐 Session Conversation store。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 +默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,飞行期间可以继续输入和继续发送。`sendSession` 在序列化之前注册 Session 提交回显(`session.beginSubmission`),让出一帧使回显在点击当帧渲染,图片经浏览器原生 `FileReader` data-URL 路径编码。发送失败只把已发送的草稿、引用和图片 id 还原进仍未被触碰的空 composer;命令提交保持冻结的 `submitting` 阶段。回显以 observed 退休时,草稿预览把 object URL 移交 durable 图片缓存(`seedImageUrl`),transcript 节点无需字节往返即可显示。 + 普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Queue Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置继续选择 Queue 或 Steer 键盘操作。可继续 subagent 保留独立的 Send 与 Stop 操作([决策](../../../.agents/notes/implemented/bug-fix/2026-08-20-running-draft-primary-send.zh.md))。 diff --git a/packages/client/ui-conversation/tests/historical-images.client.spec.ts b/packages/client/ui-conversation/tests/historical-images.client.spec.ts index 71aa3d6018..7b44cd2016 100644 --- a/packages/client/ui-conversation/tests/historical-images.client.spec.ts +++ b/packages/client/ui-conversation/tests/historical-images.client.spec.ts @@ -25,4 +25,41 @@ describe('HistoricalImageCache', () => { await expect(pending).rejects.toThrow('ui-conversation image scope was released before loading completed') await runtime.dispose() }) + + it('adopts a seeded URL, reuses it for later resolves, and revokes it with the Session scope', async () => { + const revoked: string[] = [] + const originalRevoke = URL.revokeObjectURL + URL.revokeObjectURL = (url: string) => { revoked.push(url) } + try { + const runtime = await SlotTestRuntime.create() + const sessionId = await runtime.sessions.add({ id: 's1', session: {} }) + const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions) + const attachment = { + attachmentId: AttachmentId('image-seeded'), mediaType: 'image/png', bytes: 1, width: 1, height: 1, + } as const + + expect(cache.seed(sessionId, attachment, 'blob:seeded')).toBe(true) + // Ownership is exclusive: a second seed of the same reference refuses, + // and resolve() serves the adopted URL without a byte round-trip. + expect(cache.seed(sessionId, attachment, 'blob:duplicate')).toBe(false) + await expect(cache.resolve(sessionId, attachment)).resolves.toBe('blob:seeded') + + await runtime.sessions.remove(sessionId) + await Promise.resolve() + expect(revoked).toContain('blob:seeded') + await runtime.dispose() + } finally { + URL.revokeObjectURL = originalRevoke + } + }) + + it('refuses to seed for an unknown session', async () => { + const runtime = await SlotTestRuntime.create() + const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions) + const attachment = { + attachmentId: AttachmentId('image-unknown'), mediaType: 'image/png', bytes: 1, width: 1, height: 1, + } as const + expect(cache.seed('missing' as never, attachment, 'blob:orphan')).toBe(false) + await runtime.dispose() + }) }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 4633d07e20..8950cf5945 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -131,6 +131,183 @@ describe('ConversationController', () => { }) }) +describe('sendSession submission echo', () => { + /** Bench with an observable beginSubmission on the session face. */ + async function echoBench() { + const b = await bench() + const retire: { onRetire?: (retirement: unknown) => void } = {} + const abandon = vi.fn() + const beginSubmission = vi.fn((input: { onRetire?: (retirement: unknown) => void }) => { + retire.onRetire = input.onRetire + return { requestId: 'req-echo' as never, abandon } + }) + await b.runtime.sessions.updateSessionSnapshot('s1', () => {}) + const face = b.runtime.sessions.binding('s1')!.session as unknown as Record + face['beginSubmission'] = beginSubmission + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:echo-1') + const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) + const restore = () => { + created.mockRestore() + revoked.mockRestore() + } + return { ...b, beginSubmission, abandon, retire, revoked, restore } + } + + it('registers the echo before serialization and prompts with its identity', async () => { + const b = await echoBench() + try { + const [attachment] = b.root.createDraftImages([ + new File([Uint8Array.of(1, 2, 3)], 'a.png', { type: 'image/png' }), + ]) + const session = b.runtime.sessions.binding('s1')!.session + const sending = b.root.sendSession(session, '带图', [attachment!.id], 'queue') + // Synchronous: the echo is registered before any encoding starts. + expect(b.beginSubmission).toHaveBeenCalledWith(expect.objectContaining({ + text: '带图', + images: [expect.objectContaining({ previewUrl: 'blob:echo-1', name: 'a.png' })], + })) + expect(b.prompt).not.toHaveBeenCalled() + await expect(sending).resolves.toEqual({ kind: 'success' }) + expect(b.prompt).toHaveBeenCalledWith( + [ + { type: 'image', mediaType: 'image/png', data: expect.any(String), name: 'a.png' }, + { type: 'text', text: '带图' }, + ], + 'queue', + undefined, + 'req-echo', + ) + // The draft stays registered until the echo's observed retirement. + expect(b.root.draftImages([attachment!.id])).toHaveLength(1) + b.retire.onRetire?.({ reason: 'observed', attachments: [] }) + expect(b.root.draftImages([attachment!.id])).toEqual([]) + expect(b.revoked).toHaveBeenCalledWith('blob:echo-1') + } finally { + b.restore() + } + await b.runtime.dispose() + }) + + it('hands the preview URL to the image cache on observed retirement instead of revoking it', async () => { + const b = await echoBench() + try { + const seedImageUrl = vi.fn(() => true) + b.runtime.ctx.provide('uiConversation') + b.runtime.ctx.set('uiConversation', { seedImageUrl }) + const [attachment] = b.root.createDraftImages([ + new File([Uint8Array.of(9)], 'seeded.png', { type: 'image/png' }), + ]) + const session = b.runtime.sessions.binding('s1')!.session + await b.root.sendSession(session, '', [attachment!.id], 'queue') + const ref = { attachmentId: 'att-1' } + b.retire.onRetire?.({ reason: 'observed', attachments: [ref] }) + expect(seedImageUrl).toHaveBeenCalledWith('s1', ref, 'blob:echo-1') + expect(b.root.draftImages([attachment!.id])).toEqual([]) + expect(b.revoked).not.toHaveBeenCalled() + // Failed retirement keeps nothing to do; a second retire of released ids is a no-op. + b.retire.onRetire?.({ reason: 'observed', attachments: [ref] }) + } finally { + b.restore() + } + await b.runtime.dispose() + }) + + it('keeps the drafts registered when the echo retires as failed (composer restore path)', async () => { + const b = await echoBench() + try { + b.prompt.mockResolvedValueOnce({ + ok: false, error: { code: 'attachment-error', message: 'nope', details: {} }, + } as never) + const [attachment] = b.root.createDraftImages([ + new File([Uint8Array.of(7)], 'kept.png', { type: 'image/png' }), + ]) + const session = b.runtime.sessions.binding('s1')!.session + await expect(b.root.sendSession(session, '失败', [attachment!.id], 'queue')) + .resolves.toEqual({ kind: 'error' }) + b.retire.onRetire?.({ reason: 'failed' }) + expect(b.root.draftImages([attachment!.id])).toHaveLength(1) + expect(b.revoked).not.toHaveBeenCalled() + } finally { + b.restore() + } + await b.runtime.dispose() + }) + + it('abandons the echo when encoding fails before the prompt', async () => { + const b = await echoBench() + class FailingReader { + onload: (() => void) | null = null + onerror: (() => void) | null = null + error = new Error('read failed') + readAsDataURL(): void { + queueMicrotask(() => this.onerror?.()) + } + } + vi.stubGlobal('FileReader', FailingReader) + try { + const [attachment] = b.root.createDraftImages([ + new File([Uint8Array.of(1)], 'broken.png', { type: 'image/png' }), + ]) + const session = b.runtime.sessions.binding('s1')!.session + await expect(b.root.sendSession(session, 'x', [attachment!.id], 'queue')) + .rejects.toThrow('read failed') + expect(b.abandon).toHaveBeenCalledOnce() + expect(b.prompt).not.toHaveBeenCalled() + } finally { + vi.unstubAllGlobals() + b.restore() + } + await b.runtime.dispose() + }) + + it('yields through the macrotask fallback where no frame clock exists', async () => { + const b = await echoBench() + vi.stubGlobal('requestAnimationFrame', undefined) + try { + const session = b.runtime.sessions.binding('s1')!.session + await expect(b.root.sendSession(session, '纯文本', [], 'queue')).resolves.toEqual({ kind: 'success' }) + expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: '纯文本' }], 'queue', undefined, 'req-echo') + } finally { + vi.unstubAllGlobals() + b.restore() + } + await b.runtime.dispose() + }) +}) + +describe('draft image dimension probe', () => { + it('fills intrinsic dimensions from the header probe and skips runtimes without Image', async () => { + const b = await bench() + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:probe') + class InstantImage { + onload: (() => void) | null = null + naturalWidth = 0 + naturalHeight = 0 + set src(_value: string) { + this.naturalWidth = 640 + this.naturalHeight = 480 + this.onload?.() + } + } + vi.stubGlobal('Image', InstantImage) + try { + const [probed] = b.root.createDraftImages([ + new File([Uint8Array.of(1)], 'probed.png', { type: 'image/png' }), + ]) + expect(probed).toMatchObject({ width: 640, height: 480 }) + vi.stubGlobal('Image', undefined) + const [unprobed] = b.root.createDraftImages([ + new File([Uint8Array.of(2)], 'unprobed.png', { type: 'image/png' }), + ]) + expect(unprobed?.width).toBeUndefined() + } finally { + vi.unstubAllGlobals() + created.mockRestore() + } + await b.runtime.dispose() + }) +}) + describe('InputHub queue steering (empty-draft accelerated Enter)', () => { const row = (id: string): QueuedMessage => ({ id: id as never, From f1606e31d247535498f8c89b6369fb32589371b7 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 26 Aug 2026 12:12:08 +0800 Subject: [PATCH 06/10] =?UTF-8?q?test(web):=20=E6=8F=90=E4=BA=A4=E5=9B=9E?= =?UTF-8?q?=E6=98=BE=E7=9A=84=E7=BB=84=E8=A3=85=E8=B7=AF=E5=BE=84=20e2e=20?= =?UTF-8?q?=E4=B8=8E=E4=B8=8D=E5=8F=AF=E8=A7=81=E6=A0=87=E8=AE=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PendingSubmissionBubble 携带 data-submission-echo 标记(渲染不变,仅供检测), 新增 keyless 组装 e2e:发送按键当下回显即在流中、composer 已清空可编辑, durable 节点到达后原位替换且只剩一条气泡。 --- apps/web/tests/submission-echo.e2e.ts | 65 +++++++++++++++++++ .../tests/control-queue.host.spec.ts | 4 +- ...session-pending-submissions.client.spec.ts | 3 +- .../ui-chat/src/client/chat/MessageItem.tsx | 12 +++- .../ui-chat/tests/chat-view.client.spec.tsx | 5 +- .../tests/historical-images.client.spec.ts | 2 +- .../tests/input-machine.client.spec.ts | 32 +++++++++ .../service-orchestration.client.spec.ts | 2 +- 8 files changed, 115 insertions(+), 10 deletions(-) create mode 100644 apps/web/tests/submission-echo.e2e.ts diff --git a/apps/web/tests/submission-echo.e2e.ts b/apps/web/tests/submission-echo.e2e.ts new file mode 100644 index 0000000000..42f04e730d --- /dev/null +++ b/apps/web/tests/submission-echo.e2e.ts @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +// Local submission echo over the BUILT client graph (keyless FixtureApiClient +// transport): a text-plus-image send paints its echo bubble synchronously on +// the submit keystroke — before serialization, transport, or the fixture's +// durable admission — with the composer already cleared and editable, and the +// durable user/message replaces the echo without a duplicate. The fixture host +// echoes the prompt requestId as the durable source's rpcId, so the retirement +// path here is the production correlation, not a test hook. +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { expect, it } from 'vitest' +import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' + +installAssembledBootEnv() + +it('paints the submission echo on the send keystroke and swaps it for the durable node', async () => { + mountAssembledApp() + + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 }) + const start = tree.querySelector('button[aria-label="New session in fixture"]') + if (start === null) throw new Error('fixture Workspace new-session action missing') + fireEvent.click(start) + + const textarea = await screen.findByPlaceholderText('Describe what you want to build', {}, { timeout: 10_000 }) + const image = new File([new Uint8Array([137, 80, 78, 71])], 'echoed.png', { type: 'image/png' }) + fireEvent.paste(textarea, { + clipboardData: { + items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }], + getData: () => '', + }, + }) + await waitFor(() => { + if (document.querySelector('[role="group"][aria-label="Pending images"] img') === null) { + throw new Error('attachment rail missing') + } + }, { timeout: 5_000 }) + fireEvent.change(textarea, { target: { value: '回显这条消息' } }) + fireEvent.keyDown(textarea, { key: 'Enter' }) + + // Synchronously after the keystroke: the echo bubble is in the flow with + // the draft text and the object-URL preview, while the prompt has not even + // been serialized yet (it starts after a paint yield). The composer is + // already cleared, editable, and free of the rail. + const echo = document.querySelector('[data-submission-echo]') + if (echo === null) throw new Error('submission echo missing on the send keystroke') + expect(echo.textContent).toContain('回显这条消息') + expect(echo.querySelector('img')?.getAttribute('src')?.split(':')[0]).toBe('blob') + expect((textarea as HTMLTextAreaElement).value).toBe('') + expect((textarea as HTMLTextAreaElement).readOnly).toBe(false) + expect(document.querySelector('[role="group"][aria-label="Pending images"]')).toBeNull() + + // The fixture's durable user/message (source.rpcId echoes the prompt + // requestId) replaces the echo: one bubble, no marker left, and the image + // now renders from the durable gallery. + await waitFor(() => { + if (document.querySelector('[data-submission-echo]') !== null) { + throw new Error('submission echo still present after the durable node arrived') + } + }, { timeout: 10_000 }) + expect(screen.getAllByText('回显这条消息')).toHaveLength(1) + await waitFor(() => { + if (document.querySelector('[data-align="end"] img') === null) { + throw new Error('durable user gallery missing') + } + }, { timeout: 10_000 }) +}) diff --git a/packages/api/session-controller/tests/control-queue.host.spec.ts b/packages/api/session-controller/tests/control-queue.host.spec.ts index e3fe868c89..b4645dc2f3 100644 --- a/packages/api/session-controller/tests/control-queue.host.spec.ts +++ b/packages/api/session-controller/tests/control-queue.host.spec.ts @@ -83,9 +83,9 @@ describe('Session control queue projection', () => { const opened = await iterator.next() if (opened.done || opened.value.type !== 'baseline') throw new Error('missing baseline') const items = opened.value.value.queues['queue-session' as SessionId] ?? [] - expect(items).toMatchObject([ + expect(items.map(item => ({ id: item.id, placement: item.placement, rpcId: item.rpcId }))).toEqual([ { id: identified.id, placement: 'queued', rpcId: 'req-42' }, - { id: expect.anything(), placement: 'steering' }, + { id: items[1]?.id, placement: 'steering', rpcId: undefined }, ]) expect('rpcId' in (items[1] ?? {})).toBe(false) diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts index 0ab7073c3f..76b58abf79 100644 --- a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -2,7 +2,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' -import type { MessageSource } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import { Session } from '../src/client/sessions/session.ts' @@ -43,7 +42,7 @@ function promptEvent(seq: number, rpcId: SessionRequestId, refs: readonly ImageA ...refs.map(attachment => ({ type: 'image' as const, attachment })), { type: 'text' as const, text: '发送' }, ], - source: { kind: 'user', rpcId } as MessageSource, + source: { kind: 'user', rpcId }, }), } as unknown as SessionEvent } diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index 6a3e26ff69..58af540f70 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -216,7 +216,7 @@ function projectUserText(text: string, sessionLabels: readonly string[]): ReactN /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, renderMessageImages, actions, pending = false, referenceLabels = [], previewImages, t, + content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, t, }: { content: readonly unknown[] renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] @@ -224,6 +224,8 @@ function UserStyleBubble({ actions?: (text: string) => ReactNode /** Whether this is the Host-authoritative pre-admission steering projection. */ pending?: boolean + /** Whether this is a local submission echo (invisible marker; the echo renders exactly like its durable replacement). */ + echo?: boolean /** Exact session mention labels associated by the adjacent recall node. */ referenceLabels?: readonly string[] /** Local submission-echo previews replacing the content-derived image group. */ @@ -235,7 +237,12 @@ function UserStyleBubble({ const truncated = (total: number): string => t('json.truncated', { total }) const showBubble = text !== '' || rest.length > 0 return ( -
+
{renderMessageImages({ images, align: 'end' })} {showBubble &&
@@ -315,6 +322,7 @@ export function PendingSubmissionBubble({ submission, renderMessageImages, t }: content={content} previewImages={previewImages} renderMessageImages={renderMessageImages} + echo t={t} actions={text => ( { }, ) const view = render() - expect(view.getByText('即发即显')).toBeTruthy() + expect(view.getByText('即发即显').closest('[data-submission-echo]')).not.toBeNull() // The durable node arrives while the echo is STILL in the session // snapshot: the render-time rpcId dedupe keeps exactly one bubble. @@ -682,12 +682,13 @@ describe('ChatView', () => { { kind: 'user', seq: 2, time: 2_000, content: [{ type: 'text', text: '即发即显' }] as never, - source: { kind: 'user', rpcId: 'req-1' } as never, + source: { kind: 'user', rpcId: 'req-1' }, }, ], }) }) expect(view.getAllByText('即发即显')).toHaveLength(1) + expect(view.container.querySelector('[data-submission-echo]')).toBeNull() // The delayed snapshot retirement changes nothing visible. act(() => { h.setSession({ pendingSubmissions: [] }) }) diff --git a/packages/client/ui-conversation/tests/historical-images.client.spec.ts b/packages/client/ui-conversation/tests/historical-images.client.spec.ts index 7b44cd2016..a8f777d12c 100644 --- a/packages/client/ui-conversation/tests/historical-images.client.spec.ts +++ b/packages/client/ui-conversation/tests/historical-images.client.spec.ts @@ -28,7 +28,7 @@ describe('HistoricalImageCache', () => { it('adopts a seeded URL, reuses it for later resolves, and revokes it with the Session scope', async () => { const revoked: string[] = [] - const originalRevoke = URL.revokeObjectURL + const originalRevoke = URL.revokeObjectURL.bind(URL) URL.revokeObjectURL = (url: string) => { revoked.push(url) } try { const runtime = await SlotTestRuntime.create() diff --git a/packages/client/ui-conversation/tests/input-machine.client.spec.ts b/packages/client/ui-conversation/tests/input-machine.client.spec.ts index c9938c6c10..0b601c4ff2 100644 --- a/packages/client/ui-conversation/tests/input-machine.client.spec.ts +++ b/packages/client/ui-conversation/tests/input-machine.client.spec.ts @@ -550,6 +550,38 @@ describe('input-machine: undo / redo', () => { expect(n.state.draft).toBe('typed during flight') }) + it('runs concurrent detached sends and settles them independently in any order', () => { + const m = new InputMachine() + m.dispatch({ type: 'draft-changed', draft: '第一条' }) + const first = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') + m.dispatch({ type: 'draft-changed', draft: '第二条' }) + const second = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') + expect(m.state.phase).toBe('plain') + expect(m.state.draft).toBe('') + expect(second.attempt.seq).toBeGreaterThan(first.attempt.seq) + // Later attempt fails first: its draft restores into the empty composer. + m.dispatch({ type: 'sink-settled', attempt: second.attempt, ok: false, message: 'boom' }) + expect(m.state.draft).toBe('第二条') + // The earlier failure then finds a non-empty composer and must not clobber it. + m.dispatch({ type: 'sink-settled', attempt: first.attempt, ok: false, message: 'boom' }) + expect(m.state.draft).toBe('第二条') + // Release aborts nothing further: both settlements already consumed their records. + expect(m.dispatch({ type: 'release' })).toEqual([]) + }) + + it('release aborts every in-flight detached send', () => { + const m = new InputMachine() + m.dispatch({ type: 'draft-changed', draft: 'A' }) + const first = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') + m.dispatch({ type: 'draft-changed', draft: 'B' }) + const second = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'default-sink') + m.dispatch({ type: 'release' }) + expect(first.attempt.signal.aborted).toBe(true) + expect(second.attempt.signal.aborted).toBe(true) + // Settlements after release are dropped stale events. + expect(m.dispatch({ type: 'sink-settled', attempt: first.attempt, ok: false, message: 'late' })).toEqual([]) + }) + it('a failed detached flight restores the sent draft and occurrences into an untouched composer', () => { const m = new InputMachine() m.dispatch({ type: 'draft-changed', draft: 'restore me' }) diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 8950cf5945..39ced8ab36 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -170,7 +170,7 @@ describe('sendSession submission echo', () => { await expect(sending).resolves.toEqual({ kind: 'success' }) expect(b.prompt).toHaveBeenCalledWith( [ - { type: 'image', mediaType: 'image/png', data: expect.any(String), name: 'a.png' }, + { type: 'image', mediaType: 'image/png', data: expect.any(String) as string, name: 'a.png' }, { type: 'text', text: '带图' }, ], 'queue', From c01cf6e54972289f1c6f1cf22bdc4c3dcef98d03 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 26 Aug 2026 12:12:59 +0800 Subject: [PATCH 07/10] =?UTF-8?q?test:=20exactOptionalPropertyTypes=20?= =?UTF-8?q?=E4=B8=8B=E7=9A=84=20onRetire=20=E6=8D=95=E8=8E=B7=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ui-conversation/tests/service-orchestration.client.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts index 39ced8ab36..cfca122d19 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.client.spec.ts @@ -135,7 +135,7 @@ describe('sendSession submission echo', () => { /** Bench with an observable beginSubmission on the session face. */ async function echoBench() { const b = await bench() - const retire: { onRetire?: (retirement: unknown) => void } = {} + const retire: { onRetire?: ((retirement: unknown) => void) | undefined } = {} const abandon = vi.fn() const beginSubmission = vi.fn((input: { onRetire?: (retirement: unknown) => void }) => { retire.onRetire = input.onRetire From 2dd59b2ca191ee7012c12aedf3d8405c082c3e20 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 26 Aug 2026 13:49:20 +0800 Subject: [PATCH 08/10] test(client): cover instant image echo branches --- .../ui-attachment/tests/message-image.client.spec.tsx | 8 +++++++- .../client/ui-chat/tests/apply-inject.client.spec.tsx | 4 +++- .../client-runtime/tests/runtime.client.spec.tsx | 3 +++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index d9c3008246..76a720b983 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -207,10 +207,16 @@ describe('ImageGallery', () => { const empty = render() expect(empty.container.firstChild).toBeNull() const view = render( - , + , ) expect(view.container.querySelector('[data-align="end"]')).not.toBeNull() await waitFor(() => { expect(view.getAllByAltText('history.png')).toHaveLength(2) }) + expect(view.getByAltText('echo.png')).toBeTruthy() }) it('renders a lone image large and several images as square tiles', () => { diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index 0fd861a14a..328868a2dc 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -169,8 +169,10 @@ describe('Chat inject API', () => { injected.chatScroll.save(null) expect(injected.chatScroll.read()).toBeNull() - await expect(injected.loadImage(ATTACHMENT)).resolves.toEqual(expect.any(String)) + const loaded = await injected.loadImage(ATTACHMENT) + expect(loaded).toEqual(expect.any(String)) expect(b.session.readAttachment).toHaveBeenCalledWith(ATTACHMENT.attachmentId) + expect(injected.loadImage.peek?.(ATTACHMENT)).toBe(loaded) await b.runtime.dispose() }) }) diff --git a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx index 02d239b117..5216f778c2 100644 --- a/packages/test-support/client-runtime/tests/runtime.client.spec.tsx +++ b/packages/test-support/client-runtime/tests/runtime.client.spec.tsx @@ -433,6 +433,9 @@ describe('fixture session face', () => { expect(() => bare.command()).toThrow(/command is not stubbed/) expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/) expect(() => bare.rename()).toThrow(/rename is not stubbed/) + const submission = bare.beginSubmission() + expect(submission.requestId).toBe('test-submission-1') + expect(() => { submission.abandon() }).not.toThrow() await runtime.dispose() }) From 7817ed3d82aec1b5940d6d9ea64f9b7292c41121 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 26 Aug 2026 14:09:31 +0800 Subject: [PATCH 09/10] docs: refresh Claude SDK notices --- THIRD_PARTY_NOTICES.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index d47cdd7d2c..08c7f13f0f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -118,18 +118,18 @@ pnpm applies local patches to the following packages at install time, so shipped The project owner authorizes distribution of every version of the official `@anthropic-ai/claude-agent-sdk` package and the official Claude Code CLI/platform payloads that each version declares through `optionalDependencies`. This identity-scoped authorization does not classify their declared terms as permissive and does not cover any unrelated runtime package; version, declared-license, and payload-set changes still require the ordinary dependency, lockfile, compatibility, terms, and notices review. -The installed SDK 0.3.220 declares the following optional platform packages. Each carries the official Claude Code 2.1.220 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. +The installed SDK 0.3.241 declares the following optional platform packages. Each carries the official Claude Code 2.1.241 executable; the package identities and versions come from the SDK manifest, while the declared license field is verified against the platform payload installed for the current host. | Optional platform package | Version | Declared license | | --- | --- | --- | -| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.220 | SEE LICENSE IN LICENSE.md | -| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.220 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-darwin-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-darwin-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-arm64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-arm64-musl) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-linux-x64-musl`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-linux-x64-musl) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-arm64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-arm64) | 0.3.241 | SEE LICENSE IN LICENSE.md | +| [`@anthropic-ai/claude-agent-sdk-win32-x64`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk-win32-x64) | 0.3.241 | SEE LICENSE IN LICENSE.md | ## Development-only npm dependencies From dc825114979d64cde3b2628267f619f8161bae3d Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 26 Aug 2026 14:49:29 +0800 Subject: [PATCH 10/10] ci(windows): restore complete coverage sharding --- .github/workflows/ci.yml | 2 +- scripts/ci-workflow.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 182d66d6fc..20fa5fc99c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -452,7 +452,7 @@ jobs: timeout-minutes: 120 env: DSH_COVERAGE_MAX_WORKERS: '6' - DSH_COVERAGE_PARTITIONS: '6' + DSH_COVERAGE_PARTITIONS: '4' DSH_COVERAGE_TEST_TIMEOUT_MS: '30000' DSH_GATE_CONCURRENCY: '3' steps: diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 084e30c754..499d5a38d7 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -98,9 +98,9 @@ describe('CI workflow', () => { )) expect(buildCommands.map(step => step.run)).toContain('pnpm run check:ci:windows-blocking') - // windows-coverage runs the 6-partition profile. + // windows-coverage runs the 4-partition profile. expect(windowsCoverage.name).toBe('windows node 24 / coverage') - expect(windowsCoverage.env).toMatchObject({ DSH_COVERAGE_PARTITIONS: '6' }) + expect(windowsCoverage.env).toMatchObject({ DSH_COVERAGE_PARTITIONS: '4' }) const coverageSteps = windowsCoverage.steps as unknown[] const coverageCommands = coverageSteps.filter((step): step is Record & { run: string } => ( isRecord(step) && typeof step.run === 'string'