Skip to content

Commit 629696f

Browse files
committed
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.
1 parent c0b97b0 commit 629696f

21 files changed

Lines changed: 551 additions & 19 deletions

‎src/crates/assembly/core/src/agentic/coordination/host_message_queue.rs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,7 @@ impl DialogScheduler {
451451
reason: None,
452452
target_turn_id: None,
453453
steering_id: None,
454+
steering_display: None,
454455
};
455456
s.entries.insert(
456457
message.turn_id.clone(),
@@ -666,6 +667,19 @@ impl DialogScheduler {
666667
e.view.reason = None;
667668
e.view.target_turn_id = Some(target.clone());
668669
e.view.steering_id = Some(steering_id);
670+
e.view.steering_display =
671+
Some(openbitfun_runtime_ports::DialogQueueSteeringDisplay {
672+
target_turn_id: target.clone(),
673+
content: turn
674+
.original_user_input
675+
.clone()
676+
.unwrap_or_else(|| turn.user_input.clone()),
677+
composer_presentation: turn
678+
.user_message_metadata
679+
.as_ref()
680+
.and_then(|metadata| metadata.get("composerPresentation"))
681+
.cloned(),
682+
});
669683
e.held = Some(turn);
670684
}
671685
self.round_injection_buffer.push(session, injection);

‎src/crates/assembly/core/src/agentic/coordination/host_message_queue_tests.rs‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -196,13 +196,15 @@ async fn host_queue_cancel_is_idempotent_and_never_cancels_active_turn() {
196196
#[tokio::test]
197197
async fn host_queue_steering_retains_payload_until_formal_turn_handoff_and_rejects_cancel() {
198198
let (scheduler, _, _root, epoch) = fixture().await;
199+
let mut input = message("queued-a");
200+
let display_content = "display text ".repeat(250);
201+
input.display_content = Some(display_content.clone());
202+
input.metadata.insert(
203+
"composerPresentation".into(),
204+
serde_json::json!({"version":1}),
205+
);
199206
scheduler
200-
.manage_host_queue(request(
201-
Some(&epoch),
202-
Action::Submit {
203-
message: message("queued-a"),
204-
},
205-
))
207+
.manage_host_queue(request(Some(&epoch), Action::Submit { message: input }))
206208
.await
207209
.unwrap();
208210
let promote = request(
@@ -214,7 +216,16 @@ async fn host_queue_steering_retains_payload_until_formal_turn_handoff_and_rejec
214216
},
215217
);
216218
let snapshot = scheduler.manage_host_queue(promote.clone()).await.unwrap();
217-
assert_eq!(snapshot.receipt.unwrap().status, Status::SteeringPending);
219+
let receipt = snapshot.receipt.unwrap();
220+
assert_eq!(receipt.status, Status::SteeringPending);
221+
assert!(receipt.preview_truncated);
222+
let display = receipt.steering_display.unwrap();
223+
assert_eq!(display.target_turn_id, "active-turn");
224+
assert_eq!(display.content, display_content);
225+
assert_eq!(
226+
display.composer_presentation,
227+
Some(serde_json::json!({"version":1}))
228+
);
218229
scheduler.manage_host_queue(promote).await.unwrap();
219230
let injections = scheduler
220231
.round_injection_source
@@ -254,7 +265,9 @@ async fn host_queue_steering_retains_payload_until_formal_turn_handoff_and_rejec
254265
))
255266
.await
256267
.unwrap();
257-
assert_eq!(result.receipt.unwrap().status, Status::Queued);
268+
let receipt = result.receipt.unwrap();
269+
assert_eq!(receipt.status, Status::Queued);
270+
assert_eq!(receipt.steering_display.unwrap(), display);
258271
assert_eq!(result.used, 1);
259272
assert_eq!(scheduler.queues.depth("host-queue-session"), 1);
260273
assert_eq!(

‎src/crates/contracts/runtime-ports/src/dialog_queue.rs‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,16 @@ impl DialogQueueStatus {
7070
}
7171
}
7272

