Q202: list width, office people and chart values work from the keyboard and read as text

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 11:10:14 -04:00
1 parent ca7721358e
commit a64b44e3ea
8 files changed
+147 -20

No files matched your search

+3
View File
@@ -20,6 +20,9 @@ const APPROVAL_KINDS: Record<string, string> = {
};
export const approvalKindLabel = (kind: string) => labelOf("approval kind", APPROVAL_KINDS, kind);
const OFFICE_ACTIVITIES: Record<string, string> = { active: t("Active"), thinking: t("Thinking"), busy: t("Busy"), idle: t("Idle") };
export const officeActivityLabel = (activity: string) => labelOf("office activity", OFFICE_ACTIVITIES, activity);
const APPROVAL_STATES: Record<string, string> = { pending: t("Pending"), approved: t("Approved"), declined: t("Declined"), superseded: t("Superseded") };
export const approvalStateLabel = (state: string) => labelOf("approval state", APPROVAL_STATES, state);
+7
View File
@@ -699,6 +699,13 @@ export const zh: Record<string, string> = {
"Waiting for the {name} sign-in in your browser (check {check} of {checks}).": "正在等待你在浏览器中完成 {name} 登录(第 {check}/{checks} 次检查)。",
"Stop waiting": "停止等待",
"People to mention": "可提及的人",
"{title}: use the arrow keys to read each value": "{title}:用方向键逐项读取数值",
"{rate}% of judged work accepted": "已评判的工作中 {rate}% 被接受",
"No work judged yet": "还没有评判过的工作",
"Office floor. People here: {count}. The person list in the toolbar shows each of them.": "办公室。在场人数:{count}。工具栏的人员列表可逐一查看。",
"Show a person": "查看某人",
"Show a person…": "查看某人…",
"{name}, {activity}": "{name},{activity}",
"Layout": "布局",
"View": "视图",
"Run status": "运行状态",
+16
View File
@@ -5,6 +5,7 @@ import { activeOrgId, requireActiveEmployee } from "./types";
import { hashString, renderOffice, type RenderPerson } from "./office/office-renderer";
import { OfficeSimulation, cameraFor, type MoveInput } from "./office/simulation";
import { AgentDetailCard } from "./office/AgentDetailCard";
import { officeActivityLabel } from "../i18n/labels";
import { cardPositionFor, CARD_FALLBACK_H } from "./office/agent-card-position";
import type { AgentActivity, Camera, OfficeAgentInput, Vec } from "./office/office-types";
import { t } from "../i18n/i18n";
@@ -235,6 +236,8 @@ export function OfficePage({ snapshot }: PageProps) {
<div style={{ flex: 1, overflow: "hidden", position: "relative" }}>
<canvas
ref={canvasRef}
role="img"
aria-label={t("Office floor. People here: {count}. The person list in the toolbar shows each of them.", { count: inputs.length })}
onClick={onCanvasClick}
onMouseMove={onCanvasMouseMove}
style={{ width: "100%", height: "100%", display: "block" }}
@@ -245,6 +248,19 @@ export function OfficePage({ snapshot }: PageProps) {
</div>
)}
<div className="office-toolbar" role="toolbar" aria-label={t("Office tools")}>
<select
className="input"
aria-label={t("Show a person")}
value={selectedId ?? ""}
onChange={(event) => {
setTool("select");
setSelectedId(event.target.value || null);
}}
style={{ width: 200, height: 30 }}
>
<option value="">{t("Show a person…")}</option>
{inputs.map((input) => <option key={input.id} value={input.id}>{t("{name}, {activity}", { name: input.name, activity: officeActivityLabel(input.activity) })}</option>)}
</select>
<button
type="button"
className={`office-tool ${tool === "whip" ? "active" : ""}`.trim()}
+17 -1
View File
@@ -1,4 +1,4 @@
import { useRef, useState, type PointerEvent } from "react";
import { useRef, useState, type PointerEvent, type KeyboardEvent } from "react";
import { Avatar, Icon, IconButton } from "../../shared/ui";
import type { ChatPresenceRecord } from "../../../../shared/domain";
import { formatRelative } from "../types";
@@ -63,6 +63,17 @@ export function ChatSidebar({
const next = Math.min(PANE_MAX_W, Math.max(PANE_MIN_W, dragStart.current.width + event.clientX - dragStart.current.pointerX));
setPaneWidth(next);
};
const RESIZE_STEP = 16;
const onHandleKey = (event: KeyboardEvent<HTMLDivElement>) => {
const next = event.key === "ArrowLeft" ? paneWidth - RESIZE_STEP
: event.key === "ArrowRight" ? paneWidth + RESIZE_STEP
: event.key === "Home" ? PANE_MIN_W : event.key === "End" ? PANE_MAX_W : null;
if (next === null) return;
event.preventDefault();
const clamped = Math.min(PANE_MAX_W, Math.max(PANE_MIN_W, next));
setPaneWidth(clamped);
window.localStorage.setItem(PANE_WIDTH_KEY, String(clamped));
};
const onHandleUp = (event: PointerEvent<HTMLDivElement>) => {
if (!dragStart.current) return;
dragStart.current = null;
@@ -94,6 +105,11 @@ export function ChatSidebar({
role="separator"
aria-orientation="vertical"
aria-label={t("Resize conversation list")}
aria-valuemin={PANE_MIN_W}
aria-valuemax={PANE_MAX_W}
aria-valuenow={paneWidth}
tabIndex={0}
onKeyDown={onHandleKey}
onPointerDown={onHandleDown}
onPointerMove={onHandleMove}
onPointerUp={onHandleUp}
+41 -11
View File
@@ -1,6 +1,6 @@
import { workloadBars } from "./workload-bars";
import type { UsageStatsRecord } from "../../../../shared/usage-stats";
import { useState, type ReactNode } from "react";
import { useState, type ReactNode, type KeyboardEvent } from "react";
import type { AgentRecord, TaskRecord } from "../../../../shared/domain";
import { t } from "../../i18n/i18n";
@@ -8,16 +8,42 @@ const CHART_H = 116;
const CHART_W = 260;
const BAR_GAP = 4;
/** A metric tile that lifts on hover and surfaces a live readout in its
* header — the hovered datum's value, or a resting summary otherwise. */
function ChartCard({ title, readout, children }: { title: string; readout: string; children: ReactNode }) {
/** A metric tile with a live readout: hover, or focus it and use the arrow keys, to read one datum; every value is also listed as text. */
function ChartCard({ title, readout, values, point, onPoint, children }: {
title: string;
readout: string;
/** Each datum as text, in order. */
values: string[];
point?: number | null;
onPoint?: (index: number | null) => void;
children: ReactNode;
}) {
const step = (event: KeyboardEvent<HTMLDivElement>) => {
if (!onPoint || values.length === 0) return;
const current = point ?? -1;
const next = event.key === "ArrowRight" ? Math.min(values.length - 1, current + 1)
: event.key === "ArrowLeft" ? Math.max(0, current <= 0 ? 0 : current - 1)
: event.key === "Home" ? 0 : event.key === "End" ? values.length - 1 : null;
if (next === null) return;
event.preventDefault();
onPoint(next);
};
return (
<section className="card metric-card">
<section className="card metric-card" aria-label={title}>
<div className="row" style={{ marginBottom: 12, gap: 8 }}>
<span className="card-label">{title}</span>
<span className="metric-readout num">{readout}</span>
<span className="metric-readout num" aria-live="polite">{readout}</span>
</div>
{children}
<div
tabIndex={onPoint ? 0 : undefined}
role={onPoint ? "group" : undefined}
aria-label={onPoint ? t("{title}: use the arrow keys to read each value", { title }) : undefined}
onKeyDown={step}
onBlur={() => onPoint?.(null)}
>
{children}
</div>
<ul className="sr-only">{values.map((value, index) => <li key={index}>{value}</li>)}</ul>
</section>
);
}
@@ -33,7 +59,7 @@ export function AgentWorkloadChart({ agents, tasks }: { agents: AgentRecord[]; t
const readout = hover !== null && data[hover] ? `${data[hover]!.label} · ${data[hover]!.count}` : t("{n} open", { n: total });
return (
<ChartCard title={t("Agent workload")} readout={readout}>
<ChartCard title={t("Agent workload")} readout={readout} values={data.map((d) => `${d.label}: ${d.count}`)} point={hover} onPoint={setHover}>
<svg viewBox={`0 0 ${CHART_W} ${CHART_H}`} style={{ width: "100%", height: CHART_H }}>
{data.map((d, i) => {
const h = (d.count / max) * (CHART_H - 22);
@@ -69,7 +95,7 @@ export function RunActivityChart({ days }: { days: UsageDays }) {
const readout = hover !== null && buckets[hover] ? `${buckets[hover]!.label} · ${buckets[hover]!.count}` : t("{n} in the last 7 days", { n: total });
return (
<ChartCard title={t("Run activity")} readout={readout}>
<ChartCard title={t("Run activity")} readout={readout} values={buckets.map((b) => `${b.label}: ${b.count}`)} point={hover} onPoint={setHover}>
<svg viewBox={`0 0 ${CHART_W} ${CHART_H}`} style={{ width: "100%", height: CHART_H }}>
{buckets.map((b, i) => {
const h = (b.count / max) * (CHART_H - 24);
@@ -108,7 +134,11 @@ export function AcceptanceChart({ days }: { days: UsageDays }) {
const offset = circumference - (rate / 100) * circumference;
return (
<ChartCard title={t("Work accepted (7 days)")} readout={t("{accepted} done · {returned} sent back · sessions: {ok} ended, {failed} failed", { accepted, returned, ok: completed, failed })}>
<ChartCard
title={t("Work accepted (7 days)")}
readout={t("{accepted} done · {returned} sent back · sessions: {ok} ended, {failed} failed", { accepted, returned, ok: completed, failed })}
values={[judged > 0 ? t("{rate}% of judged work accepted", { rate }) : t("No work judged yet")]}
>
<svg viewBox="0 0 100 100" style={{ width: 96, height: 96, margin: "4px auto", display: "block" }}>
<circle cx="50" cy="50" r="42" fill="none" stroke="var(--panel-3)" strokeWidth="8" />
<circle
@@ -144,7 +174,7 @@ export function CostTrendChart({ days }: { days: UsageDays }) {
: week;
return (
<ChartCard title={t("Token usage")} readout={readout}>
<ChartCard title={t("Token usage")} readout={readout} values={tokenBuckets.map((b) => `${b.label}: ${compact(b.tokens)}`)} point={hover} onPoint={setHover}>
<svg viewBox={`0 0 ${CHART_W} ${CHART_H}`} style={{ width: "100%", height: CHART_H }}>
<polyline points={points} fill="none" stroke="var(--accent)" strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
{coords.map((c, i) => (
@@ -1,14 +1,8 @@
import { Avatar, Icon, IconButton, Pill } from "../../shared/ui";
import { roleLevelLabel } from "../../i18n/labels";
import { officeActivityLabel, roleLevelLabel } from "../../i18n/labels";
import { t } from "../../i18n/i18n";
import type { AgentActivity, OfficeAgentInput } from "./office-types";
const ACTIVITY_LABEL: Record<AgentActivity, string> = {
active: t("Active"),
thinking: t("Thinking"),
busy: t("Busy"),
idle: t("Idle"),
};
const ACTIVITY_TONE: Record<AgentActivity, "green" | "violet" | "amber" | undefined> = {
active: "green",
@@ -46,7 +40,7 @@ export function AgentDetailCard({ agent, onClose }: { agent: OfficeAgentInput; o
<div className="row" style={{ gap: 6, marginTop: 10, flexWrap: "wrap" }}>
<Pill>{agent.department}</Pill>
<Pill>{roleLevelLabel(agent.roleLevel)}</Pill>
<Pill tone={ACTIVITY_TONE[agent.activity]} dot>{ACTIVITY_LABEL[agent.activity]}</Pill>
<Pill tone={ACTIVITY_TONE[agent.activity]} dot>{officeActivityLabel(agent.activity)}</Pill>
</div>
{agent.taskTitle && (
<div className="row" style={{ gap: 7, marginTop: 12, alignItems: "flex-start" }}>
+3
View File
@@ -671,5 +671,8 @@ a { color: inherit; }
}
}
/* Read by assistive technology, not shown. */
.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; }
/* The object a link or search hit opened. */
[data-revealed="true"] { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
+58
View File
@@ -104,3 +104,61 @@ test("mention suggestions are announced, and an input method's Enter never picks
await closeApp(app);
}
});
test("the list width, office people and chart values all work from the keyboard and read as text", async () => {
const profileDir = join(tmpdir(), `agentcompany-new-e2e-nomouse-${randomUUID()}`);
mkdirSync(profileDir, { recursive: true });
const builtinDir = seedBuiltinSkillsFixture(join(tmpdir(), `agentcompany-new-e2e-nomouse-builtin-${randomUUID()}`));
const app = await electron.launch({
args: [resolve("out/main/index.js")],
env: { ...process.env, AGENTCOMPANY_NEW_PROFILE_DIR: profileDir, AGENTCOMPANY_NEW_AUTORUN: "0", AGENTCOMPANY_NEW_BUILTIN_SKILLS_DIR: builtinDir },
});
try {
const page = await app.firstWindow();
await onboard(page, "No Mouse Co");
// A chart is one stop in the Tab order; arrows read its values aloud, and all of them are listed as text.
await page.getByRole("button", { name: "Home" }).click();
const runs = page.getByRole("region", { name: "Run activity" });
const chart = runs.getByRole("group", { name: /Run activity: use the arrow keys/ });
await chart.focus();
await page.keyboard.press("Home");
const readout = runs.locator("[aria-live=polite]");
await expect(readout).toHaveText(/^\S+ · 0$/);
const values = runs.locator(".sr-only li");
await expect(values).toHaveCount(7);
await expect(readout).toHaveText((await values.nth(0).textContent())!.replace(": ", " · "));
await page.keyboard.press("End");
await expect(readout).toHaveText((await values.nth(6).textContent())!.replace(": ", " · "));
await page.keyboard.press("ArrowLeft");
await expect(readout).toHaveText((await values.nth(5).textContent())!.replace(": ", " · "));
await page.keyboard.press("Tab");
await expect(readout).toHaveText(/in the last 7 days/);
// The conversation list resizes with the arrow keys, within its limits, and keeps the width.
await page.getByRole("button", { name: "Messages" }).click();
const handle = page.getByRole("separator", { name: "Resize conversation list" });
await handle.focus();
const before = Number(await handle.getAttribute("aria-valuenow"));
await page.keyboard.press("ArrowRight");
await expect(handle).toHaveAttribute("aria-valuenow", String(Math.min(480, before + 16)));
await page.keyboard.press("End");
await expect(handle).toHaveAttribute("aria-valuenow", "480");
await page.keyboard.press("Home");
await expect(handle).toHaveAttribute("aria-valuenow", "200");
await page.keyboard.press("ArrowLeft");
await expect(handle).toHaveAttribute("aria-valuenow", "200");
expect(await page.evaluate(() => window.localStorage.getItem("agentcompany:chat-pane-width"))).toBe("200");
// In the office, each person can be chosen from a list, and the floor says who is there.
await page.getByRole("button", { name: "Office", exact: true }).click();
await expect(page.getByRole("img", { name: /People here: 1./ })).toBeVisible();
const people = page.getByRole("combobox", { name: "Show a person" });
await people.selectOption({ label: "CEO, Idle" });
await expect(page.getByRole("dialog", { name: "Agent CEO" })).toBeVisible();
await people.selectOption({ label: "Show a person…" });
await expect(page.getByRole("dialog", { name: "Agent CEO" })).toHaveCount(0);
} finally {
await closeApp(app);
}
});