feat(web): polish responsive accessible interface

This commit is contained in:
Wenxin Duan
2026-09-08 10:23:15 +08:00
parent 36b6cbac3e
commit c4a88e4aae
7 changed files with 80 additions and 11 deletions
+6
View File
@@ -9,6 +9,7 @@ import { SkillsPanel } from "./components/skills/SkillsPanel.js";
import { EventTimeline } from "./components/timeline/EventTimeline.js"; import { EventTimeline } from "./components/timeline/EventTimeline.js";
import { ApprovalDialog } from "./components/approvals/ApprovalDialog.js"; import { ApprovalDialog } from "./components/approvals/ApprovalDialog.js";
import { ModelSettings, type ModelView } from "./components/settings/ModelSettings.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"; import { HarnessClientStore } from "./store/harness-client-store.js";
type AppStore = HarnessRuntimeStore & { 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 busy = Object.values(snapshot.runs).some((run) => run.status === "running");
const approvalPending = Object.values(snapshot.approvals).some((approval) => approval.status === "pending"); const approvalPending = Object.values(snapshot.approvals).some((approval) => approval.status === "pending");
const pendingApproval = Object.values(snapshot.approvals).find((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(() => { useEffect(() => {
if (!autoStart) return undefined; if (!autoStart) return undefined;
@@ -127,6 +132,7 @@ export function App({ store: suppliedStore, autoStart = true, modelConfigured: s
}); });
}} }}
/> />
<CompletionAnnouncer status={latestAssistantStatus} />
</AssistantRuntimeProvider> </AssistantRuntimeProvider>
); );
} }
@@ -0,0 +1,5 @@
import type { MessageStatus } from "../../store/reducer.js";
export function CompletionAnnouncer({ status }: { status: MessageStatus | undefined }) {
return <div className="sr-only" role="status" aria-live="polite">{status === "complete" ? "回答已完成" : ""}</div>;
}
+24 -6
View File
@@ -1,5 +1,5 @@
import { Activity, MessageSquare, PanelsTopLeft } from "lucide-react"; 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"; import { TopBar, type TopBarProps } from "../topbar/TopBar.js";
type WorkspaceTab = "chat" | "skills" | "events"; type WorkspaceTab = "chat" | "skills" | "events";
@@ -17,13 +17,25 @@ export function AppShell({ topBar, skills, chat, timeline }: { topBar: TopBarPro
return () => media.removeEventListener("change", update); return () => media.removeEventListener("change", update);
}, []); }, []);
const selectAdjacentTab = (current: WorkspaceTab, event: KeyboardEvent<HTMLButtonElement>) => {
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 ( return (
<div className="app-shell"> <div className="app-shell">
<TopBar {...topBar} /> <TopBar {...topBar} />
<nav className="mobile-tabs" aria-label="工作区导航" role="tablist"> <nav className="mobile-tabs" aria-label="工作区导航" role="tablist">
<TabButton id="chat" active={activeTab === "chat"} onSelect={setActiveTab}><MessageSquare size={17} />Chat</TabButton> <TabButton id="chat" active={activeTab === "chat"} onSelect={setActiveTab} onKeyDown={selectAdjacentTab}><MessageSquare size={17} aria-hidden="true" />Chat</TabButton>
<TabButton id="skills" active={activeTab === "skills"} onSelect={setActiveTab}><PanelsTopLeft size={17} />Skills</TabButton> <TabButton id="skills" active={activeTab === "skills"} onSelect={setActiveTab} onKeyDown={selectAdjacentTab}><PanelsTopLeft size={17} aria-hidden="true" />Skills</TabButton>
<TabButton id="events" active={activeTab === "events"} onSelect={setActiveTab}><Activity size={17} />Events</TabButton> <TabButton id="events" active={activeTab === "events"} onSelect={setActiveTab} onKeyDown={selectAdjacentTab}><Activity size={17} aria-hidden="true" />Events</TabButton>
</nav> </nav>
<div className="workspace-grid"> <div className="workspace-grid">
<div id="skills-panel" role={mobile ? "tabpanel" : undefined} hidden={mobile && activeTab !== "skills"}>{skills}</div> <div id="skills-panel" role={mobile ? "tabpanel" : undefined} hidden={mobile && activeTab !== "skills"}>{skills}</div>
@@ -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<HTMLButtonElement>) => void;
children: ReactNode;
}) {
return ( return (
<button type="button" role="tab" aria-selected={active} aria-controls={`${id}-panel`} tabIndex={active ? 0 : -1} onClick={() => onSelect(id)}> <button id={`${id}-tab`} type="button" role="tab" aria-selected={active} aria-controls={`${id}-panel`} tabIndex={active ? 0 : -1} onClick={() => onSelect(id)} onKeyDown={(event) => onKeyDown(id, event)}>
{children} {children}
</button> </button>
); );
@@ -32,9 +32,13 @@ export function ModelSettings({ open, busy, current, onClose, onSave, onTest, on
const [saved, setSaved] = useState<ModelView | undefined>(current); const [saved, setSaved] = useState<ModelView | undefined>(current);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const firstInput = useRef<HTMLSelectElement>(null); const firstInput = useRef<HTMLSelectElement>(null);
const returnFocus = useRef<HTMLElement | null>(null);
useEffect(() => { 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]); }, [open]);
useEffect(() => { useEffect(() => {
setSaved(current); setSaved(current);
@@ -70,7 +74,10 @@ export function ModelSettings({ open, busy, current, onClose, onSave, onTest, on
return ( return (
<div className="dialog-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}> <div className="dialog-backdrop" onMouseDown={(event) => { if (event.target === event.currentTarget) onClose(); }}>
<section className="dialog settings-dialog" role="dialog" aria-modal="true" aria-labelledby="settings-title" onKeyDown={(event) => { if (event.key === "Escape") onClose(); }}> <section className="dialog settings-dialog" role="dialog" aria-modal="true" aria-labelledby="settings-title" onKeyDown={(event) => {
if (event.key === "Escape") onClose();
if (event.key === "Tab") trapSettingsFocus(event);
}}>
<div className="dialog-title-row"> <div className="dialog-title-row">
<div><span className="eyebrow">SESSION MODEL</span><h2 id="settings-title">模型连接</h2></div> <div><span className="eyebrow">SESSION MODEL</span><h2 id="settings-title">模型连接</h2></div>
<button className="icon-button" type="button" aria-label="关闭模型设置" onClick={onClose}><X size={19} /></button> <button className="icon-button" type="button" aria-label="关闭模型设置" onClick={onClose}><X size={19} /></button>
@@ -106,3 +113,16 @@ function translateModelError(error: unknown): string {
if ("code" in error && error.code === "BASE_URL_NOT_ALLOWED") return "该 Base URL 不符合安全策略"; if ("code" in error && error.code === "BASE_URL_NOT_ALLOWED") return "该 Base URL 不符合安全策略";
return error.message; return error.message;
} }
function trapSettingsFocus(event: React.KeyboardEvent<HTMLElement>): void {
const controls = [...event.currentTarget.querySelectorAll<HTMLElement>("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();
}
}
+2 -1
View File
@@ -1,6 +1,6 @@
* { box-sizing: border-box; } * { box-sizing: border-box; }
html, body, #root { min-width: 320px; min-height: 100%; margin: 0; } 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, textarea, select, input { font: inherit; }
button, select { color: inherit; } button, select { color: inherit; }
button { cursor: pointer; } button { cursor: pointer; }
@@ -9,3 +9,4 @@ button:disabled { cursor: not-allowed; opacity: .45; }
code { font-family: "SFMono-Regular", Consolas, monospace; } code { font-family: "SFMono-Regular", Consolas, monospace; }
h1, h2, p { margin-top: 0; } h1, h2, p { margin-top: 0; }
[hidden] { display: none !important; } [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; }
+2 -2
View File
@@ -59,7 +59,7 @@
.composer-hint { padding: 0 3px 8px; color: var(--warning); } .composer-hint { padding: 0 3px 8px; color: var(--warning); }
.composer-footnote { padding-top: 8px; text-align: center; } .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 { 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; } .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 { 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; } .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; } .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; } .spin { animation: spin 1.2s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } } @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; } } @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; } }
+19
View File
@@ -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(<TopBar sessionId="ses_1" connection="open" modelLabel="gpt-test" supportsImages onOpenSettings={vi.fn()} onReset={vi.fn()} />);
expect(screen.getByRole("button", { name: "打开模型设置" })).toBeVisible();
expect(screen.getByRole("button", { name: "重置训练会话" })).toBeVisible();
});
it("announces completion but not every streaming token", () => {
const { rerender } = render(<CompletionAnnouncer status="running" />);
expect(screen.getByRole("status")).toBeEmptyDOMElement();
rerender(<CompletionAnnouncer status="complete" />);
expect(screen.getByRole("status")).toHaveTextContent("回答已完成");
});
});