`var(--column-${id})`).join(" ") } as CSSProperties}>
- {
+ {
const next = new Set(selected);
rows.forEach(({ id }) => event.currentTarget.checked ? next.add(id) : next.delete(id));
onStateChange({ ...state, selectedSessionIds: [...next] });
@@ -127,20 +127,20 @@ export function SessionTable({ rows, state, capabilities, page, onStateChange, o
const sortable = column.sortable && capabilities.sortableColumns.includes(column.id);
return {sortable ? : column.label};
})}
-
+
- {rows.map((session, index) =>
{ if (element) rowRefs.current.set(session.id, element); else rowRefs.current.delete(session.id); }} onFocus={() => state.focusedSessionId !== session.id && onStateChange({ ...state, focusedSessionId: session.id })} onKeyDown={(event) => handleRowKeyDown(event, index, session.id)}>
- updateSelection(session.id, event.currentTarget.checked)} />
- {visible.has("status") && 状态:{session.status}}
- {visible.has("id") && {session.id}}
- {visible.has("identity") && {session.host}{session.user}}
- {visible.has("os") && {session.os}}
- {visible.has("address") && {session.address}}
- {visible.has("seen") && {session.seen}}
- {visible.has("tags") && {session.tags.map((tag) => {tag})}}
-
+
{rows.map((session, index) =>
{ if (element) rowRefs.current.set(session.id, element); else rowRefs.current.delete(session.id); }} onFocus={() => state.focusedSessionId !== session.id && onStateChange({ ...state, focusedSessionId: session.id })} onKeyDown={(event) => handleRowKeyDown(event, index, session.id)}>
+ updateSelection(session.id, event.currentTarget.checked)} />
+ {visible.has("status") && 状态:{session.status}}
+ {visible.has("id") && {session.id}}
+ {visible.has("identity") && {session.host}{session.user}}
+ {visible.has("os") && {session.os}}
+ {visible.has("address") && {session.address}}
+ {visible.has("seen") && {session.seen}}
+ {visible.has("tags") && {session.tags.map((tag) => {tag})}}
+
)}
-
+
>
);
diff --git a/src/accessibility.test.tsx b/src/accessibility.test.tsx
new file mode 100644
index 0000000..1fa9bea
--- /dev/null
+++ b/src/accessibility.test.tsx
@@ -0,0 +1,15 @@
+import { render } from "@testing-library/react";
+import axe from "axe-core";
+import { describe, expect, it } from "vitest";
+import { App } from "./App";
+
+describe("自动化可访问性扫描", () => {
+ it("应用壳层无可自动确认的 WCAG A/AA 违规", async () => {
+ const { container } = render(
);
+ const result = await axe.run(container, {
+ runOnly: { type: "tag", values: ["wcag2a", "wcag2aa"] },
+ rules: { "color-contrast": { enabled: false } },
+ });
+ expect(result.violations.map(({ id, nodes }) => ({ id, targets: nodes.map(({ target }) => target) }))).toEqual([]);
+ });
+});
diff --git a/src/features/activity/activity.css b/src/features/activity/activity.css
index 3fe8ac6..88ca252 100644
--- a/src/features/activity/activity.css
+++ b/src/features/activity/activity.css
@@ -2,18 +2,18 @@
.activity-status__mark { width:8px; height:8px; border:2px solid currentColor; border-radius:50% }
.activity-status--running .activity-status__mark { border-radius:2px }
.activity-status--succeeded { color:#277a4b }.activity-status--failed,.activity-status--cancelled { color:#a23d3d }
-.activity-list { display:grid; border:1px solid #d9dde5; border-radius:8px; overflow:hidden }
-.activity-list__row { min-height:38px; display:grid; grid-template-columns:minmax(150px,1fr) 100px 90px; align-items:center; gap:12px; padding:0 12px; border:0; border-bottom:1px solid #e6e8ed; background:#fff; text-align:left; color:inherit }
-.activity-list__row:hover,.activity-list__row[aria-pressed="true"] { background:#f1f4f9 }
+.activity-list { display:grid; border:1px solid var(--border); border-radius:5px; overflow:hidden }
+.activity-list__row { min-height:38px; display:grid; grid-template-columns:minmax(150px,1fr) 100px 90px; align-items:center; gap:12px; padding:0 12px; border:0; border-bottom:1px solid var(--border); background:#15191f; text-align:left; color:inherit }
+.activity-list__row:hover,.activity-list__row[aria-pressed="true"] { background:var(--selected) }
.activity-empty { padding:24px; color:#667085; text-align:center; border:1px dashed #cfd4dc; border-radius:8px }
.activity-detail { display:grid; gap:16px }.activity-detail>header { display:flex; justify-content:space-between; gap:16px; align-items:flex-start }.activity-detail h2 { margin:0;font-size:16px }.activity-detail dl,.activity-dialog dl { display:grid; grid-template-columns:120px 1fr; margin:0; gap:8px 12px }.activity-detail dt,.activity-dialog dt { color:#667085 }.activity-detail dd,.activity-dialog dd { margin:0; overflow-wrap:anywhere }
.activity-timeline { display:flex; padding:0; list-style:none; gap:8px }.activity-timeline li { display:grid; border-left:3px solid #8793a8; padding-left:10px; min-width:130px }.activity-timeline time { color:#667085;font-size:11px }
-.activity-output { border:1px solid #cfd4dc; border-radius:8px; overflow:hidden }.activity-output header,.activity-events>header { display:flex; align-items:center; gap:8px; padding:8px 10px; background:#f5f6f8 }.activity-output header span,.activity-events header strong { margin-right:auto }.activity-output__body { box-sizing:border-box; min-height:120px; max-height:300px; overflow:auto; padding:10px; background:#17191d; color:#e7eaf0; font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace }.activity-redacted { color:#ffce73 }
-.activity-result { max-height:220px; overflow:auto; margin:0; padding:12px; background:#f5f6f8; border-radius:6px; font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace }
+.activity-output { border:1px solid #cfd4dc; border-radius:8px; overflow:hidden }.activity-output header,.activity-events>header { display:flex; align-items:center; gap:8px; padding:8px 10px; background:#0f1115 }.activity-output header span,.activity-events header strong { margin-right:auto }.activity-output__body { box-sizing:border-box; min-height:120px; max-height:300px; overflow:auto; padding:10px; background:#17191d; color:#e7eaf0; font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace }.activity-redacted { color:#ffce73 }
+.activity-result { max-height:220px; overflow:auto; margin:0; padding:12px; background:#0f1115; border-radius:6px; font:12px/1.5 ui-monospace,SFMono-Regular,Consolas,monospace }
.activity-danger { color:#a12323; border-color:#cf7777 }
.activity-dialog-backdrop { position:fixed;inset:0;z-index:1000;display:grid;place-items:center;background:#1118 }.activity-dialog { width:min(520px,calc(100vw - 32px));box-sizing:border-box;background:white;border-radius:10px;padding:20px;box-shadow:0 18px 60px #0004 }.activity-dialog footer { display:flex;justify-content:flex-end;gap:8px;margin-top:20px }
.activity-filters { display:flex;align-items:end;gap:12px;flex-wrap:wrap }.activity-filters label { display:grid;gap:4px;font-size:12px }.activity-filters select { min-height:58px }
-.activity-events { min-height:200px;border:1px solid #d9dde5;border-radius:8px;overflow:hidden }.activity-events__feed { max-height:420px;overflow:auto }.activity-event { width:100%;display:grid;grid-template-columns:175px 1fr 210px;gap:12px;text-align:left;padding:9px 12px;background:#fff;border:0;border-bottom:1px solid #e6e8ed;color:inherit }.activity-event:hover { background:#f5f7fa }.activity-event span { color:#667085 }.activity-event--new { background:#edf5ff }
+.activity-events { min-height:200px;border:1px solid var(--border);border-radius:5px;overflow:hidden }.activity-events__feed{max-height:420px;overflow:auto}.activity-event{width:100%;display:grid;grid-template-columns:175px 1fr 210px;gap:12px;text-align:left;padding:9px 12px;background:#15191f;border:0;border-bottom:1px solid var(--border);color:inherit}.activity-event:hover,.activity-event--new{background:var(--selected)}.activity-event span{color:var(--muted)}
.activity-banner { display:flex;justify-content:space-between;align-items:center;gap:12px;padding:10px 12px;border:1px solid #e2b75e;border-radius:7px;background:#fff8e5;color:#654b14 }
button,input,select { font:inherit }button { cursor:pointer;border:1px solid #bbc2ce;border-radius:5px;padding:5px 9px;background:#fff }button:focus-visible,input:focus-visible,select:focus-visible,[tabindex]:focus-visible { outline:3px solid #477ee8;outline-offset:2px }button:disabled { opacity:.5;cursor:not-allowed }
@media (max-width:700px) { .activity-list,.activity-events { overflow-x:auto }.activity-list__row,.activity-event { min-width:620px } }
diff --git a/src/features/workbench/index.tsx b/src/features/workbench/index.tsx
new file mode 100644
index 0000000..55319c8
--- /dev/null
+++ b/src/features/workbench/index.tsx
@@ -0,0 +1,42 @@
+import { useState } from "react";
+import type { Event, Task } from "../../protocol/model";
+import { demoSession } from "../../protocol/fixtures";
+import { EventDetail, EventFilters, EventStream, TaskDetail, TaskList, type EventFiltersValue, type TaskOutputEntry } from "../activity";
+import { ArtifactList, ArtifactMetadata, ArtifactPreview, AuditDetail, AuditTable, type ArtifactRecord, type AuditRecord } from "../records";
+import { SessionOverview, type SessionOverviewData } from "../sessions";
+
+export const DEMO_BOUNDARY = "演示数据 · 内存 adapter · 未连接真实 Teamserver";
+const task = { task_id: "task-842", capability_id: "session.collect", state: "RUNNING", cancellable: true, created_at: "2026-08-03T19:10:00Z", updated_at: "2026-08-03T19:12:00Z", server_id: "atlas", project_id: "northwind", session_id: "NW-042" } as Task;
+const outputs = [{ id: "out-1", timestamp: "2026-08-03T19:12:01Z", text: "已接收任务,等待 Teamserver 输出", redacted: false }] as unknown as TaskOutputEntry[];
+const event = { event_id: "evt-1208", type: "TASK_UPDATED", timestamp: "2026-08-03T19:12:00Z", cursor: "cursor-1208", sequence: 1208, context: { server_id: "atlas", project_id: "northwind", session_id: "NW-042", task_id: "task-842" }, payload: { state: "RUNNING" } } as Event;
+const artifact: ArtifactRecord = { id: "artifact-12", name: "collection-summary.pdf", size: 39, mimeType: "application/pdf", sha256: "d35f8c7a…8e2c", createdAt: "2026-08-03 19:12 UTC" };
+const audit: AuditRecord = { id: "audit-32", actor: "operator@example.test", target: "Northwind / NW-042", operation: "SubmitTask", parameters: { capability: "session.collect", token: "demo-secret" }, result: "SUCCEEDED", requestId: "req-4be9", timestamp: "2026-08-03 19:10 UTC" };
+
+function Heading({ title, detail }: { title: string; detail: string }) { return <>
{DEMO_BOUNDARY}
>; }
+
+export function OverviewPage() { return <>
连接状态
CONNECTED · 42 ms · Protocol 1.0
当前活动
248 Sessions · 7 Tasks · 2 未读事件
>; }
+
+export function SessionDetailPage() {
+ const data = { capabilities: [], tasks: [task], events: [event], artifacts: [], notes: "服务端演示备注", tags: demoSession.tags } as unknown as SessionOverviewData;
+ return <>
>;
+}
+
+export function TasksPage() {
+ const [selected, setSelected] = useState
(task); const [cancel, setCancel] = useState(false);
+ return <> setCancel(true)} />
{cancel && 确认取消 task-842?目标:Atlas / Northwind / NW-042。此演示不会发送真实请求。
}>;
+}
+
+export function EventsPage() {
+ const [filters, setFilters] = useState({ types: [] }); const [selected, setSelected] = useState();
+ return <>
>;
+}
+
+export function ArtifactsPage() {
+ const [selected, setSelected] = useState(artifact);
+ return <>>;
+}
+
+export function AuditPage() {
+ const [selected, setSelected] = useState();
+ return <> undefined} onSelect={setSelected} />{selected ? : 选择记录查看已脱敏参数
} >;
+}
diff --git a/src/shell/OperationTabs.tsx b/src/shell/OperationTabs.tsx
index 4b0ed3b..0897011 100644
--- a/src/shell/OperationTabs.tsx
+++ b/src/shell/OperationTabs.tsx
@@ -1,7 +1,8 @@
import { Icon } from "../ui";
import type { OperationTab } from "../workspace";
-export interface OperationTabsProps { serverName: string; tabs: readonly OperationTab[]; activeTabId: string; onActivate: (tabId: string) => void; onClose?: (tabId: string) => void; }
-export function OperationTabs({ serverName, tabs, activeTabId, onActivate, onClose }: OperationTabsProps) {
- return {tabs.map((tab) => )}
;
+export interface OperationTabsProps { serverName: string; tabs: readonly OperationTab[]; activeTabId: string; onActivate: (tabId: string) => void; onClose?: (tabId: string) => void; onTogglePin?: (tabId: string) => void; }
+export function OperationTabs({ serverName, tabs, activeTabId, onActivate, onClose, onTogglePin }: OperationTabsProps) {
+ const active = tabs.find(({ id }) => id === activeTabId) ?? tabs[0];
+ return { const current = tabs.findIndex(({ id }) => id === activeTabId); if (event.key === "ArrowRight") onActivate(tabs[(current + 1) % tabs.length].id); if (event.key === "ArrowLeft") onActivate(tabs[(current - 1 + tabs.length) % tabs.length].id); }}>{tabs.map((tab) => )}
{onTogglePin && }{onClose && !active.pinned && }
;
}
diff --git a/src/shell/WorkspaceLayout.tsx b/src/shell/WorkspaceLayout.tsx
index b9724eb..0ab95aa 100644
--- a/src/shell/WorkspaceLayout.tsx
+++ b/src/shell/WorkspaceLayout.tsx
@@ -6,5 +6,5 @@ import { ProjectSidebar } from "./ProjectSidebar";
import { StatusBar } from "./StatusBar";
import { TeamserverRail } from "./TeamserverRail";
-export interface WorkspaceLayoutProps { servers: TeamserverWorkspace[]; server: TeamserverWorkspace; activeTab: OperationTab; activePage?: string; children: ReactNode; onSwitchServer: (id: string) => void; onActivateTab: (id: string) => void; onNavigate?: (page: string) => void; }
-export function WorkspaceLayout({ servers, server, activeTab, activePage = activeTab.title, children, onSwitchServer, onActivateTab, onNavigate }: WorkspaceLayoutProps) { return ; }
+export interface WorkspaceLayoutProps { servers: TeamserverWorkspace[]; server: TeamserverWorkspace; activeTab: OperationTab; activePage?: string; children: ReactNode; onSwitchServer: (id: string) => void; onActivateTab: (id: string) => void; onNavigate?: (page: string) => void; onCloseTab?: (id: string) => void; onTogglePin?: (id: string) => void; }
+export function WorkspaceLayout({ servers, server, activeTab, activePage = activeTab.title, children, onSwitchServer, onActivateTab, onNavigate, onCloseTab, onTogglePin }: WorkspaceLayoutProps) { return ; }
diff --git a/src/styles.css b/src/styles.css
index 173327b..89916e6 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -40,11 +40,8 @@ nav strong { color: #f1888f; font: 600 10px ui-monospace, monospace; }
.nav-heading { margin: 18px 9px 5px; font-size: 10px; color: #737c89; font-weight: 650; text-transform: uppercase; letter-spacing: .08em; }
.memory-note { margin-top: auto; padding: 9px; border-top: 1px solid var(--border); font-size: 10px; color: #727b88; }
.workspace { display: grid; grid-template-rows: 38px 42px 1fr 24px; min-width: 0; height: 100vh; background: var(--content); }
-.tabbar { display: flex; background: #111419; border-bottom: 1px solid var(--border); overflow: hidden; }
-.tabbar button { min-width: 132px; max-width: 190px; border: 0; border-right: 1px solid var(--border); background: transparent; padding: 0 11px; font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: background 100ms ease; }
-.tabbar button:hover { background: var(--hover); color: #cbd0d8; }
-.tabbar button.active { background: var(--content); color: var(--text); box-shadow: inset 0 2px var(--info); }
-.tabbar button { display:flex;align-items:center;justify-content:space-between;gap:8px; }.tab-label{overflow:hidden;text-overflow:ellipsis}.close { color: #69717d; display:inline-flex; }
+.tabbar-wrap { display:flex; min-width:0; background:#111419; border-bottom:1px solid var(--border); }.tabbar { display:flex; flex:1; overflow:hidden; }
+.tabbar > button { min-width:132px;max-width:190px;border:0;border-right:1px solid var(--border);background:transparent;padding:0 11px;font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden }.tabbar > button:hover{background:var(--hover)}.tabbar > button.active{background:var(--content);color:var(--text);box-shadow:inset 0 2px var(--info)}.tab-controls{display:flex;border-left:1px solid var(--border)}.tab-controls button{width:30px;border:0;background:transparent;color:#8b94a2;padding:0}.tab-controls button:hover{background:var(--hover)}.tab-label{overflow:hidden;text-overflow:ellipsis}
.running { display: inline-block; width: 6px; height: 6px; border:1px solid currentColor; border-radius: 2px; background: var(--warning); margin-right: 7px; }
.contextbar { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--border); padding: 0 16px; font-size: 11px; color: var(--muted); background: #171b21; }
.contextbar i { color: #515967; font-style: normal; }
@@ -110,6 +107,10 @@ input[type="checkbox"] { accent-color: var(--info); width: 12px; height: 12px; }
.group-tree li > div { display: grid; grid-template-columns: 24px 1fr auto; align-items: center; }.group-tree button, .operation-form button, .operation-confirmation button { border: 1px solid var(--border); background: #1b2027; border-radius: 4px; padding: 5px 8px; }.group-tree .selected { background: var(--selected); }
.operation-form { display: grid; gap: 12px; }.operation-form label { display: grid; gap: 5px; }.operation-form input, .operation-form select, select { background: #111419; border: 1px solid var(--border); border-radius: 4px; padding: 7px; }.operation-form small { color: var(--muted); }.operation-confirmation pre { max-height: 220px; overflow: auto; background: #0f1115; padding: 10px; }.operation-confirmation > div:last-child { display: flex; justify-content: flex-end; gap: 7px; }
@media (max-width: 1050px) { .overview-grid { grid-template-columns: 1fr; } }
+.demo-boundary { display:inline-block; margin:0 0 12px; padding:4px 7px; color:#d9b15f; border:1px solid #5d4b29; background:#292316; border-radius:4px; font:10px ui-monospace,monospace; }
+.split-view { display:grid; grid-template-columns:minmax(320px,.9fr) minmax(360px,1.1fr); gap:14px; margin-top:14px; }.split-view>*{min-width:0}
+.record-detail,.inline-alert { border:1px solid var(--border); border-radius:5px; background:#15191f; padding:14px; }.record-detail dl{display:grid;grid-template-columns:100px 1fr;gap:7px;margin:0 0 14px}.record-detail dd{margin:0;overflow-wrap:anywhere}.record-detail pre{max-height:300px;overflow:auto;background:#0f1115;padding:10px}.inline-alert{position:fixed;z-index:30;inset:auto 24px 40px auto;max-width:420px;box-shadow:0 12px 36px #000a}.inline-alert button{margin-right:7px}
+@media (max-width:900px){.split-view{grid-template-columns:1fr}}
.connection-center { max-width: 1120px; display: grid; grid-template-columns: minmax(520px, 1fr) 330px; gap: 14px; }
.connection-list { grid-row: span 3; }
diff --git a/src/workspace.test.ts b/src/workspace.test.ts
index 3a35a5b..5d3e9a4 100644
--- a/src/workspace.test.ts
+++ b/src/workspace.test.ts
@@ -54,4 +54,15 @@ describe("workspaceReducer", () => {
expect(restarted.servers.atlas.filter).toBe("");
expect(restarted).not.toBe(changed);
});
+
+ it("固定标签不可关闭,取消固定后关闭并恢复相邻标签", () => {
+ let state = createInitialWorkspaceState();
+ state = workspaceReducer(state, { type: "activateTab", serverId: "atlas", tabId: "nw-042" });
+ state = workspaceReducer(state, { type: "togglePinTab", serverId: "atlas", tabId: "nw-042" });
+ expect(workspaceReducer(state, { type: "closeTab", serverId: "atlas", tabId: "nw-042" })).toBe(state);
+ state = workspaceReducer(state, { type: "togglePinTab", serverId: "atlas", tabId: "nw-042" });
+ state = workspaceReducer(state, { type: "closeTab", serverId: "atlas", tabId: "nw-042" });
+ expect(state.servers.atlas.tabs.some(({ id }) => id === "nw-042")).toBe(false);
+ expect(state.servers.atlas.activeTabId).toBe("nw-sessions");
+ });
});
diff --git a/src/workspace.ts b/src/workspace.ts
index ceec88d..da37348 100644
--- a/src/workspace.ts
+++ b/src/workspace.ts
@@ -11,6 +11,7 @@ export interface OperationTab {
readonly context: TabContext;
readonly title: string;
readonly running?: boolean;
+ readonly pinned?: boolean;
}
export interface TeamserverWorkspace {
@@ -38,7 +39,9 @@ export type WorkspaceAction =
| { type: "navigate"; serverId: string; project: string; navigation: string }
| { type: "setFilter"; serverId: string; filter: string }
| { type: "setUnread"; serverId: string; unread: number }
- | { type: "openTab"; serverId: string; tab: OperationTab };
+ | { type: "openTab"; serverId: string; tab: OperationTab }
+ | { type: "closeTab"; serverId: string; tabId: string }
+ | { type: "togglePinTab"; serverId: string; tabId: string };
const seedWorkspaces: readonly TeamserverWorkspace[] = [
{
@@ -132,6 +135,20 @@ export function workspaceReducer(state: WorkspaceState, action: WorkspaceAction)
if (existing) return { ...server, activeTabId: existing.id };
return { ...server, tabs: [...server.tabs, action.tab], activeTabId: action.tab.id };
});
+ case "closeTab":
+ return updateServer(state, action.serverId, (server) => {
+ const target = server.tabs.find(({ id }) => id === action.tabId);
+ if (!target || target.pinned || server.tabs.length === 1) return server;
+ const index = server.tabs.findIndex(({ id }) => id === action.tabId);
+ const tabs = server.tabs.filter(({ id }) => id !== action.tabId);
+ const activeTabId = server.activeTabId === action.tabId ? tabs[Math.max(0, index - 1)].id : server.activeTabId;
+ return { ...server, tabs, activeTabId };
+ });
+ case "togglePinTab":
+ return updateServer(state, action.serverId, (server) => ({
+ ...server,
+ tabs: server.tabs.map((tab) => tab.id === action.tabId ? { ...tab, pinned: !tab.pinned } : tab),
+ }));
}
}