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:
1 parent
ca7721358e
commit
a64b44e3ea
8 files changed
+147
-20
No files matched your search
@@ -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);
|
||||
|
||||
|
||||
@@ -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": "运行状态",
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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" }}>
|
||||
|
||||
@@ -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; }
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user