From 16bcdcf91b8481c5697e3a9f3c95fffa1ca378e0 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 23 Oct 2026 19:52:17 +0800 Subject: [PATCH 01/13] fix(ui): distinguish unused sessions from missing usage telemetry --- .../usage/SessionUsageComponents.test.tsx | 22 +++++++++++++++++++ .../usage/SessionUsageEmptyState.tsx | 14 ++++++++++++ .../components/usage/SessionUsagePanel.tsx | 6 +++++ .../usage/SessionUsageReportCard.tsx | 6 +++++ .../components/usage/usageReportUtils.ts | 12 +++++++++- src/web-ui/src/locales/en-US/flow-chat.json | 4 +++- src/web-ui/src/locales/zh-CN/flow-chat.json | 4 +++- src/web-ui/src/locales/zh-TW/flow-chat.json | 4 +++- 8 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 src/web-ui/src/flow_chat/components/usage/SessionUsageEmptyState.tsx diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx b/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx index 51e560c08f..31c3584244 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsageComponents.test.tsx @@ -532,6 +532,28 @@ describe('Session usage report UI components', () => { }); }; + it.each([undefined, null])('distinguishes a new session from missing telemetry with a %s file count', changedFiles => { + const report = usageReport({ + scope: { kind: 'entire_session', turnCount: 0, includesSubagents: false }, + tokens: { source: 'unavailable', cacheCoverage: 'unavailable' }, + models: [{ modelId: 'unknown_model', callCount: 0 }], + tools: [], files: { scope: 'unavailable', files: [] }, + errors: { totalErrors: 0, toolErrors: 0, modelErrors: 0, examples: [] }, + compression: { compactionCount: 0, manualCompactionCount: 0, automaticCompactionCount: 0 }, + }); + // Rust Option serializes as null; legacy payloads may omit the field. + Object.assign(report.files, { changedFiles }); + for (const Component of [SessionUsageReportCard, SessionUsagePanel]) { + render(); + expect(container.querySelector('[data-testid="session-usage-empty"]')).not.toBeNull(); + expect(container.textContent).not.toContain('Unavailable'); + render(); + expect(container.querySelector('[data-testid="session-usage-empty"]')).toBeNull(); + render(); + expect(container.querySelector('[data-testid="session-usage-empty"]')).toBeNull(); + } + }); + it('renders localized partial coverage and cache unavailable without showing zero', () => { const onOpenDetails = vi.fn(); const report = usageReport(); diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsageEmptyState.tsx b/src/web-ui/src/flow_chat/components/usage/SessionUsageEmptyState.tsx new file mode 100644 index 0000000000..84a015ed5f --- /dev/null +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsageEmptyState.tsx @@ -0,0 +1,14 @@ +import { Empty } from '@openbitfun/ui'; +import { useTranslation } from 'react-i18next'; + +export function SessionUsageEmptyState() { + const { t } = useTranslation('flow-chat'); + return ( + + ); +} diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.tsx b/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.tsx index 626372db84..9b9f5c8b9c 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.tsx +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsagePanel.tsx @@ -15,6 +15,7 @@ import { import { calculateShare, buildSessionUsageExportMarkdown, + isUnusedSession, formatHitRatePercent, formatUsageDuration, formatUsageNumber, @@ -39,6 +40,7 @@ import { subscribeUsageExportRedactPathsPreference, getDisplayTurnIndex, } from './usageReportUtils'; +import { SessionUsageEmptyState } from './SessionUsageEmptyState'; import type { SessionUsagePanelTab } from './sessionUsagePanelTypes'; import { sessionWorkspaceId } from '../../session-drivers/sessionFileNavigation'; import './SessionUsagePanel.scss'; @@ -136,6 +138,10 @@ export const SessionUsagePanel: React.FC = ({ ); } + if (isUnusedSession(report)) { + return ; + } + const coverageTone = getCoverageTone(report.coverage.level); const effectiveSessionId = sessionId ?? report.sessionId; const effectiveWorkspacePath = workspacePath ?? report.workspace.pathLabel; diff --git a/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.tsx b/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.tsx index 3da3649b22..a6c5321c3b 100644 --- a/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.tsx +++ b/src/web-ui/src/flow_chat/components/usage/SessionUsageReportCard.tsx @@ -8,6 +8,7 @@ import type { SessionUsageReport } from '@/infrastructure/api/service-api/Sessio import { copyTextToClipboard } from '@/shared/utils/textSelection'; import { buildSessionUsageExportMarkdown, + isUnusedSession, formatHitRateSuffix, formatUsageDuration, formatUsageNumber, @@ -30,6 +31,7 @@ import { setUsageExportRedactPathsPreference, subscribeUsageExportRedactPathsPreference, } from './usageReportUtils'; +import { SessionUsageEmptyState } from './SessionUsageEmptyState'; import type { SessionUsagePanelTab } from './sessionUsagePanelTypes'; import './SessionUsageReportCard.scss'; @@ -160,6 +162,10 @@ export const SessionUsageReportCard: React.FC = ({ ); } + if (isUnusedSession(report)) { + return ; + } + const coverageTone = getCoverageTone(report.coverage.level); const tokenTotal = report.tokens.totalTokens; const cachedTokenText = report.tokens.cacheCoverage === 'unavailable' diff --git a/src/web-ui/src/flow_chat/components/usage/usageReportUtils.ts b/src/web-ui/src/flow_chat/components/usage/usageReportUtils.ts index 9df10da8ba..c6891503d2 100644 --- a/src/web-ui/src/flow_chat/components/usage/usageReportUtils.ts +++ b/src/web-ui/src/flow_chat/components/usage/usageReportUtils.ts @@ -15,9 +15,19 @@ export const USAGE_EXPORT_REDACT_PATHS_STORAGE_KEY = 'openbitfun.sessionUsage.ex type UsageRedactPathsPreferenceListener = (redactPaths: boolean) => void; const usageRedactPathsPreferenceListeners = new Set(); +/** A missing telemetry field is not evidence that a session never ran. */ +export function isUnusedSession(report: SessionUsageReport): boolean { + return report.scope.kind === 'entire_session' && report.scope.turnCount === 0 && + !report.models.some(model => model.callCount > 0) && + !report.tools.some(tool => tool.callCount > 0) && + !(report.tokens.totalTokens || report.tokens.inputTokens || report.tokens.outputTokens) && + report.errors.totalErrors === 0 && report.compression.compactionCount === 0 && + report.slowest.length === 0 && hasNoRecordedFileChanges(report); +} + export function hasNoRecordedFileChanges(report: SessionUsageReport): boolean { return report.files.files.length === 0 && - (report.files.changedFiles === undefined || report.files.changedFiles === 0); + (report.files.changedFiles == null || report.files.changedFiles === 0); } export function isSessionUsageReport(value: unknown): value is SessionUsageReport { diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 9440d9cff9..9ca6bf6786 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -314,7 +314,9 @@ "errors": "No error examples", "errorsDescription": "No sampled tool or model errors were recorded.", "slowest": "No slow spans", - "slowestDescription": "No timed turn, model-call, or tool spans were recorded." + "slowestDescription": "No timed turn, model-call, or tool spans were recorded.", + "session": "No activity yet", + "sessionDescription": "Send a message in this session to start. Model usage, tools and timing will appear here after the session runs." }, "help": { "wall": "Span from the first recorded turn start to the last recorded turn end. Idle gaps can be included.", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 08a40e3fa5..b4cc041c6f 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -314,7 +314,9 @@ "filesDescription": "当前会话没有找到文件编辑记录。", "errorsDescription": "本次报告没有记录到可展示的工具或模型错误样例。", "slowest": "无慢耗时记录", - "slowestDescription": "没有记录到带耗时的轮次、模型调用或工具片段。" + "slowestDescription": "没有记录到带耗时的轮次、模型调用或工具片段。", + "session": "还没有会话活动", + "sessionDescription": "在此会话中发送一条消息即可开始。运行后,这里会显示模型用量、工具调用和耗时。" }, "runtime": { "open": "生成会话用量", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index aa086bccb5..880ec3d3f8 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -314,7 +314,9 @@ "filesDescription": "目前工作階段沒有找到檔案編輯記錄。", "errorsDescription": "本次報告沒有記錄到可顯示的工具或模型錯誤範例。", "slowest": "無慢耗時記錄", - "slowestDescription": "沒有記錄到帶耗時的回合、模型呼叫或工具片段。" + "slowestDescription": "沒有記錄到帶耗時的回合、模型呼叫或工具片段。", + "session": "還沒有工作階段活動", + "sessionDescription": "在此工作階段中傳送一則訊息即可開始。執行後,這裡會顯示模型用量、工具呼叫和耗時。" }, "runtime": { "open": "產生工作階段用量", From 7985646e0dc219fc565e1f923083cce1c3a6f4d6 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Fri, 23 Oct 2026 19:54:40 +0800 Subject: [PATCH 02/13] fix(a11y): associate settings labels through field wrappers --- design-system/packages/ui/README.md | 9 ++++ .../ui/src/components/Checkbox/Checkbox.tsx | 3 ++ .../ui/src/components/Combobox/Combobox.tsx | 8 ++-- .../ui/src/components/Field/Field.tsx | 3 +- .../components/Field/FieldAccessibility.tsx | 23 +++++++++ .../ui/src/components/Input/Input.tsx | 3 ++ .../components/NumberInput/NumberInput.tsx | 10 +++- .../ui/src/components/Select/Select.tsx | 20 +++++--- .../ui/src/components/Switch/Switch.tsx | 3 ++ .../ui/src/components/Textarea/Textarea.tsx | 8 +++- design-system/packages/ui/src/index.ts | 2 + .../common/ConfigPageLayout.test.tsx | 48 +++++++++++++++++++ .../components/common/ConfigPageLayout.tsx | 17 +++++-- 13 files changed, 139 insertions(+), 18 deletions(-) create mode 100644 design-system/packages/ui/src/components/Field/FieldAccessibility.tsx diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index fbf2c26448..e365049467 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -889,3 +889,12 @@ The outer animated surface owns both paint and backdrop blur; its content is transparent and unfiltered. An opaque fallback covers unsupported filters and reduced-transparency preferences. Tooltip declares ownership of its entrance motion to avoid the product's additional popup animation. + +### Layout-owned field labels + +`FieldAccessibilityProvider` accepts `aria-labelledby` and `aria-describedby` +references for layouts that render labels outside their controls. Input, Switch, +Checkbox, Select, Combobox, MultiSelect, NumberInput, SearchField and Textarea +consume those references through intervening wrappers. A control's explicit name +or own label takes precedence; descriptions are combined. Give each child in a +composite field its own name. Nested `Field` controls retain their own labels. diff --git a/design-system/packages/ui/src/components/Checkbox/Checkbox.tsx b/design-system/packages/ui/src/components/Checkbox/Checkbox.tsx index 8abf641203..615a16ad50 100644 --- a/design-system/packages/ui/src/components/Checkbox/Checkbox.tsx +++ b/design-system/packages/ui/src/components/Checkbox/Checkbox.tsx @@ -1,3 +1,4 @@ +import { useFieldAccessibility } from "../Field/FieldAccessibility"; import { Check as LucideCheck, Minus as LucideMinus } from 'lucide-react'; import { forwardRef, @@ -44,6 +45,7 @@ export const Checkbox = forwardRef(function Che size = "md", ...props }, forwardedRef) { + const fieldAccessibility = useFieldAccessibility(props, label !== undefined || children !== undefined); const inputRef = useRef(null); useImperativeHandle(forwardedRef, () => inputRef.current as HTMLInputElement); @@ -67,6 +69,7 @@ export const Checkbox = forwardRef(function Che (function Collec const activeDescendant = resolvedOpen && activeIndex >= 0 ? optionId(activeIndex) : undefined; + const fieldAccessibility = useFieldAccessibility({ ...props, "aria-describedby": ariaDescribedBy }, Boolean(label)); const resolvedTriggerLabel = ariaLabel - ?? (label || providedId || typeof placeholder !== "string" ? undefined : placeholder); + ?? (label || fieldAccessibility["aria-labelledby"] || providedId || typeof placeholder !== "string" ? undefined : placeholder); const resolvedLabelledBy = ariaLabel ? ariaLabelledBy - : ariaLabelledBy ?? (label ? labelId : undefined); + : ariaLabelledBy ?? (label ? labelId : fieldAccessibility["aria-labelledby"]); const resolvedDescribedBy = [ - ariaDescribedBy, + fieldAccessibility["aria-describedby"], errorMessage ? errorId : undefined, ].filter(Boolean).join(" ") || undefined; diff --git a/design-system/packages/ui/src/components/Field/Field.tsx b/design-system/packages/ui/src/components/Field/Field.tsx index e6b40b3ef0..97c737f51e 100644 --- a/design-system/packages/ui/src/components/Field/Field.tsx +++ b/design-system/packages/ui/src/components/Field/Field.tsx @@ -1,3 +1,4 @@ +import { FieldAccessibilityProvider } from "./FieldAccessibility"; import { cloneElement, forwardRef, @@ -112,7 +113,7 @@ export const Field = forwardRef(function Field({ {controlLeading} )} - {control} + {control} {controlTrailing !== undefined && controlTrailing !== null && ( {controlTrailing} diff --git a/design-system/packages/ui/src/components/Field/FieldAccessibility.tsx b/design-system/packages/ui/src/components/Field/FieldAccessibility.tsx new file mode 100644 index 0000000000..344e50fb27 --- /dev/null +++ b/design-system/packages/ui/src/components/Field/FieldAccessibility.tsx @@ -0,0 +1,23 @@ +import { createContext, useContext, type AriaAttributes, type ReactNode } from "react"; + +type FieldAccessibility = Pick; +const FieldAccessibilityContext = createContext({}); + +/** Associates a layout-owned label with controls, including controls inside wrappers. + * Composite fields should give each child its own accessible name. + */ +export function FieldAccessibilityProvider({ children, ...value }: FieldAccessibility & { children: ReactNode }) { + return {children}; +} + +export function useFieldAccessibility(props: AriaAttributes, hasOwnLabel = false): FieldAccessibility { + const inherited = useContext(FieldAccessibilityContext); + return { + "aria-labelledby": props["aria-labelledby"] ?? ( + props["aria-label"] || hasOwnLabel ? undefined : inherited["aria-labelledby"] + ), + "aria-describedby": [...new Set([ + props["aria-describedby"], inherited["aria-describedby"], + ].filter(Boolean).flatMap(value => value!.split(/\s+/)))].join(" ") || undefined, + }; +} diff --git a/design-system/packages/ui/src/components/Input/Input.tsx b/design-system/packages/ui/src/components/Input/Input.tsx index 772961a6a8..2602a686ca 100644 --- a/design-system/packages/ui/src/components/Input/Input.tsx +++ b/design-system/packages/ui/src/components/Input/Input.tsx @@ -1,3 +1,4 @@ +import { useFieldAccessibility } from "../Field/FieldAccessibility"; import { forwardRef, useRef, @@ -41,6 +42,7 @@ export const Input = forwardRef(function Input({ type = "text", ...props }, ref) { + const fieldAccessibility = useFieldAccessibility(props); const compositionActiveRef = useRef(false); const fieldSurface = useFieldSurface(); const handleChange: ChangeEventHandler = (event) => { @@ -67,6 +69,7 @@ export const Input = forwardRef(function Input({ )} (functi "aria-invalid": ariaInvalid, }, ref) { const fieldSurface = useFieldSurface(); + const fieldAccessibility = useFieldAccessibility({ + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, + "aria-describedby": ariaDescribedBy, + }, Boolean(label)); const format = useCallback((next: number) => precision > 0 ? next.toFixed(precision) : String(Math.round(next)), [precision]); const clamp = useCallback((next: number) => Math.min(max, Math.max(min, next)), [max, min]); const [draft, setDraft] = useState(() => format(value)); @@ -106,8 +112,8 @@ export const NumberInput = forwardRef(functi (function Select }, forwardedRef) { const designSystem = useDesignSystem(); const fieldSurface = useFieldSurface(); + const fieldAccessibility = useFieldAccessibility({ + "aria-label": ariaLabel, + "aria-labelledby": ariaLabelledBy, + "aria-describedby": ariaDescribedBy, + }); const generatedId = useId(); const id = providedId ?? `openbitfun-select-${generatedId}`; const listboxId = `${id}-listbox`; @@ -390,12 +396,12 @@ export const Select = forwardRef(function Select