diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index 62be917fe4..eb528c299e 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -25,6 +25,7 @@ import { isStartupOverlayPresent, } from './startup/startupOverlay'; import { ToolbarModeProvider } from '../flow_chat/components/toolbar-mode/ToolbarModeProvider'; +import AskUserAnnouncer from './components/NavPanel/AskUserAnnouncer'; const log = createLogger('App'); @@ -803,6 +804,11 @@ function App() { {/* Announcement / feature-demo / tips system */} + {/* AskUserQuestion waiting-state aria-live announcer. + Mounted here (inside ToolbarModeProvider, outside LazyAppLayout) + so it persists across both normal and Toolbar Mode. */} + + diff --git a/src/web-ui/src/app/components/NavPanel/AskUserAnnouncer.test.ts b/src/web-ui/src/app/components/NavPanel/AskUserAnnouncer.test.ts new file mode 100644 index 0000000000..6356ad2788 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/AskUserAnnouncer.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { computeAnnouncementMessage } from './AskUserAnnouncer'; + +type TFunc = (key: string, params?: Record) => string; + +/** Mock t that embeds the key and params so tests can assert on them. */ +function mockT(key: string, params?: Record): string { + if (!params) return key; + const parts: string[] = []; + for (const [k, v] of Object.entries(params)) { + parts.push(`${k}=${v}`); + } + return `${key}[${parts.join(',')}]`; +} + +function map(entries: [string, string][]): Map { + return new Map(entries); +} + +describe('computeAnnouncementMessage', () => { + const t = mockT as TFunc; + + it('announces single add with session name', () => { + const prev = map([]); + const current = map([['s1', 'Task A']]); + const msg = computeAnnouncementMessage(prev, current, t); + expect(msg).toContain('ariaNeedsInputWithName'); + expect(msg).toContain('name=Task A'); + }); + + it('announces second consecutive add with the new session name (not skipped)', () => { + // First add: A starts waiting + const prev1 = map([]); + const current1 = map([['s1', 'Task A']]); + const msg1 = computeAnnouncementMessage(prev1, current1, t); + expect(msg1).toContain('name=Task A'); + + // Second add: B starts waiting while A is still waiting + const prev2 = current1; + const current2 = map([['s1', 'Task A'], ['s2', 'Task B']]); + const msg2 = computeAnnouncementMessage(prev2, current2, t); + // Must be different from msg1 (not the same string) + expect(msg2).not.toBe(msg1); + expect(msg2).toContain('name=Task B'); + }); + + it('announces plural count when multiple sessions added simultaneously', () => { + const prev = map([]); + const current = map([['s1', 'Task A'], ['s2', 'Task B']]); + const msg = computeAnnouncementMessage(prev, current, t); + expect(msg).toContain('ariaNeedsInputPlural'); + expect(msg).toContain('count=2'); + }); + + it('announces partial resolution with remaining count', () => { + const prev = map([['s1', 'Task A'], ['s2', 'Task B']]); + const current = map([['s2', 'Task B']]); + const msg = computeAnnouncementMessage(prev, current, t); + expect(msg).toContain('ariaInputResolvedRemaining'); + expect(msg).toContain('name=Task A'); + expect(msg).toContain('count=1'); + }); + + it('announces all resolved when last waiting session is removed', () => { + const prev = map([['s1', 'Task A']]); + const current = map([]); + const msg = computeAnnouncementMessage(prev, current, t); + expect(msg).toContain('ariaInputResolved'); + }); + + it('prioritises added over removed when both happen (swap)', () => { + const prev = map([['s1', 'Task A']]); + const current = map([['s2', 'Task B']]); + const msg = computeAnnouncementMessage(prev, current, t); + // Added takes priority — should announce the new session, not the resolved one + expect(msg).toContain('ariaNeedsInputWithName'); + expect(msg).toContain('name=Task B'); + }); + + it('returns empty string when nothing changed', () => { + const prev = map([['s1', 'Task A']]); + const current = map([['s1', 'Task A']]); + const msg = computeAnnouncementMessage(prev, current, t); + expect(msg).toBe(''); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/AskUserAnnouncer.tsx b/src/web-ui/src/app/components/NavPanel/AskUserAnnouncer.tsx new file mode 100644 index 0000000000..4cab680a92 --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/AskUserAnnouncer.tsx @@ -0,0 +1,120 @@ +import { useEffect, useRef } from 'react'; +import { useI18n, i18nService } from '@/infrastructure/i18n'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import { stateMachineManager } from '@/flow_chat/state-machine'; +import { SessionExecutionState } from '@/flow_chat/state-machine/types'; +import { hasPendingAskUserQuestion, resolveTrackedTurn } from '@/flow_chat/utils/askUserQuestionState'; +import { resolveSessionTitle } from '@/flow_chat/utils/sessionTitle'; + +type TFunc = (key: string, params?: Record) => string; + +/** + * Compute an aria-live message from the previous and current sets of waiting + * session titles. Exported as a pure function for unit testing. + * + * - Single add: "Session '' needs your input" + * - Multi add: " sessions need your input" + * - All resolved: "Sessions no longer waiting for input" + * - Partial: "Session '' received input. still waiting." + */ +export function computeAnnouncementMessage( + prevTitles: Map, + currentTitles: Map, + t: TFunc, +): string { + const added: string[] = []; + const removed: string[] = []; + for (const [id, title] of currentTitles) { + if (!prevTitles.has(id)) added.push(title); + } + for (const [id, title] of prevTitles) { + if (!currentTitles.has(id)) removed.push(title); + } + + if (added.length > 0) { + return added.length === 1 + ? t('nav.sessions.ariaNeedsInputWithName', { name: added[0] }) + : t('nav.sessions.ariaNeedsInputPlural', { count: currentTitles.size }); + } + if (removed.length > 0) { + if (currentTitles.size === 0) { + return t('nav.sessions.ariaInputResolved'); + } + return t('nav.sessions.ariaInputResolvedRemaining', { name: removed[0], count: currentTitles.size }); + } + return ''; +} + +/** + * Collect the current set of sessions waiting for AskUserQuestion input. + * Returns a Map of sessionId → display title. Excludes transient and + * subagent sessions (same filter as the visible nav list). + */ +function collectWaitingTitles(): Map { + const state = flowChatStore.getState(); + const result = new Map(); + for (const session of state.sessions.values()) { + if (session.isTransient || session.sessionKind === 'subagent') continue; + const machineState = stateMachineManager.getCurrentState(session.sessionId); + if ( + machineState !== SessionExecutionState.PROCESSING && + machineState !== SessionExecutionState.FINISHING + ) { + continue; + } + if (hasPendingAskUserQuestion(resolveTrackedTurn(session))) { + result.set(session.sessionId, resolveSessionTitle(session, (key, options) => i18nService.t(key, options))); + } + } + return result; +} + +/** + * Single-instance aria-live announcer for AskUserQuestion waiting-state + * changes. Rendered once in NavPanel to avoid duplicate announcements from + * per-workspace SessionsSection instances. + * + * Uses direct DOM text-content manipulation (clear → rAF → set) to force + * screen-reader re-announcement even when the message text is identical to + * the previous one. + */ +export default function AskUserAnnouncer() { + const { t } = useI18n('common'); + const liveRef = useRef(null); + const prevWaitingRef = useRef>(new Map()); + const tRef = useRef(t); + tRef.current = t; + + useEffect(() => { + const update = () => { + const current = collectWaitingTitles(); + const prev = prevWaitingRef.current; + const message = computeAnnouncementMessage(prev, current, tRef.current); + + if (message && liveRef.current) { + // Clear then set on next frame to force screen-reader re-announcement + // even when the message text is identical to the previous one. + liveRef.current.textContent = ''; + requestAnimationFrame(() => { + if (liveRef.current) { + liveRef.current.textContent = message; + } + }); + } + + prevWaitingRef.current = current; + }; + + update(); + const unsubStore = flowChatStore.subscribe(update); + const unsubMachines = stateMachineManager.subscribeGlobal(update); + return () => { + unsubStore(); + unsubMachines(); + }; + }, []); + + return ( + + ); +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss index 452e47ab4b..8f0af3588c 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.scss @@ -198,14 +198,23 @@ animation: bitfun-nav-session-spin 1s linear infinite; } + &.is-ask-user { + // Mix warning hue with text-primary to guarantee ≥3:1 contrast in both + // Light and Dark themes (pure --color-warning falls below threshold in + // Light theme). 70% text-primary keeps the icon readable at 14px. + color: color-mix(in srgb, var(--color-text-primary) 70%, var(--color-warning) 30%); + transition: opacity $motion-fast $easing-standard; + animation: bitfun-nav-ask-user-pulse 2s ease-in-out infinite; + } + .bitfun-nav-panel__inline-item:hover &, .bitfun-nav-panel__inline-item.is-active & { opacity: 1; } } - // 「助理会话」区块:行前导图标悬停微微放大(与顶部 __top-action-icon-slot 的 1.07 一致;运行中 Loader 仅用旋转动画) - &__items--session-blocks &__inline-item:hover &__inline-item-icon:not(.is-running) { + // 「助理会话」区块:行前导图标悬停微微放大(与顶部 __top-action-icon-slot 的 1.07 一致;运行中 Loader/问号仅用自身动画) + &__items--session-blocks &__inline-item:hover &__inline-item-icon:not(.is-running):not(.is-ask-user) { transform: scale(1.07); } @@ -686,6 +695,18 @@ } } +@keyframes bitfun-nav-ask-user-pulse { + 0%, + 100% { + opacity: 0.9; + transform: scale(1); + } + 50% { + opacity: 1; + transform: scale(1.12); + } +} + @keyframes bitfun-nav-background-subagent-bot-cycle { 0%, 62%, @@ -727,6 +748,11 @@ opacity: 0.8; } + .bitfun-nav-panel__inline-item-icon.is-ask-user { + animation: none; + opacity: 0.9; + } + .bitfun-nav-panel__inline-item-review-badge svg { animation: none; } @@ -743,11 +769,11 @@ } } - .bitfun-nav-panel__items--session-blocks .bitfun-nav-panel__inline-item:hover .bitfun-nav-panel__inline-item-icon:not(.is-running) { + .bitfun-nav-panel__items--session-blocks .bitfun-nav-panel__inline-item:hover .bitfun-nav-panel__inline-item-icon:not(.is-running):not(.is-ask-user) { transform: scale(1); } - .bitfun-nav-panel__items--session-blocks .bitfun-nav-panel__inline-item-icon:not(.is-running) { + .bitfun-nav-panel__items--session-blocks .bitfun-nav-panel__inline-item-icon:not(.is-running):not(.is-ask-user) { transition: opacity $motion-fast $easing-standard; } } diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 5a6f8ecc69..fa56329704 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -7,12 +7,13 @@ import React, { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { Pencil, Trash2, Check, X, Bot, Code2, ClipboardList, Panda, MoreHorizontal, Loader2, Archive, Clock3, Copy } from 'lucide-react'; +import { Pencil, Trash2, Check, X, Bot, Code2, ClipboardList, Panda, MoreHorizontal, Loader2, Archive, Clock3, Copy, CircleHelp } from 'lucide-react'; import { IconButton, Input, Tooltip } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { flowChatStore } from '../../../../../flow_chat/store/FlowChatStore'; import { flowChatManager } from '../../../../../flow_chat/services/FlowChatManager'; import type { FlowChatState, Session } from '../../../../../flow_chat/types/flow-chat'; +import { hasPendingAskUserQuestion, resolveTrackedTurn } from '../../../../../flow_chat/utils/askUserQuestionState'; import { useSceneStore } from '../../../../stores/sceneStore'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; import { createLogger } from '@/shared/utils/logger'; @@ -232,11 +233,13 @@ const SessionsSection: React.FC = ({ const parts: string[] = [s.activeSessionId ?? '']; for (const session of s.sessions.values()) { const latestTurn = session.dialogTurns[session.dialogTurns.length - 1]; + const trackedTurn = resolveTrackedTurn(session); + const hasAskUser = hasPendingAskUserQuestion(trackedTurn); parts.push( `${session.sessionId}|${session.isTransient ? '1':'0'}|${session.sessionKind}|` + `${session.parentSessionId ?? ''}|${session.parentToolCallId ?? ''}|${session.subagentType ?? ''}|` + `${session.workspacePath ?? ''}|${session.mode ?? ''}|${session.needsUserAttention ? '1':'0'}|` + - `${session.hasUnreadCompletion ? '1':'0'}|${latestTurn?.status ?? ''}|${session.title ?? ''}` + `${session.hasUnreadCompletion ? '1':'0'}|${latestTurn?.status ?? ''}|${hasAskUser ? '1':'0'}|${trackedTurn?.id ?? ''}|${session.title ?? ''}` ); } return parts.join(';'); @@ -957,6 +960,9 @@ const SessionsSection: React.FC = ({ const sessionModeKey = resolveSessionModeType(session); const sessionTitle = resolveSessionTitle(session); const isRunning = runningSessionIds.has(session.sessionId); + const isWaitingForUserAnswer = isRunning && hasPendingAskUserQuestion( + resolveTrackedTurn(session), + ); const isHighPriority = !!session.needsUserAttention; const backgroundSubagentActivity = !isChildSession ? backgroundSubagentActivityByParent.get(session.sessionId) @@ -1054,13 +1060,24 @@ const SessionsSection: React.FC = ({ {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)會話不能在助理區域建立。",