From 33bb93ac7b5b2d7ed4ff94d6aee648e786c31c63 Mon Sep 17 00:00:00 2001 From: Ubuntu Date: Mon, 3 Aug 2026 19:26:41 +0000 Subject: [PATCH] =?UTF-8?q?=E5=BB=BA=E7=AB=8B=E8=AE=BE=E8=AE=A1=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E5=B9=B6=E6=8B=86=E5=88=86=E5=BA=94=E7=94=A8=E5=A3=B3?= =?UTF-8?q?=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: multica-agent --- src/App.tsx | 67 ++----------------- src/main.tsx | 2 + src/shell/ContextBar.tsx | 3 + src/shell/OperationTabs.tsx | 7 ++ src/shell/ProjectSidebar.tsx | 8 +++ src/shell/StatusBar.tsx | 3 + src/shell/TeamserverRail.tsx | 7 ++ src/shell/WorkspaceLayout.tsx | 10 +++ src/shell/index.ts | 6 ++ src/styles.css | 40 +++++------ src/styles/tokens.css | 10 +++ src/ui/index.tsx | 121 ++++++++++++++++++++++++++++++++++ src/ui/ui.css | 8 +++ src/ui/ui.test.tsx | 13 ++++ 14 files changed, 223 insertions(+), 82 deletions(-) create mode 100644 src/shell/ContextBar.tsx create mode 100644 src/shell/OperationTabs.tsx create mode 100644 src/shell/ProjectSidebar.tsx create mode 100644 src/shell/StatusBar.tsx create mode 100644 src/shell/TeamserverRail.tsx create mode 100644 src/shell/WorkspaceLayout.tsx create mode 100644 src/shell/index.ts create mode 100644 src/styles/tokens.css create mode 100644 src/ui/index.tsx create mode 100644 src/ui/ui.css create mode 100644 src/ui/ui.test.tsx diff --git a/src/App.tsx b/src/App.tsx index ec50883..27c0862 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,8 @@ import { SessionQueryIntent, SessionTable } from "./SessionTable"; import { demoSessions } from "./protocol/fixtures"; import { initializeSessionTableStates, updateSessionTableState } from "./sessionTableState"; import { toSessionRow } from "./sessionPresentation"; +import { WorkspaceLayout } from "./shell"; +import { Button, Icon } from "./ui"; import { createInitialWorkspaceState, selectActiveTab, @@ -22,68 +24,11 @@ export function App() { const tableState = { ...tableStates[server.id], filter: server.filter }; return ( -
- - - - -
-
- {server.tabs.map((tab) => ( - - ))} -
-
- ◆ LOCKED{server.name} / {activeTab.context.project} / {activeTab.context.session} / {activeTab.title} -
+ workspace.servers[id])} server={server} activeTab={activeTab} onSwitchServer={(serverId) => dispatch({ type: "switchServer", serverId })} onActivateTab={(tabId) => dispatch({ type: "activateTab", serverId: server.id, tabId })}>

{activeTab.title}

{activeTab.context.project} 中的 248 个 Session · 4 个实时更新

-
+
{ if (state.filter !== server.filter) dispatch({ type: "setFilter", serverId: server.id, filter: state.filter }); @@ -91,8 +36,6 @@ export function App() { }} onQueryIntent={(intent) => setLastQueryIntent((current) => ({ ...current, [server.id]: intent }))} /> {lastQueryIntent[server.id] ? `查询意图:${lastQueryIntent[server.id]?.type}` : "等待服务端查询意图"}
-
● {server.connection}身份 operator@example延迟 38ms协议 v1.0事件流 ✓ 已同步内存工作区
-
-
+ ); } diff --git a/src/main.tsx b/src/main.tsx index 9891129..93991c1 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -2,6 +2,8 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { App } from "./App"; import "./styles.css"; +import "./styles/tokens.css"; +import "./ui/ui.css"; createRoot(document.getElementById("root")!).render( diff --git a/src/shell/ContextBar.tsx b/src/shell/ContextBar.tsx new file mode 100644 index 0000000..2893f33 --- /dev/null +++ b/src/shell/ContextBar.tsx @@ -0,0 +1,3 @@ +import { Icon } from "../ui"; +import type { OperationTab } from "../workspace"; +export function ContextBar({ serverName, tab }: { serverName: string; tab: OperationTab }) { return
LOCKED{serverName} / {tab.context.project} / {tab.context.session} / {tab.title}
; } diff --git a/src/shell/OperationTabs.tsx b/src/shell/OperationTabs.tsx new file mode 100644 index 0000000..4b0ed3b --- /dev/null +++ b/src/shell/OperationTabs.tsx @@ -0,0 +1,7 @@ +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) => )}
; +} diff --git a/src/shell/ProjectSidebar.tsx b/src/shell/ProjectSidebar.tsx new file mode 100644 index 0000000..19d9f3f --- /dev/null +++ b/src/shell/ProjectSidebar.tsx @@ -0,0 +1,8 @@ +import { Icon, Input, StatusIndicator } from "../ui"; +import type { TeamserverWorkspace } from "../workspace"; + +const navigation = ["Overview", "Sessions", "Tasks", "Events", "Artifacts", "Audit"]; +export interface ProjectSidebarProps { server: TeamserverWorkspace; activePage: string; onNavigate?: (page: string) => void; } +export function ProjectSidebar({ server, activePage, onNavigate }: ProjectSidebarProps) { + return ; +} diff --git a/src/shell/StatusBar.tsx b/src/shell/StatusBar.tsx new file mode 100644 index 0000000..ac820be --- /dev/null +++ b/src/shell/StatusBar.tsx @@ -0,0 +1,3 @@ +import { StatusIndicator } from "../ui"; +import type { ConnectionState } from "../workspace"; +export function StatusBar({ connection }: { connection: ConnectionState }) { return
身份 operator@example延迟 38ms协议 v1.0事件流:已同步内存工作区
; } diff --git a/src/shell/TeamserverRail.tsx b/src/shell/TeamserverRail.tsx new file mode 100644 index 0000000..752f439 --- /dev/null +++ b/src/shell/TeamserverRail.tsx @@ -0,0 +1,7 @@ +import { Badge, Icon, IconButton, StatusIndicator, Tooltip } from "../ui"; +import type { TeamserverWorkspace } from "../workspace"; + +export interface TeamserverRailProps { servers: TeamserverWorkspace[]; activeServerId: string; onSwitch: (serverId: string) => void; onAdd?: () => void; } +export function TeamserverRail({ servers, activeServerId, onSwitch, onAdd }: TeamserverRailProps) { + return ; +} diff --git a/src/shell/WorkspaceLayout.tsx b/src/shell/WorkspaceLayout.tsx new file mode 100644 index 0000000..14d8ed6 --- /dev/null +++ b/src/shell/WorkspaceLayout.tsx @@ -0,0 +1,10 @@ +import { ReactNode } from "react"; +import type { OperationTab, TeamserverWorkspace } from "../workspace"; +import { ContextBar } from "./ContextBar"; +import { OperationTabs } from "./OperationTabs"; +import { ProjectSidebar } from "./ProjectSidebar"; +import { StatusBar } from "./StatusBar"; +import { TeamserverRail } from "./TeamserverRail"; + +export interface WorkspaceLayoutProps { servers: TeamserverWorkspace[]; server: TeamserverWorkspace; activeTab: OperationTab; children: ReactNode; onSwitchServer: (id: string) => void; onActivateTab: (id: string) => void; } +export function WorkspaceLayout({ servers, server, activeTab, children, onSwitchServer, onActivateTab }: WorkspaceLayoutProps) { return
{children}
; } diff --git a/src/shell/index.ts b/src/shell/index.ts new file mode 100644 index 0000000..6fbcc65 --- /dev/null +++ b/src/shell/index.ts @@ -0,0 +1,6 @@ +export * from "./ContextBar"; +export * from "./OperationTabs"; +export * from "./ProjectSidebar"; +export * from "./StatusBar"; +export * from "./TeamserverRail"; +export * from "./WorkspaceLayout"; diff --git a/src/styles.css b/src/styles.css index 67633f4..b40922e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -6,21 +6,20 @@ --online: #38b57a; --warning: #d9a441; --error: #e05b63; --info: #4c8dda; } * { box-sizing: border-box; } -body { margin: 0; min-width: 900px; min-height: 100vh; background: var(--app); } -button, input { font: inherit; color: inherit; } +body { margin: 0; min-width: 0; min-height: 100vh; overflow: auto; background: var(--app); } +button, input, select { font: inherit; color: inherit; } button { cursor: pointer; } -button:focus-visible, input:focus-visible, [tabindex]:focus-visible { outline: 2px solid var(--info); outline-offset: -2px; } -.shell { display: grid; grid-template-columns: 52px 260px 1fr; min-height: 100vh; background: var(--app); } +button:focus-visible, input:focus-visible, select:focus-visible, [tabindex]:focus-visible { outline: none; box-shadow: var(--focus-ring); position: relative; z-index: 1; } +.shell { display: grid; grid-template-columns: 52px 260px minmax(600px, 1fr); min-width: 912px; min-height: 100vh; background: var(--app); } .server-rail { background: #101318; border-right: 1px solid var(--border); padding: 10px 7px; display: flex; flex-direction: column; align-items: center; gap: 8px; } .brand { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid #3a414c; border-radius: 5px; color: var(--text); background: #1b2027; font: 700 14px ui-monospace, "JetBrains Mono", monospace; margin-bottom: 9px; } -.server-button { position: relative; width: 36px; height: 36px; border: 1px solid transparent; border-radius: 5px; background: #1b2027; color: #a9b0bc; font: 600 10px ui-monospace, "JetBrains Mono", monospace; transition: background 100ms ease, border-color 100ms ease; } +.server-button { position: relative; width: 36px; height: 36px; display:grid; place-items:center; border: 1px solid transparent; border-radius: 5px; background: #1b2027; color: #a9b0bc; font: 600 10px ui-monospace, "JetBrains Mono", monospace; transition: background 100ms ease, border-color 100ms ease; } .server-button:hover { background: var(--hover); border-color: #343c47; color: var(--text); } .server-button.active { background: var(--selected); border-color: #3c4959; color: var(--text); } .server-button.active::before { content: ""; position: absolute; width: 2px; height: 22px; background: var(--info); left: -8px; top: 6px; border-radius: 1px; } .server-button.add { color: var(--muted); font-size: 18px; background: transparent; border: 1px dashed #3a414c; } -.presence { position: absolute; width: 8px; height: 8px; border: 2px solid #101318; border-radius: 50%; right: -3px; bottom: -3px; background: var(--online); } -.presence.degraded { background: var(--warning); } -.badge { position: absolute; min-width: 16px; height: 16px; padding: 0 4px; line-height: 16px; background: var(--error); color: white; border-radius: 8px; right: -7px; top: -7px; font: 600 9px ui-monospace, monospace; } +.server-button .ui-status { position:absolute;right:-3px;bottom:-3px;background:#101318;border-radius:50%;padding:2px; } +.server-button .ui-badge { position:absolute;right:-7px;top:-7px; } .project-sidebar { background: var(--nav); border-right: 1px solid var(--border); display: flex; flex-direction: column; padding: 18px 12px 9px; } .project-sidebar header { padding: 0 7px 13px; } .eyebrow { color: var(--muted); font-size: 10px; font-weight: 650; letter-spacing: .1em; margin: 0 0 6px; } @@ -29,14 +28,14 @@ h1 { font-size: 14px; line-height: 20px; margin: 0 0 8px; font-weight: 600; } .connection::before { content: "✓"; margin-right: 4px; } .connection.degraded { color: #e5bb67; background: #322a1d; border-color: #5a4727; } .connection.degraded::before { content: "!"; } -.search, .table-toolbar label { display: flex; align-items: center; gap: 7px; border: 1px solid var(--border); border-radius: 4px; background: #111419; color: var(--muted); } -.search { margin: 8px 1px 17px; padding: 6px 8px; } -.search input, .table-toolbar input { min-width: 0; width: 100%; border: 0; outline: 0; background: transparent; } +.table-toolbar label { display: flex; align-items: center; gap: 7px; border: 1px solid var(--border); border-radius: 4px; background: #111419; color: var(--muted); } +.sidebar-search { margin: 8px 1px 17px; } +.table-toolbar input { min-width: 0; width: 100%; border: 0; outline: 0; background: transparent; } kbd { border: 1px solid #343b45; border-radius: 3px; padding: 1px 4px; font-size: 9px; white-space: nowrap; } nav { display: flex; flex-direction: column; gap: 2px; } -nav a { display: flex; justify-content: space-between; padding: 6px 9px; border-radius: 4px; color: #aab1bd; font-size: 12px; transition: background 100ms ease; } -nav a:hover { background: var(--hover); } -nav a.selected { background: var(--selected); color: var(--text); font-weight: 550; box-shadow: inset 2px 0 var(--info); } +nav button { display: flex; justify-content: space-between; padding: 6px 9px; border:0; border-radius: 4px; background:transparent; color: #aab1bd; font-size: 12px; text-align:left; transition: background 100ms ease; } +nav button:hover { background: var(--hover); } +nav button.selected { background: var(--selected); color: var(--text); font-weight: 550; box-shadow: inset 2px 0 var(--info); } 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; } @@ -45,12 +44,12 @@ nav strong { color: #f1888f; font: 600 10px ui-monospace, monospace; } .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); } -.close { color: #69717d; margin-left: 15px; } -.running { display: inline-block; width: 6px; height: 6px; border-radius: 2px; background: var(--warning); margin-right: 7px; } +.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; } +.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; } .contextbar b { color: #cdd2da; font: 500 11px ui-monospace, "JetBrains Mono", monospace; } -.context-lock { color: #77a9e4; font: 650 9px ui-monospace, monospace; border-right: 1px solid var(--border); padding-right: 10px; margin-right: 2px; } +.context-lock { display:inline-flex;align-items:center;gap:4px;color: #77a9e4; font: 650 9px ui-monospace, monospace; border-right: 1px solid var(--border); padding-right: 10px; margin-right: 2px; } .content { overflow: auto; padding: 22px 24px 30px; background: var(--content); } .content-heading { display: flex; align-items: center; justify-content: space-between; padding-bottom: 18px; } .content-heading h2 { font-size: 20px; line-height: 27px; margin: 0; font-weight: 600; letter-spacing: -.01em; } @@ -94,7 +93,8 @@ nav strong { color: #f1888f; font: 600 10px ui-monospace, monospace; } .query-intent { display: block; margin-top: 7px; color: #737c89; font: 9px ui-monospace, monospace; } .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; } .statusbar { display: flex; gap: 20px; align-items: center; padding: 0 10px; border-top: 1px solid #2b323c; color: #78818d; background: #111419; font-size: 9px; } -.statusbar span:first-child { color: #75c99c; } +.statusbar { overflow-x:auto;white-space:nowrap; }.statusbar .ui-status { color: #75c99c; } input[type="checkbox"] { accent-color: var(--info); width: 12px; height: 12px; } -@media (max-width: 1050px) { .shell { grid-template-columns: 52px 230px 1fr; } .content { padding-inline: 16px; } } -@media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important; } } +@media (max-width: 1050px) { .shell { grid-template-columns: 52px 220px minmax(600px,1fr); min-width:872px; } .content { padding-inline: 16px; } } +@media (prefers-reduced-motion: reduce) { *,*::before,*::after { scroll-behavior:auto!important;transition-duration:0.01ms!important;animation-duration:0.01ms!important;animation-iteration-count:1!important; } } +@media (forced-colors: active) { button:focus-visible,input:focus-visible,select:focus-visible,[tabindex]:focus-visible { outline:2px solid Highlight;outline-offset:2px;box-shadow:none; } } diff --git a/src/styles/tokens.css b/src/styles/tokens.css new file mode 100644 index 0000000..c2b046f --- /dev/null +++ b/src/styles/tokens.css @@ -0,0 +1,10 @@ +:root { + --color-app: #0f1115; --color-nav: #14171c; --color-content: #181c22; --color-hover: #20252d; + --color-selected: #27313d; --color-border: #2a3039; --color-text: #e7eaf0; --color-muted: #929aa8; + --color-online: #38b57a; --color-warning: #d9a441; --color-danger: #e05b63; --color-info: #4c8dda; + --space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-5: 24px; + --font-xs: 9px; --font-sm: 11px; --font-md: 13px; --font-lg: 20px; + --line-sm: 1.3; --line-md: 1.5; --radius-sm: 4px; --radius-md: 6px; + --border-subtle: 1px solid var(--color-border); --z-popover: 30; --z-dialog: 50; + --duration-fast: 100ms; --duration-normal: 160ms; --focus-ring: 0 0 0 2px #0f1115, 0 0 0 4px #73b7ff; +} diff --git a/src/ui/index.tsx b/src/ui/index.tsx new file mode 100644 index 0000000..697071f --- /dev/null +++ b/src/ui/index.tsx @@ -0,0 +1,121 @@ +import { + ButtonHTMLAttributes, + HTMLAttributes, + InputHTMLAttributes, + ReactNode, + RefObject, + SelectHTMLAttributes, + useEffect, + useId, + useRef, + useState, +} from "react"; + +export type Intent = "neutral" | "primary" | "danger"; +export type StatusTone = "online" | "warning" | "offline" | "info"; + +export interface ButtonProps extends ButtonHTMLAttributes { + intent?: Intent; + loading?: boolean; +} + +export function Button({ intent = "neutral", loading = false, disabled, className = "", children, ...props }: ButtonProps) { + return ; +} + +export interface IconButtonProps extends ButtonProps { label: string; } +export function IconButton({ label, children, ...props }: IconButtonProps) { + return ; +} + +export function Icon({ name, size = 14 }: { name: "add" | "search" | "close" | "lock" | "more" | "columns" | "check" | "warning"; size?: number }) { + const paths = { + add: , + search: <>, + close: , + lock: <>, + more: <>, + columns: <>, + check: , + warning: <>, + }; + return ; +} + +export function Badge({ children, tone = "neutral", className = "" }: { children: ReactNode; tone?: StatusTone | "neutral" | "danger"; className?: string }) { + return {children}; +} + +export function StatusIndicator({ status, label, compact = false }: { status: StatusTone; label: string; compact?: boolean }) { + const mark = status === "online" ? : status === "warning" ? : ; + return {mark}{!compact && {label}}{compact ? label : ""}; +} + +export interface InputProps extends InputHTMLAttributes { label?: string; leading?: ReactNode; } +export function Input({ label, leading, className = "", ...props }: InputProps) { + const id = useId(); + return ; +} + +export interface SelectProps extends SelectHTMLAttributes { label?: string; options: { value: string; label: string }[]; } +export function Select({ label, options, className = "", ...props }: SelectProps) { + return ; +} + +export function Checkbox({ label, ...props }: InputHTMLAttributes & { label: ReactNode }) { + return ; +} + +export interface TabsProps { label: string; tabs: { id: string; label: ReactNode; title?: string }[]; activeId: string; onChange: (id: string) => void; } +export function Tabs({ label, tabs, activeId, onChange }: TabsProps) { + return
{tabs.map((tab) => )}
; +} + +export function Tooltip({ content, children }: { content: ReactNode; children: ReactNode }) { + return {children}{content}; +} + +export interface OverlayProps { open: boolean; onOpenChange: (open: boolean) => void; children: ReactNode; } +function useDismiss(open: boolean, onOpenChange: (open: boolean) => void, container: RefObject) { + const returnFocus = useRef(null); + useEffect(() => { + if (!open) return; + returnFocus.current = document.activeElement as HTMLElement; + const escape = (event: KeyboardEvent) => event.key === "Escape" && onOpenChange(false); + const outside = (event: PointerEvent) => !container.current?.contains(event.target as Node) && onOpenChange(false); + document.addEventListener("keydown", escape); + document.addEventListener("pointerdown", outside); + return () => { document.removeEventListener("keydown", escape); document.removeEventListener("pointerdown", outside); returnFocus.current?.focus(); }; + }, [open, onOpenChange, container]); +} + +export function Popover({ open, onOpenChange, children }: OverlayProps) { + const ref = useRef(null); + useDismiss(open, onOpenChange, ref); + return open ?
{children}
: null; +} + +export function Dialog({ open, onOpenChange, title, children }: OverlayProps & { title: string }) { + const ref = useRef(null); + const titleId = useId(); + useDismiss(open, onOpenChange, ref); + useEffect(() => { if (open) ref.current?.querySelector("button, input, select, [tabindex]")?.focus(); }, [open]); + if (!open) return null; + return
event.target === event.currentTarget && onOpenChange(false)}>

{title}

onOpenChange(false)}>
{children}
; +} + +export function EmptyState({ title, description, action }: { title: string; description?: string; action?: ReactNode }) { + return
{title}{description &&

{description}

}{action}
; +} +export function Skeleton({ width = "100%", className = "" }: { width?: string | number; className?: string }) { return