Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions web/src/components/run-workspace-tabs.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useState } from "react";
import { Activity, Gauge, History } from "lucide-react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { RunWorkspaceTabs, type RunTab } from "./run-workspace";

const items: Array<{ id: RunTab; label: string; icon: typeof Activity }> = [
{ id: "activity", label: "Activity", icon: Activity },
{ id: "overview", label: "Overview", icon: Gauge },
{ id: "events", label: "Events", icon: History },
];

function Harness() {
const [activeTab, setActiveTab] = useState<RunTab>("activity");
return <RunWorkspaceTabs activeTab={activeTab} ariaLabel="Run views"
items={items} onSelect={setActiveTab}>
<h2>{activeTab}</h2>
</RunWorkspaceTabs>;
}

describe("RunWorkspaceTabs", () => {
it("exposes one active tab and connects it to the visible panel", () => {
render(<Harness />);

const activity = screen.getByRole("tab", { name: "Activity" });
const overview = screen.getByRole("tab", { name: "Overview" });
const panel = screen.getByRole("tabpanel");
expect(activity).toHaveAttribute("aria-selected", "true");
expect(activity).toHaveAttribute("tabindex", "0");
expect(overview).toHaveAttribute("aria-selected", "false");
expect(overview).toHaveAttribute("tabindex", "-1");
expect(activity).toHaveAttribute("aria-controls", panel.id);
expect(panel).toHaveAttribute("aria-labelledby", activity.id);
expect(panel).toHaveTextContent("activity");
const overviewPanel = document.getElementById(overview.getAttribute("aria-controls") ?? "");
expect(overviewPanel).toHaveAttribute("hidden");
expect(overviewPanel).toHaveAttribute("aria-labelledby", overview.id);
});

it("moves focus and activates tabs with arrows, Home, and End", async () => {
const user = userEvent.setup();
render(<Harness />);
const activity = screen.getByRole("tab", { name: "Activity" });
const overview = screen.getByRole("tab", { name: "Overview" });
const events = screen.getByRole("tab", { name: "Events" });

activity.focus();
await user.keyboard("{ArrowRight}");
expect(overview).toHaveFocus();
expect(overview).toHaveAttribute("aria-selected", "true");
expect(screen.getByRole("tabpanel")).toHaveTextContent("overview");

await user.keyboard("{End}");
expect(events).toHaveFocus();
await user.keyboard("{ArrowRight}");
expect(activity).toHaveFocus();
await user.keyboard("{ArrowLeft}");
expect(events).toHaveFocus();
await user.keyboard("{Home}");
expect(activity).toHaveFocus();
});
});
77 changes: 66 additions & 11 deletions web/src/components/run-workspace.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { Fragment, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Activity,
Expand Down Expand Up @@ -88,7 +88,7 @@ import { AgentGraphPanel, DelegationsPanel, ExternalSkillsSection, FanoutPanel,
import { RunActivityTimeline } from "./run-activity-timeline";
import { EmbeddedAnalyzerPanel } from "./embedded-analyzer-panel";

type RunTab = "activity" | "overview" | "journey" | "actions" | "approvals" | "diffs" | "repository" | "files" | "evidence" | "verify" | "handoff" |
export type RunTab = "activity" | "overview" | "journey" | "actions" | "approvals" | "diffs" | "repository" | "files" | "evidence" | "verify" | "handoff" |
"receipts" | "agents" | "delegations" | "fanout" | "findings" | "events" | "work" |
"notes" | "artifacts" | "tools" | "analyzer";

Expand Down Expand Up @@ -119,6 +119,66 @@ const tabs: Array<{ id: RunTab; label: [string, string]; icon: typeof Activity }

const compactTabs = new Set<RunTab>(["activity", "approvals", "diffs", "repository", "files"]);

export function RunWorkspaceTabs({ activeTab, ariaLabel, children, items, onSelect }: {
activeTab: RunTab;
ariaLabel: string;
children: React.ReactNode;
items: Array<{ id: RunTab; label: string; icon: typeof Activity }>;
onSelect: (tab: RunTab) => void;
}) {
const tabSetID = useId();
const tabRefs = useRef(new Map<RunTab, HTMLButtonElement>());
const tabID = (id: RunTab) => `${tabSetID}-tab-${id}`;
const panelID = (id: RunTab) => `${tabSetID}-panel-${id}`;
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, current: RunTab) => {
const currentIndex = items.findIndex(({ id }) => id === current);
if (currentIndex < 0 || items.length === 0) return;
let nextIndex: number;
switch (event.key) {
case "ArrowRight":
nextIndex = (currentIndex + 1) % items.length;
break;
case "ArrowLeft":
nextIndex = (currentIndex - 1 + items.length) % items.length;
break;
case "Home":
nextIndex = 0;
break;
case "End":
nextIndex = items.length - 1;
break;
default:
return;
}
event.preventDefault();
const next = items[nextIndex];
onSelect(next.id);
tabRefs.current.get(next.id)?.focus();
};

return <>
<nav aria-label={ariaLabel} aria-orientation="horizontal" className="workspace-tabs" role="tablist">
{items.map(({ id, label, icon: Icon }) => (
<button aria-controls={panelID(id)} aria-selected={activeTab === id}
className={activeTab === id ? "active" : ""} id={tabID(id)} key={id}
onClick={() => onSelect(id)} onKeyDown={(event) => handleKeyDown(event, id)}
ref={(node) => {
if (node) tabRefs.current.set(id, node);
else tabRefs.current.delete(id);
}} role="tab" tabIndex={activeTab === id ? 0 : -1} type="button">
<Icon aria-hidden="true" size={15} />{label}
</button>
))}
</nav>
{items.map(({ id }) => (
<div aria-labelledby={tabID(id)} className="workspace-content" hidden={activeTab !== id}
id={panelID(id)} key={id} role="tabpanel" tabIndex={activeTab === id ? 0 : -1}>
{activeTab === id ? children : null}
</div>
))}
</>;
}

export function RunWorkspace({ client, runID, onOpenPlugins }: {
client: CyberAgentClient;
runID: string;
Expand Down Expand Up @@ -272,14 +332,9 @@ export function RunWorkspace({ client, runID, onOpenPlugins }: {
</div>
</div>
</header>
<nav aria-label="Run 视图" className="workspace-tabs" role="tablist">
{visibleTabs.map(({ id, label, icon: Icon }) => (
<button aria-selected={tab === id} className={tab === id ? "active" : ""} key={id} onClick={() => setTab(id)} role="tab" type="button">
<Icon aria-hidden="true" size={15} />{t(...label)}
</button>
))}
</nav>
<div className="workspace-content">
<RunWorkspaceTabs activeTab={tab} ariaLabel={t("Run 视图", "Run views")}
items={visibleTabs.map(({ id, label, icon }) => ({ id, label: t(...label), icon }))}
onSelect={setTab}>
{tab === "activity" && (
activityQuery.isLoading ? <LoadingState label="加载活动" /> :
activityQuery.isError || !activityQuery.data ?
Expand Down Expand Up @@ -359,7 +414,7 @@ export function RunWorkspace({ client, runID, onOpenPlugins }: {
)}
{tab === "analyzer" && client.hasEmbeddedAnalyzerExecution &&
<EmbeddedAnalyzerPanel client={client} runID={runID} />}
</div>
</RunWorkspaceTabs>
{detail.run.session_id && <SessionComposer client={client}
contextPartial={Boolean(contextMessagesQuery.hasNextPage)} contextTokens={contextTokens}
onOpenPlugins={onOpenPlugins} run={detail.run} sessionID={detail.run.session_id}
Expand Down