实现多 Teamserver 内存工作区状态层

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Ubuntu 2026-08-03 19:04:28 +00:00
parent 1a32791922
commit 938ecf86a0
3 changed files with 184 additions and 41 deletions

View File

@ -1,5 +1,10 @@
import { useMemo, useState } from "react"; import { useReducer } from "react";
import { initialWorkspaces, selectWorkspace } from "./workspace"; import {
createInitialWorkspaceState,
selectActiveTab,
selectActiveWorkspace,
workspaceReducer,
} from "./workspace";
const sessions = [ const sessions = [
{ id: "NW-042", status: "ONLINE", host: "ws-fin-042", user: "nora.chen", os: "Windows 11", address: "10.42.8.17", seen: "14s ago", tags: ["finance", "priority"] }, { id: "NW-042", status: "ONLINE", host: "ws-fin-042", user: "nora.chen", os: "Windows 11", address: "10.42.8.17", seen: "14s ago", tags: ["finance", "priority"] },
@ -9,25 +14,19 @@ const sessions = [
]; ];
export function App() { export function App() {
const [activeServerId, setActiveServerId] = useState(initialWorkspaces[0].id); const [workspace, dispatch] = useReducer(workspaceReducer, undefined, createInitialWorkspaceState);
const [activeTabs, setActiveTabs] = useState<Record<string, string>>( const server = selectActiveWorkspace(workspace);
Object.fromEntries(initialWorkspaces.map((server) => [server.id, server.activeTabId])), const activeTab = selectActiveTab(server);
);
const server = useMemo(
() => selectWorkspace(initialWorkspaces, activeServerId) ?? initialWorkspaces[0],
[activeServerId],
);
const activeTab = server.tabs.find((tab) => tab.id === activeTabs[server.id]) ?? server.tabs[0];
return ( return (
<main className="shell"> <main className="shell">
<aside className="server-rail" aria-label="Teamserver 列表"> <aside className="server-rail" aria-label="Teamserver 列表">
<div className="brand">G</div> <div className="brand">G</div>
{initialWorkspaces.map((item) => ( {workspace.serverOrder.map((serverId) => workspace.servers[serverId]).map((item) => (
<button <button
className={`server-button ${item.id === server.id ? "active" : ""}`} className={`server-button ${item.id === server.id ? "active" : ""}`}
key={item.id} key={item.id}
onClick={() => setActiveServerId(item.id)} onClick={() => dispatch({ type: "switchServer", serverId: item.id })}
aria-label={`切换到 ${item.name}`} aria-label={`切换到 ${item.name}`}
title={item.name} title={item.name}
> >
@ -68,23 +67,23 @@ export function App() {
aria-selected={tab.id === activeTab.id} aria-selected={tab.id === activeTab.id}
className={tab.id === activeTab.id ? "active" : ""} className={tab.id === activeTab.id ? "active" : ""}
key={tab.id} key={tab.id}
onClick={() => setActiveTabs((tabs) => ({ ...tabs, [server.id]: tab.id }))} onClick={() => dispatch({ type: "activateTab", serverId: server.id, tabId: tab.id })}
title={`${tab.project} / ${tab.session}`} title={`${tab.context.project} / ${tab.context.session}`}
> >
{tab.running && <span className="running" />}{tab.title}<span className="close">×</span> {tab.running && <span className="running" />}{tab.title}<span className="close">×</span>
</button> </button>
))} ))}
</div> </div>
<div className="contextbar" aria-label="锁定的操作上下文"> <div className="contextbar" aria-label="锁定的操作上下文">
<span className="context-lock"> LOCKED</span>{server.name} <i>/</i> {activeTab.project} <i>/</i> <b>{activeTab.session}</b> <i>/</i> {activeTab.title} <span className="context-lock"> LOCKED</span>{server.name} <i>/</i> {activeTab.context.project} <i>/</i> <b>{activeTab.context.session}</b> <i>/</i> {activeTab.title}
</div> </div>
<article className="content"> <article className="content">
<div className="content-heading"> <div className="content-heading">
<div><h2>{activeTab.title}</h2><p>{activeTab.project} 248 Session · 4 </p></div> <div><h2>{activeTab.title}</h2><p>{activeTab.context.project} 248 Session · 4 </p></div>
<div className="heading-actions"><button className="secondary"></button><button className="primary"> </button></div> <div className="heading-actions"><button className="secondary"></button><button className="primary"> </button></div>
</div> </div>
<div className="table-toolbar"> <div className="table-toolbar">
<label><span></span><input aria-label="筛选 Session" placeholder="筛选 Session…" /></label> <label><span></span><input aria-label="筛选 Session" placeholder="筛选 Session…" value={server.filter} onChange={(event) => dispatch({ type: "setFilter", serverId: server.id, filter: event.target.value })} /></label>
<button></button><button></button><button> </button> <button></button><button></button><button> </button>
<span className="toolbar-spacer" /><button aria-label="调整列"> </button><button aria-label="更多操作"></button> <span className="toolbar-spacer" /><button aria-label="调整列"> </button><button aria-label="更多操作"></button>
</div> </div>

57
src/workspace.test.ts Normal file
View File

@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
createInitialWorkspaceState,
selectActiveTab,
selectActiveWorkspace,
type WorkspaceAction,
workspaceReducer,
} from "./workspace";
describe("workspaceReducer", () => {
it("隔离每个 Teamserver 的导航、筛选与未读状态", () => {
const initial = createInitialWorkspaceState();
const actions: WorkspaceAction[] = [
{ type: "navigate", serverId: "atlas", project: "Orchid", navigation: "Audit" },
{ type: "setFilter", serverId: "atlas", filter: "status:online" },
{ type: "setUnread", serverId: "atlas", unread: 0 },
];
const changed = actions.reduce(workspaceReducer, initial);
expect(changed.servers.atlas).toMatchObject({ activeProject: "Orchid", navigation: "Audit", filter: "status:online", unread: 0 });
expect(changed.servers.ember).toMatchObject({ activeProject: "Sandbox", navigation: "Events", filter: "", unread: 5 });
});
it("切回 Teamserver 时恢复各自的激活标签", () => {
let state = createInitialWorkspaceState();
state = workspaceReducer(state, { type: "activateTab", serverId: "atlas", tabId: "nw-042" });
state = workspaceReducer(state, { type: "switchServer", serverId: "ember" });
state = workspaceReducer(state, { type: "activateTab", serverId: "ember", tabId: "sb-107" });
state = workspaceReducer(state, { type: "switchServer", serverId: "atlas" });
expect(selectActiveTab(selectActiveWorkspace(state)).id).toBe("nw-042");
expect(state.servers.ember.activeTabId).toBe("sb-107");
});
it("导航与重复 tab ID 均不能重定向已创建标签的上下文", () => {
let state = createInitialWorkspaceState();
const originalContext = state.servers.atlas.tabs[1].context;
state = workspaceReducer(state, { type: "navigate", serverId: "atlas", project: "Orchid", navigation: "Sessions" });
state = workspaceReducer(state, {
type: "openTab",
serverId: "atlas",
tab: { id: "nw-042", title: "被拒绝的替换", context: { serverId: "atlas", project: "Orchid", session: "OR-999" } },
});
expect(state.servers.atlas.tabs[1].context).toEqual(originalContext);
expect(state.servers.atlas.tabs[1].title).toBe("Session Overview");
});
it("退出等价于丢弃内存状态,新实例不恢复业务状态", () => {
const changed = workspaceReducer(createInitialWorkspaceState(), { type: "setFilter", serverId: "atlas", filter: "secret" });
const restarted = createInitialWorkspaceState();
expect(changed.servers.atlas.filter).toBe("secret");
expect(restarted.servers.atlas.filter).toBe("");
expect(restarted).not.toBe(changed);
});
});

View File

@ -1,25 +1,46 @@
export type ConnectionState = "CONNECTED" | "DEGRADED" | "RECONNECTING"; export type ConnectionState = "CONNECTED" | "DEGRADED" | "RECONNECTING";
export interface TabContext {
readonly serverId: string;
readonly project: string;
readonly session: string;
}
export interface OperationTab { export interface OperationTab {
id: string; readonly id: string;
project: string; readonly context: TabContext;
session: string; readonly title: string;
title: string; readonly running?: boolean;
running?: boolean;
} }
export interface TeamserverWorkspace { export interface TeamserverWorkspace {
id: string; readonly id: string;
name: string; readonly name: string;
shortName: string; readonly shortName: string;
connection: ConnectionState; readonly connection: ConnectionState;
unread: number; readonly tabs: readonly OperationTab[];
activeProject: string;
activeTabId: string; activeTabId: string;
tabs: OperationTab[]; activeProject: string;
navigation: string;
filter: string;
unread: number;
} }
export const initialWorkspaces: TeamserverWorkspace[] = [ export interface WorkspaceState {
activeServerId: string;
servers: Record<string, TeamserverWorkspace>;
serverOrder: readonly string[];
}
export type WorkspaceAction =
| { type: "switchServer"; serverId: string }
| { type: "activateTab"; serverId: string; tabId: string }
| { 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 };
const seedWorkspaces: readonly TeamserverWorkspace[] = [
{ {
id: "atlas", id: "atlas",
name: "Atlas Teamserver", name: "Atlas Teamserver",
@ -27,11 +48,13 @@ export const initialWorkspaces: TeamserverWorkspace[] = [
connection: "CONNECTED", connection: "CONNECTED",
unread: 2, unread: 2,
activeProject: "Northwind", activeProject: "Northwind",
navigation: "Sessions",
filter: "",
activeTabId: "nw-sessions", activeTabId: "nw-sessions",
tabs: [ tabs: [
{ id: "nw-sessions", project: "Northwind", session: "All sessions", title: "Sessions" }, { id: "nw-sessions", context: { serverId: "atlas", project: "Northwind", session: "All sessions" }, title: "Sessions" },
{ id: "nw-042", project: "Northwind", session: "NW-042", title: "Session Overview" }, { id: "nw-042", context: { serverId: "atlas", project: "Northwind", session: "NW-042" }, title: "Session Overview" },
{ id: "or-audit", project: "Orchid", session: "Project", title: "Audit", running: true }, { id: "or-audit", context: { serverId: "atlas", project: "Orchid", session: "Project" }, title: "Audit", running: true },
], ],
}, },
{ {
@ -41,17 +64,81 @@ export const initialWorkspaces: TeamserverWorkspace[] = [
connection: "DEGRADED", connection: "DEGRADED",
unread: 5, unread: 5,
activeProject: "Sandbox", activeProject: "Sandbox",
navigation: "Events",
filter: "",
activeTabId: "sb-events", activeTabId: "sb-events",
tabs: [ tabs: [
{ id: "sb-events", project: "Sandbox", session: "Project", title: "Events" }, { id: "sb-events", context: { serverId: "ember", project: "Sandbox", session: "Project" }, title: "Events" },
{ id: "sb-107", project: "Sandbox", session: "SB-107", title: "Task Output", running: true }, { id: "sb-107", context: { serverId: "ember", project: "Sandbox", session: "SB-107" }, title: "Task Output", running: true },
], ],
}, },
]; ];
export function selectWorkspace( function cloneServer(server: TeamserverWorkspace): TeamserverWorkspace {
workspaces: TeamserverWorkspace[], return {
workspaceId: string, ...server,
): TeamserverWorkspace | undefined { tabs: server.tabs.map((tab) => ({ ...tab, context: { ...tab.context } })),
return workspaces.find(({ id }) => id === workspaceId); };
}
/** 每次进程启动都从种子数据创建全新状态,不读取或写入任何持久化存储。 */
export function createInitialWorkspaceState(): WorkspaceState {
return {
activeServerId: seedWorkspaces[0].id,
serverOrder: seedWorkspaces.map(({ id }) => id),
servers: Object.fromEntries(seedWorkspaces.map((server) => [server.id, cloneServer(server)])),
};
}
function updateServer(
state: WorkspaceState,
serverId: string,
update: (server: TeamserverWorkspace) => TeamserverWorkspace,
): WorkspaceState {
const server = state.servers[serverId];
if (!server) return state;
const nextServer = update(server);
if (nextServer === server) return state;
return { ...state, servers: { ...state.servers, [serverId]: nextServer } };
}
export function workspaceReducer(state: WorkspaceState, action: WorkspaceAction): WorkspaceState {
switch (action.type) {
case "switchServer":
return state.servers[action.serverId] && action.serverId !== state.activeServerId
? { ...state, activeServerId: action.serverId }
: state;
case "activateTab":
return updateServer(state, action.serverId, (server) =>
server.tabs.some(({ id }) => id === action.tabId) && server.activeTabId !== action.tabId
? { ...server, activeTabId: action.tabId }
: server,
);
case "navigate":
return updateServer(state, action.serverId, (server) => ({
...server,
activeProject: action.project,
navigation: action.navigation,
}));
case "setFilter":
return updateServer(state, action.serverId, (server) => ({ ...server, filter: action.filter }));
case "setUnread":
return updateServer(state, action.serverId, (server) => ({ ...server, unread: Math.max(0, action.unread) }));
case "openTab":
return updateServer(state, action.serverId, (server) => {
if (action.tab.context.serverId !== action.serverId) return server;
const existing = server.tabs.find(({ id }) => id === action.tab.id);
// 相同 ID 只能重新激活,不能借导航或重复创建改写其锁定上下文。
if (existing) return { ...server, activeTabId: existing.id };
return { ...server, tabs: [...server.tabs, action.tab], activeTabId: action.tab.id };
});
}
}
export function selectActiveWorkspace(state: WorkspaceState): TeamserverWorkspace {
return state.servers[state.activeServerId] ?? state.servers[state.serverOrder[0]];
}
export function selectActiveTab(server: TeamserverWorkspace): OperationTab {
return server.tabs.find(({ id }) => id === server.activeTabId) ?? server.tabs[0];
} }