73+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
74+
#[serde(rename_all = "camelCase")]
75+
pub struct DialogQueueSteeringDisplay {
76+
/// The original target survives the brief queued handoff to a formal Turn.
77+
pub target_turn_id: String,
78+
pub content: String,
79+
#[serde(default, skip_serializing_if = "Option::is_none")]
80+
pub composer_presentation: Option<serde_json::Value>,
81+
}
82+
7383
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
7484
#[serde(rename_all = "camelCase")]
7585
pub struct DialogQueueItem {
@@ -83,6 +93,10 @@ pub struct DialogQueueItem {
8393
pub reason: Option<String>,
8494
pub target_turn_id: Option<String>,
8595
pub steering_id: Option<String>,
96+
/// Display-only receipt for accepted steering; never a second history record.
97+
/// Older hosts omit this field and clients retain the composer queue UI.
98+
#[serde(default, skip_serializing_if = "Option::is_none")]
99+
pub steering_display: Option<DialogQueueSteeringDisplay>,
86100
}
87101

88102
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -132,4 +146,20 @@ mod tests {
132146
let request: DialogQueueRequest = serde_json::from_value(input.clone()).unwrap();
133147
assert_eq!(serde_json::to_value(request).unwrap(), input);
134148
}
149+
150+
#[test]
151+
fn queue_item_legacy_and_display_receipts_round_trip() {
152+
let legacy = serde_json::json!({"turnId":"next", "displayContent":"short",
153+
"previewTruncated":true, "attachmentCount":2, "agentType":"Standard",
154+
"createdAtMs":1, "status":"steering_pending", "reason":null,
155+
"targetTurnId":"active", "steeringId":"steer"});
156+
let item: DialogQueueItem = serde_json::from_value(legacy.clone()).unwrap();
157+
assert!(item.steering_display.is_none());
158+
assert_eq!(serde_json::to_value(item).unwrap(), legacy);
159+
let mut extended = legacy;
160+
extended["steeringDisplay"] = serde_json::json!({"targetTurnId":"active", "content":"complete prompt",
161+
"composerPresentation":{"version":1}});
162+
let item: DialogQueueItem = serde_json::from_value(extended.clone()).unwrap();
163+
assert_eq!(serde_json::to_value(item).unwrap(), extended);
164+
}
135165
}

