Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/web-ui/src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -803,6 +804,11 @@ function App() {
{/* Announcement / feature-demo / tips system */}
<AnnouncementProvider />

{/* AskUserQuestion waiting-state aria-live announcer.
Mounted here (inside ToolbarModeProvider, outside LazyAppLayout)
so it persists across both normal and Toolbar Mode. */}
<AskUserAnnouncer />

</ToolbarModeProvider>
</SSHRemoteProvider>
</ViewModeProvider>
Expand Down
86 changes: 86 additions & 0 deletions src/web-ui/src/app/components/NavPanel/AskUserAnnouncer.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import { computeAnnouncementMessage } from './AskUserAnnouncer';

type TFunc = (key: string, params?: Record<string, unknown>) => string;

/** Mock t that embeds the key and params so tests can assert on them. */
function mockT(key: string, params?: Record<string, unknown>): 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<string, string> {
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('');
});
});
120 changes: 120 additions & 0 deletions src/web-ui/src/app/components/NavPanel/AskUserAnnouncer.tsx
Original file line number Diff line number Diff line change
@@ -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, unknown>) => 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 '<name>' needs your input"
* - Multi add: "<n> sessions need your input"
* - All resolved: "Sessions no longer waiting for input"
* - Partial: "Session '<name>' received input. <n> still waiting."
*/
export function computeAnnouncementMessage(

Check warning on line 20 in src/web-ui/src/app/components/NavPanel/AskUserAnnouncer.tsx

View workflow job for this annotation

GitHub Actions / Frontend Build

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
prevTitles: Map<string, string>,
currentTitles: Map<string, string>,
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<string, string> {
const state = flowChatStore.getState();
const result = new Map<string, string>();
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<HTMLSpanElement>(null);
const prevWaitingRef = useRef<Map<string, string>>(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 (
<span ref={liveRef} role="status" aria-live="polite" className="sr-only" />
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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%,
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -232,11 +233,13 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
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(';');
Expand Down Expand Up @@ -957,6 +960,9 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
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)
Expand Down Expand Up @@ -1054,13 +1060,24 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
{showSessionModeIcon ? (
<span className="bitfun-nav-panel__inline-item-icon-slot">
{isRunning ? (
<Loader2
size={14}
className={[
'bitfun-nav-panel__inline-item-icon',
'is-running',
].join(' ')}
/>
isWaitingForUserAnswer ? (
<CircleHelp
size={14}
className={[
'bitfun-nav-panel__inline-item-icon',
'is-ask-user',
].join(' ')}
aria-hidden="true"
/>
) : (
<Loader2
size={14}
className={[
'bitfun-nav-panel__inline-item-icon',
'is-running',
].join(' ')}
/>
)
) : (
<SessionIcon
size={14}
Expand Down Expand Up @@ -1100,6 +1117,10 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
</span>
) : null}

{isWaitingForUserAnswer ? (
<span className="sr-only">{t('nav.sessions.needsUserInput')}</span>
) : null}

{isEditing ? (
<div className="bitfun-nav-panel__inline-item-edit" onClick={e => e.stopPropagation()}>
<Input
Expand Down
Loading
Loading