From 629696f79f8d6fec6d6f7d7c2f4e04329833b109 Mon Sep 17 00:00:00 2001 From: wsp Date: Mon, 12 Oct 2026 14:23:11 +0800 Subject: [PATCH] feat(chat): show accepted steering messages immediately Project host-accepted queue messages into FlowChat before the formal user turn starts, using the ordinary user-message appearance. Preserve virtual row identity through canonical turn adoption, hide queue rows only while a transcript owns them, and restore recovery controls when handoff is blocked. Scope previews to device activation and host queue epoch, with fallback for older hosts. Add optional display receipts, compatibility and reconciliation tests, and focused verification guidance. Keep history ownership and runtime execution order unchanged. --- .../coordination/host_message_queue.rs | 14 +++ .../coordination/host_message_queue_tests.rs | 29 ++++-- .../runtime-ports/src/dialog_queue.rs | 30 ++++++ src/shared/dialog-queue/HostDialogQueue.ts | 2 + .../components/HostPendingQueuePanel.tsx | 9 +- .../modern/FLOWCHAT_VERIFICATION.md | 16 +++ .../modern/FLOWCHAT_VIRTUALIZATION.md | 21 ++++ .../modern/UserMessageItem.test.tsx | 28 ++++++ .../components/modern/UserMessageItem.tsx | 25 +++-- .../components/modern/VirtualItemRenderer.tsx | 2 + .../components/modern/virtualItemIdentity.ts | 1 + .../services/hostQueuePresentation.ts | 43 ++++++++ .../timeline/hostQueueProjection.test.ts | 91 +++++++++++++++++ .../flow_chat/timeline/hostQueueProjection.ts | 97 +++++++++++++++++++ .../timeline/useConversationTimeline.ts | 6 +- .../timeline/useHostQueueTimeline.test.tsx | 88 +++++++++++++++++ .../timeline/useHostQueueTimeline.ts | 61 ++++++++++++ .../flow_chat/types/flow-chat-projection.ts | 4 + src/web-ui/src/locales/en-US/flow-chat.json | 1 + src/web-ui/src/locales/zh-CN/flow-chat.json | 1 + src/web-ui/src/locales/zh-TW/flow-chat.json | 1 + 21 files changed, 551 insertions(+), 19 deletions(-) create mode 100644 src/web-ui/src/flow_chat/services/hostQueuePresentation.ts create mode 100644 src/web-ui/src/flow_chat/timeline/hostQueueProjection.test.ts create mode 100644 src/web-ui/src/flow_chat/timeline/hostQueueProjection.ts create mode 100644 src/web-ui/src/flow_chat/timeline/useHostQueueTimeline.test.tsx create mode 100644 src/web-ui/src/flow_chat/timeline/useHostQueueTimeline.ts diff --git a/src/crates/assembly/core/src/agentic/coordination/host_message_queue.rs b/src/crates/assembly/core/src/agentic/coordination/host_message_queue.rs index 70f76c4079..f8343f6f22 100644 --- a/src/crates/assembly/core/src/agentic/coordination/host_message_queue.rs +++ b/src/crates/assembly/core/src/agentic/coordination/host_message_queue.rs @@ -451,6 +451,7 @@ impl DialogScheduler { reason: None, target_turn_id: None, steering_id: None, + steering_display: None, }; s.entries.insert( message.turn_id.clone(), @@ -666,6 +667,19 @@ impl DialogScheduler { e.view.reason = None; e.view.target_turn_id = Some(target.clone()); e.view.steering_id = Some(steering_id); + e.view.steering_display = + Some(openbitfun_runtime_ports::DialogQueueSteeringDisplay { + target_turn_id: target.clone(), + content: turn + .original_user_input + .clone() + .unwrap_or_else(|| turn.user_input.clone()), + composer_presentation: turn + .user_message_metadata + .as_ref() + .and_then(|metadata| metadata.get("composerPresentation")) + .cloned(), + }); e.held = Some(turn); } self.round_injection_buffer.push(session, injection); diff --git a/src/crates/assembly/core/src/agentic/coordination/host_message_queue_tests.rs b/src/crates/assembly/core/src/agentic/coordination/host_message_queue_tests.rs index 3fa19dbc74..83b7d6775b 100644 --- a/src/crates/assembly/core/src/agentic/coordination/host_message_queue_tests.rs +++ b/src/crates/assembly/core/src/agentic/coordination/host_message_queue_tests.rs @@ -196,13 +196,15 @@ async fn host_queue_cancel_is_idempotent_and_never_cancels_active_turn() { #[tokio::test] async fn host_queue_steering_retains_payload_until_formal_turn_handoff_and_rejects_cancel() { let (scheduler, _, _root, epoch) = fixture().await; + let mut input = message("queued-a"); + let display_content = "display text ".repeat(250); + input.display_content = Some(display_content.clone()); + input.metadata.insert( + "composerPresentation".into(), + serde_json::json!({"version":1}), + ); scheduler - .manage_host_queue(request( - Some(&epoch), - Action::Submit { - message: message("queued-a"), - }, - )) + .manage_host_queue(request(Some(&epoch), Action::Submit { message: input })) .await .unwrap(); let promote = request( @@ -214,7 +216,16 @@ async fn host_queue_steering_retains_payload_until_formal_turn_handoff_and_rejec }, ); let snapshot = scheduler.manage_host_queue(promote.clone()).await.unwrap(); - assert_eq!(snapshot.receipt.unwrap().status, Status::SteeringPending); + let receipt = snapshot.receipt.unwrap(); + assert_eq!(receipt.status, Status::SteeringPending); + assert!(receipt.preview_truncated); + let display = receipt.steering_display.unwrap(); + assert_eq!(display.target_turn_id, "active-turn"); + assert_eq!(display.content, display_content); + assert_eq!( + display.composer_presentation, + Some(serde_json::json!({"version":1})) + ); scheduler.manage_host_queue(promote).await.unwrap(); let injections = scheduler .round_injection_source @@ -254,7 +265,9 @@ async fn host_queue_steering_retains_payload_until_formal_turn_handoff_and_rejec )) .await .unwrap(); - assert_eq!(result.receipt.unwrap().status, Status::Queued); + let receipt = result.receipt.unwrap(); + assert_eq!(receipt.status, Status::Queued); + assert_eq!(receipt.steering_display.unwrap(), display); assert_eq!(result.used, 1); assert_eq!(scheduler.queues.depth("host-queue-session"), 1); assert_eq!( diff --git a/src/crates/contracts/runtime-ports/src/dialog_queue.rs b/src/crates/contracts/runtime-ports/src/dialog_queue.rs index bf25d0c95c..b9c4e2e7a0 100644 --- a/src/crates/contracts/runtime-ports/src/dialog_queue.rs +++ b/src/crates/contracts/runtime-ports/src/dialog_queue.rs @@ -70,6 +70,16 @@ impl DialogQueueStatus { } } +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DialogQueueSteeringDisplay { + /// The original target survives the brief queued handoff to a formal Turn. + pub target_turn_id: String, + pub content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub composer_presentation: Option, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DialogQueueItem { @@ -83,6 +93,10 @@ pub struct DialogQueueItem { pub reason: Option, pub target_turn_id: Option, pub steering_id: Option, + /// Display-only receipt for accepted steering; never a second history record. + /// Older hosts omit this field and clients retain the composer queue UI. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub steering_display: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -132,4 +146,20 @@ mod tests { let request: DialogQueueRequest = serde_json::from_value(input.clone()).unwrap(); assert_eq!(serde_json::to_value(request).unwrap(), input); } + + #[test] + fn queue_item_legacy_and_display_receipts_round_trip() { + let legacy = serde_json::json!({"turnId":"next", "displayContent":"short", + "previewTruncated":true, "attachmentCount":2, "agentType":"Standard", + "createdAtMs":1, "status":"steering_pending", "reason":null, + "targetTurnId":"active", "steeringId":"steer"}); + let item: DialogQueueItem = serde_json::from_value(legacy.clone()).unwrap(); + assert!(item.steering_display.is_none()); + assert_eq!(serde_json::to_value(item).unwrap(), legacy); + let mut extended = legacy; + extended["steeringDisplay"] = serde_json::json!({"targetTurnId":"active", "content":"complete prompt", + "composerPresentation":{"version":1}}); + let item: DialogQueueItem = serde_json::from_value(extended.clone()).unwrap(); + assert_eq!(serde_json::to_value(item).unwrap(), extended); + } } diff --git a/src/shared/dialog-queue/HostDialogQueue.ts b/src/shared/dialog-queue/HostDialogQueue.ts index 136e80cf4b..ed37f73b52 100644 --- a/src/shared/dialog-queue/HostDialogQueue.ts +++ b/src/shared/dialog-queue/HostDialogQueue.ts @@ -14,6 +14,8 @@ export interface QueueItem { turnId: string; displayContent: string; previewTruncated: boolean; attachmentCount: number; agentType: string; createdAtMs: number; status: QueueStatus; reason: string | null; targetTurnId: string | null; steeringId: string | null; + /** Optional host-authoritative display receipt; absent on older hosts. */ + steeringDisplay?: { targetTurnId: string; content: string; composerPresentation?: unknown }; } export interface QueueSnapshot { sessionId: string; queueEpoch: string; revision: number; activeTurnId: string | null; diff --git a/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.tsx b/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.tsx index fb1c7e28d3..7a9d4f2c86 100644 --- a/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.tsx +++ b/src/web-ui/src/flow_chat/components/HostPendingQueuePanel.tsx @@ -5,7 +5,8 @@ import { useI18n } from '@/infrastructure/i18n'; import { Button, Icon, IconButton, OverflowText, Tooltip } from '@openbitfun/ui'; import { RotateCw } from 'lucide-react'; import './HostPendingQueuePanel.scss'; -import { HostDialogQueue, observeHostQueue, type QueueOutboxRecord, type QueueItem } from '../../../../shared/dialog-queue/HostDialogQueue'; +import { hostQueuePresentation, observePresentedHostQueue } from '../services/hostQueuePresentation'; +import { HostDialogQueue, type QueueOutboxRecord, type QueueItem } from '../../../../shared/dialog-queue/HostDialogQueue'; import { ChatComposerQueue, ChatComposerQueueHeader, ChatComposerQueueTitle, ChatComposerQueueList, ChatComposerQueueItem, ChatComposerQueueItemContent, ChatComposerQueueItemActions, @@ -14,12 +15,14 @@ import { export function HostPendingQueuePanel({ queue, onRestore }: { queue: HostDialogQueue; onRestore: (item: QueuedMessage) => boolean }) { const { t } = useI18n('flow-chat'); const view = useSyncExternalStore(queue.subscribe, queue.getSnapshot, queue.getSnapshot); + const presentation = hostQueuePresentation(queue); + const projected = useSyncExternalStore(presentation.subscribe, presentation.getSnapshot, presentation.getSnapshot); const [expanded, setExpanded] = useState(true); const [showHelp, setShowHelp] = useState(false); const listId = useId(); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); - useEffect(() => observeHostQueue(queue), [queue]); + useEffect(() => observePresentedHostQueue(queue), [queue]); const run = async (operation: () => Promise) => { setBusy(true); setError(null); try { await operation(); } catch (e) { setError(String(e)); } @@ -49,7 +52,7 @@ export function HostPendingQueuePanel({ queue, onRestore }: { queue: HostDialogQ } await queue.dismiss(saved); }; - const items = view.snapshot?.items ?? []; + const items = (view.snapshot?.items ?? []).filter(item => item.status === 'blocked' || !projected.has(item.turnId)); const visibleError = error || (view.error?.includes('Session is not loaded') ? null : view.error); if (!items.length && !view.pending.length && !visibleError) return null; return diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md index 485d2fdb4b..66614883d1 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md @@ -16,6 +16,7 @@ each missing what the other had. | Test | Contract it holds | |---|---| +| `../../timeline/hostQueueProjection.test.ts`, `../../timeline/useHostQueueTimeline.test.tsx`, `../HostPendingQueuePanel.test.tsx` | host-accepted preview, canonical handoff/event ordering, stable keys, blocked recovery, old-host fallback, history-window visibility and device lease cleanup; fixtures only, no live remote transport evidence | | `TurnFooter.test.tsx`, `modelRoundItemMemo.test.ts` | latest-turn identity, hover/focus across virtual rows, lease cleanup, no projection churn and the existing completion reveal gate; behavior only, not visual proof | | `../../../shared/ui/Tooltip.test.tsx` | shared metric click opening, immediate scroll dismissal, delayed-open cancellation, viewport-exit observer cleanup and no automatic reopening; DOM/observer behavior only, not native scrolling or visual proof | | `../../utils/turnUsageMetrics.test.ts` | cache denominator, whole-turn output rate, speed boundaries, invalid and unavailable telemetry | @@ -345,6 +346,21 @@ Keyboard focus has its own outline and does not navigate until activation. 11. With reduced motion enabled, the hover fan changes without animation. Touch navigation must not leave a hover fan behind. +### Accepted steering + +1. Send immediately during a long tool/model round in the main transcript and + BTW pane. After host acceptance, verify the message uses the ordinary user-message + appearance without a pending label and leaves the composer queue. Confirm long text and attachment count. +2. Let the formal Turn start. Verify one message remains and + attachments appear normally. Send multiple messages and check ordering. +3. Exercise blocked handoff, history navigation, transcript closure, disconnect, + reconnect, host restart and device switching; recovery controls must remain + available and another device's preview must never appear. +4. Repeat on a remote workspace and Peer Device host with two controllers, one + sending from mobile. Mobile retains its existing queue UI. Check an older + host without display receipts retains the existing desktop queue UI. Detached + dispatch and ACP use their existing paths and have no preview requirement. + ### History paging demand 1. Page upward at least three times from a long session's live tail, and page diff --git a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md index 26a0d6da69..be33961aa7 100644 --- a/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md +++ b/src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md @@ -1,5 +1,26 @@ # FlowChat Virtualization +## Accepted steering display + +The host queue's optional `steeringDisplay` receipt projects an accepted message +after its target Turn, without changing recorded rounds or writing a second +history record. Full display text and composer references come from the host; +pending attachments show their count rather than retransmitting image bytes. +Accepted text messages use the ordinary user-message appearance without a pending +status label; internal admission state still disables edit and rollback actions. +Formal Turn handoff keeps the original queue Turn id and the preview's virtual +key. Canonical history replaces the preview even when events precede the receipt. +Queue disappearance alone is not consumption: the observer queries the receipt +and retains the pending shell until history arrives. Blocked messages show a +not-applied label and restore composer recovery controls. + +Only mounted transcript projections lease hidden queue rows. History windows +without the target, older hosts without display receipts, and closed transcripts +retain queue controls. Leases and previews are isolated by queue client, surface +activation and host queue epoch. Restart recovery remains with the existing +outbox; mobile retains its existing queue presentation. No scroll placement or +mount animation is added, and execution order remains host-owned. + ## Measured row spacing `VirtualItemRenderer` establishes a `flow-root` on the measured wrapper so diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx index af29d00e6d..c0198fa911 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx @@ -438,6 +438,34 @@ describe('UserMessageItem steering tag', () => { expect(container.querySelector('.user-message-item__actions')?.parentElement).toBe(meta); }); + it.each(['pending', 'failed'] as const)('shows host queue %s feedback without edit or rollback actions', status => { + act(() => root.render( + + + , + )); + if (status === 'failed') expect(container.querySelector('.user-message-item__steering-tag')?.textContent) + .toContain('steering.statusNotApplied'); + expect(container.querySelector('.user-message-item__steering-tag')?.textContent).toContain('hostQueue.attachments'); + expect(container.querySelector('.user-message-item__edit-btn')).toBeNull(); + expect(container.querySelector('.user-message-item__rollback-btn')).toBeNull(); + expect(container.querySelector('.user-message-item__copy-btn')).not.toBeNull(); + }); + + it('renders an accepted text message without a pending status label', () => { + act(() => root.render( + + + , + )); + expect(container.querySelector('.user-message-item__steering-tag')).toBeNull(); + expect(container.textContent).toContain('Accepted instruction'); + expect(container.querySelector('.user-message-item__edit-btn')).toBeNull(); + expect(container.querySelector('.user-message-item__rollback-btn')).toBeNull(); + }); + it('does not render a steering tag after steering is triggered', () => { act(() => { root.render( diff --git a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx index 0404320862..69c0d3d610 100644 --- a/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx @@ -76,6 +76,8 @@ interface UserMessageItemProps { steeringStatus?: FlowUserSteeringItem['status']; submissionPhase?: 'forming' | 'failed'; submissionError?: string; + hostQueueStatus?: 'pending' | 'failed'; + hostQueueAttachmentCount?: number; } function buildPresentationRerunPayload(presentation: ComposerPresentation): { @@ -108,7 +110,7 @@ function buildPresentationRerunPayload(presentation: ComposerPresentation): { } export const UserMessageItem = React.memo( - ({ message, turnId, absoluteTurnIndex, turnStatus, steeringStatus, submissionPhase, submissionError }) => { + ({ message, turnId, absoluteTurnIndex, turnStatus, steeringStatus, submissionPhase, submissionError, hostQueueStatus, hostQueueAttachmentCount }) => { const { t, formatDate } = useI18n('flow-chat'); const { sessionId, @@ -203,7 +205,7 @@ export const UserMessageItem = React.memo( const isFailed = resolvedTurnStatus === 'error'; const resolvedSessionId = sessionId ?? currentSession?.sessionId; useSubmittedMessageMotion(shellRef, resolvedSessionId, turnId, message?.id, - isFailed || isEditing || submissionPhase === 'failed', submissionPhase); + isFailed || isEditing || submissionPhase === 'failed' || !!hostQueueStatus, submissionPhase); const sessionMachine = useSessionStateMachine(resolvedSessionId ?? null); const sessionExecutionState = sessionMachine && sessionMachine.sessionId === resolvedSessionId ? sessionMachine.currentState @@ -227,6 +229,7 @@ export const UserMessageItem = React.memo( ); const canRollback = !steeringStatus && + !hostQueueStatus && canShowRollbackAction && !!resolvedSessionId && actionTurnIndex >= 0 && @@ -241,9 +244,9 @@ export const UserMessageItem = React.memo( !isRemoteSession && !isThreadGoalSystemMessage && !isSystemTriggered && - !steeringStatus; + !steeringStatus && !hostQueueStatus; const canEdit = canEditBase && isSessionIdle && !isEditSubmitting && !sessionMutation; - const canShowEditAction = allowUserMessageEdit && !isFailed && !isThreadGoalSystemMessage; + const canShowEditAction = allowUserMessageEdit && !isFailed && !isThreadGoalSystemMessage && !hostQueueStatus; const editDisabledReason = isDispatchSession ? t('message.editDisabledDispatch') : isRemoteSession @@ -266,7 +269,17 @@ export const UserMessageItem = React.memo( : !isSessionIdle ? t('message.rollbackDisabledBusy') : t('message.cannotRollback'); - const steeringTag = steeringStatus === 'pending' + const hostQueueLabel = hostQueueStatus + ? [hostQueueStatus === 'failed' ? t('steering.statusNotApplied') : '', + hostQueueAttachmentCount ? t('hostQueue.attachments', { count: hostQueueAttachmentCount }) : ''] + .filter(Boolean).join(' · ') + : ''; + const steeringTag = hostQueueLabel + ? { + className: 'user-message-item__steering-tag--pending', + label: hostQueueLabel, + } + : steeringStatus === 'pending' ? { className: 'user-message-item__steering-tag--pending', label: t('steering.statusPending'), @@ -763,7 +776,7 @@ export const UserMessageItem = React.memo( icon={} /> - ) : canShowRollbackAction && !steeringStatus ? ( + ) : canShowRollbackAction && !steeringStatus && !hostQueueStatus ? ( ( turnStatus={item.turnStatus} submissionPhase={item.submissionPhase} submissionError={item.submissionError} + hostQueueStatus={item.hostQueueStatus} + hostQueueAttachmentCount={item.hostQueueAttachmentCount} /> ); diff --git a/src/web-ui/src/flow_chat/components/modern/virtualItemIdentity.ts b/src/web-ui/src/flow_chat/components/modern/virtualItemIdentity.ts index 4e38058b09..ff6a511a3e 100644 --- a/src/web-ui/src/flow_chat/components/modern/virtualItemIdentity.ts +++ b/src/web-ui/src/flow_chat/components/modern/virtualItemIdentity.ts @@ -5,6 +5,7 @@ export function getVirtualItemStableKey(item: VirtualItem): string { if (item.timeline) return `block:${item.timeline.key}`; switch (item.type) { case 'user-message': + return item.presentationKey ?? `${item.type}:${item.turnId}:${item.data.id}`; case 'user-steering-message': return `${item.type}:${item.turnId}:${item.data.id}`; case 'model-round': diff --git a/src/web-ui/src/flow_chat/services/hostQueuePresentation.ts b/src/web-ui/src/flow_chat/services/hostQueuePresentation.ts new file mode 100644 index 0000000000..b8611e7653 --- /dev/null +++ b/src/web-ui/src/flow_chat/services/hostQueuePresentation.ts @@ -0,0 +1,43 @@ +import { observeHostQueue, type HostDialogQueue } from '../../../../shared/dialog-queue/HostDialogQueue'; + +const EMPTY: ReadonlySet = new Set(); +/** Mounted transcript leases: a queue row hides only while a transcript projects it. */ +class HostQueuePresentation { + private leases = new Map(); + private hidden: ReadonlySet = EMPTY; + private listeners = new Set<() => void>(); + getSnapshot = () => this.hidden; + subscribe = (listener: () => void) => { + this.listeners.add(listener); return () => { this.listeners.delete(listener); }; + }; + set(owner: object, ids: readonly string[]) { + if (ids.length) this.leases.set(owner, ids); else this.leases.delete(owner); + const next = new Set([...this.leases.values()].flat()); + if (next.size === this.hidden.size && [...next].every(id => this.hidden.has(id))) return; + this.hidden = next.size ? next : EMPTY; + this.listeners.forEach(listener => listener()); + } +} +const stores = new WeakMap(); +export function hostQueuePresentation(queue: HostDialogQueue) { + let store = stores.get(queue); + if (!store) { store = new HostQueuePresentation(); stores.set(queue, store); } + return store; +} + +const observers = new WeakMap void }>(); +/** Composer and transcript share one observer; closing both never cancels host work. */ +export function observePresentedHostQueue(queue: HostDialogQueue): () => void { + let observer = observers.get(queue); + if (!observer) { + observer = { count: 0, stop: observeHostQueue(queue) }; + observers.set(queue, observer); + } + observer.count++; + let closed = false; + return () => { + if (closed) return; + closed = true; + if (--observer.count === 0) { observer.stop(); observers.delete(queue); } + }; +} diff --git a/src/web-ui/src/flow_chat/timeline/hostQueueProjection.test.ts b/src/web-ui/src/flow_chat/timeline/hostQueueProjection.test.ts new file mode 100644 index 0000000000..c87b06d5bf --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/hostQueueProjection.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import type { QueueItem, QueueSnapshot } from '../../../../shared/dialog-queue/HostDialogQueue'; +import type { VirtualItem } from '../types/flow-chat-projection'; +import { getVirtualItemStableKey } from '../components/modern/virtualItemIdentity'; +import { HostQueueProjection } from './hostQueueProjection'; + +const active: VirtualItem[] = [{ type: 'user-message', turnId: 'active', data: { id: 'user', content: 'initial', timestamp: 1 } }, + { type: 'model-round', turnId: 'active', isLastRound: true, isTurnComplete: false, + data: { id: 'round', index: 0, startTime: 1, status: 'streaming', isStreaming: true, isComplete: false, items: [] } }]; +const accepted = (patch: Partial = {}): QueueItem => ({ turnId: 'next', displayContent: 'short', previewTruncated: true, + attachmentCount: 2, agentType: 'Standard', createdAtMs: 2, status: 'steering_pending', reason: null, + targetTurnId: 'active', steeringId: 'steer', + steeringDisplay: { targetTurnId: 'active', content: 'long message '.repeat(1000), composerPresentation: { version: 1 } }, ...patch }); +const snapshot = (items: QueueItem[], patch: Partial = {}): QueueSnapshot => ({ sessionId: 'session', + queueEpoch: 'epoch', revision: 1, activeTurnId: 'active', capacity: 20, used: items.length, receipt: null, items, ...patch }); +const formal: VirtualItem = { type: 'user-message', turnId: 'next', data: { id: 'canonical-id', content: 'long message', timestamp: 3 } }; + +describe('host-accepted steering display', () => { + it('projects full text and attachment count after acknowledgement without mutating rounds or queue data', () => { + const input = snapshot([accepted()]); + const before = JSON.stringify({ active, input }); + const projection = new HostQueueProjection(); + const result = projection.project(active, input); + expect(result.items.slice(0, 2)).toEqual(active); + expect(result.hidden).toEqual(['next']); + expect(result.items[2]).toMatchObject({ type: 'user-message', turnId: 'next', hostQueueStatus: 'pending', + hostQueueAttachmentCount: 2, data: { content: accepted().steeringDisplay!.content, metadata: { composerPresentation: { version: 1 } } } }); + expect(JSON.stringify({ active, input })).toBe(before); + expect(projection.project(active, snapshot([accepted()])).items[2]).toBe(result.items[2]); + }); + + it('keeps one stable row through queued handoff, missing receipts and canonical adoption', () => { + const projection = new HostQueueProjection(); + const first = projection.project(active, snapshot([accepted()])); + const key = getVirtualItemStableKey(first.items[2]); + const handingOff = accepted({ status: 'queued', targetTurnId: null, steeringId: null }); + expect(projection.project(active, snapshot([handingOff])).hidden).toEqual(['next']); + const missing = projection.project(active, snapshot([])); + expect(missing.missing).toEqual(['next']); + expect(getVirtualItemStableKey(missing.items[2])).toBe(key); + const started = snapshot([], { receipt: accepted({ status: 'started' }) }); + expect(projection.project(active, started).missing).toEqual([]); + const adopted = projection.project([...active, formal], started); + expect(adopted.items).toHaveLength(3); + expect(adopted.items[2]).toMatchObject({ data: formal.data }); + expect(adopted.items[2]).not.toHaveProperty('hostQueueStatus'); + expect(getVirtualItemStableKey(adopted.items[2])).toBe(key); + expect(projection.project([...active, formal], snapshot([])).items[2]).toBe(adopted.items[2]); + }); + + it('handles canonical events before ACK and replaces a forming shell without duplicate user rows', () => { + const projection = new HostQueueProjection(); + expect(projection.project([...active, formal], snapshot([accepted()])).items).toHaveLength(3); + const other = new HostQueueProjection(); + const forming: VirtualItem = { ...formal, submissionPhase: 'forming' }; + const result = other.project([...active, forming], snapshot([accepted()])); + expect(result.items.filter(item => item.turnId === 'next')).toHaveLength(1); + expect(result.items[2]).toMatchObject({ hostQueueStatus: 'pending' }); + }); + + it('restores blocked entries to composer controls, removes cancelled previews and resets on host restart', () => { + const projection = new HostQueueProjection(); + projection.project(active, snapshot([accepted()])); + const blocked = projection.project(active, snapshot([accepted({ status: 'blocked', reason: 'not consumed' })])); + expect(blocked.hidden).toEqual([]); + expect(blocked.items[2]).toMatchObject({ hostQueueStatus: 'failed' }); + expect(projection.project(active, snapshot([], { receipt: accepted({ status: 'cancelled' }) })).items).toEqual(active); + projection.project(active, snapshot([accepted()])); + expect(projection.project(active, snapshot([], { queueEpoch: 'restart' })).items).toBe(active); + }); + + it('leaves old hosts and offscreen history queues visible and supports a fresh observer from the host snapshot', () => { + const old = accepted({ steeringDisplay: undefined }); + expect(new HostQueueProjection().project(active, snapshot([old]))).toMatchObject({ items: active, hidden: [] }); + const disconnected = new HostQueueProjection(); + expect(disconnected.project([], snapshot([accepted()])).hidden).toEqual([]); + expect(disconnected.project(active, snapshot([accepted()])).hidden).toEqual(['next']); + expect(new HostQueueProjection().project(active, snapshot([accepted()])).items[2]).toMatchObject({ hostQueueStatus: 'pending' }); + }); + + it('preserves host acceptance order for several messages and hands each off independently', () => { + const projection = new HostQueueProjection(); + const second = accepted({ turnId: 'second', steeringId: 'steer-2', createdAtMs: 3 }); + const first = projection.project(active, snapshot([accepted(), second])); + expect(first.items.slice(2).map(row => row.turnId)).toEqual(['next', 'second']); + const next = projection.project([...active, formal], snapshot([second])); + expect(next.items.filter(row => row.type === 'user-message').map(row => row.turnId)).toEqual(['active', 'next', 'second']); + expect(next.hidden).toEqual(['second']); + expect(getVirtualItemStableKey(next.items[2])).toBe(getVirtualItemStableKey(first.items[2])); + }); +}); diff --git a/src/web-ui/src/flow_chat/timeline/hostQueueProjection.ts b/src/web-ui/src/flow_chat/timeline/hostQueueProjection.ts new file mode 100644 index 0000000000..b99d82c182 --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/hostQueueProjection.ts @@ -0,0 +1,97 @@ +import type { QueueItem, QueueSnapshot } from '../../../../shared/dialog-queue/HostDialogQueue'; +import type { VirtualItem } from '../types/flow-chat-projection'; + +type UserRow = Extract; + +/** Pure display reconciliation. Queue receipts are not canonical history writes. */ +export class HostQueueProjection { + private epoch?: string; + private accepted = new Map(); + private adopted = new Set(); + private keys = new Map(); + private rows = new Map(); + private canonicalRows = new WeakMap(); + + project(sources: VirtualItem[], snapshot: QueueSnapshot | null) { + if (this.epoch !== snapshot?.queueEpoch) { + this.epoch = snapshot?.queueEpoch; + this.accepted.clear(); this.adopted.clear(); this.keys.clear(); this.rows.clear(); + this.canonicalRows = new WeakMap(); + } + if (!snapshot) return { items: sources, hidden: [] as string[], missing: [] as string[] }; + for (const item of [...snapshot.items, ...(snapshot.receipt ? [snapshot.receipt] : [])]) { + if (item.status === 'cancelled' || item.status === 'failed' || item.status === 'interrupted') { + this.accepted.delete(item.turnId); + continue; + } + if (!item.steeringDisplay || !item.steeringDisplay.targetTurnId + || typeof item.steeringDisplay.content !== 'string') continue; + this.accepted.set(item.turnId, item); + } + const canonical = new Set(sources.filter(item => item.type === 'user-message' && !item.submissionPhase).map(item => item.turnId)); + for (const id of canonical) if (this.accepted.has(id)) this.adopted.add(id); + // Legacy inline receipts may still arrive from a compatible host. + for (const source of sources) if (source.type === 'user-steering-message') { + for (const item of this.accepted.values()) if (item.steeringId === source.steeringId) this.adopted.add(item.turnId); + } + const byTarget = new Map(); + const hidden: string[] = []; + const missing: string[] = []; + const present = new Set(snapshot.items.map(item => item.turnId)); + const sourceTurns = new Set(sources.map(item => item.turnId)); + const lastIndexes = new Map(sources.map((item, index) => [item.turnId, index])); + let latestCanonicalTurn: string | undefined; + for (const source of sources) { + if (!(source.type === 'user-message' && source.submissionPhase)) latestCanonicalTurn = source.turnId; + } + for (const item of this.accepted.values()) { + if (this.adopted.has(item.turnId)) { + if (present.has(item.turnId)) hidden.push(item.turnId); + continue; + } + const display = item.steeringDisplay!; + const failed = item.status === 'blocked'; + if (!sourceTurns.has(display.targetTurnId) + && (failed || !snapshot.activeTurnId || !sourceTurns.has(snapshot.activeTurnId))) continue; + if (!present.has(item.turnId) && item.status !== 'started' && item.status !== 'completed' && item.status !== 'steered') { + missing.push(item.turnId); + } + const cached = this.rows.get(item.turnId); + // Snapshot polling should not remount or invalidate identical preview leaves. + const row = cached && JSON.stringify(cached.item) === JSON.stringify(item) ? cached.row : { + type: 'user-message' as const, + turnId: item.turnId, + presentationKey: `host-queue-user:${snapshot.queueEpoch}:${item.turnId}`, + data: { id: `host_queue_${item.turnId}`, content: display.content, timestamp: item.createdAtMs, + metadata: display.composerPresentation ? { composerPresentation: display.composerPresentation } : undefined }, + hostQueueStatus: failed ? 'failed' as const : 'pending' as const, + hostQueueAttachmentCount: item.attachmentCount, + }; + this.rows.set(item.turnId, { item, row }); + this.keys.set(item.turnId, row.presentationKey!); + // Pending user Turns follow all admitted work. If the first of several + // steering messages has started, the remaining previews follow its round + // rather than jumping ahead of that first message. + const anchor = !failed && latestCanonicalTurn ? latestCanonicalTurn : display.targetTurnId; + const targetRows = byTarget.get(anchor) ?? []; + targetRows.push(row); byTarget.set(anchor, targetRows); + if (!failed && present.has(item.turnId)) hidden.push(item.turnId); + } + if (!byTarget.size && !this.keys.size) return { items: sources, hidden, missing }; + const items: VirtualItem[] = []; + sources.forEach((source, index) => { + const key = source.type === 'user-message' ? this.keys.get(source.turnId) : undefined; + if (!(source.type === 'user-message' && source.submissionPhase && key)) { + if (key && source.type === 'user-message') { + let row = this.canonicalRows.get(source); + if (!row) { row = { ...source, presentationKey: key }; this.canonicalRows.set(source, row); } + items.push(row); + } else items.push(source); + } + if (lastIndexes.get(source.turnId) === index) { + items.push(...(byTarget.get(source.turnId) ?? [])); + } + }); + return { items, hidden, missing }; + } +} diff --git a/src/web-ui/src/flow_chat/timeline/useConversationTimeline.ts b/src/web-ui/src/flow_chat/timeline/useConversationTimeline.ts index 87f2a5209e..b0c7735cf8 100644 --- a/src/web-ui/src/flow_chat/timeline/useConversationTimeline.ts +++ b/src/web-ui/src/flow_chat/timeline/useConversationTimeline.ts @@ -7,9 +7,11 @@ import { useFlowChatVolatileContext } from '../components/modern/FlowChatContext import type { VirtualItem } from '../types/flow-chat-projection'; import { ConversationDocumentProjection } from './document'; import { useFlowChatReaderScope } from './readerState'; +import { useHostQueueTimeline } from './useHostQueueTimeline'; export function useConversationTimeline(sources: VirtualItem[], sessionId: string | undefined, viewport: string) { const { t } = useI18n('flow-chat'); + const transcriptSources = useHostQueueTimeline(sources, sessionId); const surface = getActiveSurfaceScope(); const reader = useFlowChatReaderScope(`${surface.epoch}:${viewport}:${sessionId ?? ''}`); const revision = useSyncExternalStore(reader.subscribeProjection, reader.getProjectionRevision, reader.getProjectionRevision); @@ -35,10 +37,10 @@ export function useConversationTimeline(sources: VirtualItem[], sessionId: strin const items = useMemo(() => { // Reader mutations can change a projection while the source array is stable. void revision; - return projection.project(sources, { + return projection.project(transcriptSources, { reader, sessionId, groupStates: groupStates ?? exploreGroupStates, expandedToolCapsules, pendingPermissionToolCallIds, toolLabels: labels, }); - }, [projection, sources, reader, sessionId, revision, groupStates, exploreGroupStates, expandedToolCapsules, pendingPermissionToolCallIds, labels]); + }, [projection, transcriptSources, reader, sessionId, revision, groupStates, exploreGroupStates, expandedToolCapsules, pendingPermissionToolCallIds, labels]); return { items, reader }; } diff --git a/src/web-ui/src/flow_chat/timeline/useHostQueueTimeline.test.tsx b/src/web-ui/src/flow_chat/timeline/useHostQueueTimeline.test.tsx new file mode 100644 index 0000000000..7e3ff97b20 --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/useHostQueueTimeline.test.tsx @@ -0,0 +1,88 @@ +/** @vitest-environment jsdom */ +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { expect, it, vi } from 'vitest'; +import { HostDialogQueue, type QueueItem, type QueueSnapshot } from '../../../../shared/dialog-queue/HostDialogQueue'; +import { activateSurface, getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; +import { hostQueuePresentation } from '../services/hostQueuePresentation'; +import { HostPendingQueuePanel } from '../components/HostPendingQueuePanel'; +import type { VirtualItem } from '../types/flow-chat-projection'; +import { useHostQueueTimeline } from './useHostQueueTimeline'; + +const selected = vi.hoisted(() => ({ queue: undefined as HostDialogQueue | undefined })); +vi.mock('../services/hostDialogQueue', () => ({ hostQueueSupported: () => !!selected.queue, hostDialogQueue: () => selected.queue })); +vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }) })); +vi.mock('../services/flow-chat-manager/PendingQueueModule', () => ({ pendingQueueManager: { enqueue: vi.fn() } })); + +const source: VirtualItem[] = [{ type: 'user-message', turnId: 'active', data: { id: 'u', content: 'initial', timestamp: 1 } }]; +const item: QueueItem = { turnId: 'next', displayContent: 'steering', previewTruncated: false, attachmentCount: 0, + agentType: 'Standard', createdAtMs: 2, status: 'steering_pending', reason: null, targetTurnId: 'active', steeringId: 's', + steeringDisplay: { targetTurnId: 'active', content: 'steering' } }; +const emptyStorage = { list: async () => [], put: async () => {}, remove: async () => {} }; + +it('hides only transcript-owned items, restores blocked controls and drops leases on surface change', async () => { + let snapshot: QueueSnapshot = { sessionId: 'session', queueEpoch: 'host', revision: 1, activeTurnId: 'active', + items: [item], used: 1, capacity: 20, receipt: null }; + const queue = new HostDialogQueue('scope', 'session', async () => snapshot, emptyStorage); + selected.queue = queue; + const scope = getActiveSurfaceScope(); + let rendered: VirtualItem[] = []; + function Transcript({ items }: { items: VirtualItem[] }) { + rendered = useHostQueueTimeline(items, 'session'); + return
{rendered.map(row => row.turnId).join(',')}
; + } + const container = document.createElement('div'); + const root = createRoot(container); + const render = (items: VirtualItem[]) => root.render(<> true} />); + try { + await act(async () => { render([]); await queue.refresh(); }); + expect(container.querySelectorAll('li')).toHaveLength(1); + await act(async () => render(source)); + expect(rendered).toHaveLength(2); + expect(container.querySelectorAll('li')).toHaveLength(0); + expect(hostQueuePresentation(queue).getSnapshot().has('next')).toBe(true); + snapshot = { ...snapshot, revision: 2, items: [{ ...item, status: 'blocked' }] }; + await act(async () => { await queue.refresh(); }); + expect(container.querySelectorAll('li')).toHaveLength(1); + expect(rendered[1]).toMatchObject({ hostQueueStatus: 'failed' }); + snapshot = { ...snapshot, revision: 3, items: [item] }; + await act(async () => { await queue.refresh(); }); + expect(hostQueuePresentation(queue).getSnapshot().has('next')).toBe(true); + await act(async () => { activateSurface('other-device'); }); + expect(scope.isCurrent()).toBe(false); + expect(hostQueuePresentation(queue).getSnapshot().size).toBe(0); + selected.queue = undefined; + await act(async () => render(source)); + expect(rendered).toBe(source); + } finally { + await act(async () => root.unmount()); + selected.queue = undefined; + activateSurface('local'); + } +}); + +it('queries a disappeared pending receipt and waits for canonical history instead of assuming consumption', async () => { + let snapshot: QueueSnapshot = { sessionId: 'session', queueEpoch: 'host', revision: 1, activeTurnId: 'active', + items: [item], used: 1, capacity: 20, receipt: null }; + const invoke = vi.fn(async request => request.action === 'get' ? { ...snapshot, receipt: { ...item, status: 'started' as const } } : snapshot); + const queue = new HostDialogQueue('scope', 'session', invoke, emptyStorage); + selected.queue = queue; + const container = document.createElement('div'); + const root = createRoot(container); + let rendered: VirtualItem[] = []; + function Transcript({ items }: { items: VirtualItem[] }) { + rendered = useHostQueueTimeline(items, 'session'); return null; + } + try { + await act(async () => { root.render(); await queue.refresh(); }); + snapshot = { ...snapshot, revision: 2, items: [] }; + await act(async () => { await queue.refresh(); }); + await act(async () => { await Promise.resolve(); }); + expect(invoke.mock.calls.some(([request]) => request.action === 'get' && request.turnId === 'next')).toBe(true); + expect(rendered[1]).toMatchObject({ hostQueueStatus: 'pending' }); + const formal: VirtualItem = { type: 'user-message', turnId: 'next', data: { id: 'real', content: 'steering', timestamp: 3 } }; + await act(async () => root.render()); + expect(rendered).toHaveLength(2); + expect(rendered[1]).not.toHaveProperty('hostQueueStatus'); + } finally { await act(async () => root.unmount()); selected.queue = undefined; } +}); diff --git a/src/web-ui/src/flow_chat/timeline/useHostQueueTimeline.ts b/src/web-ui/src/flow_chat/timeline/useHostQueueTimeline.ts new file mode 100644 index 0000000000..4889a792f7 --- /dev/null +++ b/src/web-ui/src/flow_chat/timeline/useHostQueueTimeline.ts @@ -0,0 +1,61 @@ +import { useEffect, useLayoutEffect, useMemo, useRef, useSyncExternalStore } from 'react'; +import type { QueueView } from '../../../../shared/dialog-queue/HostDialogQueue'; +import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; +import { createLogger } from '@/shared/utils/logger'; +import { hostDialogQueue, hostQueueSupported } from '../services/hostDialogQueue'; +import { hostQueuePresentation, observePresentedHostQueue } from '../services/hostQueuePresentation'; +import type { VirtualItem } from '../types/flow-chat-projection'; +import { HostQueueProjection } from './hostQueueProjection'; + +const EMPTY: QueueView = { snapshot: null, pending: [], error: null }; +const emptySnapshot = () => EMPTY; +const noopSubscribe = () => () => {}; +const log = createLogger('HostQueueTimeline'); + +export function useHostQueueTimeline(sources: VirtualItem[], sessionId?: string) { + const scope = getActiveSurfaceScope(); + const queue = sessionId && hostQueueSupported(sessionId) ? hostDialogQueue(sessionId) : undefined; + const view = useSyncExternalStore(queue?.subscribe ?? noopSubscribe, queue?.getSnapshot ?? emptySnapshot, emptySnapshot); + const projectionRef = useRef<{ + queue: typeof queue; epoch: number; sessionId?: string; value: HostQueueProjection; + }>(); + if (!projectionRef.current || projectionRef.current.queue !== queue + || projectionRef.current.epoch !== scope.epoch || projectionRef.current.sessionId !== sessionId) { + projectionRef.current = { queue, epoch: scope.epoch, sessionId, value: new HostQueueProjection() }; + } + const projection = projectionRef.current.value; + const result = useMemo(() => projection.project(sources, view.snapshot), [projection, sources, view.snapshot]); + const owner = useRef({}); + const tried = useRef(new Set()); + useEffect(() => { + if (!queue || !scope.isCurrent()) return; + const stop = observePresentedHostQueue(queue); + scope.signal.addEventListener('abort', stop, { once: true }); + return () => { scope.signal.removeEventListener('abort', stop); stop(); }; + }, [queue, scope]); + useLayoutEffect(() => { + if (!queue || !scope.isCurrent()) return; + const presentation = hostQueuePresentation(queue); + const lease = owner.current; + const clear = () => presentation.set(lease, []); + scope.signal.addEventListener('abort', clear, { once: true }); + return () => { scope.signal.removeEventListener('abort', clear); clear(); }; + }, [queue, scope]); + useLayoutEffect(() => { + if (queue && scope.isCurrent()) hostQueuePresentation(queue).set(owner.current, result.hidden); + }, [queue, scope, result]); + useEffect(() => { + if (!queue || !view.snapshot || view.error || !scope.isCurrent()) return; + const epoch = view.snapshot.queueEpoch; + for (const id of result.missing) { + const key = `${queue.scope}:${scope.epoch}:${epoch}:${id}`; + if (tried.current.has(key)) continue; + tried.current.add(key); + void queue.receipt(id, epoch).catch(error => { + tried.current.delete(key); + if (scope.isCurrent()) log.warn('Unable to reconcile accepted steering receipt', { sessionId, turnId: id, error }); + }); + } + }, [queue, scope, sessionId, view, result]); + return result.items; +} diff --git a/src/web-ui/src/flow_chat/types/flow-chat-projection.ts b/src/web-ui/src/flow_chat/types/flow-chat-projection.ts index 67b8bd0254..30f8ee64ef 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat-projection.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat-projection.ts @@ -30,6 +30,10 @@ type VirtualContent = /** Display-only state for a foreground send before its Turn is projected. */ submissionPhase?: 'forming' | 'failed'; submissionError?: string; + /** Host queue preview only; canonical history is never mutated. */ + hostQueueStatus?: 'pending' | 'failed'; + hostQueueAttachmentCount?: number; + presentationKey?: string; } | { type: 'user-steering-message'; diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 43ea76a65d..0d4ace770d 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -3507,6 +3507,7 @@ "steering": { "title": "Steer", "injectedAt": "Injected before round {{round}}", + "statusNotApplied": "Not applied; retry in the message queue", "statusPending": "Waiting to trigger", "statusInjected": "Triggered" }, diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index d6b0c1bb44..7b5abefa53 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -3507,6 +3507,7 @@ "steering": { "title": "立即引导", "injectedAt": "已在第 {{round}} 轮前插入", + "statusNotApplied": "未生效,请在消息队列中重试", "statusPending": "等待触发", "statusInjected": "已触发" }, diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 0da8ccc993..2d15ca4a80 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -3507,6 +3507,7 @@ "steering": { "title": "立即引導", "injectedAt": "已在第 {{round}} 輪前插入", + "statusNotApplied": "未生效,請在訊息佇列中重試", "statusPending": "等待觸發", "statusInjected": "已觸發" },