From 69c6b773155cc3cc8626be36589ab10fbf5f7854 Mon Sep 17 00:00:00 2001 From: ArrogHie Date: Tue, 14 Jul 2026 10:42:09 +0800 Subject: [PATCH 1/2] feat(nav): show question mark icon when AskUserQuestion is pending Replace the spinning Loader2 icon with a pulsing CircleHelp icon in the left nav session list when a running session has a pending AskUserQuestion tool call, so users can distinguish 'waiting for your input' from 'agent is working'. - Extract findPendingAskUserQuestion into a shared util (askUserQuestionState.ts) with a boolean wrapper hasPendingAskUserQuestion - Add hasPendingAskUserQuestion to the SessionsSection selector string so the nav row re-renders when the pending status changes (fixes active session not refreshing) - Render CircleHelp (is-ask-user class, warning color + pulse animation) instead of Loader2 when isRunning && isWaitingForUserAnswer - Add prefers-reduced-motion fallback and hover-scale exclusions for the new icon class fix(nav): use tracked turn, aria-live, and contrast fix for AskUserQuestion icon Address three P2 review findings: 1. Use state-machine tracked turn (currentDialogTurnId) instead of last dialogTurn to detect pending AskUserQuestion. When a newer turn is queued while the tracked turn still has a pending question, the last-turn check would miss it. Added resolveTrackedTurn helper and included tracked turn ID in the selector fingerprint. 2. Add aria-live=polite status region so screen readers announce when sessions start/stop waiting for user input without focus movement. The CircleHelp icon is now decorative (aria-hidden); announcement is centralized and deduplicated. 3. Fix Light-theme contrast: replace pure --color-warning with color-mix(text-primary 70%, warning 30%) to exceed the 3:1 non-text contrast threshold. Raised pulse minimum opacity from 0.85 to 0.9. Added regression tests for tracked turn resolution with queued turns. fix(a11y): single-owner aria-live, distinguishable messages, per-row status Address three P2 accessibility review findings: 1. Move aria-live region from per-workspace SessionsSection (multi- instance) to a single AskUserAnnouncer component rendered once in NavPanel. Eliminates duplicate announcements across workspace instances and filters out transient/subagent sessions. 2. Rewrite announcement logic: explicitly compute added/removed session IDs with titles. Single add announces session name; multi-add announces count; partial resolution announces resolved name + remaining count; all-resolved announces clearance. Uses clear-then-rAF DOM manipulation to force re-announcement even with identical text. Added computeAnnouncementMessage pure function with 7 tests covering consecutive add, partial resolution, swap, and no-change scenarios. 3. Add per-row sr-only status text ('Needs your input') inside each waiting session row so screen-reader users can identify which session needs action when navigating the list. CircleHelp icon stays aria-hidden. Added i18n keys (ariaNeedsInputWithName, ariaInputResolvedRemaining) to en-US, zh-CN, zh-TW locales. Code by AI. --- .../NavPanel/AskUserAnnouncer.test.ts | 86 ++++++++ .../components/NavPanel/AskUserAnnouncer.tsx | 120 +++++++++++ .../src/app/components/NavPanel/NavPanel.tsx | 2 + .../sections/sessions/SessionsSection.scss | 34 ++- .../sections/sessions/SessionsSection.tsx | 39 +++- .../flow_chat/utils/agentCompanionActivity.ts | 46 +--- .../utils/askUserQuestionState.test.ts | 197 ++++++++++++++++++ .../flow_chat/utils/askUserQuestionState.ts | 91 ++++++++ src/web-ui/src/locales/en-US/common.json | 5 + src/web-ui/src/locales/zh-CN/common.json | 5 + src/web-ui/src/locales/zh-TW/common.json | 5 + 11 files changed, 575 insertions(+), 55 deletions(-) create mode 100644 src/web-ui/src/app/components/NavPanel/AskUserAnnouncer.test.ts create mode 100644 src/web-ui/src/app/components/NavPanel/AskUserAnnouncer.tsx create mode 100644 src/web-ui/src/flow_chat/utils/askUserQuestionState.test.ts create mode 100644 src/web-ui/src/flow_chat/utils/askUserQuestionState.ts 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/NavPanel.tsx b/src/web-ui/src/app/components/NavPanel/NavPanel.tsx index 20396292a9..b9109d36df 100644 --- a/src/web-ui/src/app/components/NavPanel/NavPanel.tsx +++ b/src/web-ui/src/app/components/NavPanel/NavPanel.tsx @@ -20,6 +20,7 @@ import { useNavSceneStore } from '../../stores/navSceneStore'; import { getSceneNav } from '../../scenes/nav-registry'; import type { SceneTabId } from '../SceneBar/types'; import MainNav from './MainNav'; +import AskUserAnnouncer from './AskUserAnnouncer'; import PersistentFooterActions from './components/PersistentFooterActions'; import { PeerRemoteBadge } from '@/infrastructure/peer-device/PeerRemoteBadge'; import './NavPanel.scss'; @@ -83,6 +84,7 @@ const NavPanel: React.FC = ({ className = '' }) => { return (