From c4a88e4aae9ad3508ec2736419b54f30ce69d4c6 Mon Sep 17 00:00:00 2001 From: Wenxin Duan Date: Tue, 8 Sep 2026 10:23:15 +0800 Subject: [PATCH] feat(web): polish responsive accessible interface --- apps/web/src/App.tsx | 6 ++++ .../components/common/CompletionAnnouncer.tsx | 5 ++++ apps/web/src/components/layout/AppShell.tsx | 30 +++++++++++++++---- .../src/components/settings/ModelSettings.tsx | 24 +++++++++++++-- apps/web/src/styles/base.css | 3 +- apps/web/src/styles/components.css | 4 +-- apps/web/tests/accessibility.test.tsx | 19 ++++++++++++ 7 files changed, 80 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/components/common/CompletionAnnouncer.tsx create mode 100644 apps/web/tests/accessibility.test.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 684d276..64575fd 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -9,6 +9,7 @@ import { SkillsPanel } from "./components/skills/SkillsPanel.js"; import { EventTimeline } from "./components/timeline/EventTimeline.js"; import { ApprovalDialog } from "./components/approvals/ApprovalDialog.js"; import { ModelSettings, type ModelView } from "./components/settings/ModelSettings.js"; +import { CompletionAnnouncer } from "./components/common/CompletionAnnouncer.js"; import { HarnessClientStore } from "./store/harness-client-store.js"; type AppStore = HarnessRuntimeStore & { @@ -60,6 +61,10 @@ export function App({ store: suppliedStore, autoStart = true, modelConfigured: s const busy = Object.values(snapshot.runs).some((run) => run.status === "running"); const approvalPending = Object.values(snapshot.approvals).some((approval) => approval.status === "pending"); const pendingApproval = Object.values(snapshot.approvals).find((approval) => approval.status === "pending"); + const latestAssistantStatus = [...snapshot.messageOrder] + .reverse() + .map((messageId) => snapshot.messages[messageId]) + .find((message) => message?.role === "assistant")?.status; useEffect(() => { if (!autoStart) return undefined; @@ -127,6 +132,7 @@ export function App({ store: suppliedStore, autoStart = true, modelConfigured: s }); }} /> + ); } diff --git a/apps/web/src/components/common/CompletionAnnouncer.tsx b/apps/web/src/components/common/CompletionAnnouncer.tsx new file mode 100644 index 0000000..b388232 --- /dev/null +++ b/apps/web/src/components/common/CompletionAnnouncer.tsx @@ -0,0 +1,5 @@ +import type { MessageStatus } from "../../store/reducer.js"; + +export function CompletionAnnouncer({ status }: { status: MessageStatus | undefined }) { + return
{status === "complete" ? "回答已完成" : ""}
; +} diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index 1dcf058..d3c9f9d 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -1,5 +1,5 @@ import { Activity, MessageSquare, PanelsTopLeft } from "lucide-react"; -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useState, type KeyboardEvent, type ReactNode } from "react"; import { TopBar, type TopBarProps } from "../topbar/TopBar.js"; type WorkspaceTab = "chat" | "skills" | "events"; @@ -17,13 +17,25 @@ export function AppShell({ topBar, skills, chat, timeline }: { topBar: TopBarPro return () => media.removeEventListener("change", update); }, []); + const selectAdjacentTab = (current: WorkspaceTab, event: KeyboardEvent) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight" && event.key !== "Home" && event.key !== "End") return; + event.preventDefault(); + const order: WorkspaceTab[] = ["chat", "skills", "events"]; + const currentIndex = order.indexOf(current); + const targetIndex = event.key === "Home" ? 0 : event.key === "End" ? order.length - 1 : (currentIndex + (event.key === "ArrowRight" ? 1 : -1) + order.length) % order.length; + const target = order[targetIndex]; + if (target === undefined) return; + setActiveTab(target); + document.getElementById(`${target}-tab`)?.focus(); + }; + return (
@@ -34,9 +46,15 @@ export function AppShell({ topBar, skills, chat, timeline }: { topBar: TopBarPro ); } -function TabButton({ id, active, onSelect, children }: { id: WorkspaceTab; active: boolean; onSelect: (tab: WorkspaceTab) => void; children: ReactNode }) { +function TabButton({ id, active, onSelect, onKeyDown, children }: { + id: WorkspaceTab; + active: boolean; + onSelect: (tab: WorkspaceTab) => void; + onKeyDown: (tab: WorkspaceTab, event: KeyboardEvent) => void; + children: ReactNode; +}) { return ( - ); diff --git a/apps/web/src/components/settings/ModelSettings.tsx b/apps/web/src/components/settings/ModelSettings.tsx index f6592bd..c3691f8 100644 --- a/apps/web/src/components/settings/ModelSettings.tsx +++ b/apps/web/src/components/settings/ModelSettings.tsx @@ -32,9 +32,13 @@ export function ModelSettings({ open, busy, current, onClose, onSave, onTest, on const [saved, setSaved] = useState(current); const [message, setMessage] = useState(""); const firstInput = useRef(null); + const returnFocus = useRef(null); useEffect(() => { - if (open) firstInput.current?.focus(); + if (!open) return undefined; + returnFocus.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; + firstInput.current?.focus(); + return () => returnFocus.current?.focus(); }, [open]); useEffect(() => { setSaved(current); @@ -70,7 +74,10 @@ export function ModelSettings({ open, busy, current, onClose, onSave, onTest, on return (
{ if (event.target === event.currentTarget) onClose(); }}> -
{ if (event.key === "Escape") onClose(); }}> +
{ + if (event.key === "Escape") onClose(); + if (event.key === "Tab") trapSettingsFocus(event); + }}>
SESSION MODEL

模型连接

@@ -106,3 +113,16 @@ function translateModelError(error: unknown): string { if ("code" in error && error.code === "BASE_URL_NOT_ALLOWED") return "该 Base URL 不符合安全策略"; return error.message; } + +function trapSettingsFocus(event: React.KeyboardEvent): void { + const controls = [...event.currentTarget.querySelectorAll("button:not(:disabled), input:not(:disabled), select:not(:disabled)")]; + const first = controls[0]; + const last = controls.at(-1); + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first?.focus(); + } +} diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 94bc5d5..c8e9be1 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -1,6 +1,6 @@ * { box-sizing: border-box; } html, body, #root { min-width: 320px; min-height: 100%; margin: 0; } -body { background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; } +body { overflow: hidden; background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; } button, textarea, select, input { font: inherit; } button, select { color: inherit; } button { cursor: pointer; } @@ -9,3 +9,4 @@ button:disabled { cursor: not-allowed; opacity: .45; } code { font-family: "SFMono-Regular", Consolas, monospace; } h1, h2, p { margin-top: 0; } [hidden] { display: none !important; } +.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } diff --git a/apps/web/src/styles/components.css b/apps/web/src/styles/components.css index beba38e..e4aafb7 100644 --- a/apps/web/src/styles/components.css +++ b/apps/web/src/styles/components.css @@ -59,7 +59,7 @@ .composer-hint { padding: 0 3px 8px; color: var(--warning); } .composer-footnote { padding-top: 8px; text-align: center; } .filter-label { display: grid; gap: 7px; padding: 12px 16px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; } -.filter-label select { height: 36px; padding: 0 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-raised); } +.filter-label select { height: 44px; padding: 0 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-raised); } .timeline { overflow-y: auto; padding: 6px 0 24px; } .event-row { display: grid; grid-template-columns: 14px minmax(0, 1fr); gap: 9px; padding: 12px 16px; border-bottom: 1px solid var(--border); } .event-row > svg { margin-top: 3px; color: #4e7573; } @@ -133,5 +133,5 @@ .image-preview-dialog img { display: block; width: 100%; max-height: calc(100dvh - 66px); border-radius: 11px; object-fit: contain; } .spin { animation: spin 1.2s linear infinite; } @keyframes spin { to { transform: rotate(360deg); } } -@media (prefers-reduced-motion: reduce) { .spin { animation: none; } } +@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; } } @media (max-width: 720px) { .topbar { grid-template-columns: 1fr auto; padding: 0 12px; } .topbar__actions { grid-column: 2; } .thread-viewport { padding: 20px 16px; } .composer-area { padding: 0 10px 10px; } .tool-summary { grid-template-columns: repeat(2, 1fr); } .tool-summary > div + div { border-left: 0; } .settings-form { grid-template-columns: 1fr; } .settings-form > * { grid-column: 1; } .settings-actions { flex-wrap: wrap; } } diff --git a/apps/web/tests/accessibility.test.tsx b/apps/web/tests/accessibility.test.tsx new file mode 100644 index 0000000..3b938f0 --- /dev/null +++ b/apps/web/tests/accessibility.test.tsx @@ -0,0 +1,19 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { CompletionAnnouncer } from "../src/components/common/CompletionAnnouncer.js"; +import { TopBar } from "../src/components/topbar/TopBar.js"; + +describe("accessibility contracts", () => { + it("gives icon-only actions accessible names", () => { + render(); + expect(screen.getByRole("button", { name: "打开模型设置" })).toBeVisible(); + expect(screen.getByRole("button", { name: "重置训练会话" })).toBeVisible(); + }); + + it("announces completion but not every streaming token", () => { + const { rerender } = render(); + expect(screen.getByRole("status")).toBeEmptyDOMElement(); + rerender(); + expect(screen.getByRole("status")).toHaveTextContent("回答已完成"); + }); +});