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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }]
|
||||
: []
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user