Compare commits

..

2 Commits

Author SHA1 Message Date
dev
f95b1683e7 Merge pull request 'GAZ-12: 完成 Session 列表到详情导航' (#14) from agent/docker/86e8574c into main
Reviewed-on: #14
2026-08-03 20:08:54 +00:00
Ubuntu
f6a256d483 完成 Session 列表到详情导航
Co-authored-by: multica-agent <github@multica.ai>
2026-08-03 20:07:21 +00:00
5 changed files with 34 additions and 9 deletions

View File

@ -28,6 +28,14 @@ describe("Teamserver 工作区", () => {
expect(screen.getByText("10.42.8.17")).toBeInTheDocument();
});
it("从 Session 表格打开上下文锁定的详情标签", () => {
render(<App />);
fireEvent.doubleClick(screen.getByRole("row", { name: /NW-038/ }));
expect(screen.getAllByRole("tab", { name: "Session Overview" }).find((tab) => tab.getAttribute("aria-selected") === "true")).toBeDefined();
expect(screen.getByLabelText("锁定的操作上下文")).toHaveTextContent("Northwind / NW-038 / Session Overview");
expect(screen.getByRole("heading", { name: "NW-038" })).toBeInTheDocument();
});
it("通过拆分后的侧边栏进入主线新增的连接中心", () => {
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "连接中心" }));

View File

@ -24,7 +24,7 @@ export function App() {
dispatch({ type: "navigate", serverId: server.id, project: server.activeProject, navigation: page });
dispatch({ type: "openTab", serverId: server.id, tab: { id: `${server.activeProject.toLowerCase()}-${page.toLowerCase().replaceAll(" ", "-")}`, title, context: { serverId: server.id, project: server.activeProject, session: "Project" } } });
};
const sessionsPage = <><div className="content-heading"><div><h2>Sessions</h2><p>{activeTab.context.project} 248 Session · 4 </p></div></div><p className="demo-boundary" role="status"> · adapter · Teamserver</p><SessionTable rows={sessions} state={tableState} capabilities={{ filtering: true, sortableColumns: ["status", "id", "seen"], columnVisibility: true, pagination: true }} page={{ start: 1, end: 50, total: 248, hasPrevious: false, hasNext: true }} onStateChange={(state) => { if (state.filter !== server.filter) dispatch({ type: "setFilter", serverId: server.id, filter: state.filter }); setTableStates((current) => updateSessionTableState(current, server.id, state)); }} onQueryIntent={(intent) => setLastQueryIntent((current) => ({ ...current, [server.id]: intent }))} /><output className="query-intent" aria-live="polite">{lastQueryIntent[server.id] ? `查询意图:${lastQueryIntent[server.id]?.type}` : "等待服务端查询意图"}</output></>;
const pages: Record<string, ReactNode> = { Overview: <OverviewPage />, Sessions: sessionsPage, "Session Overview": <SessionDetailPage />, Tasks: <TasksPage />, "Task Output": <TasksPage />, Events: <EventsPage />, Artifacts: <ArtifactsPage />, Audit: <AuditPage />, "连接中心": <ConnectionCenter adapter={connectionAdapter} developmentMode={import.meta.env.DEV} /> };
const sessionsPage = <><div className="content-heading"><div><h2>Sessions</h2><p>{activeTab.context.project} 248 Session · 4 </p></div></div><p className="demo-boundary" role="status"> · adapter · Teamserver</p><SessionTable rows={sessions} state={tableState} capabilities={{ filtering: true, sortableColumns: ["status", "id", "seen"], columnVisibility: true, pagination: true }} page={{ start: 1, end: 50, total: 248, hasPrevious: false, hasNext: true }} onStateChange={(state) => { if (state.filter !== server.filter) dispatch({ type: "setFilter", serverId: server.id, filter: state.filter }); setTableStates((current) => updateSessionTableState(current, server.id, state)); }} onOpenSession={(session) => dispatch({ type: "openTab", serverId: server.id, tab: { id: `${server.id}-${activeTab.context.project}-${session.id}-overview`, title: "Session Overview", context: { serverId: server.id, project: activeTab.context.project, session: session.id } } })} onQueryIntent={(intent) => setLastQueryIntent((current) => ({ ...current, [server.id]: intent }))} /><output className="query-intent" aria-live="polite">{lastQueryIntent[server.id] ? `查询意图:${lastQueryIntent[server.id]?.type}` : "等待服务端查询意图"}</output></>;
const pages: Record<string, ReactNode> = { Overview: <OverviewPage />, Sessions: sessionsPage, "Session Overview": <SessionDetailPage sessionId={activeTab.context.session} />, Tasks: <TasksPage />, "Task Output": <TasksPage />, Events: <EventsPage />, Artifacts: <ArtifactsPage />, Audit: <AuditPage />, "连接中心": <ConnectionCenter adapter={connectionAdapter} developmentMode={import.meta.env.DEV} /> };
return <WorkspaceLayout servers={workspace.serverOrder.map((id) => workspace.servers[id])} server={server} activeTab={activeTab} activePage={server.navigation} onNavigate={navigate} onSwitchServer={(serverId) => dispatch({ type: "switchServer", serverId })} onActivateTab={(tabId) => dispatch({ type: "activateTab", serverId: server.id, tabId })} onCloseTab={(tabId) => dispatch({ type: "closeTab", serverId: server.id, tabId })} onTogglePin={(tabId) => dispatch({ type: "togglePinTab", serverId: server.id, tabId })}><article className="content" aria-label={`${activeTab.title} 页面`}>{pages[activeTab.title] ?? <OverviewPage />}</article></WorkspaceLayout>;
}

