feat: remove "插话" badge displayed in user message
This commit is contained in:
@@ -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: b4b1e5653705c76bac3e0227e6df77143a11cbbe
|
||||
README.zh.md: 74e0f3dc0ebaf74e2e065c6b88f3a30fce94b391
|
||||
README.md: c2db1fba330a5ba968ba8fbbb5825ee62e699b4c
|
||||
README.zh.md: bc2c3297ee75bd30c9090d9d4dadf673cc3e0484
|
||||
|
||||
@@ -45,4 +45,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
- **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.
|
||||
- **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline.
|
||||
- **Queue edit is text-only** — rows containing non-text blocks still show a flattened preview, but their edit control is disabled because the inline editor cannot preserve those blocks. A text row's edit mode replaces delete with save and cancel; Enter saves and Escape cancels. QueueDock exposes no send-now control.
|
||||
- **Web exposes pending Queue only** — the Host omits pending steering from the Queue snapshot until steering has its own interaction. A consumed `steering/message` still renders in the durable transcript so external steering remains truthful on replay.
|
||||
- **Web exposes pending Queue only** — the composer and `conversation.send` never submit `mode:'steer'`. The Host omits pending steering from the Queue snapshot. A consumed `steering/message` still folds into the durable transcript as a plain bubble (no interjection chrome) so external/host steering remains truthful on replay.
|
||||
|
||||
@@ -45,4 +45,4 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
|
||||
- **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。
|
||||
- **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。
|
||||
- **Queue 编辑仅支持文本**:包含非文本块的行仍显示扁平化预览,但由于内联编辑器无法保留这些块,其编辑控件会被禁用。文本行进入编辑模式后,删除会替换为保存和取消;Enter 保存,Escape 取消。QueueDock 不提供立即发送控件。
|
||||
- **Web 仅暴露待处理 Queue**:在 steering(中途引导)拥有专用交互之前,Host 不会把待处理 steering 纳入 Queue 快照。已消费的 `steering/message` 仍会渲染到持久 transcript(文本记录)中,因此从外部提交的 steering 在回放时仍能如实呈现。
|
||||
- **Web 仅暴露待处理 Queue**:composer 与 `conversation.send` 从不提交 `mode:'steer'`。Host 不会把待处理 steering(中途引导)纳入 Queue 快照。已消费的 `steering/message` 仍会折叠进持久 transcript(文本记录),并以无「插话」徽章的普通气泡呈现,因此从外部/Host 提交的 steering 在回放时仍能如实呈现。
|
||||
|
||||
@@ -20,16 +20,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,8 +1,8 @@
|
||||
// MessageItem: the four simple node kinds — user bubble (right-aligned, with
|
||||
// clock + copy / branch / edit IconActions), steering (badged bubble), context
|
||||
// injection and unknown-surface JSON rows. Props are frozen node slices off
|
||||
// the snapshot cache; memo holds across streaming because unchanged nodes
|
||||
// keep their references.
|
||||
// clock + copy / branch / edit IconActions), steering (same bubble, no
|
||||
// actions), context injection and unknown-surface JSON rows. Props are frozen
|
||||
// node slices off the snapshot cache; memo holds across streaming because
|
||||
// unchanged nodes keep their references.
|
||||
|
||||
import { memo } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
@@ -94,7 +94,6 @@ export const MessageItem = memo(function MessageItem({ node, onFork, t }: Messag
|
||||
return (
|
||||
<div className={css.userRow}>
|
||||
<div className={css.bubble}>
|
||||
<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>
|
||||
|
||||
@@ -29,7 +29,7 @@ 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(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.
|
||||
@@ -60,7 +60,7 @@ export interface InputActions {
|
||||
/** Single public draft write path (full next draft; occurrence math via diff scan). */
|
||||
setDraft(text: string): 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. */
|
||||
@@ -239,7 +239,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 }
|
||||
@@ -258,5 +258,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'): void
|
||||
defaultSink(text: string): void
|
||||
}
|
||||
|
||||
/** Guard tier from the machine phase. */
|
||||
@@ -68,7 +68,7 @@ export class SessionInputShell implements SessionInput {
|
||||
/** The public provide-channel action face (one stable identity per session — decision 20). */
|
||||
readonly actions: InputActions = {
|
||||
setDraft: (text) => { this.setDraft(text) },
|
||||
submit: (mode) => { this.submit(mode) },
|
||||
submit: () => { this.submit() },
|
||||
}
|
||||
|
||||
// Real wall clock: the typing-run merge window must actually expire in
|
||||
@@ -151,10 +151,9 @@ 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 {
|
||||
this.run(this.core.dispatch({ type: 'enter', mode }))
|
||||
submit(): void {
|
||||
this.run(this.core.dispatch({ type: 'enter' }))
|
||||
const phase = this.snapshot.phase
|
||||
if (phase === 'adjudicating' || phase === 'submitting') {
|
||||
this.deps.popup?.()?.dismiss()
|
||||
@@ -339,7 +338,7 @@ export class SessionInputShell implements SessionInput {
|
||||
return
|
||||
}
|
||||
case 'default-sink': {
|
||||
this.sinkSerialized(fx.draft, fx.mode)
|
||||
this.sinkSerialized(fx.draft)
|
||||
return
|
||||
}
|
||||
default:
|
||||
@@ -354,10 +353,10 @@ 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 occurrences = this.core.state.occurrences
|
||||
if (occurrences.length === 0) {
|
||||
this.deps.defaultSink(draft.trim(), mode)
|
||||
this.deps.defaultSink(draft.trim())
|
||||
return
|
||||
}
|
||||
const slash = this.deps.slash?.()
|
||||
@@ -377,7 +376,7 @@ export class SessionInputShell implements SessionInput {
|
||||
cursor = part.offset + 1
|
||||
}
|
||||
out += draft.slice(cursor)
|
||||
this.deps.defaultSink(out.trim(), mode)
|
||||
this.deps.defaultSink(out.trim())
|
||||
},
|
||||
(error: unknown) => {
|
||||
controller.abort()
|
||||
|
||||
@@ -56,7 +56,7 @@ export class InputHub implements InputService {
|
||||
slash: () => this.controller(actx),
|
||||
popup: () => this.popup(actx),
|
||||
queue: queueReadFaceOf(session),
|
||||
defaultSink: (text, mode) => { this.sink(session, text, mode) },
|
||||
defaultSink: (text) => { this.sink(session, text) },
|
||||
})
|
||||
this.shells.set(id, shell)
|
||||
// The one teardown axis: listeners, shell, and map entries all ride the
|
||||
@@ -112,12 +112,12 @@ export class InputHub implements InputService {
|
||||
* exactly one path; a failed first prompt is an ordinary prompt failure
|
||||
* (error strip via promptError, draft restored only while untouched).
|
||||
*/
|
||||
private sink(session: SessionFace, text: string, mode: 'queue' | 'steer'): void {
|
||||
private sink(session: SessionFace, text: string): void {
|
||||
if (text === '') return
|
||||
const shell = this.shells.get(session.sessionId)
|
||||
// Commit, not an editable clear: undo must not resurrect sent content.
|
||||
shell?.commitSend()
|
||||
void session.prompt([{ type: 'text', text }], mode).then(
|
||||
void session.prompt([{ type: 'text', text }], 'queue').then(
|
||||
(result) => {
|
||||
if (!result.ok && shell?.snapshot.draft === '') shell.setDraft(text)
|
||||
},
|
||||
|
||||
@@ -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[] = []
|
||||
@@ -163,7 +162,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)
|
||||
@@ -462,18 +461,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) }]
|
||||
@@ -482,11 +481,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[] {
|
||||
@@ -507,7 +506,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 }]
|
||||
: []
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ export const zh = {
|
||||
'chat.loadOlder': '加载更早',
|
||||
'chat.toBottom': '回到底部',
|
||||
'message.extraBlock': '附加内容块',
|
||||
'message.steering': '插话',
|
||||
'message.contextInjection': '上下文注入',
|
||||
'message.unknownSurface': '未知 surface 事件:{type}',
|
||||
'message.unknownBlock': '未知内容块',
|
||||
@@ -124,7 +123,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.unknownSurface': 'Unknown surface event: {type}',
|
||||
'message.unknownBlock': 'Unknown content block',
|
||||
|
||||
@@ -25,12 +25,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.
|
||||
@@ -71,11 +70,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.
|
||||
* @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')
|
||||
const result = await session.prompt([{ type: 'text', text }], mode)
|
||||
const result = await session.prompt([{ type: 'text', text }], 'queue')
|
||||
if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`)
|
||||
}
|
||||
|
||||
|
||||
@@ -172,7 +172,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 => {
|
||||
@@ -266,7 +266,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
|
||||
|
||||
@@ -101,7 +101,7 @@ async function bench() {
|
||||
}
|
||||
const actions = info.props['inputActions'] as {
|
||||
setDraft: (text: string) => void
|
||||
submit: (mode?: 'queue' | 'steer') => void
|
||||
submit: () => void
|
||||
}
|
||||
return { state, actions }
|
||||
}
|
||||
@@ -140,25 +140,25 @@ describe('conversation slot inject surface', () => {
|
||||
const { state, actions } = b.inputSurface(ROOT)
|
||||
// Whitespace-only: the machine treats it as empty — no prompt, draft kept.
|
||||
actions.setDraft(' ')
|
||||
actions.submit('queue')
|
||||
actions.submit()
|
||||
expect(b.sessionFake.prompt).not.toHaveBeenCalled()
|
||||
expect(state.getSnapshot().draft).toBe(' ')
|
||||
// Success: cleared and stays cleared.
|
||||
actions.setDraft('hello')
|
||||
actions.submit('queue')
|
||||
actions.submit()
|
||||
expect(state.getSnapshot().draft).toBe('')
|
||||
await Promise.resolve()
|
||||
expect(b.sessionFake.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
|
||||
// Failure: restored (draft still empty when the rejection lands).
|
||||
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
|
||||
actions.setDraft('retry me')
|
||||
actions.submit('queue')
|
||||
actions.submit()
|
||||
await vi.waitFor(() => {
|
||||
expect(state.getSnapshot().draft).toBe('retry me')
|
||||
})
|
||||
// Failure landing after new typing: no clobber (restore fills empty only).
|
||||
b.sessionFake.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'b', details: { reason: 'b' } } })
|
||||
actions.submit('queue')
|
||||
actions.submit()
|
||||
actions.setDraft('typed during flight')
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(state.getSnapshot().draft).toBe('typed during flight')
|
||||
|
||||
@@ -99,7 +99,7 @@ describe('MessageItem arms', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '复制' }))
|
||||
})
|
||||
|
||||
it('steering bubbles carry the interjection badge and non-text rest blocks, without user actions', () => {
|
||||
it('steering bubbles render text and non-text rest blocks, without user actions or a badge', () => {
|
||||
const view = render(
|
||||
<MessageItem t={t} node={{
|
||||
kind: 'steering', seq: 2, turn: 1, source: null,
|
||||
@@ -107,7 +107,7 @@ describe('MessageItem arms', () => {
|
||||
} as never}
|
||||
/>,
|
||||
)
|
||||
expect(view.getByText('插话')).toBeTruthy()
|
||||
expect(view.queryByText('插话')).toBeNull()
|
||||
expect(view.getByText('steer!')).toBeTruthy()
|
||||
expect(view.getByText(/附加内容块/)).toBeTruthy()
|
||||
expect(view.queryByRole('button', { name: '复制' })).toBeNull()
|
||||
|
||||
@@ -127,7 +127,7 @@ describe('Enter semantics', () => {
|
||||
it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => {
|
||||
const { textarea, sink } = bench({ draft: 'hello' })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('hello', 'queue')
|
||||
expect(sink).toHaveBeenCalledWith('hello')
|
||||
fireEvent.keyDown(textarea, { key: 'Enter', repeat: true })
|
||||
expect(sink).toHaveBeenCalledTimes(1)
|
||||
const empty = bench({ draft: ' ' })
|
||||
@@ -195,7 +195,7 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
expect(textarea.disabled).toBe(false) // running no longer locks
|
||||
fireEvent.change(textarea, { target: { value: '排队消息2' } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('排队消息2', 'queue')
|
||||
expect(sink).toHaveBeenCalledWith('排队消息2')
|
||||
expect(button.getAttribute('aria-label')).toBe('停止生成')
|
||||
fireEvent.click(button)
|
||||
expect(stop).toHaveBeenCalledTimes(1)
|
||||
@@ -211,7 +211,7 @@ describe('running and lock semantics (queue cut 1)', () => {
|
||||
it('idle primary sends and disables on empty draft', () => {
|
||||
const { button, sink } = bench({ draft: 'go' })
|
||||
fireEvent.click(button)
|
||||
expect(sink).toHaveBeenCalledWith('go', 'queue')
|
||||
expect(sink).toHaveBeenCalledWith('go')
|
||||
const empty = bench()
|
||||
expect(empty.button.disabled).toBe(true)
|
||||
})
|
||||
@@ -320,7 +320,7 @@ describe('machine pending lock', () => {
|
||||
},
|
||||
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
|
||||
)
|
||||
shell.submit('queue')
|
||||
shell.submit()
|
||||
})
|
||||
expect(shell.snapshot.phase).toBe('submitting')
|
||||
const textarea = view.container.querySelector('textarea')!
|
||||
|
||||
@@ -40,9 +40,9 @@ function effectAt<T extends InputEffect['type']>(
|
||||
}
|
||||
|
||||
/** Drive plain → adjudicating and hand back the minted attempt. */
|
||||
function enterAdjudicating(m: InputMachine, draft: string, mode: 'queue' | 'steer' = 'queue'): SubmitAttempt {
|
||||
function enterAdjudicating(m: InputMachine, draft: string): SubmitAttempt {
|
||||
m.dispatch({ type: 'draft-changed', draft })
|
||||
const fx = m.dispatch({ type: 'enter', mode })
|
||||
const fx = m.dispatch({ type: 'enter' })
|
||||
return effectAt(fx, 0, 'adjudicate').attempt
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ function enterSubmitting(m: InputMachine, name: string, args: string): { attempt
|
||||
m.dispatch({ type: 'draft-changed', draft: `/${name.slice(0, 2)}` })
|
||||
m.dispatch({ type: 'begin-command', claim, span: spanOf(m, 0, m.state.draft.length) })
|
||||
m.dispatch({ type: 'draft-changed', draft: claim.token + args })
|
||||
const fx = m.dispatch({ type: 'enter', mode: 'queue' })
|
||||
const fx = m.dispatch({ type: 'enter' })
|
||||
return { attempt: effectAt(fx, 0, 'begin-submit').attempt, claim }
|
||||
}
|
||||
|
||||
@@ -63,24 +63,24 @@ function staleAttempt(): SubmitAttempt {
|
||||
describe('input-machine: plain × enter', () => {
|
||||
it('empty and whitespace-only drafts produce nothing', () => {
|
||||
const m = new InputMachine()
|
||||
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
|
||||
expect(m.dispatch({ type: 'enter' })).toEqual([])
|
||||
m.dispatch({ type: 'draft-changed', draft: ' \n ' })
|
||||
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
|
||||
expect(m.dispatch({ type: 'enter' })).toEqual([])
|
||||
expect(m.state.phase).toBe('plain')
|
||||
})
|
||||
|
||||
it('non-command text falls to the default sink with the given mode', () => {
|
||||
it('non-command text falls to the default sink', () => {
|
||||
const m = new InputMachine()
|
||||
m.dispatch({ type: 'draft-changed', draft: 'hello world' })
|
||||
expect(m.dispatch({ type: 'enter', mode: 'steer' }))
|
||||
.toEqual([{ type: 'default-sink', draft: 'hello world', mode: 'steer' }])
|
||||
expect(m.dispatch({ type: 'enter' }))
|
||||
.toEqual([{ type: 'default-sink', draft: 'hello world' }])
|
||||
expect(m.state.phase).toBe('plain')
|
||||
})
|
||||
|
||||
it('leading "/" enters adjudicating with a minted attempt carrying the draft snapshot', () => {
|
||||
const m = new InputMachine()
|
||||
m.dispatch({ type: 'draft-changed', draft: '/goal x' })
|
||||
const fx = m.dispatch({ type: 'enter', mode: 'queue' })
|
||||
const fx = m.dispatch({ type: 'enter' })
|
||||
const eff = effectAt(fx, 0, 'adjudicate')
|
||||
expect(eff.draft).toBe('/goal x')
|
||||
expect(eff.attempt.draftSnapshot).toBe('/goal x')
|
||||
@@ -91,14 +91,14 @@ describe('input-machine: plain × enter', () => {
|
||||
it('leading is judged after trim including newlines', () => {
|
||||
const m = new InputMachine()
|
||||
m.dispatch({ type: 'draft-changed', draft: '\n\n/goal x' })
|
||||
expect(m.dispatch({ type: 'enter', mode: 'queue' })[0]?.type).toBe('adjudicate')
|
||||
expect(m.dispatch({ type: 'enter' })[0]?.type).toBe('adjudicate')
|
||||
})
|
||||
|
||||
it('a non-whitespace prefix before "/" is not leading — default sink', () => {
|
||||
const m = new InputMachine()
|
||||
m.dispatch({ type: 'draft-changed', draft: '第一行\n/goal x' })
|
||||
expect(m.dispatch({ type: 'enter', mode: 'queue' }))
|
||||
.toEqual([{ type: 'default-sink', draft: '第一行\n/goal x', mode: 'queue' }])
|
||||
expect(m.dispatch({ type: 'enter' }))
|
||||
.toEqual([{ type: 'default-sink', draft: '第一行\n/goal x' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -124,11 +124,11 @@ 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 preserving the enter mode', () => {
|
||||
it('undefined outcome falls back to the default sink', () => {
|
||||
const m = new InputMachine()
|
||||
const attempt = enterAdjudicating(m, '/unknown thing', 'steer')
|
||||
const attempt = enterAdjudicating(m, '/unknown thing')
|
||||
expect(m.dispatch({ type: 'adjudicated', attempt, outcome: undefined }))
|
||||
.toEqual([{ type: 'default-sink', draft: '/unknown thing', mode: 'steer' }])
|
||||
.toEqual([{ type: 'default-sink', draft: '/unknown thing' }])
|
||||
expect(m.state.phase).toBe('plain')
|
||||
})
|
||||
|
||||
@@ -152,7 +152,7 @@ describe('input-machine: adjudication outcomes', () => {
|
||||
it('enter is a no-op while adjudicating (pending lock)', () => {
|
||||
const m = new InputMachine()
|
||||
enterAdjudicating(m, '/goal x')
|
||||
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
|
||||
expect(m.dispatch({ type: 'enter' })).toEqual([])
|
||||
expect(m.state.phase).toBe('adjudicating')
|
||||
})
|
||||
|
||||
@@ -587,7 +587,7 @@ describe('input-machine: paste plane', () => {
|
||||
|
||||
const b = new InputMachine()
|
||||
b.dispatch({ type: 'paste-begin', text: 'plain text', selection: { start: 0, end: 0 } })
|
||||
b.dispatch({ type: 'enter', mode: 'queue' })
|
||||
b.dispatch({ type: 'enter' })
|
||||
expect(b.state.paste).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -755,7 +755,7 @@ describe('input-machine: submitting transaction', () => {
|
||||
it('enter and begin-command are locked; draft-changed is recorded without leaving submitting', () => {
|
||||
const m = new InputMachine()
|
||||
enterSubmitting(m, 'goal', 'x')
|
||||
expect(m.dispatch({ type: 'enter', mode: 'queue' })).toEqual([])
|
||||
expect(m.dispatch({ type: 'enter' })).toEqual([])
|
||||
expect(m.dispatch({ type: 'draft-changed', draft: '/goal y' })).toEqual([])
|
||||
expect(m.state).toMatchObject({ phase: 'submitting', draft: '/goal y' })
|
||||
})
|
||||
@@ -768,7 +768,7 @@ describe('input-machine: submitting transaction', () => {
|
||||
m.dispatch({ type: 'draft-changed', draft: '/go', editRange: { start: 0, end: 1, insertedLength: 0 } })
|
||||
m.dispatch({ type: 'begin-command', claim: claimOf('goal'), span: spanOf(m, 0, 3) })
|
||||
m.dispatch({ type: 'draft-changed', draft: '/goal go' })
|
||||
const attempt = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
|
||||
const attempt = effectAt(m.dispatch({ type: 'enter' }), 0, 'begin-submit').attempt
|
||||
const fx = m.dispatch({ type: 'submit-settled', attempt, ok: true, outcome: { kind: 'success', text: 'goal set' } })
|
||||
expect(fx).toEqual([{ type: 'notice', level: 'info', text: 'goal set' }])
|
||||
expect(m.state).toMatchObject({ phase: 'plain', draft: '', occurrences: [] })
|
||||
@@ -809,7 +809,7 @@ describe('input-machine: submitting transaction', () => {
|
||||
const m = new InputMachine()
|
||||
const { attempt: first } = enterSubmitting(m, 'goal', 'x')
|
||||
m.dispatch({ type: 'submit-settled', attempt: first, ok: false, message: 'retry' })
|
||||
const second = effectAt(m.dispatch({ type: 'enter', mode: 'queue' }), 0, 'begin-submit').attempt
|
||||
const second = effectAt(m.dispatch({ type: 'enter' }), 0, 'begin-submit').attempt
|
||||
expect(second.seq).not.toBe(first.seq)
|
||||
expect(m.dispatch({ type: 'submit-settled', attempt: first, ok: true })).toEqual([])
|
||||
expect(m.state.phase).toBe('submitting')
|
||||
|
||||
@@ -85,7 +85,7 @@ describe('matrix row: plain', () => {
|
||||
fireEvent.change(textarea, { target: { value: '普通消息' } })
|
||||
expect(shell.snapshot.claim).toBeUndefined()
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('普通消息', 'queue')
|
||||
expect(sink).toHaveBeenCalledWith('普通消息')
|
||||
expect(shell.snapshot.phase).toBe('plain')
|
||||
})
|
||||
})
|
||||
@@ -184,7 +184,7 @@ describe('matrix row: locked (session disabled)', () => {
|
||||
expect((textarea).disabled).toBe(false)
|
||||
fireEvent.change(textarea, { target: { value: '排队' } })
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).toHaveBeenCalledWith('排队', 'queue')
|
||||
expect(sink).toHaveBeenCalledWith('排队')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -224,7 +224,7 @@ describe('scenario D: execute-kind /compact', () => {
|
||||
act(() => { b2.shell.setDraft('/compact 现在') })
|
||||
fireEvent.keyDown(b2.textarea, { key: 'Enter' })
|
||||
// execute with trailing → matchEnter answers undefined → default sink.
|
||||
await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', 'queue') })
|
||||
await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在') })
|
||||
expect(b2.executed).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -278,7 +278,7 @@ describe('scenario I: unknown /xyz + enter', () => {
|
||||
const b = await bench()
|
||||
act(() => { b.shell.setDraft('/xyz 干点啥') })
|
||||
fireEvent.keyDown(b.textarea, { key: 'Enter' })
|
||||
await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', 'queue') })
|
||||
await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥') })
|
||||
expect(b.shell.snapshot.phase).toBe('plain')
|
||||
expect(b.execute).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -33,11 +33,11 @@ async function bench() {
|
||||
describe('ConversationService', () => {
|
||||
it('routes operations through the public Session binding', async () => {
|
||||
const b = await bench()
|
||||
await b.scoped.send('hello', 'steer')
|
||||
await b.scoped.send('hello')
|
||||
await b.scoped.updateQueue('item-1' as never, { kind: 'remove' })
|
||||
await b.scoped.cancel()
|
||||
await b.scoped.loadOlder()
|
||||
expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'steer')
|
||||
expect(b.prompt).toHaveBeenCalledWith([{ type: 'text', text: 'hello' }], 'queue')
|
||||
expect(b.updateQueue).toHaveBeenCalledWith('item-1', { kind: 'remove' })
|
||||
expect(b.cancel).toHaveBeenCalledOnce()
|
||||
expect(b.loadOlder).toHaveBeenCalledOnce()
|
||||
@@ -47,7 +47,7 @@ describe('ConversationService', () => {
|
||||
it('folds Session business failures into callback rejections', async () => {
|
||||
const b = await bench()
|
||||
b.prompt.mockResolvedValueOnce({ ok: false, error: { code: 'agent-busy', message: 'busy', details: {} } } as never)
|
||||
await expect(b.scoped.send('x', 'queue')).rejects.toThrow('conversation.send failed: agent-busy: busy')
|
||||
await expect(b.scoped.send('x')).rejects.toThrow('conversation.send failed: agent-busy: busy')
|
||||
b.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'nope', details: {} } } as never)
|
||||
await expect(b.scoped.cancel()).rejects.toThrow('conversation.cancel failed: internal: nope')
|
||||
await b.runtime.dispose()
|
||||
@@ -55,9 +55,9 @@ describe('ConversationService', () => {
|
||||
|
||||
it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => {
|
||||
const b = await bench()
|
||||
await expect(b.root.send('x', 'queue')).rejects.toThrow(/requires a session scope/)
|
||||
await expect(b.root.send('x')).rejects.toThrow(/requires a session scope/)
|
||||
await b.runtime.sessions.remove('s1')
|
||||
await expect(b.scoped.send('x', 'queue')).rejects.toThrow(/resolved no binding/)
|
||||
await expect(b.scoped.send('x')).rejects.toThrow(/resolved no binding/)
|
||||
await b.runtime.dispose()
|
||||
// No SessionsService at all: a bare context (the runtime always provides one).
|
||||
const bare = new Context()
|
||||
@@ -65,6 +65,6 @@ describe('ConversationService', () => {
|
||||
input: new InputHub(bare),
|
||||
}).await()
|
||||
const orphan = bare.get('conversation') as ConversationService
|
||||
await expect(orphan.send('x', 'queue')).rejects.toThrow(/sessions service unavailable/)
|
||||
await expect(orphan.send('x')).rejects.toThrow(/sessions service unavailable/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -206,7 +206,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
fireEvent.change(box, { target: { value: 'ordinary revised' } })
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised')
|
||||
fireEvent.keyDown(box, { key: 'Enter' })
|
||||
expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue')
|
||||
expect(b.sink).toHaveBeenCalledWith('ordinary revised')
|
||||
fireEvent.click(b.view.getByRole('button', { name: 'Root' }))
|
||||
expect(b.open).toHaveBeenCalledWith(sid('root'))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user