refactor(agent): complete inbox lifecycle migration

This commit is contained in:
_Kerman
2026-08-03 12:25:33 +08:00
parent dc1d542092
commit 49e90695cc
214 changed files with 6019 additions and 4235 deletions

View File

@@ -12,7 +12,7 @@ export type {
WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelTarget, QueueAction, SessionModels,
ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
GoalsApi, GoalRef,
SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView,
CredentialsApi, CredentialView, ConfigurableProviderView, LlmApi,

View File

@@ -17,7 +17,8 @@ export type {
ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView,
CommandsApi, CommandDescriptor, SkillsApi, SkillEntry,
ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
MessageId, ModelReasoningEffort, ModelTarget, QueueAction, SessionModels,
MessageId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels,
SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt,
RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode,
ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt,
IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk,

View File

@@ -4,11 +4,12 @@
// string here (narrow to real brands when convenient).
import type { CommandId } from '@deepseek-ai/dsh-commands/brand'
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { TodoItem } from '@deepseek-ai/dsh-session/types'
import type {
MessageId, RpcError, SessionId, ToolCallView, ToolResultView,
RpcError, SessionId, SubagentAddress, ToolCallView, ToolResultView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { PendingInteraction } from './pending.ts'
export type { TodoItem }
@@ -104,6 +105,8 @@ export interface AssistantMessageNode {
/** A steering message injected mid-turn. */
export interface SteeringMessageNode {
kind: 'steering'
/** Stable identity shared with its pre-admission inbox occurrence. */
messageId: MessageId
seq: number
/** Unix epoch ms from the source session event. */
time: number
@@ -122,15 +125,15 @@ export interface ContextMessageNode {
source: unknown
}
/** Durable notice that a failed model request is waiting for another attempt. */
/** Durable notice that a closed failed step is waiting for a model-request retry. */
export type ModelRetryNode = LlmRetryEventData & {
kind: 'model-retry'
seq: number
/** Unix epoch ms from the llm/retry session event. */
time: number
/**
* Client-derived lifecycle: scheduled until another attempt emits retry or
* chunk evidence, started once it does, or cancelled if the turn aborts first.
* Client-derived lifecycle: scheduled until a retry turn starts, started
* once it does, or cancelled when the failed turn aborts first.
*/
retryState: 'scheduled' | 'started' | 'cancelled'
}
@@ -271,9 +274,15 @@ export interface RunningToolCall {
}
/** One independently addressable row from the transient queue snapshot. */
/** One transient inbox occurrence from the authoritative `session/queue` snapshot. */
export interface QueuedMessage {
readonly id: MessageId
/** Stable message identity used for transient-to-durable steering handoff. */
readonly messageId: MessageId
/** Agent-resolved placement; only queued rows accept queue mutations. */
readonly placement: 'queued' | 'steering'
/** Complete content used to render pending steering before it becomes durable. */
readonly content: readonly ContentBlock[]
readonly preview: string
/** Complete editable text; null when the message contains non-text blocks. */
readonly text: string | null
@@ -332,9 +341,14 @@ export interface ConversationSnapshot {
*/
codeDispatches: ReadonlyMap<string, readonly CodeSubCall[]>
pending: readonly PendingInteraction[]
/** Authoritative transient inbox snapshot, replaced after every host-side change. */
/** Authoritative transient inbox snapshot, including queued and steering placements. */
queue: readonly QueuedMessage[]
running: boolean
/**
* Catalog-discovered continuation address. Its parent availability controls
* human input; null means ordinary session transport.
*/
subagent: { address: SubagentAddress; parentAvailable: boolean } | null
/** Input-area shape (see {@link ComposerPhase}); derived here, switched on by consumers. */
composerPhase: ComposerPhase
/** Set after host/session-removed; the UI grays out and disables input. */

View File

@@ -6,7 +6,7 @@ import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type {
HistoryEntry, IApiClient, MessageId, MuxFrame, QueueAction, RpcError,
RpcId, RpcResult, SessionId, ToolEventView,
RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView,
} from '@deepseek-ai/dsh-client-connection/client'
// Value import from the inline-safe wire layer (not the connection plugin):
// plugin-to-plugin value imports are a bundle purity error.
@@ -34,6 +34,10 @@ const MAX_RETRY_DELAY_MS = 2_147_483_647
/** Manager-owned observers of a Session object's local state edges. */
export interface SessionOptions {
/** Catalog-discovered address selecting non-activating subagent transport. */
address?: SubagentAddress
/** Whether the exact direct parent Agent was live at the latest catalog read. */
parentAvailable?: boolean
/**
* First ACCEPTED prompt on a blank session (fires at most once, on the
* prompt RPC's success response): the manager mirrors the blank→false flip
@@ -119,6 +123,8 @@ export class Session implements SessionFace {
private dispatchesRev = 0
private dispatchesCache: { rev: number; value: ReadonlyMap<string, readonly CodeSubCall[]> } | null = null
private running = false
private address: SubagentAddress | undefined
private parentAvailable = false
/**
* Sticky send marker, private input of the composerPhase derivation: set
* synchronously before prompt()'s first await, never reset — the blank →
@@ -174,6 +180,8 @@ export class Session implements SessionFace {
private readonly options: SessionOptions = {},
) {
this.projections = options.projections ?? new ProjectionValueStore()
this.address = options.address
this.parentAvailable = options.parentAvailable ?? false
this.snapshotCache = this.buildSnapshot()
}
@@ -213,7 +221,21 @@ export class Session implements SessionFace {
this.notifier.markDirty()
let result: RpcResult<{ accepted: true }>
try {
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
if (this.address === undefined) {
result = (await this.api.sessions.prompt({ sessionId: this.sessionId, mode, content })).result
} else if (this.address.mode === 'one-shot') {
result = {
ok: false,
error: {
code: 'subagent-not-resumable',
message: 'one-shot subagent conversations are read-only',
details: { childSessionId: this.address.childSessionId },
},
}
} else {
const routed = (await this.api.subagents.prompt({ ...this.address, content })).result
result = routed.ok ? { ok: true, value: { accepted: true } } : routed
}
} catch (error) {
result = transportError(error)
}
@@ -253,6 +275,19 @@ export class Session implements SessionFace {
* @returns the cancel result.
*/
async cancel(): Promise<RpcResult<{ accepted: true }>> {
if (this.address !== undefined) {
const result: RpcResult<{ accepted: true }> = {
ok: false,
error: {
code: 'subagent-delivery-unavailable',
message: 'subagent activation cancellation is unavailable',
details: { childSessionId: this.address.childSessionId },
},
}
this.promptError = { op: 'stop', error: result.error }
this.notifier.markDirty()
return result
}
let result: RpcResult<{ accepted: true }>
try {
result = (await this.api.sessions.cancel({ sessionId: this.sessionId })).result
@@ -318,9 +353,7 @@ export class Session implements SessionFace {
this.loadingOlder = true
this.notifier.markDirty()
try {
const { result } = await this.api.sessions.history({
sessionId: this.sessionId, beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES,
})
const { result } = await this.history({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES })
if (!result.ok) return // keep the window as-is; do not overwrite openError (open already succeeded)
const older = result.value.events
if (older.length === 0) {
@@ -413,8 +446,11 @@ export class Session implements SessionFace {
case 'session/queue': {
this.queued = frame.items.map(item => ({
id: item.id,
preview: queuePreviewOf(item.content),
text: queueTextOf(item.content),
messageId: item.message.id,
placement: item.placement,
content: item.message.content,
preview: queuePreviewOf(item.message.content),
text: queueTextOf(item.message.content),
}))
this.queueRev++
this.notifier.markDirty()
@@ -479,6 +515,32 @@ export class Session implements SessionFace {
this.notifier.markDirty()
}
/**
* Install or clear the catalog-discovered transport address. A changed
* address rebuilds an already-open window through its new history route.
* @param address - direct parent/child address, or undefined for ordinary transport.
* @param parentAvailable - latest exact-parent availability hint.
*/
configureSubagent(address: SubagentAddress | undefined, parentAvailable = false): void {
const same = this.address?.parentSessionId === address?.parentSessionId
&& this.address?.childSessionId === address?.childSessionId
&& this.address?.mode === address?.mode
this.address = address
this.parentAvailable = parentAvailable
if (!same && this.openState !== 'cold') void this.resync()
else this.notifier.markDirty()
}
/**
* Update only the parent availability hint from a catalog refresh.
* @param available - whether the exact direct parent is live.
*/
handleSubagentParentAvailable(available: boolean): void {
if (this.parentAvailable === available) return
this.parentAvailable = available
this.notifier.markDirty()
}
/**
* Blank-bit relay from the authoritative summary source (list baseline and
* the session-added frame). Monotone: once any signal (local first send,
@@ -533,7 +595,7 @@ export class Session implements SessionFace {
this.openError = null
this.notifier.markDirty()
try {
let { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
let { result } = await this.history({ maxMessages: PAGE_MESSAGES })
if (generation !== this.openGeneration) return
if (!result.ok) {
this.openState = 'error'
@@ -544,7 +606,7 @@ export class Session implements SessionFace {
// Gap detection (§D.3-4): baseline past the window tail and liveBuffer did not cover it -> pull the tail page once more.
const tailSeq = this.windowTailSeq()
if (this.subscribedLastSeq !== null && tailSeq !== null && this.subscribedLastSeq > tailSeq) {
result = (await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })).result
result = (await this.history({ maxMessages: PAGE_MESSAGES })).result
if (generation !== this.openGeneration) return
if (result.ok) this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
}
@@ -588,9 +650,20 @@ export class Session implements SessionFace {
this.events.push(event)
this.views.push(view)
this.transcript.append(event, view)
this.handoffPendingSteering(event)
this.applyEventSideEffects(event, view)
}
/** Retire the first matching live steering occurrence when its durable event takes over. */
private handoffPendingSteering(event: SessionEvent): void {
if (event.type !== 'steering/message') return
const index = this.queued.findIndex(item =>
item.placement === 'steering' && item.messageId === event.data.message.id)
if (index === -1) return
this.queued = this.queued.filter((_item, candidate) => candidate !== index)
this.queueRev++
}
/** Land a live session/event (open/repair in flight -> buffer; overlapping seq -> drop;
* a seq gap -> buffer + tail-page repull instead of appending a hole (audit S3: a gap is an
* expected reconnect-window artifact, repaired by refetch). The window stays one contiguous
@@ -621,7 +694,7 @@ export class Session implements SessionFace {
this.stitching = true
const generation = this.openGeneration
try {
const { result } = await this.api.sessions.history({ sessionId: this.sessionId, maxMessages: PAGE_MESSAGES })
const { result } = await this.history({ maxMessages: PAGE_MESSAGES })
// Failure or superseded by a full resync: drop — the resync path rebuilds and clears the buffer itself.
if (result.ok && generation === this.openGeneration && this.openState === 'open') {
this.installWindow(result.value.events, result.value.hasMore, result.value.projections)
@@ -646,7 +719,6 @@ export class Session implements SessionFace {
if (this.partial !== null && this.partial.turn === data.turn && this.partial.step === data.step) {
this.partial = null
}
this.settleScheduledRetry('started', data.turn)
this.derivedNodes.push({
kind: 'model-retry',
seq: event.seq,
@@ -720,8 +792,8 @@ export class Session implements SessionFace {
case 'turn/start':
return
case 'assistant/chunk': {
this.settleScheduledRetry('started', event.data.turn)
const { turn, step, chunk } = event.data
this.settleScheduledRetry('started', turn)
if (this.partial === null || this.partial.turn !== turn || this.partial.step !== step) {
this.partial = new PartialAccumulator(turn, step)
}
@@ -748,9 +820,7 @@ export class Session implements SessionFace {
return
}
case 'turn/end': {
if (event.data.reason.kind === 'error') {
this.settleScheduledRetry('started', event.data.turn)
} else if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'interrupted') {
if (event.data.reason.kind === 'aborted') {
this.settleScheduledRetry('cancelled', event.data.turn)
}
if (
@@ -758,22 +828,20 @@ export class Session implements SessionFace {
&& !this.derivedNodes.some(node => node.kind === 'model-retry' && node.turn === event.data.turn)
) {
const failure = event.data.reason.error
const failedTurn = event.data.turn
const code = failure !== null && typeof failure === 'object'
&& typeof (failure as { code?: unknown }).code === 'string'
? (failure as { code: string }).code
: undefined
const code = failure !== null && typeof failure === 'object' && 'code' in failure
&& typeof failure.code === 'string' ? failure.code : undefined
this.derivedNodes.push({
kind: 'turn-error',
seq: event.seq,
time: event.time,
turn: failedTurn,
turn: event.data.turn,
step: event.data.step,
message: displayFailureMessage(failure),
...code === undefined ? {} : { code },
...(code === undefined ? {} : { code }),
})
this.derivedRev++
}
if (event.data.reason.kind === 'error') this.settleScheduledRetry('started', event.data.turn)
// Aborted turns never finalize. The accumulated partial is VALUE, not residue: freeze it
// into an interrupted terminal node (pulse stops, text survives) instead of deleting it.
// Shared by live and window-replay paths, so a refresh reconstructs the same frozen node
@@ -895,6 +963,9 @@ export class Session implements SessionFace {
codeDispatches: this.dispatchesCache.value,
queue: this.queueCache.value,
running: this.running,
subagent: this.address === undefined
? null
: { address: this.address, parentAvailable: this.parentAvailable },
composerPhase: derivePhase(
// Command lifecycle nodes are not conversation: running /permission
// or /plan on a fresh session keeps the hero (the client mirror of
@@ -912,6 +983,17 @@ export class Session implements SessionFace {
lastAgentError: this.lastAgentError,
}
}
/** Select ordinary or addressed history transport from the stored browser fact. */
private history(payload: { beforeSeq?: number; maxMessages?: number }): Promise<RpcResponse<{
events: HistoryEntry[]
hasMore: boolean
projections?: ProjectionsBaseline
}>> {
return this.address === undefined
? this.api.sessions.history({ sessionId: this.sessionId, ...payload })
: this.api.subagents.history({ ...this.address, ...payload })
}
}
/** Validate the plugin-owned payload at the session-event wire boundary. */

View File

@@ -4,11 +4,10 @@
* projection, and snapshot reference stability.
*/
import { describe, expect, it } from 'vitest'
import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
import type {
MessageId, MuxFrame, RpcId, SessionId,
} from '@deepseek-ai/dsh-client-connection/client'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types'
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
import type { MessageId, MuxFrame, RpcId, SessionId } from '@deepseek-ai/dsh-client-connection/client'
import { Session } from '../src/client/sessions/session.ts'
import { SessionManager } from '../src/client/sessions/manager.ts'
import { FakeApiClient } from './fake-api.ts'
@@ -16,12 +15,14 @@ import { FakeApiClient } from './fake-api.ts'
const SID = 'fk-q1' as SessionId
const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }]
const rid = (id: string): RpcId => id as RpcId
const mid = (id: string): MessageId => id as MessageId
const iid = (id: string): MessageId => id as MessageId
interface QueueFixture {
id: string
body: string
content?: ContentBlock[]
placement?: 'queued' | 'steering'
message?: UserMessage
}
/** Build one authoritative queue snapshot. */
@@ -29,12 +30,13 @@ function queueFrame(items: QueueFixture[]): MuxFrame {
return {
type: 'session/queue',
sessionId: SID,
items: items.map(item => freezeMessage({
...createUserMessage({
items: items.map(item => ({
id: iid(item.id),
placement: item.placement ?? 'queued',
message: item.message ?? createUserMessage({
content: item.content ?? text(item.body),
source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never,
}),
id: mid(item.id),
})),
}
}
@@ -49,8 +51,14 @@ describe('queue snapshot intake', () => {
session.handleMuxEnvelope(rid('env-1'), queueFrame([
{ id: 'q-1', body: '第一条 排队\n消息' },
]))
expect(session.getSnapshot().queue).toEqual([
{ id: 'q-1', preview: '第一条 排队 消息', text: '第一条 排队\n消息' },
const queue = session.getSnapshot().queue
expect(typeof queue[0]?.messageId).toBe('string')
expect(queue).toMatchObject([
{
id: 'q-1', placement: 'queued',
content: [{ type: 'text', text: '第一条 排队\n消息' }],
preview: '第一条 排队 消息', text: '第一条 排队\n消息',
},
])
})
@@ -61,8 +69,14 @@ describe('queue snapshot intake', () => {
body: '',
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never],
}]))
expect(session.getSnapshot().queue).toEqual([
{ id: 'q-image', preview: 'hi [image]', text: null },
const queue = session.getSnapshot().queue
expect(typeof queue[0]?.messageId).toBe('string')
expect(queue).toMatchObject([
{
id: 'q-image', placement: 'queued',
content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' }],
preview: 'hi [image]', text: null,
},
])
})
@@ -85,8 +99,14 @@ describe('queue snapshot intake', () => {
session.handleMuxEnvelope(rid('env-5'), queueFrame([
{ id: 'q-2', body: 'two edited' },
]))
expect(session.getSnapshot().queue).toEqual([
{ id: 'q-2', preview: 'two edited', text: 'two edited' },
const queue = session.getSnapshot().queue
expect(typeof queue[0]?.messageId).toBe('string')
expect(queue).toMatchObject([
{
id: 'q-2', placement: 'queued',
content: [{ type: 'text', text: 'two edited' }],
preview: 'two edited', text: 'two edited',
},
])
session.handleMuxEnvelope(rid('env-6'), queueFrame([]))
expect(session.getSnapshot().queue).toEqual([])
@@ -99,6 +119,55 @@ describe('queue snapshot intake', () => {
session.handleAgentError('unrelated')
expect(session.getSnapshot().queue).toBe(before)
})
it('retains steering placement and complete content in the same authoritative snapshot', () => {
const session = makeSession()
session.handleMuxEnvelope(rid('env-steering'), queueFrame([
{ id: 'q-next', body: 'later' },
{ id: 's-now', body: 'interrupt now', placement: 'steering' },
]))
expect(session.getSnapshot().queue.map(item => ({
id: item.id, placement: item.placement, content: item.content,
}))).toEqual([
{ id: 'q-next', placement: 'queued', content: text('later') },
{ id: 's-now', placement: 'steering', content: text('interrupt now') },
])
})
it('hands off exactly one current occurrence when live steering becomes durable', async () => {
const session = makeSession()
await session.open()
const message = createUserMessage({
content: text('same message'),
source: { kind: 'user' },
})
session.handleMuxEnvelope(rid('env-same-id'), queueFrame([
{ id: 's-first', body: '', placement: 'steering', message },
{ id: 's-second', body: '', placement: 'steering', message },
]))
const durable = {
seq: 0,
time: 1_700_000_000_000,
type: 'steering/message',
surfaceOp: 'append',
data: { turn: 1, message },
} as SessionEvent
session.handleMuxEnvelope(rid('env-durable'), {
type: 'session/event', sessionId: SID, event: durable,
})
expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second'])
expect(session.getSnapshot().nodes.filter(node => node.kind === 'steering')).toHaveLength(1)
session.handleMuxEnvelope(rid('env-reused-id'), queueFrame([
{ id: 's-later', body: '', placement: 'steering', message },
]))
session.handleMuxEnvelope(rid('env-replayed-durable'), {
type: 'session/event', sessionId: SID, event: durable,
})
expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later'])
})
})
describe('queue operation transport', () => {
@@ -108,13 +177,22 @@ describe('queue operation transport', () => {
session.handleMuxEnvelope(rid('env-op'), queueFrame([{ id: 'q-op', body: 'pending' }]))
const before = session.getSnapshot().queue
await expect(session.updateQueue(mid('q-op'), { kind: 'edit', content: text('next') }))
await expect(session.updateQueue(iid('q-op'), { kind: 'edit', content: text('next') }))
.resolves.toEqual({ ok: true, value: { accepted: true } })
expect(api.callsOf('session.updateQueue')).toEqual([{
sessionId: SID,
itemId: 'q-op',
action: { kind: 'edit', content: text('next') },
}])
await expect(session.updateQueue(iid('q-op'), { kind: 'steer' }))
.resolves.toEqual({ ok: true, value: { accepted: true } })
expect(api.callsOf('session.updateQueue')).toEqual([
{
sessionId: SID,
itemId: 'q-op',
action: { kind: 'edit', content: text('next') },
},
{
sessionId: SID,
itemId: 'q-op',
action: { kind: 'steer' },
},
])
expect(session.getSnapshot().queue).toBe(before)
})
})

View File

@@ -11,6 +11,7 @@ import type {
ReferenceInsert, SubmitOutcome, TokenSpan,
} from '@deepseek-ai/dsh-client-ui-slash/client'
import type { QueueRow } from '../contract/queue.ts'
import type { InputSubmitMode } from '../contract/composer-submission.ts'
/**
* The scoped-event application verbs: the hub's bail listeners call these,
@@ -28,8 +29,11 @@ export interface InputTarget {
export interface SessionInput extends InputTarget {
/** Single write path for draft text (all mutation rides machine events). */
setDraft(text: string): void
/** THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. */
submit(): void
/**
* THE complexity sink: enter adjudication, submit transaction, and the default sink live inside.
* @param mode - delivery intent retained through asynchronous adjudication and serialization.
*/
submit(mode?: InputSubmitMode): void
/**
* Surface a notice outside the machine's own effect stream: detached
* command results and business notifications render through here.
@@ -82,8 +86,8 @@ export interface ComposerKeyboard {
readonly snapshot: InputState
/** Draft write with the DOM-observed edit shape (narrows occurrence math). */
setDraft(text: string, editRange?: EditRange): void
/** Newline at the selection as a machine transaction (Ctrl+Enter path). */
newline(selection: EditSelection): void
/** Submit with an explicit delivery mode resolved by the keyboard policy. */
submit(mode: InputSubmitMode): void
undo(): void
redo(): void
/** Paste over the selection (sync components ride the same transaction). */
@@ -191,7 +195,7 @@ export interface InputState {
readonly occurrences: readonly Occurrence[]
/** Live paste-match attempt (absent when no paste is matchable). */
readonly paste?: PasteAttemptState
/** Read-only queue projection from the reconnect baseline and durable inbox events. */
/** Read-only transient inbox projection (`session/queue`, including pending steering). */
readonly queue: readonly QueuedMessage[]
}
@@ -206,6 +210,8 @@ export interface SubmitAttempt {
readonly signal: AbortSignal
/** Draft at enter time; rollback restores it only while the live draft still equals it. */
readonly draftSnapshot: string
/** Default-message delivery intent retained while slash adjudication is pending. */
readonly mode: InputSubmitMode
}
/**
@@ -217,8 +223,6 @@ export interface SubmitAttempt {
export type InputEvent =
/** Full next draft from the textarea; editRange narrows the occurrence math (absent → diff scan). */
| { readonly type: 'draft-changed'; readonly draft: string; readonly editRange?: EditRange }
/** Insert '\n' replacing the selection (F1: the execCommand newline path moved into the machine). */
| { readonly type: 'newline'; readonly selection: EditSelection }
| { readonly type: 'begin-command'; readonly claim: CommandClaim; readonly span: TokenSpan }
/** Place one U+FFFC at the span and mint the occurrence (scoped insert-reference event payload). */
| { readonly type: 'insert-ref'; readonly reference: ReferenceInsert; readonly span: TokenSpan }
@@ -239,7 +243,7 @@ export type InputEvent =
| { readonly type: 'paste-upgrade'; readonly attemptId: number; readonly span: TokenSpan; readonly reference: ReferenceInsert }
/** Shell-observed attempt killers the machine cannot see itself (caret/selection ops, Slash interaction updates). */
| { readonly type: 'invalidate-paste' }
| { readonly type: 'enter' }
| { readonly type: 'enter'; readonly mode: InputSubmitMode }
| { 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 }
@@ -258,5 +262,5 @@ 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 draft: string }
| { readonly type: 'default-sink'; readonly draft: string; readonly mode: InputSubmitMode }
| { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string }

View File

@@ -94,106 +94,109 @@ export function QueueDock({ useSession, updateQueue, notify, t }: QueueDockProps
</button>
)}
<ul id={listId} className={css.list} hidden={!listVisible}>
{listVisible && queue.map(row => (
<li key={row.id} className={css.row}>
{editing?.id === row.id
? (
<input
autoFocus
className={css.editor}
aria-label={t('queue.edit')}
value={editing.text}
onChange={(event) => { setEditing({ id: row.id, text: event.currentTarget.value }) }}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setEditing(null)
return
}
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
void saveEdit()
}
}}
/>
)
: <span className={css.preview}>{row.preview}</span>}
{queueMutable && <div className={css.actions}>
{editing?.id === row.id
{listVisible && queue.map((row) => {
const rowEditing = editing?.id === row.id ? editing : null
return (
<li key={row.id} className={css.row}>
{rowEditing !== null
? (
<>
<button
type="button"
className={css.action}
aria-label={t('queue.save')}
title={t('queue.save')}
disabled={busy !== null || editing.text.trim() === ''}
onClick={() => { void saveEdit() }}
>
<IconCheckOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label={t('queue.cancelEdit')}
title={t('queue.cancelEdit')}
disabled={busy !== null}
onClick={() => { setEditing(null) }}
>
<IconCloseOutline16 size={14} />
</button>
</>
<input
autoFocus
className={css.editor}
aria-label={t('queue.edit')}
value={rowEditing.text}
onChange={(event) => { setEditing({ id: row.id, text: event.currentTarget.value }) }}
onKeyDown={(event) => {
if (event.key === 'Escape') {
setEditing(null)
return
}
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
void saveEdit()
}
}}
/>
)
: (
<>
<button
type="button"
className={css.action}
aria-label={t('queue.edit')}
title={row.text === null ? t('queue.edit.unsupported') : t('queue.edit')}
disabled={busy !== null || row.text === null}
onClick={() => {
if (row.text !== null) setEditing({ id: row.id, text: row.text })
}}
>
<IconEditOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label={t('queue.remove')}
title={t('queue.remove')}
disabled={busy !== null}
onClick={() => {
void applyAction(
row.id,
{ kind: 'remove' },
t('queue.removeFailed'),
)
}}
>
<IconTrashOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label={t('queue.steer')}
title={running ? t('queue.steer') : t('queue.steer.unavailable')}
disabled={busy !== null || !running}
onClick={() => {
void applyAction(
row.id,
{ kind: 'steer' },
t('queue.steerFailed'),
)
}}
>
<IconSendOutline16 size={14} />
</button>
</>
)}
</div>}
</li>
))}
: <span className={css.preview}>{row.preview}</span>}
{queueMutable && <div className={css.actions}>
{rowEditing !== null
? (
<>
<button
type="button"
className={css.action}
aria-label={t('queue.save')}
title={t('queue.save')}
disabled={busy !== null || rowEditing.text.trim() === ''}
onClick={() => { void saveEdit() }}
>
<IconCheckOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label={t('queue.cancelEdit')}
title={t('queue.cancelEdit')}
disabled={busy !== null}
onClick={() => { setEditing(null) }}
>
<IconCloseOutline16 size={14} />
</button>
</>
)
: (
<>
<button
type="button"
className={css.action}
aria-label={t('queue.edit')}
title={row.text === null ? t('queue.edit.unsupported') : t('queue.edit')}
disabled={busy !== null || row.text === null}
onClick={() => {
if (row.text !== null) setEditing({ id: row.id, text: row.text })
}}
>
<IconEditOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label={t('queue.remove')}
title={t('queue.remove')}
disabled={busy !== null}
onClick={() => {
void applyAction(
row.id,
{ kind: 'remove' },
t('queue.removeFailed'),
)
}}
>
<IconTrashOutline16 size={14} />
</button>
<button
type="button"
className={css.action}
aria-label={t('queue.steer')}
title={running ? t('queue.steer') : t('queue.steer.unavailable')}
disabled={busy !== null || !running}
onClick={() => {
void applyAction(
row.id,
{ kind: 'steer' },
t('queue.steerFailed'),
)
}}
>
<IconSendOutline16 size={14} />
</button>
</>
)}
</div>}
</li>
)
})}
</ul>
</div>
</div>

View File

@@ -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-goal/README.md
README.md: 5df2df3a4d0814b55066a096263a9ff382498083
README.zh.md: 6ab7ff00ff2682324f78249b9865a913a622139e
README.md: 2512594bddc0cfbd89e9b7ba47d98c9c470b1618
README.zh.md: f05b5ea17056e62046581138dfa41b31a1bc3d19