View File

@ -8,9 +8,9 @@ const rows: SessionRow[] = [
{ id: "S-2", status: "OFFLINE", host: "beta", user: "two", os: "Windows", address: "10.0.0.2", seen: "2m", tags: ["b"] },
];
function Harness({ onIntent = () => undefined }: { onIntent?: (intent: SessionQueryIntent) => void }) {
function Harness({ onIntent = () => undefined, onOpenSession }: { onIntent?: (intent: SessionQueryIntent) => void; onOpenSession?: (session: SessionRow) => void }) {
const [state, setState] = useState(createSessionTableViewState);
return <SessionTable rows={rows} state={state} capabilities={{ filtering: true, sortableColumns: ["status", "id", "seen"], columnVisibility: true, pagination: true }} page={{ start: 1, end: 2, total: 200, hasPrevious: false, hasNext: true }} onStateChange={setState} onQueryIntent={onIntent} />;
return <SessionTable rows={rows} state={state} capabilities={{ filtering: true, sortableColumns: ["status", "id", "seen"], columnVisibility: true, pagination: true }} page={{ start: 1, end: 2, total: 200, hasPrevious: false, hasNext: true }} onStateChange={setState} onQueryIntent={onIntent} onOpenSession={onOpenSession} />;
}
describe("SessionTable", () => {
@ -61,4 +61,15 @@ describe("SessionTable", () => {
fireEvent.click(screen.getByRole("checkbox", { name: "来源地址" }));
expect(screen.queryByText("10.0.0.1")).not.toBeInTheDocument();
});
it("双击或按 Enter 打开当前 Session", () => {
const onOpenSession = vi.fn();
render(<Harness onOpenSession={onOpenSession} />);
const first = screen.getByRole("row", { name: /S-1/ });
fireEvent.doubleClick(first);
first.focus();
fireEvent.keyDown(first, { key: "Enter" });
expect(onOpenSession).toHaveBeenNthCalledWith(1, rows[0]);
expect(onOpenSession).toHaveBeenNthCalledWith(2, rows[0]);
});
});

View File

@ -37,6 +37,7 @@ export interface SessionTableProps {
page: { start: number; end: number; total: number; hasPrevious: boolean; hasNext: boolean };
onStateChange: (state: SessionTableViewState) => void;
onQueryIntent: (intent: SessionQueryIntent) => void;
onOpenSession?: (session: SessionRow) => void;
}
export interface SessionTableCapabilities {
@ -64,7 +65,7 @@ function nextSort(state: SessionTableViewState, column: SessionColumnId): SortDi
return state.sort?.column === column && state.sort.direction === "ascending" ? "descending" : "ascending";
}
export function SessionTable({ rows, state, capabilities, page, onStateChange, onQueryIntent }: SessionTableProps) {
export function SessionTable({ rows, state, capabilities, page, onStateChange, onQueryIntent, onOpenSession }: SessionTableProps) {
const rowRefs = useRef(new Map<string, HTMLDivElement>());
const selected = new Set(state.selectedSessionIds);
const visible = new Set(state.visibleColumns);
@ -97,6 +98,10 @@ export function SessionTable({ rows, state, capabilities, page, onStateChange, o
};
const handleRowKeyDown = (event: KeyboardEvent<HTMLDivElement>, index: number, id: string) => {
if (event.key === "Enter" && event.target === event.currentTarget) {
event.preventDefault();
const session = rows[index]; if (session) onOpenSession?.(session);
}
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Home" || event.key === "End") {
event.preventDefault();
const target = event.key === "Home" ? 0 : event.key === "End" ? rows.length - 1 : index + (event.key === "ArrowDown" ? 1 : -1);
@ -138,7 +143,7 @@ export function SessionTable({ rows, state, capabilities, page, onStateChange, o
})}
<span role="columnheader" />
</div>
</div><div role="rowgroup">{rows.map((session, index) => <div className={`table-row session-data-row ${session.id === state.focusedSessionId ? "focused" : ""}`} role="row" aria-selected={selected.has(session.id)} key={session.id} tabIndex={session.id === state.focusedSessionId || (!state.focusedSessionId && index === 0) ? 0 : -1} ref={(element) => { if (element) rowRefs.current.set(session.id, element); else rowRefs.current.delete(session.id); }} onFocus={() => state.focusedSessionId !== session.id && onStateChange({ ...state, focusedSessionId: session.id })} onClick={(event) => handleRowClick(event, index, session.id)} onKeyDown={(event) => handleRowKeyDown(event, index, session.id)}>
</div><div role="rowgroup">{rows.map((session, index) => <div className={`table-row session-data-row ${session.id === state.focusedSessionId ? "focused" : ""}`} role="row" aria-selected={selected.has(session.id)} aria-label={`${session.id},双击或按 Enter 打开详情`} key={session.id} tabIndex={session.id === state.focusedSessionId || (!state.focusedSessionId && index === 0) ? 0 : -1} ref={(element) => { if (element) rowRefs.current.set(session.id, element); else rowRefs.current.delete(session.id); }} onFocus={() => state.focusedSessionId !== session.id && onStateChange({ ...state, focusedSessionId: session.id })} onClick={(event) => handleRowClick(event, index, session.id)} onDoubleClick={() => onOpenSession?.(session)} onKeyDown={(event) => handleRowKeyDown(event, index, session.id)}>
{visible.has("status") && <span role="cell"><em className={`status-mark ${session.status.toLowerCase()}`} aria-hidden="true" /><span className="sr-only"></span>{session.status}</span>}
{visible.has("id") && <strong role="cell" className="mono">{session.id}</strong>}
{visible.has("identity") && <span role="cell"><b>{session.host}</b><small>{session.user}</small></span>}

View File

@ -1,6 +1,6 @@
import { useState } from "react";
import type { Event, Task } from "../../protocol/model";
import { demoSession } from "../../protocol/fixtures";
import { demoSession, demoSessions } 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";
@ -16,9 +16,10 @@ function Heading({ title, detail }: { title: string; detail: string }) { return
export function OverviewPage() { return <><Heading title="Project Overview" detail="Northwind 项目健康与活动摘要" /><div className="overview-grid"><section><h3></h3><p>CONNECTED · 42 ms · Protocol 1.0</p></section><section><h3></h3><p>248 Sessions · 7 Tasks · 2 </p></section></div></>; }
export function SessionDetailPage() {
export function SessionDetailPage({ sessionId }: { sessionId?: string }) {
const session = demoSessions.find((item) => item.session_id === sessionId) ?? demoSession;
const data = { capabilities: [], tasks: [task], events: [event], artifacts: [], notes: "服务端演示备注", tags: demoSession.tags } as unknown as SessionOverviewData;
return <><Heading title="Session Overview" detail="标准协议 Session 详情" /><SessionOverview session={demoSession} data={data} /></>;
return <><Heading title="Session Overview" detail={`${session.name} · 标准协议 Session 详情`} /><SessionOverview session={session} data={data} /></>;
}
export function TasksPage() {