feat(web): add Agent Studio workspace UI

This commit is contained in:
Wenxin Duan
2026-09-07 17:17:04 +08:00
parent 63ea92b770
commit 145033974e
18 changed files with 647 additions and 2 deletions
+76
View File
@@ -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<void>;
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 (
<AssistantRuntimeProvider runtime={runtime}>
<AppShell
topBar={{
...(snapshot.sessionId === undefined ? {} : { sessionId: snapshot.sessionId }),
connection: snapshot.connection,
modelLabel: model.modelId,
supportsImages: model.supportsImages,
onOpenSettings: () => { window.dispatchEvent(new CustomEvent("agent-studio:open-settings")); },
onReset: () => window.location.reload(),
}}
skills={<SkillsPanel skills={snapshot.skills} />}
chat={<Thread
runtime={runtime}
busy={busy}
modelConfigured={model.configured}
connection={snapshot.connection}
error={snapshot.error}
approvalPending={approvalPending}
/>}
timeline={<EventTimeline events={snapshot.timeline} />}
/>
</AssistantRuntimeProvider>
);
}
@@ -11,7 +11,7 @@ export type HarnessRuntimeStore = {
cancel(): Promise<unknown>; cancel(): Promise<unknown>;
}; };
export function useHarnessRuntime(store: HarnessRuntimeStore) { export function useHarnessRuntime(store: HarnessRuntimeStore, options: { isSendDisabled?: boolean } = {}) {
const snapshot = useSyncExternalStore( const snapshot = useSyncExternalStore(
(subscriber) => store.subscribe(subscriber), (subscriber) => store.subscribe(subscriber),
() => store.getSnapshot(), () => store.getSnapshot(),
@@ -32,6 +32,7 @@ export function useHarnessRuntime(store: HarnessRuntimeStore) {
messages, messages,
convertMessage: (message) => message, convertMessage: (message) => message,
isRunning, isRunning,
isSendDisabled: options.isSendDisabled,
onNew: async (message) => { onNew: async (message) => {
await store.sendMessage(extractMessageInput(message)); await store.sendMessage(extractMessageInput(message));
}, },
+38
View File
@@ -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 (
<div className="composer-area">
{!modelConfigured ? <p className="composer-hint">请先配置模型连接</p> : null}
<ComposerPrimitive.Root className="composer">
<ComposerPrimitive.Input
className="composer__input"
placeholder={modelConfigured ? "向 Agent 描述任务,Enter 发送" : "配置模型后即可开始"}
aria-label="消息"
rows={2}
/>
<div className="composer__actions">
<ComposerPrimitive.AddAttachment className="icon-button" aria-label="添加图片" title="添加图片">
<Paperclip size={18} aria-hidden="true" />
</ComposerPrimitive.AddAttachment>
{busy ? (
<ComposerPrimitive.Cancel className="send-button send-button--stop" aria-label="停止当前运行">
<Square size={15} aria-hidden="true" />停止
</ComposerPrimitive.Cancel>
) : (
<ComposerPrimitive.Send className="send-button" aria-label="发送消息" disabled={!modelConfigured}>
<ArrowUp size={17} aria-hidden="true" />发送
</ComposerPrimitive.Send>
)}
</div>
</ComposerPrimitive.Root>
<p className="composer-footnote">工具执行与审批状态以服务器事件为准</p>
</div>
);
}
+66
View File
@@ -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 (
<main className="chat-panel" aria-label="Streaming Chat">
<ThreadPrimitive.Root className="thread-root">
<ThreadPrimitive.Viewport className="thread-viewport">
<div className="thread-states">
{connection === "connecting" ? <StateView kind="loading" title="正在建立训练会话" /> : null}
{connection === "reconnecting" ? <StateView kind="reconnecting" title="事件流正在重连" detail="运行不会因页面连接中断而取消。" /> : null}
{connection === "error" ? <StateView kind="error" title="会话启动失败" {...(error === undefined ? {} : { detail: error })} /> : null}
{busy ? <StateView kind="busy" title="Agent 正在运行" /> : null}
{approvalPending ? <StateView kind="approval" title="等待工具审批" /> : null}
</div>
<ThreadPrimitive.Empty>
<div className="thread-empty">
<span className="thread-empty__icon"><Sparkles size={22} aria-hidden="true" /></span>
<span className="eyebrow">TRAINING WORKSPACE</span>
<h1>从一次可观察的 Agent 任务开始</h1>
<p>查看 Skill 如何渐进加载、工具如何执行,以及写入动作如何等待你的审批。</p>
<button className="secondary-button" type="button" onClick={() => runtime.thread.composer.setText(demoPrompt)}>
填入演示任务
</button>
</div>
</ThreadPrimitive.Empty>
<ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} />
<ThreadPrimitive.ScrollToBottom className="scroll-button" aria-label="滚动到底部">
<ChevronDown size={18} aria-hidden="true" />
</ThreadPrimitive.ScrollToBottom>
</ThreadPrimitive.Viewport>
<Composer busy={busy} modelConfigured={modelConfigured} />
</ThreadPrimitive.Root>
</main>
);
}
function UserMessage() {
return (
<MessagePrimitive.Root className="message message--user">
<span className="message__avatar"><User size={16} aria-hidden="true" /></span>
<div className="message__body"><span className="message__label">你</span><MessagePrimitive.Parts /></div>
</MessagePrimitive.Root>
);
}
function AssistantMessage() {
return (
<MessagePrimitive.Root className="message message--assistant">
<span className="message__avatar"><Bot size={16} aria-hidden="true" /></span>
<div className="message__body"><span className="message__label">Agent</span><MessagePrimitive.Parts /></div>
</MessagePrimitive.Root>
);
}
@@ -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 (
<div className={`state-view state-view--${kind}`} role={kind === "error" ? "alert" : "status"}>
<Icon aria-hidden="true" size={18} />
<div>
<strong>{title}</strong>
{detail === undefined ? null : <p>{detail}</p>}
</div>
</div>
);
}
@@ -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<WorkspaceTab>("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 (
<div className="app-shell">
<TopBar {...topBar} />
<nav className="mobile-tabs" aria-label="工作区导航" role="tablist">
<TabButton id="chat" active={activeTab === "chat"} onSelect={setActiveTab}><MessageSquare size={17} />Chat</TabButton>
<TabButton id="skills" active={activeTab === "skills"} onSelect={setActiveTab}><PanelsTopLeft size={17} />Skills</TabButton>
<TabButton id="events" active={activeTab === "events"} onSelect={setActiveTab}><Activity size={17} />Events</TabButton>
</nav>
<div className="workspace-grid">
<div id="skills-panel" role={mobile ? "tabpanel" : undefined} hidden={mobile && activeTab !== "skills"}>{skills}</div>
<div id="chat-panel" role={mobile ? "tabpanel" : undefined} hidden={mobile && activeTab !== "chat"}>{chat}</div>
<div id="events-panel" role={mobile ? "tabpanel" : undefined} hidden={mobile && activeTab !== "events"}>{timeline}</div>
</div>
</div>
);
}
function TabButton({ id, active, onSelect, children }: { id: WorkspaceTab; active: boolean; onSelect: (tab: WorkspaceTab) => void; children: ReactNode }) {
return (
<button type="button" role="tab" aria-selected={active} aria-controls={`${id}-panel`} tabIndex={active ? 0 : -1} onClick={() => onSelect(id)}>
{children}
</button>
);
}
@@ -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 (
<section className="side-panel skills-panel" aria-label="Skills Catalog">
<div className="panel-heading">
<div>
<span className="eyebrow">DISCOVERY</span>
<h2>Skills</h2>
</div>
<span className="count-badge" aria-label={`${String(skills.length)} 个 Skill`}>{skills.length}</span>
</div>
<p className="panel-intro">只展示目录元信息。完整指令由 Agent 在需要时读取。</p>
{skills.length === 0 ? (
<div className="panel-empty"><BookOpen size={18} aria-hidden="true" />尚未发现 Skill</div>
) : (
<ul className="skill-list">
{skills.map((skill) => (
<li className="skill-item" key={`${skill.source}:${skill.name}`}>
<div className="skill-item__title">
<strong>{skill.name}</strong>
{skill.activity === "requested" ? <LoaderCircle className="spin" size={15} aria-label="正在请求" /> : null}
{skill.activity === "loaded" ? <Check size={15} aria-label="已加载" /> : null}
</div>
<p>{skill.description}</p>
<div className="skill-meta"><span>{skill.source}</span><code>{skill.filePath}</code></div>
{skill.diagnostics.map((diagnostic, index) => (
<div className="diagnostic" key={`${diagnostic.message}:${String(index)}`}>
<AlertCircle size={14} aria-hidden="true" />{diagnostic.message}
</div>
))}
</li>
))}
</ul>
)}
</section>
);
}
@@ -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<EventCategory>("all");
const viewportRef = useRef<HTMLDivElement>(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 (
<section className="side-panel timeline-panel" aria-label="Event Timeline">
<div className="panel-heading">
<div><span className="eyebrow">EVENT STORE</span><h2>Timeline</h2></div>
<Activity size={18} aria-hidden="true" />
</div>
<label className="filter-label">
<span>筛选事件</span>
<select value={category} onChange={(event) => setCategory(event.target.value as EventCategory)}>
<option value="all">全部</option>
<option value="agent">Agent</option>
<option value="tools">工具与审批</option>
<option value="system">系统</option>
</select>
</label>
<div
className="timeline"
ref={viewportRef}
onScroll={(event) => {
const element = event.currentTarget;
nearBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 48;
}}
>
{recent.length === 0 ? (
<div className="panel-empty"><Activity size={18} aria-hidden="true" />等待事件</div>
) : recent.map((event) => (
<article className="event-row" key={event.eventId}>
<CircleDot size={13} aria-hidden="true" />
<div>
<div className="event-row__head"><code>#{event.sequence}</code><strong>{event.type}</strong></div>
<time dateTime={event.timestamp}>{formatTime(event.timestamp)}</time>
</div>
</article>
))}
</div>
{visible.length > recent.length ? <p className="timeline-note">仅显示最近 100 条匹配事件</p> : null}
</section>
);
}
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));
}
+57
View File
@@ -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 (
<header className="topbar">
<div className="brand-lockup">
<span className="brand-mark"><Bot size={18} aria-hidden="true" /></span>
<div>
<strong>Agent Studio</strong>
<span>MINI HARNESS</span>
</div>
</div>
<div className="topbar__status" aria-label="会话状态">
<span className={`connection-dot connection-dot--${connection}`} aria-hidden="true" />
<span>{connectionLabel(connection)}</span>
<span className="topbar__divider" aria-hidden="true" />
<span className="session-code">{shortSession(sessionId)}</span>
</div>
<div className="topbar__actions">
<span className="status-chip">{modelLabel}</span>
<span className="status-chip">
{supportsImages ? <Eye size={14} aria-hidden="true" /> : <EyeOff size={14} aria-hidden="true" />}
{supportsImages ? "Vision" : "Text"}
</span>
<button className="icon-button" type="button" aria-label="重置训练会话" title="重置训练会话" onClick={onReset}>
<RotateCcw size={18} aria-hidden="true" />
</button>
<button className="icon-button" type="button" aria-label="打开模型设置" title="打开模型设置" onClick={onOpenSettings}>
<Settings2 size={18} aria-hidden="true" />
</button>
</div>
</header>
);
}
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;
}
+12
View File
@@ -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(<StrictMode><App /></StrictMode>);
+11
View File
@@ -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; }
+78
View File
@@ -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; } }
+22
View File
@@ -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; }
}
+20
View File
@@ -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%);
}
+58
View File
@@ -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(<App store={makeStore()} autoStart={false} modelConfigured />);
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(<App store={store} autoStart={false} modelConfigured />);
await user.click(screen.getByRole("button", { name: "填入演示任务" }));
expect(screen.getByRole<HTMLTextAreaElement>("textbox").value).toContain("demo-account");
expect(store.sendMessage).not.toHaveBeenCalled();
});
it("disables sending and points to settings when the model is missing", () => {
render(<App store={makeStore()} autoStart={false} modelConfigured={false} />);
expect(screen.getByRole("button", { name: "发送消息" })).toBeDisabled();
expect(screen.getByText("请先配置模型连接")).toBeVisible();
});
});
+13
View File
@@ -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;
+17
View File
@@ -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(<StateView kind={kind} title={label} />);
expect(screen.getByText(label)).toBeVisible();
});
});
+4 -1
View File
@@ -1,8 +1,11 @@
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { defineConfig } from "vite"; import { defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
test: {
setupFiles: ["./tests/setup.ts"]
},
server: { server: {
port: 5173, port: 5173,
proxy: { proxy: {