= ({
{showSessionModeIcon ? (
{isRunning ? (
-
+ isWaitingForUserAnswer ? (
+
+ ) : (
+
+ )
) : (
= ({
) : null}
+ {isWaitingForUserAnswer ? (
+ {t('nav.sessions.needsUserInput')}
+ ) : null}
+
{isEditing ? (
e.stopPropagation()}>
();
let nextTaskOrder = 0;
-const TRANSIENT_TURN_STATUSES = new Set([
- 'pending',
- 'image_analyzing',
- 'processing',
- 'finishing',
- 'cancelling',
-]);
const LATEST_OUTPUT_MAX_CHARS = 512;
-const TERMINAL_TOOL_STATUSES = new Set([
- 'completed',
- 'error',
- 'cancelled',
- 'rejected',
-]);
function ensureTaskOrder(sessionId: string): number {
const existingOrder = taskOrderBySessionId.get(sessionId);
@@ -153,35 +144,6 @@ function extractAskUserQuestionText(tool: FlowToolItem): string | undefined {
return undefined;
}
-function findPendingAskUserQuestion(
- turn: DialogTurn | undefined,
-): FlowToolItem | undefined {
- if (!turn || !TRANSIENT_TURN_STATUSES.has(turn.status)) {
- return undefined;
- }
-
- for (let roundIndex = turn.modelRounds.length - 1; roundIndex >= 0; roundIndex -= 1) {
- const round = turn.modelRounds[roundIndex];
- for (let itemIndex = round.items.length - 1; itemIndex >= 0; itemIndex -= 1) {
- const item = round.items[itemIndex];
- if (
- item.type === 'tool'
- && item.toolName === 'AskUserQuestion'
- && !TERMINAL_TOOL_STATUSES.has(item.status)
- && !item.isParamsStreaming
- ) {
- const input = item.toolCall?.input;
- const questions = input && typeof input === 'object' ? input.questions : undefined;
- if (Array.isArray(questions) && questions.length > 0) {
- return item;
- }
- }
- }
- }
-
- return undefined;
-}
-
function askUserQuestionAttentionTask(
session: Session,
snapshot: SessionStateMachine | null,
diff --git a/src/web-ui/src/flow_chat/utils/askUserQuestionState.test.ts b/src/web-ui/src/flow_chat/utils/askUserQuestionState.test.ts
new file mode 100644
index 0000000000..ed1a446f93
--- /dev/null
+++ b/src/web-ui/src/flow_chat/utils/askUserQuestionState.test.ts
@@ -0,0 +1,197 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { flowChatStore } from '../store/FlowChatStore';
+import { stateMachineManager } from '../state-machine/SessionStateMachineManager';
+import { SessionExecutionEvent } from '../state-machine/types';
+import type { DialogTurn, Session } from '../types/flow-chat';
+import {
+ findPendingAskUserQuestion,
+ hasPendingAskUserQuestion,
+ resolveTrackedTurn,
+} from './askUserQuestionState';
+
+function resetState(): void {
+ flowChatStore.setState(() => ({
+ sessions: new Map(),
+ activeSessionId: null,
+ }));
+ stateMachineManager.clear();
+}
+
+function createAskUserQuestionTool(turnId: string, status: string): DialogTurn {
+ return {
+ id: turnId,
+ sessionId: 'session-1',
+ userMessage: {
+ id: `user-${turnId}`,
+ content: 'Help me',
+ timestamp: 1000,
+ },
+ modelRounds: [{
+ id: 'round-1',
+ index: 0,
+ items: [{
+ id: 'tool-1',
+ type: 'tool',
+ toolName: 'AskUserQuestion',
+ timestamp: 1500,
+ status: status as any,
+ toolCall: {
+ id: 'tool-call-1',
+ input: {
+ questions: [{
+ header: 'Auth method',
+ question: 'Which library should we use?',
+ options: [
+ { label: 'date-fns', description: 'Lightweight' },
+ { label: 'moment', description: 'Legacy' },
+ ],
+ }],
+ },
+ },
+ requiresConfirmation: false,
+ isParamsStreaming: false,
+ }],
+ isStreaming: false,
+ isComplete: false,
+ status: 'running',
+ startTime: 1500,
+ }],
+ status: 'processing',
+ startTime: 1000,
+ };
+}
+
+function createQueuedTurn(turnId: string): DialogTurn {
+ return {
+ id: turnId,
+ sessionId: 'session-1',
+ userMessage: {
+ id: `user-${turnId}`,
+ content: 'Follow-up question',
+ timestamp: 2000,
+ },
+ modelRounds: [],
+ status: 'pending',
+ startTime: 2000,
+ };
+}
+
+function createSessionWithTwoTurns(): Session {
+ return {
+ sessionId: 'session-1',
+ title: 'Test Session',
+ dialogTurns: [
+ createAskUserQuestionTool('turn-A', 'running'),
+ createQueuedTurn('turn-B'),
+ ],
+ status: 'idle',
+ config: { agentType: 'agentic' },
+ createdAt: 900,
+ lastActiveAt: 2000,
+ updatedAt: 2000,
+ error: null,
+ isTransient: false,
+ };
+}
+
+describe('resolveTrackedTurn', () => {
+ afterEach(() => {
+ resetState();
+ });
+
+ it('returns the tracked turn (by currentDialogTurnId), not the last turn', async () => {
+ const session = createSessionWithTwoTurns();
+ flowChatStore.setState(() => ({
+ sessions: new Map([['session-1', session]]),
+ activeSessionId: 'session-1',
+ }));
+ // State machine tracks turn-A (the one with pending AskUserQuestion)
+ await stateMachineManager.transition('session-1', SessionExecutionEvent.START, {
+ taskId: 'session-1',
+ dialogTurnId: 'turn-A',
+ });
+
+ const tracked = resolveTrackedTurn(session);
+
+ expect(tracked?.id).toBe('turn-A');
+ });
+
+ it('detects pending AskUserQuestion via tracked turn even when a newer turn is queued', async () => {
+ const session = createSessionWithTwoTurns();
+ flowChatStore.setState(() => ({
+ sessions: new Map([['session-1', session]]),
+ activeSessionId: 'session-1',
+ }));
+ await stateMachineManager.transition('session-1', SessionExecutionEvent.START, {
+ taskId: 'session-1',
+ dialogTurnId: 'turn-A',
+ });
+
+ // Last turn is turn-B (no AskUserQuestion), but tracked turn is turn-A
+ const lastTurn = session.dialogTurns[session.dialogTurns.length - 1];
+ expect(hasPendingAskUserQuestion(lastTurn)).toBe(false);
+
+ const trackedTurn = resolveTrackedTurn(session);
+ expect(hasPendingAskUserQuestion(trackedTurn)).toBe(true);
+ });
+
+ it('returns false for pending AskUserQuestion once the tool is completed', async () => {
+ const session = createSessionWithTwoTurns();
+ flowChatStore.setState(() => ({
+ sessions: new Map([['session-1', session]]),
+ activeSessionId: 'session-1',
+ }));
+ await stateMachineManager.transition('session-1', SessionExecutionEvent.START, {
+ taskId: 'session-1',
+ dialogTurnId: 'turn-A',
+ });
+
+ // Before resolving: pending AskUserQuestion detected
+ expect(hasPendingAskUserQuestion(resolveTrackedTurn(session))).toBe(true);
+
+ // Resolve: mark the tool as completed
+ session.dialogTurns[0].modelRounds[0].items[0].status = 'completed';
+
+ // After resolving: no longer pending
+ expect(hasPendingAskUserQuestion(resolveTrackedTurn(session))).toBe(false);
+ });
+
+ it('falls back to the last turn when no state machine exists', () => {
+ const session = createSessionWithTwoTurns();
+
+ // No state machine set up — should fall back to last turn (turn-B)
+ const tracked = resolveTrackedTurn(session);
+ expect(tracked?.id).toBe('turn-B');
+ expect(hasPendingAskUserQuestion(tracked)).toBe(false);
+ });
+
+ it('falls back to the last turn when currentDialogTurnId does not match any turn', async () => {
+ const session = createSessionWithTwoTurns();
+ flowChatStore.setState(() => ({
+ sessions: new Map([['session-1', session]]),
+ activeSessionId: 'session-1',
+ }));
+ // State machine tracks a turn that doesn't exist in the session
+ await stateMachineManager.transition('session-1', SessionExecutionEvent.START, {
+ taskId: 'session-1',
+ dialogTurnId: 'turn-X',
+ });
+
+ const tracked = resolveTrackedTurn(session);
+ expect(tracked?.id).toBe('turn-B');
+ });
+});
+
+describe('findPendingAskUserQuestion', () => {
+ it('returns the tool item when found', () => {
+ const turn = createAskUserQuestionTool('turn-A', 'running');
+ const item = findPendingAskUserQuestion(turn);
+ expect(item).toBeDefined();
+ expect(item?.toolName).toBe('AskUserQuestion');
+ });
+
+ it('returns undefined for a turn without AskUserQuestion', () => {
+ const turn = createQueuedTurn('turn-B');
+ expect(findPendingAskUserQuestion(turn)).toBeUndefined();
+ });
+});
diff --git a/src/web-ui/src/flow_chat/utils/askUserQuestionState.ts b/src/web-ui/src/flow_chat/utils/askUserQuestionState.ts
new file mode 100644
index 0000000000..518ff87d85
--- /dev/null
+++ b/src/web-ui/src/flow_chat/utils/askUserQuestionState.ts
@@ -0,0 +1,91 @@
+import { stateMachineManager } from '../state-machine/SessionStateMachineManager';
+import type { DialogTurn, FlowToolItem, Session } from '../types/flow-chat';
+
+export const TRANSIENT_TURN_STATUSES = new Set([
+ 'pending',
+ 'image_analyzing',
+ 'processing',
+ 'finishing',
+ 'cancelling',
+]);
+
+const TERMINAL_TOOL_STATUSES = new Set([
+ 'completed',
+ 'error',
+ 'cancelled',
+ 'rejected',
+]);
+
+/**
+ * Scan a dialog turn's model rounds (newest-first) for a non-terminal
+ * AskUserQuestion tool item whose parameters have finished streaming and
+ * whose questions array is non-empty.
+ *
+ * Works for both active and non-active sessions because it inspects the
+ * turn items directly rather than relying on the needsUserAttention flag
+ * (which is only set for non-active sessions).
+ */
+export function findPendingAskUserQuestion(
+ turn: DialogTurn | undefined,
+): FlowToolItem | undefined {
+ if (!turn || !TRANSIENT_TURN_STATUSES.has(turn.status)) {
+ return undefined;
+ }
+
+ for (let roundIndex = turn.modelRounds.length - 1; roundIndex >= 0; roundIndex -= 1) {
+ const round = turn.modelRounds[roundIndex];
+ for (let itemIndex = round.items.length - 1; itemIndex >= 0; itemIndex -= 1) {
+ const item = round.items[itemIndex];
+ if (
+ item.type === 'tool'
+ && item.toolName === 'AskUserQuestion'
+ && !TERMINAL_TOOL_STATUSES.has(item.status)
+ && !item.isParamsStreaming
+ ) {
+ const input = item.toolCall?.input;
+ const questions = input && typeof input === 'object' ? input.questions : undefined;
+ if (Array.isArray(questions) && questions.length > 0) {
+ return item;
+ }
+ }
+ }
+ }
+
+ return undefined;
+}
+
+/**
+ * Boolean wrapper around findPendingAskUserQuestion for use in selectors
+ * and render-time checks where only the presence (not the item itself) is
+ * needed.
+ */
+export function hasPendingAskUserQuestion(
+ turn: DialogTurn | undefined,
+): boolean {
+ return !!findPendingAskUserQuestion(turn);
+}
+
+/**
+ * Resolve the dialog turn that the state machine is currently tracking for a
+ * session, falling back to the last turn when the machine has no
+ * currentDialogTurnId (e.g. session never started or was reset).
+ *
+ * This is necessary because the composer may append a newer turn (e.g. a
+ * queued user message) while the state machine is still executing an older
+ * turn that has a pending AskUserQuestion. Checking the last turn would miss
+ * the pending question; the tracked turn is the correct one to inspect.
+ */
+export function resolveTrackedTurn(
+ session: Session,
+): DialogTurn | undefined {
+ const trackedTurnId = stateMachineManager
+ .get(session.sessionId)
+ ?.getContext()?.currentDialogTurnId;
+ if (trackedTurnId) {
+ const tracked = session.dialogTurns.find(turn => turn.id === trackedTurnId);
+ if (tracked) {
+ return tracked;
+ }
+ }
+ return session.dialogTurns[session.dialogTurns.length - 1];
+}
diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json
index a7c1225d9b..9dbbb25a54 100644
--- a/src/web-ui/src/locales/en-US/common.json
+++ b/src/web-ui/src/locales/en-US/common.json
@@ -188,6 +188,11 @@
"needsToolConfirm": "Needs confirmation",
"badgeNeedsInput": "Waiting",
"badgeNeedsConfirm": "Confirm",
+ "ariaNeedsInput": "A session needs your input",
+ "ariaNeedsInputWithName": "Session '{{name}}' needs your input",
+ "ariaNeedsInputPlural": "{{count}} sessions need your input",
+ "ariaInputResolved": "Sessions no longer waiting for input",
+ "ariaInputResolvedRemaining": "Session '{{name}}' received input. {{count}} still waiting.",
"badgeBackgroundSubagents": "BG {{count}}",
"backgroundSubagentsRunning": "Background subagents running: {{count}}",
"needProjectWorkspaceForSession": "Open or add a project workspace first. Code and Cowork sessions cannot be created in the assistant area.",
diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json
index bf50542d5b..bcd8f20573 100644
--- a/src/web-ui/src/locales/zh-CN/common.json
+++ b/src/web-ui/src/locales/zh-CN/common.json
@@ -188,6 +188,11 @@
"needsToolConfirm": "等待确认",
"badgeNeedsInput": "等待中",
"badgeNeedsConfirm": "待确认",
+ "ariaNeedsInput": "有会话需要你的输入",
+ "ariaNeedsInputWithName": "会话 '{{name}}' 需要你的输入",
+ "ariaNeedsInputPlural": "{{count}} 个会话需要你的输入",
+ "ariaInputResolved": "会话已不再等待输入",
+ "ariaInputResolvedRemaining": "会话 '{{name}}' 已收到输入。还有 {{count}} 个会话等待中。",
"badgeBackgroundSubagents": "后台 {{count}}",
"backgroundSubagentsRunning": "{{count}} 个后台子 Agent 运行中",
"needProjectWorkspaceForSession": "请先打开或添加项目工作区。编码与工作(Cowork)会话不能在助理区域创建。",
diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json
index b2ed7e0edd..09f6dc7f4e 100644
--- a/src/web-ui/src/locales/zh-TW/common.json
+++ b/src/web-ui/src/locales/zh-TW/common.json
@@ -188,6 +188,11 @@
"needsToolConfirm": "等待確認",
"badgeNeedsInput": "等待中",
"badgeNeedsConfirm": "待確認",
+ "ariaNeedsInput": "有會話需要你的輸入",
+ "ariaNeedsInputWithName": "會話 '{{name}}' 需要你的輸入",
+ "ariaNeedsInputPlural": "{{count}} 個會話需要你的輸入",
+ "ariaInputResolved": "會話已不再等待輸入",
+ "ariaInputResolvedRemaining": "會話 '{{name}}' 已收到輸入。還有 {{count}} 個會話等待中。",
"badgeBackgroundSubagents": "後台 {{count}}",
"backgroundSubagentsRunning": "{{count}} 個背景子 Agent 運行中",
"needProjectWorkspaceForSession": "請先開啟或新增項目工作區。編碼與工作(Cowork)會話不能在助理區域建立。",