diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx new file mode 100644 index 0000000..db15ee8 --- /dev/null +++ b/apps/web/src/App.tsx @@ -0,0 +1,76 @@ +import { AssistantRuntimeProvider } from "@assistant-ui/react"; +import { useEffect, useState, useSyncExternalStore } from "react"; +import { useHarnessRuntime, type HarnessRuntimeStore } from "./assistant/use-harness-runtime.js"; +import { Thread } from "./components/chat/Thread.js"; +import { AppShell } from "./components/layout/AppShell.js"; +import { SkillsPanel } from "./components/skills/SkillsPanel.js"; +import { EventTimeline } from "./components/timeline/EventTimeline.js"; +import { HarnessClientStore } from "./store/harness-client-store.js"; + +type AppStore = HarnessRuntimeStore & { + start(): Promise; + getModelConfig(): Promise<{ modelConfig: { configured: boolean; modelId?: string; supportsImages?: boolean } }>; +}; + +export type AppProps = { + store?: AppStore; + autoStart?: boolean; + modelConfigured?: boolean; +}; + +export function App({ store: suppliedStore, autoStart = true, modelConfigured: suppliedModelConfigured }: AppProps) { + const [ownedStore] = useState(() => new HarnessClientStore()); + const store = suppliedStore ?? ownedStore; + const snapshot = useSyncExternalStore( + (subscriber) => store.subscribe(subscriber), + () => store.getSnapshot(), + () => store.getSnapshot(), + ); + const [model, setModel] = useState({ + configured: suppliedModelConfigured ?? false, + modelId: suppliedModelConfigured === true ? "Configured model" : "No model", + supportsImages: false, + }); + const runtime = useHarnessRuntime(store, { isSendDisabled: !model.configured }); + const busy = Object.values(snapshot.runs).some((run) => run.status === "running"); + const approvalPending = Object.values(snapshot.approvals).some((approval) => approval.status === "pending"); + + useEffect(() => { + if (!autoStart) return undefined; + let active = true; + void store.start().then(async () => { + const response = await store.getModelConfig(); + if (active) setModel({ + configured: response.modelConfig.configured, + modelId: response.modelConfig.modelId ?? "No model", + supportsImages: response.modelConfig.supportsImages ?? false, + }); + }).catch(() => undefined); + return () => { active = false; }; + }, [autoStart, store]); + + return ( + + { window.dispatchEvent(new CustomEvent("agent-studio:open-settings")); }, + onReset: () => window.location.reload(), + }} + skills={} + chat={} + timeline={} + /> + + ); +} diff --git a/apps/web/src/assistant/use-harness-runtime.tsx b/apps/web/src/assistant/use-harness-runtime.tsx index 9af23ae..e3d6526 100644 --- a/apps/web/src/assistant/use-harness-runtime.tsx +++ b/apps/web/src/assistant/use-harness-runtime.tsx @@ -11,7 +11,7 @@ export type HarnessRuntimeStore = { cancel(): Promise; }; -export function useHarnessRuntime(store: HarnessRuntimeStore) { +export function useHarnessRuntime(store: HarnessRuntimeStore, options: { isSendDisabled?: boolean } = {}) { const snapshot = useSyncExternalStore( (subscriber) => store.subscribe(subscriber), () => store.getSnapshot(), @@ -32,6 +32,7 @@ export function useHarnessRuntime(store: HarnessRuntimeStore) { messages, convertMessage: (message) => message, isRunning, + isSendDisabled: options.isSendDisabled, onNew: async (message) => { await store.sendMessage(extractMessageInput(message)); }, diff --git a/apps/web/src/components/chat/Composer.tsx b/apps/web/src/components/chat/Composer.tsx new file mode 100644 index 0000000..00ba423 --- /dev/null +++ b/apps/web/src/components/chat/Composer.tsx @@ -0,0 +1,38 @@ +import { ComposerPrimitive } from "@assistant-ui/react"; +import { ArrowUp, Paperclip, Square } from "lucide-react"; + +export type ComposerProps = { + busy: boolean; + modelConfigured: boolean; +}; + +export function Composer({ busy, modelConfigured }: ComposerProps) { + return ( +
+ {!modelConfigured ?

请先配置模型连接

: null} + + +
+ + + {busy ? ( + + + ) : ( + + + )} +
+
+

工具执行与审批状态以服务器事件为准

+
+ ); +} diff --git a/apps/web/src/components/chat/Thread.tsx b/apps/web/src/components/chat/Thread.tsx new file mode 100644 index 0000000..4e49e99 --- /dev/null +++ b/apps/web/src/components/chat/Thread.tsx @@ -0,0 +1,66 @@ +import { MessagePrimitive, ThreadPrimitive, type AssistantRuntime } from "@assistant-ui/react"; +import { Bot, ChevronDown, Sparkles, User } from "lucide-react"; +import { Composer } from "./Composer.js"; +import { StateView } from "../common/StateView.js"; +import type { ConnectionStatus } from "../../store/reducer.js"; + +const demoPrompt = "请分析 demo-account 最近 7 天广告数据,并生成一份简短报告草稿。"; + +export function Thread({ runtime, busy, modelConfigured, connection, error, approvalPending }: { + runtime: AssistantRuntime; + busy: boolean; + modelConfigured: boolean; + connection: ConnectionStatus; + error: string | undefined; + approvalPending: boolean; +}) { + return ( +
+ + +
+ {connection === "connecting" ? : null} + {connection === "reconnecting" ? : null} + {connection === "error" ? : null} + {busy ? : null} + {approvalPending ? : null} +
+ +
+ + TRAINING WORKSPACE +

从一次可观察的 Agent 任务开始

+

查看 Skill 如何渐进加载、工具如何执行,以及写入动作如何等待你的审批。

+ +
+
+ + + +
+ +
+
+ ); +} + +function UserMessage() { + return ( + + +
你
+
+ ); +} + +function AssistantMessage() { + return ( + + +
Agent
+
+ ); +} diff --git a/apps/web/src/components/common/StateView.tsx b/apps/web/src/components/common/StateView.tsx new file mode 100644 index 0000000..1ab78b3 --- /dev/null +++ b/apps/web/src/components/common/StateView.tsx @@ -0,0 +1,20 @@ +import { AlertTriangle, CheckCircle2, LoaderCircle, Radio } from "lucide-react"; + +export type StateViewProps = { + kind: "loading" | "empty" | "error" | "reconnecting" | "busy" | "approval"; + title: string; + detail?: string; +}; + +export function StateView({ kind, title, detail }: StateViewProps) { + const Icon = kind === "error" ? AlertTriangle : kind === "empty" ? CheckCircle2 : kind === "reconnecting" ? Radio : LoaderCircle; + return ( +
+
+ ); +} diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..1dcf058 --- /dev/null +++ b/apps/web/src/components/layout/AppShell.tsx @@ -0,0 +1,43 @@ +import { Activity, MessageSquare, PanelsTopLeft } from "lucide-react"; +import { useEffect, useState, type ReactNode } from "react"; +import { TopBar, type TopBarProps } from "../topbar/TopBar.js"; + +type WorkspaceTab = "chat" | "skills" | "events"; + +export function AppShell({ topBar, skills, chat, timeline }: { topBar: TopBarProps; skills: ReactNode; chat: ReactNode; timeline: ReactNode }) { + const [activeTab, setActiveTab] = useState("chat"); + const [mobile, setMobile] = useState(false); + + useEffect(() => { + if (typeof window.matchMedia !== "function") return undefined; + const media = window.matchMedia("(max-width: 720px)"); + const update = () => setMobile(media.matches); + update(); + media.addEventListener("change", update); + return () => media.removeEventListener("change", update); + }, []); + + return ( +
+ + +
+ + + +
+
+ ); +} + +function TabButton({ id, active, onSelect, children }: { id: WorkspaceTab; active: boolean; onSelect: (tab: WorkspaceTab) => void; children: ReactNode }) { + return ( + + ); +} diff --git a/apps/web/src/components/skills/SkillsPanel.tsx b/apps/web/src/components/skills/SkillsPanel.tsx new file mode 100644 index 0000000..8fc3723 --- /dev/null +++ b/apps/web/src/components/skills/SkillsPanel.tsx @@ -0,0 +1,39 @@ +import { AlertCircle, BookOpen, Check, LoaderCircle } from "lucide-react"; +import type { ClientSkill } from "../../store/reducer.js"; + +export function SkillsPanel({ skills }: { skills: ClientSkill[] }) { + return ( +
+
+
+ DISCOVERY +

Skills

+
+ {skills.length} +
+

只展示目录元信息。完整指令由 Agent 在需要时读取。

+ {skills.length === 0 ? ( +
+ ) : ( +
    + {skills.map((skill) => ( +
  • +
    + {skill.name} + {skill.activity === "requested" ? : null} + {skill.activity === "loaded" ? : null} +
    +

    {skill.description}

    +
    {skill.source}{skill.filePath}
    + {skill.diagnostics.map((diagnostic, index) => ( +
    +
    + ))} +
  • + ))} +
+ )} +
+ ); +} diff --git a/apps/web/src/components/timeline/EventTimeline.tsx b/apps/web/src/components/timeline/EventTimeline.tsx new file mode 100644 index 0000000..f3349f6 --- /dev/null +++ b/apps/web/src/components/timeline/EventTimeline.tsx @@ -0,0 +1,71 @@ +import type { HarnessEvent } from "@agent-studio/shared"; +import { Activity, CircleDot } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +type EventCategory = "all" | "agent" | "tools" | "system"; + +export function EventTimeline({ events }: { events: HarnessEvent[] }) { + const [category, setCategory] = useState("all"); + const viewportRef = useRef(null); + const nearBottomRef = useRef(true); + const visible = useMemo( + () => [...events].sort((left, right) => left.sequence - right.sequence).filter((event) => matchesCategory(event, category)), + [category, events], + ); + const recent = visible.slice(-100); + + useEffect(() => { + const viewport = viewportRef.current; + if (viewport !== null && nearBottomRef.current) viewport.scrollTop = viewport.scrollHeight; + }, [recent.length]); + + return ( +
+
+
EVENT STORE

Timeline

+
+ +
{ + const element = event.currentTarget; + nearBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 48; + }} + > + {recent.length === 0 ? ( +
+ ) : recent.map((event) => ( +
+
+ ))} +
+ {visible.length > recent.length ?

仅显示最近 100 条匹配事件

: null} +
+ ); +} + +function matchesCategory(event: HarnessEvent, category: EventCategory): boolean { + if (category === "all") return true; + if (category === "tools") return event.type.startsWith("tool.") || event.type.startsWith("approval."); + if (category === "agent") return event.type.startsWith("assistant.") || event.type.startsWith("run.") || event.type.startsWith("skill."); + return event.type.startsWith("session.") || event.type.startsWith("model.") || event.type.startsWith("attachment.") || event.type === "user.message"; +} + +function formatTime(timestamp: string): string { + return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }).format(new Date(timestamp)); +} diff --git a/apps/web/src/components/topbar/TopBar.tsx b/apps/web/src/components/topbar/TopBar.tsx new file mode 100644 index 0000000..263dd10 --- /dev/null +++ b/apps/web/src/components/topbar/TopBar.tsx @@ -0,0 +1,57 @@ +import { Bot, Eye, EyeOff, RotateCcw, Settings2 } from "lucide-react"; +import type { ConnectionStatus } from "../../store/reducer.js"; + +export type TopBarProps = { + sessionId?: string; + connection: ConnectionStatus; + modelLabel: string; + supportsImages: boolean; + onOpenSettings: () => void; + onReset: () => void; +}; + +export function TopBar({ sessionId, connection, modelLabel, supportsImages, onOpenSettings, onReset }: TopBarProps) { + return ( +
+
+ +
+ Agent Studio + MINI HARNESS +
+
+
+
+
+ {modelLabel} + + {supportsImages ? + + +
+
+ ); +} + +function connectionLabel(status: ConnectionStatus): string { + if (status === "open") return "已连接"; + if (status === "connecting") return "连接中"; + if (status === "reconnecting") return "重连中"; + if (status === "error") return "连接失败"; + return "未连接"; +} + +function shortSession(sessionId?: string): string { + if (sessionId === undefined) return "SESSION PENDING"; + return sessionId.length > 18 ? `${sessionId.slice(0, 10)}...${sessionId.slice(-4)}` : sessionId; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..8507924 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App.js"; +import "./styles/tokens.css"; +import "./styles/base.css"; +import "./styles/layout.css"; +import "./styles/components.css"; + +const root = document.getElementById("root"); +if (root === null) throw new Error("Root element not found"); + +createRoot(root).render(); diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css new file mode 100644 index 0000000..94bc5d5 --- /dev/null +++ b/apps/web/src/styles/base.css @@ -0,0 +1,11 @@ +* { box-sizing: border-box; } +html, body, #root { min-width: 320px; min-height: 100%; margin: 0; } +body { background: var(--bg); color: var(--text); font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; } +button, textarea, select, input { font: inherit; } +button, select { color: inherit; } +button { cursor: pointer; } +button:disabled { cursor: not-allowed; opacity: .45; } +:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; } +code { font-family: "SFMono-Regular", Consolas, monospace; } +h1, h2, p { margin-top: 0; } +[hidden] { display: none !important; } diff --git a/apps/web/src/styles/components.css b/apps/web/src/styles/components.css new file mode 100644 index 0000000..336e9b5 --- /dev/null +++ b/apps/web/src/styles/components.css @@ -0,0 +1,78 @@ +.topbar { display: grid; grid-template-columns: minmax(190px, 1fr) auto minmax(190px, 1fr); align-items: center; gap: 16px; padding: 0 20px; border-bottom: 1px solid var(--border); background: #141515; } +.brand-lockup, .topbar__status, .topbar__actions, .panel-heading, .skill-item__title, .event-row__head, .composer__actions, .state-view { display: flex; align-items: center; } +.brand-lockup { gap: 10px; } +.brand-lockup > div { display: grid; gap: 1px; } +.brand-lockup span { color: var(--muted); font-size: 9px; letter-spacing: .16em; } +.brand-mark { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid #397c79; border-radius: 10px; color: var(--accent) !important; background: #132321; } +.topbar__status { gap: 8px; color: var(--muted); font-size: 12px; } +.connection-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); } +.connection-dot--open { background: var(--accent); box-shadow: 0 0 0 3px rgb(37 244 238 / 10%); } +.connection-dot--error { background: var(--danger); } +.connection-dot--connecting, .connection-dot--reconnecting { background: var(--warning); } +.topbar__divider { width: 1px; height: 14px; background: var(--border-strong); } +.session-code { font-family: "SFMono-Regular", Consolas, monospace; } +.topbar__actions { justify-content: flex-end; gap: 8px; } +.status-chip, .count-badge { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border); border-radius: 999px; color: #c8d1cf; background: var(--surface-raised); } +.status-chip { height: 30px; padding: 0 10px; font-size: 11px; } +.count-badge { min-width: 26px; height: 24px; justify-content: center; font-size: 11px; } +.icon-button { width: 44px; height: 44px; display: inline-grid; place-items: center; border: 0; border-radius: var(--radius-sm); color: var(--muted); background: transparent; } +.icon-button:hover { color: var(--text); background: var(--surface-raised); } +.panel-heading { justify-content: space-between; min-height: 78px; padding: 16px 18px 12px; border-bottom: 1px solid var(--border); } +.panel-heading h2 { margin: 3px 0 0; font-size: 16px; } +.eyebrow { color: var(--accent); font-size: 9px; font-weight: 700; letter-spacing: .15em; } +.panel-intro { margin: 0; padding: 14px 18px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: 12px; line-height: 1.6; } +.skill-list { overflow: auto; list-style: none; margin: 0; padding: 6px 0 24px; } +.skill-item { padding: 16px 18px; border-bottom: 1px solid var(--border); } +.skill-item__title { justify-content: space-between; gap: 8px; } +.skill-item__title svg { color: var(--accent); } +.skill-item p { margin: 7px 0 10px; color: #c4ccca; font-size: 12px; line-height: 1.5; } +.skill-meta { display: grid; gap: 5px; color: var(--muted); font-size: 10px; } +.skill-meta code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.diagnostic { display: flex; gap: 6px; margin-top: 9px; color: var(--warning); font-size: 11px; } +.panel-empty { margin: auto; display: grid; place-items: center; gap: 8px; color: var(--muted); font-size: 12px; } +.thread-root { height: 100%; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr) auto; } +.thread-viewport { position: relative; overflow-y: auto; padding: 28px clamp(20px, 5vw, 72px); } +.thread-states { position: sticky; z-index: 2; top: 0; max-width: 760px; margin: 0 auto 16px; display: grid; gap: 8px; } +.thread-empty { min-height: 100%; max-width: 580px; margin: auto; display: flex; flex-direction: column; align-items: flex-start; justify-content: center; } +.thread-empty__icon { width: 48px; height: 48px; display: grid; place-items: center; margin-bottom: 24px; border: 1px solid #367875; border-radius: 14px; color: var(--accent); background: #142523; } +.thread-empty h1 { max-width: 520px; margin: 9px 0 12px; font-size: clamp(25px, 4vw, 38px); line-height: 1.1; letter-spacing: -.03em; } +.thread-empty p { max-width: 500px; color: var(--muted); line-height: 1.7; } +.secondary-button { min-height: 44px; margin-top: 8px; padding: 0 16px; border: 1px solid var(--border-strong); border-radius: var(--radius-sm); color: var(--text); background: var(--surface-raised); } +.secondary-button:hover { border-color: #4d7472; } +.message { max-width: 760px; margin: 0 auto 28px; display: grid; grid-template-columns: 30px minmax(0, 1fr); gap: 12px; } +.message--user .message__body { width: fit-content; max-width: 88%; padding: 12px 15px; border: 1px solid var(--border); border-radius: var(--radius-md); background: var(--surface-raised); } +.message__avatar { width: 30px; height: 30px; display: grid; place-items: center; border: 1px solid var(--border); border-radius: 9px; color: var(--muted); } +.message--assistant .message__avatar { color: var(--accent); border-color: #315b59; } +.message__label { display: block; margin-bottom: 7px; color: var(--muted); font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } +.message__body p { margin-bottom: 10px; line-height: 1.7; } +.scroll-button { position: sticky; bottom: 8px; left: 50%; width: 38px; height: 38px; border: 1px solid var(--border-strong); border-radius: 50%; background: var(--surface-raised); color: var(--text); box-shadow: var(--shadow); } +.composer-area { padding: 0 clamp(16px, 4vw, 56px) 16px; } +.composer { max-width: 820px; margin: 0 auto; border: 1px solid var(--border-strong); border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--shadow); } +.composer:focus-within { border-color: #4c7775; } +.composer__input { width: 100%; min-height: 76px; max-height: 190px; resize: none; padding: 16px 18px 8px; border: 0; outline: 0; color: var(--text); background: transparent; line-height: 1.5; } +.composer__input::placeholder { color: #65716f; } +.composer__actions { min-height: 48px; justify-content: space-between; padding: 4px 7px 7px; } +.send-button { min-width: 88px; min-height: 40px; padding: 0 14px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; border: 0; border-radius: 10px; color: var(--accent-ink); background: var(--accent); font-weight: 750; } +.send-button--stop { color: white; background: var(--danger); } +.composer-hint, .composer-footnote { max-width: 820px; margin: 0 auto; color: var(--muted); font-size: 11px; } +.composer-hint { padding: 0 3px 8px; color: var(--warning); } +.composer-footnote { padding-top: 8px; text-align: center; } +.filter-label { display: grid; gap: 7px; padding: 12px 16px; border-bottom: 1px solid var(--border); color: var(--muted); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; } +.filter-label select { height: 36px; padding: 0 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-raised); } +.timeline { overflow-y: auto; padding: 6px 0 24px; } +.event-row { display: grid; grid-template-columns: 14px minmax(0, 1fr); gap: 9px; padding: 12px 16px; border-bottom: 1px solid var(--border); } +.event-row > svg { margin-top: 3px; color: #4e7573; } +.event-row__head { gap: 8px; min-width: 0; } +.event-row__head code { color: var(--muted); font-size: 10px; } +.event-row__head strong { overflow: hidden; text-overflow: ellipsis; font-size: 11px; white-space: nowrap; } +.event-row time { display: block; margin-top: 4px; color: var(--muted); font-size: 10px; } +.timeline-note { padding: 0 16px; color: var(--muted); font-size: 10px; } +.state-view { gap: 10px; padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-raised); } +.state-view svg { color: var(--accent); } +.state-view--error svg { color: var(--danger); } +.state-view strong { display: block; } +.state-view p { margin: 3px 0 0; color: var(--muted); font-size: 12px; } +.spin { animation: spin 1.2s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { .spin { animation: none; } } +@media (max-width: 720px) { .topbar { grid-template-columns: 1fr auto; padding: 0 12px; } .topbar__actions { grid-column: 2; } .thread-viewport { padding: 20px 16px; } .composer-area { padding: 0 10px 10px; } } diff --git a/apps/web/src/styles/layout.css b/apps/web/src/styles/layout.css new file mode 100644 index 0000000..2e79c91 --- /dev/null +++ b/apps/web/src/styles/layout.css @@ -0,0 +1,22 @@ +.app-shell { height: 100dvh; min-height: 620px; display: grid; grid-template-rows: 64px minmax(0, 1fr); overflow: hidden; } +.workspace-grid { display: grid; grid-template-columns: minmax(220px, 272px) minmax(420px, 1fr) minmax(260px, 330px); min-height: 0; } +.workspace-grid > div { min-width: 0; min-height: 0; } +.workspace-grid > div + div { border-left: 1px solid var(--border); } +.side-panel, .chat-panel { height: 100%; min-height: 0; } +.side-panel { display: flex; flex-direction: column; overflow: hidden; background: var(--surface); } +.chat-panel { background: var(--bg); } +.mobile-tabs { display: none; } +@media (max-width: 1080px) { + .workspace-grid { grid-template-columns: minmax(210px, 240px) minmax(400px, 1fr); } + .workspace-grid > div:last-child { display: none; } +} +@media (max-width: 720px) { + .app-shell { min-height: 520px; grid-template-rows: 64px 52px minmax(0, 1fr); } + .topbar__status, .status-chip { display: none; } + .mobile-tabs { display: grid; grid-template-columns: repeat(3, 1fr); border-bottom: 1px solid var(--border); background: var(--surface); } + .mobile-tabs button { min-height: 48px; border: 0; border-bottom: 2px solid transparent; background: transparent; color: var(--muted); display: flex; gap: 7px; align-items: center; justify-content: center; } + .mobile-tabs button[aria-selected="true"] { border-bottom-color: var(--accent); color: var(--text); } + .workspace-grid { display: block; } + .workspace-grid > div { height: 100%; } + .workspace-grid > div + div { border-left: 0; } +} diff --git a/apps/web/src/styles/tokens.css b/apps/web/src/styles/tokens.css new file mode 100644 index 0000000..49e02b0 --- /dev/null +++ b/apps/web/src/styles/tokens.css @@ -0,0 +1,20 @@ +:root { + color-scheme: dark; + --bg: #111111; + --surface: #181919; + --surface-raised: #202222; + --surface-soft: #151b1b; + --border: #2c3030; + --border-strong: #3b4141; + --text: #f3f7f6; + --muted: #93a09e; + --accent: #25f4ee; + --accent-ink: #062f2d; + --danger: #fe2c55; + --warning: #ffca5c; + --focus: #a7fffb; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 18px; + --shadow: 0 16px 40px rgb(0 0 0 / 28%); +} diff --git a/apps/web/tests/app-shell.test.tsx b/apps/web/tests/app-shell.test.tsx new file mode 100644 index 0000000..7e60302 --- /dev/null +++ b/apps/web/tests/app-shell.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import { App } from "../src/App.js"; +import { createInitialHarnessState, type HarnessClientState } from "../src/store/reducer.js"; + +function makeStore() { + const sendMessage = vi.fn().mockResolvedValue({ runId: "run_1" }); + const state: HarnessClientState = { + ...createInitialHarnessState(), + sessionId: "ses_test", + connection: "open", + skills: [{ + name: "ads-analysis", + description: "分析广告投放指标", + source: "project", + filePath: ".agents/skills/ads-analysis/SKILL.md", + diagnostics: [], + }], + }; + return { + getSnapshot: () => state, + subscribe: () => () => undefined, + sendMessage, + cancel: vi.fn().mockResolvedValue({ cancelled: false }), + start: vi.fn().mockResolvedValue(undefined), + getModelConfig: vi.fn().mockResolvedValue({ + modelConfig: { configured: true, source: "environment", providerId: "openai", api: "openai-responses", modelId: "gpt-test", supportsImages: true }, + }), + }; +} + +describe("App", () => { + it("renders the three primary work areas", () => { + render(); + expect(screen.getByRole("region", { name: "Skills Catalog" })).toBeVisible(); + expect(screen.getByRole("main", { name: "Streaming Chat" })).toBeVisible(); + expect(screen.getByRole("region", { name: "Event Timeline" })).toBeVisible(); + }); + + it("fills but does not send the demo prompt", async () => { + const user = userEvent.setup(); + const store = makeStore(); + render(); + + await user.click(screen.getByRole("button", { name: "填入演示任务" })); + + expect(screen.getByRole("textbox").value).toContain("demo-account"); + expect(store.sendMessage).not.toHaveBeenCalled(); + }); + + it("disables sending and points to settings when the model is missing", () => { + render(); + expect(screen.getByRole("button", { name: "发送消息" })).toBeDisabled(); + expect(screen.getByText("请先配置模型连接")).toBeVisible(); + }); +}); diff --git a/apps/web/tests/setup.ts b/apps/web/tests/setup.ts new file mode 100644 index 0000000..2441b1a --- /dev/null +++ b/apps/web/tests/setup.ts @@ -0,0 +1,13 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach } from "vitest"; + +afterEach(cleanup); + +class TestResizeObserver implements ResizeObserver { + public readonly observe = () => undefined; + public readonly unobserve = () => undefined; + public readonly disconnect = () => undefined; +} + +globalThis.ResizeObserver = TestResizeObserver; diff --git a/apps/web/tests/states.test.tsx b/apps/web/tests/states.test.tsx new file mode 100644 index 0000000..1e75e24 --- /dev/null +++ b/apps/web/tests/states.test.tsx @@ -0,0 +1,17 @@ +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom/vitest"; +import { describe, expect, it } from "vitest"; +import { StateView } from "../src/components/common/StateView.js"; + +describe("StateView", () => { + it.each([ + ["loading", "正在建立训练会话"], + ["reconnecting", "事件流正在重连"], + ["busy", "Agent 正在运行"], + ["approval", "等待工具审批"], + ["error", "会话启动失败"], + ] as const)("renders the %s state", (kind, label) => { + render(); + expect(screen.getByText(label)).toBeVisible(); + }); +}); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 937d5fa..d818f88 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -1,8 +1,11 @@ import react from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; +import { defineConfig } from "vitest/config"; export default defineConfig({ plugins: [react()], + test: { + setupFiles: ["./tests/setup.ts"] + }, server: { port: 5173, proxy: {