‎src/shared/dialog-queue/HostDialogQueue.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export interface QueueItem {
1414
turnId: string; displayContent: string; previewTruncated: boolean; attachmentCount: number;
1515
agentType: string; createdAtMs: number; status: QueueStatus; reason: string | null;
1616
targetTurnId: string | null; steeringId: string | null;
17+
/** Optional host-authoritative display receipt; absent on older hosts. */
18+
steeringDisplay?: { targetTurnId: string; content: string; composerPresentation?: unknown };
1719
}
1820
export interface QueueSnapshot {
1921
sessionId: string; queueEpoch: string; revision: number; activeTurnId: string | null;

‎src/web-ui/src/flow_chat/components/HostPendingQueuePanel.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { useI18n } from '@/infrastructure/i18n';
55
import { Button, Icon, IconButton, OverflowText, Tooltip } from '@openbitfun/ui';
66
import { RotateCw } from 'lucide-react';
77
import './HostPendingQueuePanel.scss';
8-
import { HostDialogQueue, observeHostQueue, type QueueOutboxRecord, type QueueItem } from '../../../../shared/dialog-queue/HostDialogQueue';
8+
import { hostQueuePresentation, observePresentedHostQueue } from '../services/hostQueuePresentation';
9+
import { HostDialogQueue, type QueueOutboxRecord, type QueueItem } from '../../../../shared/dialog-queue/HostDialogQueue';
910
import {
1011
ChatComposerQueue, ChatComposerQueueHeader, ChatComposerQueueTitle,
1112
ChatComposerQueueList, ChatComposerQueueItem, ChatComposerQueueItemContent, ChatComposerQueueItemActions,
@@ -14,12 +15,14 @@ import {
1415
export function HostPendingQueuePanel({ queue, onRestore }: { queue: HostDialogQueue; onRestore: (item: QueuedMessage) => boolean }) {
1516
const { t } = useI18n('flow-chat');
1617
const view = useSyncExternalStore(queue.subscribe, queue.getSnapshot, queue.getSnapshot);
18+
const presentation = hostQueuePresentation(queue);
19+
const projected = useSyncExternalStore(presentation.subscribe, presentation.getSnapshot, presentation.getSnapshot);
1720
const [expanded, setExpanded] = useState(true);
1821
const [showHelp, setShowHelp] = useState(false);
1922
const listId = useId();
2023
const [busy, setBusy] = useState(false);
2124
const [error, setError] = useState<string | null>(null);
22-
useEffect(() => observeHostQueue(queue), [queue]);
25+
useEffect(() => observePresentedHostQueue(queue), [queue]);
2326
const run = async (operation: () => Promise<unknown>) => {
2427
setBusy(true); setError(null);
2528
try { await operation(); } catch (e) { setError(String(e)); }
@@ -49,7 +52,7 @@ export function HostPendingQueuePanel({ queue, onRestore }: { queue: HostDialogQ
4952
}
5053
await queue.dismiss(saved);
5154
};
52-
const items = view.snapshot?.items ?? [];
55+
const items = (view.snapshot?.items ?? []).filter(item => item.status === 'blocked' || !projected.has(item.turnId));
5356
const visibleError = error || (view.error?.includes('Session is not loaded') ? null : view.error);
5457
if (!items.length && !view.pending.length && !visibleError) return null;
5558
return <ChatComposerQueue data-openbitfun-product-component="pending-queue-panel" data-openbitfun-product-part="root" className="host-pending-queue" aria-label={t('hostQueue.title')}>

‎src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VERIFICATION.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ each missing what the other had.
1616

1717
| Test | Contract it holds |
1818
|---|---|
19+
| `../../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 |
1920
| `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 |
2021
| `../../../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 |
2122
| `../../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.
345346
11. With reduced motion enabled, the hover fan changes without animation. Touch
346347
navigation must not leave a hover fan behind.
347348

349+
### Accepted steering
350+
351+
1. Send immediately during a long tool/model round in the main transcript and
352+
BTW pane. After host acceptance, verify the message uses the ordinary user-message
353+
appearance without a pending label and leaves the composer queue. Confirm long text and attachment count.
354+
2. Let the formal Turn start. Verify one message remains and
355+
attachments appear normally. Send multiple messages and check ordering.
356+
3. Exercise blocked handoff, history navigation, transcript closure, disconnect,
357+
reconnect, host restart and device switching; recovery controls must remain
358+
available and another device's preview must never appear.
359+
4. Repeat on a remote workspace and Peer Device host with two controllers, one
360+
sending from mobile. Mobile retains its existing queue UI. Check an older
361+
host without display receipts retains the existing desktop queue UI. Detached
362+
dispatch and ACP use their existing paths and have no preview requirement.
363+
348364
### History paging demand
349365

350366
1. Page upward at least three times from a long session's live tail, and page

‎src/web-ui/src/flow_chat/components/modern/FLOWCHAT_VIRTUALIZATION.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,26 @@
11
# FlowChat Virtualization
22

3+
## Accepted steering display
4+
5+
The host queue's optional `steeringDisplay` receipt projects an accepted message
6+
after its target Turn, without changing recorded rounds or writing a second
7+
history record. Full display text and composer references come from the host;
8+
pending attachments show their count rather than retransmitting image bytes.
9+
Accepted text messages use the ordinary user-message appearance without a pending
10+
status label; internal admission state still disables edit and rollback actions.
11+
Formal Turn handoff keeps the original queue Turn id and the preview's virtual
12+
key. Canonical history replaces the preview even when events precede the receipt.
13+
Queue disappearance alone is not consumption: the observer queries the receipt
14+
and retains the pending shell until history arrives. Blocked messages show a
15+
not-applied label and restore composer recovery controls.
16+
17+
Only mounted transcript projections lease hidden queue rows. History windows
18+
without the target, older hosts without display receipts, and closed transcripts
19+
retain queue controls. Leases and previews are isolated by queue client, surface
20+
activation and host queue epoch. Restart recovery remains with the existing
21+
outbox; mobile retains its existing queue presentation. No scroll placement or
22+
mount animation is added, and execution order remains host-owned.
23+
324
## Measured row spacing
425

526
`VirtualItemRenderer` establishes a `flow-root` on the measured wrapper so

‎src/web-ui/src/flow_chat/components/modern/UserMessageItem.test.tsx‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,34 @@ describe('UserMessageItem steering tag', () => {
438438
expect(container.querySelector('.user-message-item__actions')?.parentElement).toBe(meta);
439439
});
440440

441+
it.each(['pending', 'failed'] as const)('shows host queue %s feedback without edit or rollback actions', status => {
442+
act(() => root.render(
443+
<FlowChatContext.Provider value={{ allowUserMessageRollback: true, allowUserMessageEdit: true }}>
444+
<UserMessageItem message={{ id: 'preview', content: 'Accepted instruction', timestamp: 1000 }}
445+
turnId="queued-turn" hostQueueStatus={status} hostQueueAttachmentCount={2} />
446+
</FlowChatContext.Provider>,
447+
));
448+
if (status === 'failed') expect(container.querySelector('.user-message-item__steering-tag')?.textContent)
449+
.toContain('steering.statusNotApplied');
450+
expect(container.querySelector('.user-message-item__steering-tag')?.textContent).toContain('hostQueue.attachments');
451+
expect(container.querySelector('.user-message-item__edit-btn')).toBeNull();
452+
expect(container.querySelector('.user-message-item__rollback-btn')).toBeNull();
453+
expect(container.querySelector('.user-message-item__copy-btn')).not.toBeNull();
454+
});
455+
456+
it('renders an accepted text message without a pending status label', () => {
457+
act(() => root.render(
458+
<FlowChatContext.Provider value={{ allowUserMessageRollback: true, allowUserMessageEdit: true }}>
459+
<UserMessageItem message={{ id: 'preview-text', content: 'Accepted instruction', timestamp: 1000 }}
460+
turnId="queued-turn" hostQueueStatus="pending" />
461+
</FlowChatContext.Provider>,
462+
));
463+
expect(container.querySelector('.user-message-item__steering-tag')).toBeNull();
464+
expect(container.textContent).toContain('Accepted instruction');
465+
expect(container.querySelector('.user-message-item__edit-btn')).toBeNull();
466+
expect(container.querySelector('.user-message-item__rollback-btn')).toBeNull();
467+
});
468+
441469
it('does not render a steering tag after steering is triggered', () => {
442470
act(() => {
443471
root.render(

‎src/web-ui/src/flow_chat/components/modern/UserMessageItem.tsx‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ interface UserMessageItemProps {
7676
steeringStatus?: FlowUserSteeringItem['status'];
7777
submissionPhase?: 'forming' | 'failed';
7878
submissionError?: string;
79+
hostQueueStatus?: 'pending' | 'failed';
80+
hostQueueAttachmentCount?: number;
7981
}
8082

8183
function buildPresentationRerunPayload(presentation: ComposerPresentation): {
@@ -108,7 +110,7 @@ function buildPresentationRerunPayload(presentation: ComposerPresentation): {
108110
}
109111

110112
export const UserMessageItem = React.memo<UserMessageItemProps>(
111-
({ message, turnId, absoluteTurnIndex, turnStatus, steeringStatus, submissionPhase, submissionError }) => {
113+
({ message, turnId, absoluteTurnIndex, turnStatus, steeringStatus, submissionPhase, submissionError, hostQueueStatus, hostQueueAttachmentCount }) => {
112114
const { t, formatDate } = useI18n('flow-chat');
113115
const {
114116
sessionId,
@@ -203,7 +205,7 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
203205
const isFailed = resolvedTurnStatus === 'error';
204206
const resolvedSessionId = sessionId ?? currentSession?.sessionId;
205207
useSubmittedMessageMotion(shellRef, resolvedSessionId, turnId, message?.id,
206-
isFailed || isEditing || submissionPhase === 'failed', submissionPhase);
208+
isFailed || isEditing || submissionPhase === 'failed' || !!hostQueueStatus, submissionPhase);
207209
const sessionMachine = useSessionStateMachine(resolvedSessionId ?? null);
208210
const sessionExecutionState = sessionMachine && sessionMachine.sessionId === resolvedSessionId
209211
? sessionMachine.currentState
@@ -227,6 +229,7 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
227229
);
228230
const canRollback =
229231
!steeringStatus &&
232+
!hostQueueStatus &&
230233
canShowRollbackAction &&
231234
!!resolvedSessionId &&
232235
actionTurnIndex >= 0 &&
@@ -241,9 +244,9 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
241244
!isRemoteSession &&
242245
!isThreadGoalSystemMessage &&
243246
!isSystemTriggered &&
244-
!steeringStatus;
247+
!steeringStatus && !hostQueueStatus;
245248
const canEdit = canEditBase && isSessionIdle && !isEditSubmitting && !sessionMutation;
246-
const canShowEditAction = allowUserMessageEdit && !isFailed && !isThreadGoalSystemMessage;
249+
const canShowEditAction = allowUserMessageEdit && !isFailed && !isThreadGoalSystemMessage && !hostQueueStatus;
247250
const editDisabledReason = isDispatchSession
248251
? t('message.editDisabledDispatch')
249252
: isRemoteSession
@@ -266,7 +269,17 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
266269
: !isSessionIdle
267270
? t('message.rollbackDisabledBusy')
268271
: t('message.cannotRollback');
269-
const steeringTag = steeringStatus === 'pending'
272+
const hostQueueLabel = hostQueueStatus
273+
? [hostQueueStatus === 'failed' ? t('steering.statusNotApplied') : '',
274+
hostQueueAttachmentCount ? t('hostQueue.attachments', { count: hostQueueAttachmentCount }) : '']
275+
.filter(Boolean).join(' · ')
276+
: '';
277+
const steeringTag = hostQueueLabel
278+
? {
279+
className: 'user-message-item__steering-tag--pending',
280+
label: hostQueueLabel,
281+
}
282+
: steeringStatus === 'pending'
270283
? {
271284
className: 'user-message-item__steering-tag--pending',
272285
label: t('steering.statusPending'),
@@ -763,7 +776,7 @@ export const UserMessageItem = React.memo<UserMessageItemProps>(
763776
icon={<Icon name="arrow-down" size="sm" />}
764777
/>
765778
</Tooltip>
766-
) : canShowRollbackAction && !steeringStatus ? (
779+
) : canShowRollbackAction && !steeringStatus && !hostQueueStatus ? (
767780
<Tooltip content={rollbackTooltip}>
768781
<IconButton
769782
className="user-message-item__rollback-btn"

‎src/web-ui/src/flow_chat/components/modern/VirtualItemRenderer.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ export const VirtualItemRenderer = React.memo<VirtualItemRendererProps>(
6767
turnStatus={item.turnStatus}
6868
submissionPhase={item.submissionPhase}
6969
submissionError={item.submissionError}
70+
hostQueueStatus={item.hostQueueStatus}
71+
hostQueueAttachmentCount={item.hostQueueAttachmentCount}
7072
/>
7173
);
7274

0 commit comments

Comments
 (0)