Merge latest master into PR 555

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md
#	.agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md
#	packages/client/ui-conversation/README.i18n.yaml
#	packages/client/ui-conversation/src/client/chat/MessageItem.tsx
#	packages/client/ui-conversation/src/client/input/facade.ts
#	packages/client/ui-conversation/src/client/input/hub.ts
#	packages/client/ui-conversation/src/client/service.ts
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/input-bar.spec.tsx
#	packages/client/ui-conversation/tests/input-matrix.spec.tsx
#	packages/client/ui-conversation/tests/input-scenarios.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
This commit is contained in:
creatixchu
2026-07-31 20:11:28 +08:00
28 changed files with 236 additions and 175 deletions

View File

@@ -29,16 +29,6 @@
color: var(--dsw-alias-label-primary);
}
.badge {
display: inline-block;
margin-bottom: 4px;
padding: 1px 6px;
border-radius: 6px;
background: var(--dsw-alias-state-warn-primary);
color: var(--dsw-alias-label-primary-foreground);
font-size: 11px;
}
.contextRow {
padding: 2px 0;
}

View File

@@ -1,6 +1,7 @@
// MessageItem: simple chat nodes — user bubble (right-aligned, with
// clock + copy / branch IconActions), steering (badged bubble), context
// injection, compaction marker, retry disclosure, and unknown-surface JSON rows.
// clock + copy / branch IconActions), steering (same bubble, no actions),
// context injection, compaction marker, retry disclosure, and
// unknown-surface JSON rows.
import { memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
@@ -178,25 +179,31 @@ function projectUserText(text: string): ReactNode {
return <>{parts}</>
}
function UserContentStack({ parts, imageLoader, steering = false, t, truncated }: {
parts: ReturnType<typeof contentParts>
/** Right-aligned bubble shared by user and steering rows (steering has no actions). */
function UserStyleBubble({
content, imageLoader, actions, t,
}: {
content: readonly unknown[]
imageLoader: ImageLoader
steering?: boolean
/** Optional IconActions (or similar) below the bubble; receives the joined text. */
actions?: (text: string) => ReactNode
t: ChatViewSlotProps['t']
truncated: (total: number) => string
}) {
const { text, images, rest } = parts
const showBubble = steering || text !== '' || rest.length > 0
}): ReactNode {
const { text, images, rest } = contentParts(content)
const truncated = (total: number): string => t('json.truncated', { total })
const showBubble = text !== '' || rest.length > 0
return (
<div className={css.userStack}>
<ImageGallery images={images} load={imageLoader} align="end" t={t} />
{showBubble && <div className={css.bubble}>
{steering && <span className={css.badge}>{t('message.steering')}</span>}
{projectUserText(text)}
{rest.map((block, i) => (
<JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />
))}
</div>}
<div className={css.userRow}>
<div className={css.userStack}>
<ImageGallery images={images} load={imageLoader} align="end" t={t} />
{showBubble && <div className={css.bubble}>
{projectUserText(text)}
{rest.map((block, i) => (
<JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />
))}
</div>}
</div>
{actions?.(text)}
</div>
)
}
@@ -207,30 +214,26 @@ export const MessageItem = memo(function MessageItem({
const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable'))))
const truncated = (total: number): string => t('json.truncated', { total })
switch (node.kind) {
case 'user': {
const parts = contentParts(node.content)
case 'user':
return (
<div className={css.userRow}>
<UserContentStack parts={parts} imageLoader={imageLoader} t={t} truncated={truncated} />
<MessageIconActions
text={parts.text}
time={node.time}
clock="start"
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
className={css.actions}
t={t}
/>
</div>
<UserStyleBubble
content={node.content}
imageLoader={imageLoader}
t={t}
actions={text => (
<MessageIconActions
text={text}
time={node.time}
clock="start"
onBranch={onFork === undefined ? undefined : () => { onFork(node.seq) }}
className={css.actions}
t={t}
/>
)}
/>
)
}
case 'steering': {
const parts = contentParts(node.content)
return (
<div className={css.userRow}>
<UserContentStack parts={parts} imageLoader={imageLoader} steering t={t} truncated={truncated} />
</div>
)
}
case 'steering':
return <UserStyleBubble content={node.content} imageLoader={imageLoader} t={t} />
case 'context':
return (
<ContextInjectionRow content={node.content} source={node.source} t={t} />

View File

@@ -43,7 +43,7 @@ export interface SessionInput extends InputTarget {
/** Drop ids whose browser objects no longer exist. */
pruneImages(ids: readonly DraftAttachmentId[]): void
/** THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. */
submit(mode?: 'queue' | 'steer'): void
submit(): void
/**
* Surface a notice outside the machine's own effect stream: detached
* command results and business notifications render through here.
@@ -83,7 +83,7 @@ export interface InputActions {
/** Drop ids whose browser objects no longer exist. */
pruneImages(ids: readonly DraftAttachmentId[]): void
/** Enter submission (adjudication / claim transaction / default sink inside). */
submit(mode?: 'queue' | 'steer'): void
submit(): void
}
/** One surfaced notice (command results, adjudication failures). seq keys re-render of repeats. */
@@ -264,7 +264,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 mode: 'queue' | 'steer' }
| { readonly type: 'enter' }
| { 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 }
@@ -283,5 +283,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 mode: 'queue' | 'steer' }
| { readonly type: 'default-sink'; readonly draft: string }
| { readonly type: 'notice'; readonly level: 'info' | 'error'; readonly text: string }

View File

@@ -39,7 +39,7 @@ export interface SessionInputDeps {
/** Queue read face; overlaid onto InputState.queue (absent = empty). */
queue?: ObservableSnapshot<readonly QueuedMessage[]> | undefined
/** The plain-message sink (send choreography / materialize fork — the hub owns it). */
defaultSink(text: string, mode: 'queue' | 'steer', imageIds: readonly DraftAttachmentId[]): void
defaultSink(text: string, imageIds: readonly DraftAttachmentId[]): void
}
/** Guard tier from the machine phase. */
@@ -71,7 +71,7 @@ export class SessionInputShell implements SessionInput {
addImages: ids => this.addImages(ids),
removeImage: (id) => { this.removeImage(id) },
pruneImages: (ids) => { this.pruneImages(ids) },
submit: (mode) => { this.submit(mode) },
submit: () => { this.submit() },
}
// Real wall clock: the typing-run merge window must actually expire in
@@ -200,14 +200,13 @@ export class SessionInputShell implements SessionInput {
* from the machine; this method only feeds the event. Lock entry
* (adjudicating/submitting) force-closes the transient layers: the popup
* dismisses and the menu tracks frozen.
* @param mode - default-sink mode (queue appends; steer interrupts).
*/
submit(mode: 'queue' | 'steer' = 'queue'): void {
submit(): void {
if (this.snapshot.draft.trim() === '' && this.imageIds.length > 0) {
if (this.snapshot.phase === 'plain') this.deps.defaultSink('', mode, [...this.imageIds])
if (this.snapshot.phase === 'plain') this.deps.defaultSink('', [...this.imageIds])
return
}
this.run(this.core.dispatch({ type: 'enter', mode }))
this.run(this.core.dispatch({ type: 'enter' }))
const phase = this.snapshot.phase
if (phase === 'adjudicating' || phase === 'submitting') {
this.deps.popup?.()?.dismiss()
@@ -392,7 +391,7 @@ export class SessionInputShell implements SessionInput {
return
}
case 'default-sink': {
this.sinkSerialized(fx.draft, fx.mode)
this.sinkSerialized(fx.draft)
return
}
default:
@@ -407,11 +406,11 @@ export class SessionInputShell implements SessionInput {
* send — notice + draft and chips retained, never a silent downgrade to
* the clipboard text. Chip-free drafts skip the async detour.
*/
private sinkSerialized(draft: string, mode: 'queue' | 'steer'): void {
private sinkSerialized(draft: string): void {
const imageIds = [...this.imageIds]
const occurrences = this.core.state.occurrences
if (occurrences.length === 0) {
this.deps.defaultSink(draft.trim(), mode, imageIds)
this.deps.defaultSink(draft.trim(), imageIds)
return
}
const slash = this.deps.slash?.()
@@ -431,7 +430,7 @@ export class SessionInputShell implements SessionInput {
cursor = part.offset + 1
}
out += draft.slice(cursor)
this.deps.defaultSink(out.trim(), mode, imageIds)
this.deps.defaultSink(out.trim(), imageIds)
},
(error: unknown) => {
controller.abort()

View File

@@ -25,7 +25,6 @@ interface ConversationAttachmentFace {
sendSession(
session: SessionFace,
text: string,
mode: 'queue' | 'steer',
imageIds: readonly DraftAttachmentId[],
): Promise<void>
releaseDraftImage(id: DraftAttachmentId): void
@@ -67,7 +66,7 @@ export class InputHub implements InputService {
slash: () => this.controller(actx),
popup: () => this.popup(actx),
queue: queueReadFaceOf(session),
defaultSink: (text, mode, imageIds) => { this.sink(session, text, mode, imageIds) },
defaultSink: (text, imageIds) => { this.sink(session, text, imageIds) },
})
this.shells.set(id, shell)
// The one teardown axis: listeners, shell, and map entries all ride the
@@ -148,14 +147,13 @@ export class InputHub implements InputService {
private sink(
session: SessionFace,
text: string,
mode: 'queue' | 'steer',
imageIds: readonly DraftAttachmentId[],
): void {
if (text === '' && imageIds.length === 0) return
const shell = this.shells.get(session.sessionId)
// Commit, not an editable clear: undo must not resurrect sent content.
shell?.commitSend(imageIds)
void this.conversation().sendSession(session, text, mode, imageIds).catch(() => {
void this.conversation().sendSession(session, text, imageIds).catch(() => {
// Restore only into the shell that still owns the session: if the scope
// died while the send was in flight, `commitSend` already removed the
// ids from the (now disposed) shell, so the teardown release could not

View File

@@ -112,7 +112,6 @@ export class InputMachine {
private inflight: {
readonly attempt: SubmitAttempt
readonly controller: AbortController
readonly mode: 'queue' | 'steer'
} | undefined
private log: Transaction[] = []
private redoStack: Transaction[] = []
@@ -164,7 +163,7 @@ export class InputMachine {
this.paste = undefined
return []
}
case 'enter': return this.onEnter(ev.mode)
case 'enter': return this.onEnter()
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)
@@ -463,18 +462,18 @@ export class InputMachine {
// ---- submit plane ----
/** Mint the next SubmitAttempt and take the in-flight slot. */
private beginAttempt(mode: 'queue' | 'steer'): SubmitAttempt {
private beginAttempt(): SubmitAttempt {
const controller = new AbortController()
this.seq += 1
const attempt: SubmitAttempt = { seq: this.seq, signal: controller.signal, draftSnapshot: this.draft }
this.inflight = { attempt, controller, mode }
this.inflight = { attempt, controller }
return attempt
}
private onEnter(mode: 'queue' | 'steer'): InputEffect[] {
private onEnter(): InputEffect[] {
if (this.phase === 'adjudicating' || this.phase === 'submitting') return []
if (this.phase === 'claimed' && this.claim !== undefined) {
const attempt = this.beginAttempt(mode)
const attempt = this.beginAttempt()
this.phase = 'submitting'
this.paste = undefined
return [{ type: 'begin-submit', attempt, claim: this.claim, args: argsAfter(this.draft, this.claim.token) }]
@@ -483,11 +482,11 @@ export class InputMachine {
if (trimmed === '') return []
this.paste = undefined
if (trimmed.startsWith('/')) {
const attempt = this.beginAttempt(mode)
const attempt = this.beginAttempt()
this.phase = 'adjudicating'
return [{ type: 'adjudicate', attempt, draft: this.draft }]
}
return [{ type: 'default-sink', draft: this.draft, mode }]
return [{ type: 'default-sink', draft: this.draft }]
}
private onAdjudicated(attempt: SubmitAttempt, outcome: Extract<InputEvent, { type: 'adjudicated' }>['outcome']): InputEffect[] {
@@ -508,7 +507,7 @@ export class InputMachine {
this.inflight = undefined
this.phase = 'plain'
return outcome === undefined
? [{ type: 'default-sink', draft: attempt.draftSnapshot, mode: flight.mode }]
? [{ type: 'default-sink', draft: attempt.draftSnapshot }]
: []
}

View File

@@ -61,7 +61,6 @@ export const zh = {
'chat.loadOlder': '加载更早',
'chat.toBottom': '回到底部',
'message.extraBlock': '附加内容块',
'message.steering': '插话',
'message.contextInjection': '上下文注入',
'message.compaction': '上下文已压缩',
'message.compaction.expand': '点击查看压缩摘要',
@@ -176,7 +175,6 @@ export const en = {
'chat.loadOlder': 'Load earlier',
'chat.toBottom': 'Back to bottom',
'message.extraBlock': 'Extra content block',
'message.steering': 'Interjection',
'message.contextInjection': 'Context injection',
'message.compaction': 'Context compacted',
'message.compaction.expand': 'View compaction summary',

View File

@@ -27,12 +27,11 @@ export interface IConversation {
/** The per-session input machine registry (InputService face). */
readonly input: InputService
/**
* Send a prompt into the caller scope's session.
* Send a prompt into the caller scope's session (queued turn).
* @param text - prompt text, sent verbatim as one text block.
* @param mode - queue after the current turn, or steer into it.
* @returns completion; business failures reject (and land in promptError).
*/
send(text: string, mode: 'queue' | 'steer'): Promise<void>
send(text: string): Promise<void>
/**
* Apply one operation to a pending queue occurrence.
* @param itemId - agent-owned inbox occurrence identity.
@@ -110,11 +109,10 @@ export class ConversationService extends Service implements IConversation {
* session snapshot's promptError (object-layer surface); the rejection here
* exists for caller choreography (the composer restores the draft on it).
* @param text - prompt text, sent verbatim as one text block when non-empty.
* @param mode - queue after the current turn, or steer into it.
*/
async send(text: string, mode: 'queue' | 'steer'): Promise<void> {
async send(text: string): Promise<void> {
const session = this.scopedSession('send')
await this.sendFiles(session, text, mode, [])
await this.sendFiles(session, text, [])
}
/**
@@ -123,32 +121,29 @@ export class ConversationService extends Service implements IConversation {
* persisted or stale id.
* @param session - target session.
* @param text - serialized prompt text.
* @param mode - queue or steer.
* @param imageIds - ordered draft-local attachment ids.
*/
async sendSession(
session: SessionFace,
text: string,
mode: 'queue' | 'steer',
imageIds: readonly DraftAttachmentId[],
): Promise<void> {
const attachments = this.draftImages(imageIds)
if (attachments.length !== imageIds.length) {
throw new Error('conversation.sendSession: one or more draft images are no longer available')
}
await this.sendFiles(session, text, mode, attachments.map(attachment => attachment.file))
await this.sendFiles(session, text, attachments.map(attachment => attachment.file))
this.releaseDraftImages(attachments)
}
private async sendFiles(
session: SessionFace,
text: string,
mode: 'queue' | 'steer',
images: readonly File[],
): Promise<void> {
const uploaded = await this.serializeImages(images)
const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])]
const result = await session.prompt(content, mode)
const result = await session.prompt(content, 'queue')
if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`)
}

View File

@@ -213,7 +213,7 @@ export function InputBar({
e.preventDefault()
if (e.repeat) return // held-down Enter must not machine-gun sends
if (locked || machineBusy) return
inputActions.submit('queue')
inputActions.submit()
}
const onChange = (e: ChangeEvent<HTMLTextAreaElement>): void => {
@@ -355,7 +355,7 @@ export function InputBar({
return
}
/* v8 ignore next -- defensive: the primary button is disabled while empty||disabled, so a click cannot reach the false arm. */
if (!empty && !disabled && !machineBusy) inputActions.submit('queue')
if (!empty && !disabled && !machineBusy) inputActions.submit()
}
// The Access seat: the projection-fed permission chip (renders nothing