Kaynağa Gözat

feat(studio): split studio ui from #182

fanghanjun 6 ay önce
ebeveyn
işleme
1831eaa384
73 değiştirilmiş dosya ile 8409 ekleme ve 1078 silme
  1. 25 0
      packages/studio/components.json
  2. 20 2
      packages/studio/package.json
  3. 1 1
      packages/studio/src/App.test.ts
  4. 126 75
      packages/studio/src/App.tsx
  5. 0 614
      packages/studio/src/components/ChatBar.tsx
  6. 20 18
      packages/studio/src/components/Sidebar.tsx
  7. 562 0
      packages/studio/src/components/ai-elements/code-block.tsx
  8. 174 0
      packages/studio/src/components/ai-elements/confirmation.tsx
  9. 360 0
      packages/studio/src/components/ai-elements/message.tsx
  10. 1457 0
      packages/studio/src/components/ai-elements/prompt-input.tsx
  11. 226 0
      packages/studio/src/components/ai-elements/reasoning.tsx
  12. 77 0
      packages/studio/src/components/ai-elements/shimmer.tsx
  13. 173 0
      packages/studio/src/components/ai-elements/tool.tsx
  14. 56 0
      packages/studio/src/components/chat-utils.ts
  15. 199 0
      packages/studio/src/components/chat/BookFormCard.tsx
  16. 291 0
      packages/studio/src/components/chat/BookSidebar.tsx
  17. 75 0
      packages/studio/src/components/chat/ChatMessage.tsx
  18. 73 0
      packages/studio/src/components/chat/QuickActions.tsx
  19. 236 0
      packages/studio/src/components/chat/ToolExecutionSteps.tsx
  20. 103 0
      packages/studio/src/components/chat/__tests__/ToolExecutionSteps.test.ts
  21. 1 1
      packages/studio/src/components/chatbar-state.test.ts
  22. 66 0
      packages/studio/src/components/sidebar/ChaptersSection.tsx
  23. 129 0
      packages/studio/src/components/sidebar/CharacterSection.tsx
  24. 61 0
      packages/studio/src/components/sidebar/FoundationSection.tsx
  25. 124 0
      packages/studio/src/components/sidebar/ProgressSection.tsx
  26. 30 0
      packages/studio/src/components/sidebar/SidebarCard.tsx
  27. 71 0
      packages/studio/src/components/sidebar/SummarySection.tsx
  28. 76 0
      packages/studio/src/components/ui/alert.tsx
  29. 52 0
      packages/studio/src/components/ui/badge.tsx
  30. 87 0
      packages/studio/src/components/ui/button-group.tsx
  31. 58 0
      packages/studio/src/components/ui/button.tsx
  32. 19 0
      packages/studio/src/components/ui/collapsible.tsx
  33. 194 0
      packages/studio/src/components/ui/command.tsx
  34. 158 0
      packages/studio/src/components/ui/dialog.tsx
  35. 266 0
      packages/studio/src/components/ui/dropdown-menu.tsx
  36. 51 0
      packages/studio/src/components/ui/hover-card.tsx
  37. 158 0
      packages/studio/src/components/ui/input-group.tsx
  38. 20 0
      packages/studio/src/components/ui/input.tsx
  39. 199 0
      packages/studio/src/components/ui/select.tsx
  40. 23 0
      packages/studio/src/components/ui/separator.tsx
  41. 10 0
      packages/studio/src/components/ui/spinner.tsx
  42. 18 0
      packages/studio/src/components/ui/textarea.tsx
  43. 66 0
      packages/studio/src/components/ui/tooltip.tsx
  44. 12 12
      packages/studio/src/hooks/use-api.test.ts
  45. 13 13
      packages/studio/src/hooks/use-api.ts
  46. 85 0
      packages/studio/src/hooks/use-hash-route.test.ts
  47. 77 0
      packages/studio/src/hooks/use-hash-route.ts
  48. 1 1
      packages/studio/src/hooks/use-i18n.ts
  49. 2 1
      packages/studio/src/hooks/use-sse.ts
  50. 15 0
      packages/studio/src/index.css
  51. 2 2
      packages/studio/src/pages/BookDetail.tsx
  52. 446 0
      packages/studio/src/pages/ChatPage.tsx
  53. 0 317
      packages/studio/src/pages/ConfigView.tsx
  54. 21 1
      packages/studio/src/pages/Dashboard.tsx
  55. 282 0
      packages/studio/src/pages/ServiceDetailPage.tsx
  56. 86 0
      packages/studio/src/pages/ServiceListPage.tsx
  57. 0 20
      packages/studio/src/pages/config-view.test.ts
  58. 136 0
      packages/studio/src/store/chat/__tests__/message-parts.test.ts
  59. 3 0
      packages/studio/src/store/chat/index.ts
  60. 8 0
      packages/studio/src/store/chat/initialState.ts
  61. 164 0
      packages/studio/src/store/chat/parts-builder.ts
  62. 7 0
      packages/studio/src/store/chat/selectors.ts
  63. 36 0
      packages/studio/src/store/chat/slices/create/action.ts
  64. 12 0
      packages/studio/src/store/chat/slices/create/initialState.ts
  65. 454 0
      packages/studio/src/store/chat/slices/message/action.ts
  66. 11 0
      packages/studio/src/store/chat/slices/message/initialState.ts
  67. 11 0
      packages/studio/src/store/chat/store.ts
  68. 145 0
      packages/studio/src/store/chat/types.ts
  69. 2 0
      packages/studio/src/store/service/index.ts
  70. 107 0
      packages/studio/src/store/service/store.ts
  71. 62 0
      packages/studio/src/store/service/types.ts
  72. 12 0
      packages/studio/vite.config.ts
  73. 6 0
      packages/studio/vitest.config.ts

+ 25 - 0
packages/studio/components.json

@@ -0,0 +1,25 @@
+{
+  "$schema": "https://ui.shadcn.com/schema.json",
+  "style": "base-nova",
+  "rsc": false,
+  "tsx": true,
+  "tailwind": {
+    "config": "",
+    "css": "src/index.css",
+    "baseColor": "neutral",
+    "cssVariables": true,
+    "prefix": ""
+  },
+  "iconLibrary": "lucide",
+  "rtl": false,
+  "aliases": {
+    "components": "@/components",
+    "utils": "@/lib/utils",
+    "ui": "@/components/ui",
+    "lib": "@/lib",
+    "hooks": "@/hooks"
+  },
+  "menuColor": "default",
+  "menuAccent": "subtle",
+  "registries": {}
+}

+ 20 - 2
packages/studio/package.json

@@ -19,7 +19,9 @@
     "prepack": "npm run build && node ../../scripts/prepare-package-for-publish.mjs",
     "postpack": "node ../../scripts/restore-package-json.mjs",
     "prepublishOnly": "node ../../scripts/verify-no-workspace-protocol.mjs .",
-    "dev": "vite --host --port 4567",
+    "dev": "INKOS_STUDIO_PORT=4569 INKOS_PROJECT_ROOT=../.. tsx watch --clear-screen=false src/api/index.ts & vite --host --port 4567 ; kill %1 2>/dev/null",
+    "dev:client": "vite --host --port 4567",
+    "dev:server": "INKOS_STUDIO_PORT=4569 INKOS_PROJECT_ROOT=../.. tsx watch src/api/index.ts",
     "build": "npm run build:client && npm run build:server",
     "build:client": "vite build",
     "build:server": "tsc -p tsconfig.server.json",
@@ -30,13 +32,29 @@
   "dependencies": {
     "@actalk/inkos-core": "workspace:*",
     "@base-ui/react": "^1.3.0",
+    "@fontsource-variable/geist": "^5.2.8",
     "@hono/node-server": "^1.13.0",
+    "@radix-ui/react-use-controllable-state": "^1.2.2",
+    "@streamdown/cjk": "^1.0.3",
+    "@streamdown/code": "^1.1.1",
+    "@streamdown/math": "^1.0.2",
+    "@streamdown/mermaid": "^1.0.2",
+    "ai": "^6.0.159",
+    "class-variance-authority": "^0.7.1",
     "clsx": "^2.1.1",
+    "cmdk": "^1.1.1",
     "hono": "^4.7.0",
     "lucide-react": "^0.577.0",
+    "motion": "^12.38.0",
+    "nanoid": "^5.1.7",
     "react": "^19.0.0",
     "react-dom": "^19.0.0",
-    "tailwind-merge": "^3.5.0"
+    "shadcn": "^4.2.0",
+    "shiki": "^4.0.2",
+    "streamdown": "^2.5.0",
+    "tailwind-merge": "^3.5.0",
+    "tw-animate-css": "^1.4.0",
+    "zustand": "^5.0.12"
   },
   "devDependencies": {
     "@tailwindcss/vite": "^4.0.0",

+ 1 - 1
packages/studio/src/App.test.ts

@@ -11,7 +11,7 @@ describe("deriveActiveBookId", () => {
 
   it("returns undefined for non-book routes", () => {
     expect(deriveActiveBookId({ page: "dashboard" })).toBeUndefined();
-    expect(deriveActiveBookId({ page: "config" })).toBeUndefined();
+    expect(deriveActiveBookId({ page: "services" })).toBeUndefined();
     expect(deriveActiveBookId({ page: "style" })).toBeUndefined();
   });
 });

+ 126 - 75
packages/studio/src/App.tsx

@@ -1,12 +1,13 @@
 import { useState, useEffect } from "react";
+import { useHashRoute } from "./hooks/use-hash-route";
+import type { HashRoute } from "./hooks/use-hash-route";
 import { Sidebar } from "./components/Sidebar";
-import { ChatPanel } from "./components/ChatBar";
 import { Dashboard } from "./pages/Dashboard";
-import { BookDetail } from "./pages/BookDetail";
-import { BookCreate } from "./pages/BookCreate";
+import { ChatPage } from "./pages/ChatPage";
 import { ChapterReader } from "./pages/ChapterReader";
 import { Analytics } from "./pages/Analytics";
-import { ConfigView } from "./pages/ConfigView";
+import { ServiceListPage } from "./pages/ServiceListPage";
+import { ServiceDetailPage } from "./pages/ServiceDetailPage";
 import { TruthFiles } from "./pages/TruthFiles";
 import { DaemonControl } from "./pages/DaemonControl";
 import { LogViewer } from "./pages/LogViewer";
@@ -16,44 +17,28 @@ import { ImportManager } from "./pages/ImportManager";
 import { RadarView } from "./pages/RadarView";
 import { DoctorView } from "./pages/DoctorView";
 import { LanguageSelector } from "./pages/LanguageSelector";
+import { BookSidebar, BookSidebarToggle } from "./components/chat/BookSidebar";
 import { useSSE } from "./hooks/use-sse";
 import { useTheme } from "./hooks/use-theme";
 import { useI18n } from "./hooks/use-i18n";
-import { postApi, useApi } from "./hooks/use-api";
-import { Sun, Moon, MessageSquare } from "lucide-react";
-import { DEFAULT_CHAT_OPEN } from "./app-state";
+import { postApi, putApi, useApi } from "./hooks/use-api";
+import { Sun, Moon } from "lucide-react";
 
-export type Route =
-  | { page: "dashboard" }
-  | { page: "book"; bookId: string }
-  | { page: "book-create" }
-  | { page: "chapter"; bookId: string; chapterNumber: number }
-  | { page: "analytics"; bookId: string }
-  | { page: "config" }
-  | { page: "truth"; bookId: string }
-  | { page: "daemon" }
-  | { page: "logs" }
-  | { page: "genres" }
-  | { page: "style" }
-  | { page: "import" }
-  | { page: "radar" }
-  | { page: "doctor" };
+export type { HashRoute as Route } from "./hooks/use-hash-route";
 
-export function deriveActiveBookId(route: Route): string | undefined {
-  return route.page === "book" || route.page === "chapter" || route.page === "truth" || route.page === "analytics"
-    ? route.bookId
-    : undefined;
+export function deriveActiveBookId(route: HashRoute): string | undefined {
+  if ("bookId" in route) return route.bookId;
+  return undefined;
 }
 
 export function App() {
-  const [route, setRoute] = useState<Route>({ page: "dashboard" });
+  const { route, setRoute } = useHashRoute();
   const sse = useSSE();
   const { theme, setTheme } = useTheme();
-  const { t } = useI18n();
+  const { t, lang: currentLang } = useI18n();
   const { data: project, refetch: refetchProject } = useApi<{ language: string; languageExplicit: boolean }>("/project");
   const [showLanguageSelector, setShowLanguageSelector] = useState(false);
   const [ready, setReady] = useState(false);
-  const [chatOpen, setChatOpen] = useState(DEFAULT_CHAT_OPEN);
 
   const isDark = theme === "dark";
 
@@ -77,7 +62,8 @@ export function App() {
     toChapter: (bookId: string, chapterNumber: number) =>
       setRoute({ page: "chapter", bookId, chapterNumber }),
     toAnalytics: (bookId: string) => setRoute({ page: "analytics", bookId }),
-    toConfig: () => setRoute({ page: "config" }),
+    toServices: () => setRoute({ page: "services" }),
+    toServiceDetail: (id: string) => setRoute({ page: "service-detail", serviceId: id }),
     toTruth: (bookId: string) => setRoute({ page: "truth", bookId }),
     toDaemon: () => setRoute({ page: "daemon" }),
     toLogs: () => setRoute({ page: "logs" }),
@@ -92,7 +78,9 @@ export function App() {
   const activePage =
     activeBookId
       ? `book:${activeBookId}`
-      : route.page;
+      : route.page === "service-detail"
+        ? "services"
+        : route.page;
 
   if (!ready) {
     return (
@@ -124,65 +112,128 @@ export function App() {
         {/* Header Strip */}
         <header className="h-14 shrink-0 flex items-center justify-between px-8 border-b border-border/40">
           <div className="flex items-center gap-2">
-             <span className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-bold">
+             <button onClick={nav.toDashboard} className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground font-bold hover:text-foreground transition-colors">
                InkOS Studio
-             </span>
+             </button>
           </div>
 
           <div className="flex items-center gap-3">
+            <div className="flex gap-0.5 bg-muted/50 rounded-md p-0.5">
+              <button
+                onClick={async () => {
+                  await putApi("/project", { language: "zh" });
+                  refetchProject();
+                }}
+                className={`text-xs px-2 py-0.5 rounded ${currentLang === "zh" ? "bg-primary text-primary-foreground" : "text-muted-foreground"}`}
+              >
+                中
+              </button>
+              <button
+                onClick={async () => {
+                  await putApi("/project", { language: "en" });
+                  refetchProject();
+                }}
+                className={`text-xs px-2 py-0.5 rounded ${currentLang === "en" ? "bg-primary text-primary-foreground" : "text-muted-foreground"}`}
+              >
+                EN
+              </button>
+            </div>
 
             <button
               onClick={() => setTheme(isDark ? "light" : "dark")}
-              className="w-8 h-8 flex items-center justify-center rounded-lg bg-secondary text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all shadow-sm"
-              title={isDark ? "Switch to Light Mode" : "Switch to Dark Mode"}
+              className="text-muted-foreground hover:text-foreground transition-colors"
             >
-              {isDark ? <Sun size={16} /> : <Moon size={16} />}
-            </button>
-
-            {/* Chat Panel Toggle */}
-            <button
-              onClick={() => setChatOpen((prev) => !prev)}
-              className={`w-8 h-8 flex items-center justify-center rounded-lg transition-all shadow-sm ${
-                chatOpen
-                  ? "bg-primary text-primary-foreground shadow-primary/20"
-                  : "bg-secondary text-muted-foreground hover:text-primary hover:bg-primary/10"
-              }`}
-              title="Toggle AI Assistant"
-            >
-              <MessageSquare size={16} />
+              {isDark ? <Sun size={14} /> : <Moon size={14} />}
             </button>
           </div>
         </header>
 
         {/* Main Content Area */}
-        <main className="flex-1 overflow-y-auto scroll-smooth">
-          <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
-            {route.page === "dashboard" && <Dashboard nav={nav} sse={sse} theme={theme} t={t} />}
-            {route.page === "book" && <BookDetail bookId={route.bookId} nav={nav} theme={theme} t={t} sse={sse} />}
-            {route.page === "book-create" && <BookCreate nav={nav} theme={theme} t={t} />}
-            {route.page === "chapter" && <ChapterReader bookId={route.bookId} chapterNumber={route.chapterNumber} nav={nav} theme={theme} t={t} />}
-            {route.page === "analytics" && <Analytics bookId={route.bookId} nav={nav} theme={theme} t={t} />}
-            {route.page === "config" && <ConfigView nav={nav} theme={theme} t={t} />}
-            {route.page === "truth" && <TruthFiles bookId={route.bookId} nav={nav} theme={theme} t={t} />}
-            {route.page === "daemon" && <DaemonControl nav={nav} theme={theme} t={t} sse={sse} />}
-            {route.page === "logs" && <LogViewer nav={nav} theme={theme} t={t} />}
-            {route.page === "genres" && <GenreManager nav={nav} theme={theme} t={t} />}
-            {route.page === "style" && <StyleManager nav={nav} theme={theme} t={t} />}
-            {route.page === "import" && <ImportManager nav={nav} theme={theme} t={t} />}
-            {route.page === "radar" && <RadarView nav={nav} theme={theme} t={t} />}
-            {route.page === "doctor" && <DoctorView nav={nav} theme={theme} t={t} />}
-          </div>
+        <main className="flex-1 relative overflow-y-auto scroll-smooth">
+          {route.page === "dashboard" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <Dashboard nav={nav} sse={sse} theme={theme} t={t} />
+            </div>
+          )}
+          {(route.page === "book" || route.page === "book-create") && (
+            <div className="absolute inset-0 flex min-w-0">
+              <ChatPage
+                activeBookId={route.page === "book" ? route.bookId : undefined}
+                nav={nav}
+                theme={theme}
+                t={t}
+                sse={sse}
+              />
+              {route.page === "book" && (
+                <>
+                  <BookSidebar bookId={route.bookId} theme={theme} t={t} sse={sse} />
+                  <BookSidebarToggle bookId={route.bookId} theme={theme} t={t} sse={sse} />
+                </>
+              )}
+            </div>
+          )}
+          {route.page === "chapter" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <ChapterReader bookId={route.bookId} chapterNumber={route.chapterNumber} nav={nav} theme={theme} t={t} />
+            </div>
+          )}
+          {route.page === "analytics" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <Analytics bookId={route.bookId} nav={nav} theme={theme} t={t} />
+            </div>
+          )}
+          {route.page === "services" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <ServiceListPage nav={nav} />
+            </div>
+          )}
+          {route.page === "service-detail" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <ServiceDetailPage serviceId={route.serviceId} nav={nav} />
+            </div>
+          )}
+          {route.page === "truth" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <TruthFiles bookId={route.bookId} nav={nav} theme={theme} t={t} />
+            </div>
+          )}
+          {route.page === "daemon" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <DaemonControl nav={nav} theme={theme} t={t} sse={sse} />
+            </div>
+          )}
+          {route.page === "logs" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <LogViewer nav={nav} theme={theme} t={t} />
+            </div>
+          )}
+          {route.page === "genres" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <GenreManager nav={nav} theme={theme} t={t} />
+            </div>
+          )}
+          {route.page === "style" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <StyleManager nav={nav} theme={theme} t={t} />
+            </div>
+          )}
+          {route.page === "import" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <ImportManager nav={nav} theme={theme} t={t} />
+            </div>
+          )}
+          {route.page === "radar" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <RadarView nav={nav} theme={theme} t={t} />
+            </div>
+          )}
+          {route.page === "doctor" && (
+            <div className="max-w-4xl mx-auto px-6 py-12 md:px-12 lg:py-16 fade-in">
+              <DoctorView nav={nav} theme={theme} t={t} />
+            </div>
+          )}
         </main>
       </div>
-
-      {/* Right Chat Panel */}
-      <ChatPanel
-        open={chatOpen}
-        onClose={() => setChatOpen(false)}
-        t={t}
-        sse={sse}
-        activeBookId={activeBookId}
-      />
     </div>
   );
 }

+ 0 - 614
packages/studio/src/components/ChatBar.tsx

@@ -1,614 +0,0 @@
-import { useState, useRef, useEffect, useMemo } from "react";
-import type { TFunction } from "../hooks/use-i18n";
-import type { SSEMessage } from "../hooks/use-sse";
-import { cn } from "../lib/utils";
-import { fetchJson, postApi } from "../hooks/use-api";
-import {
-  // Panel controls
-  Sparkles,
-  Trash2,
-  PanelRightClose,
-  ArrowUp,
-  Loader2,
-  MessageSquare,
-  Lightbulb,
-  // Message avatars
-  User,
-  CheckCircle2,
-  XCircle,
-  BotMessageSquare,
-  // Message content badges
-  BadgeCheck,
-  CircleAlert,
-  // Status phase icons
-  Brain,
-  PenTool,
-  Shield,
-  Wrench,
-  AlertTriangle,
-  // Quick command chips
-  Zap,
-  Search,
-  FileOutput,
-  TrendingUp,
-} from "lucide-react";
-
-// ── Types ──
-
-interface ChatMessage {
-  readonly role: "user" | "assistant";
-  readonly content: string;
-  readonly timestamp: number;
-}
-
-interface SharedSessionMeta {
-  readonly activeBookId?: string;
-  readonly automationMode?: string;
-  readonly currentStage?: string;
-  readonly pendingSummary?: string;
-  readonly draftTitle?: string;
-}
-
-interface BookRef {
-  readonly id: string;
-}
-
-export function coerceSharedSessionMessages(
-  messages: ReadonlyArray<{ role: "user" | "assistant" | "system"; content: string; timestamp: number }>,
-): ReadonlyArray<ChatMessage> {
-  return messages
-    .filter((message) => message.role === "user" || message.role === "assistant")
-    .map((message) => ({
-      role: message.role as "user" | "assistant",
-      content: message.content,
-      timestamp: message.timestamp,
-    }));
-}
-
-export function resolveDirectWriteTarget(
-  activeBookId: string | undefined,
-  books: ReadonlyArray<BookRef>,
-): { bookId: string | null; reason: "active" | "single" | "missing" | "ambiguous" } {
-  if (activeBookId && books.some((book) => book.id === activeBookId)) {
-    return { bookId: activeBookId, reason: "active" };
-  }
-  if (books.length === 1) {
-    return { bookId: books[0]!.id, reason: "single" };
-  }
-  if (books.length === 0) {
-    return { bookId: null, reason: "missing" };
-  }
-  return { bookId: null, reason: "ambiguous" };
-}
-
-export function formatSharedSessionContext(meta: SharedSessionMeta): string {
-  return [
-    meta.activeBookId ?? "no-book",
-    meta.draftTitle ? `draft:${meta.draftTitle}` : undefined,
-    meta.automationMode ?? "semi",
-    meta.currentStage,
-  ].filter(Boolean).join(" · ");
-}
-
-// ── Sub-components ──
-
-function StatusIcon({ phase }: { readonly phase: string }) {
-  const lower = phase.toLowerCase();
-
-  if (lower.includes("think") || lower.includes("plan"))
-    return <Brain size={14} className="text-purple-500 animate-pulse" />;
-  if (lower.includes("writ") || lower.includes("draft") || lower.includes("stream"))
-    return <PenTool size={14} className="text-blue-500 chat-icon-write" />;
-  if (lower.includes("audit") || lower.includes("review"))
-    return <Shield size={14} className="text-amber-500 animate-pulse" />;
-  if (lower.includes("revis") || lower.includes("fix") || lower.includes("spot"))
-    return <Wrench size={14} className="text-orange-500 chat-icon-spin-slow" />;
-  if (lower.includes("complet") || lower.includes("done") || lower.includes("success"))
-    return <CheckCircle2 size={14} className="text-emerald-500 chat-icon-pop" />;
-  if (lower.includes("error") || lower.includes("fail"))
-    return <AlertTriangle size={14} className="text-destructive animate-pulse" />;
-  return <Loader2 size={14} className="text-primary animate-spin" />;
-}
-
-function EmptyState() {
-  return (
-    <div className="h-full flex flex-col items-center justify-center text-center opacity-40 select-none fade-in">
-      <div className="w-14 h-14 rounded-2xl border border-dashed border-border flex items-center justify-center mb-4 bg-secondary/30">
-        <BotMessageSquare size={24} className="text-muted-foreground" />
-      </div>
-      <p className="text-sm italic font-serif mb-1">How shall we proceed today?</p>
-      <p className="text-[10px] text-muted-foreground/60 uppercase tracking-widest">Type a command below</p>
-    </div>
-  );
-}
-
-function ThinkingBubble() {
-  return (
-    <div className="flex gap-2.5 chat-msg-assistant">
-      <div className="w-7 h-7 rounded-lg bg-secondary flex items-center justify-center shrink-0 chat-thinking-glow">
-        <Brain size={14} className="text-primary animate-pulse" />
-      </div>
-      <div className="bg-card border border-border/50 px-3.5 py-2.5 rounded-2xl rounded-tl-sm flex gap-1.5 items-center">
-        <span className="w-1.5 h-1.5 bg-primary/50 rounded-full chat-typing-dot" />
-        <span className="w-1.5 h-1.5 bg-primary/50 rounded-full chat-typing-dot" />
-        <span className="w-1.5 h-1.5 bg-primary/50 rounded-full chat-typing-dot" />
-      </div>
-    </div>
-  );
-}
-
-function MessageBubble({ msg }: { readonly msg: ChatMessage }) {
-  const isUser = msg.role === "user";
-  const isStatus = msg.content.startsWith("⋯");
-  const isSuccess = msg.content.startsWith("✓");
-  const isError = msg.content.startsWith("✗");
-
-  return (
-    <div className={cn(
-      "flex gap-2.5",
-      isUser ? "flex-row-reverse chat-msg-user" : "chat-msg-assistant",
-    )}>
-      {/* Avatar */}
-      <div className={cn(
-        "w-7 h-7 rounded-lg flex items-center justify-center shrink-0 mt-0.5 transition-colors",
-        isUser ? "bg-primary/10" : "bg-secondary",
-      )}>
-        {isUser ? (
-          <User size={14} className="text-primary" />
-        ) : isSuccess ? (
-          <CheckCircle2 size={14} className="text-emerald-500 chat-icon-pop" />
-        ) : isError ? (
-          <XCircle size={14} className="text-destructive" />
-        ) : isStatus ? (
-          <Loader2 size={14} className="text-primary animate-spin" />
-        ) : (
-          <Sparkles size={14} className="text-primary" />
-        )}
-      </div>
-
-      {/* Bubble */}
-      <div className={cn(
-        "max-w-[80%] rounded-2xl px-3.5 py-2 text-sm leading-relaxed shadow-sm",
-        isUser
-          ? "bg-primary text-primary-foreground font-medium rounded-tr-sm"
-          : isStatus
-            ? "bg-secondary/50 border border-border/30 text-muted-foreground font-mono text-xs rounded-tl-sm"
-            : "bg-card border border-border/50 text-foreground font-serif rounded-tl-sm",
-      )}>
-        {/* Status badge */}
-        {isSuccess && (
-          <span className="flex items-center gap-1.5 text-emerald-600 dark:text-emerald-400 font-sans font-medium text-[10px] mb-1 uppercase tracking-wider">
-            <BadgeCheck size={11} />
-            Complete
-          </span>
-        )}
-        {isError && (
-          <span className="flex items-center gap-1.5 text-destructive font-sans font-medium text-[10px] mb-1 uppercase tracking-wider">
-            <CircleAlert size={11} />
-            Error
-          </span>
-        )}
-
-        <div>{msg.content}</div>
-
-        {/* Timestamp */}
-        <div className={cn(
-          "text-[9px] mt-1.5 font-mono",
-          isUser ? "text-primary-foreground/40" : "text-muted-foreground/40",
-        )}>
-          {new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
-        </div>
-      </div>
-    </div>
-  );
-}
-
-function QuickChip({ icon, label, onClick }: {
-  readonly icon: React.ReactNode;
-  readonly label: string;
-  readonly onClick: () => void;
-}) {
-  return (
-    <button
-      onClick={onClick}
-      className="shrink-0 flex items-center gap-1 px-2.5 py-1 rounded-lg bg-secondary/50 border border-border/30 text-[10px] font-medium text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/5 transition-all group"
-    >
-      <span className="group-hover:scale-110 transition-transform">{icon}</span>
-      {label}
-    </button>
-  );
-}
-
-// ── Main Component ──
-
-export function ChatPanel({ open, onClose, t, sse, activeBookId }: {
-  readonly open: boolean;
-  readonly onClose: () => void;
-  readonly t: TFunction;
-  readonly sse: { messages: ReadonlyArray<SSEMessage>; connected: boolean };
-  readonly activeBookId?: string;
-}) {
-  const [input, setInput] = useState("");
-  const [messages, setMessages] = useState<ReadonlyArray<ChatMessage>>([]);
-  const [loading, setLoading] = useState(false);
-  const [sessionMeta, setSessionMeta] = useState<SharedSessionMeta>({});
-  const inputRef = useRef<HTMLInputElement>(null);
-  const scrollRef = useRef<HTMLDivElement>(null);
-
-  // Auto-scroll on new messages
-  useEffect(() => {
-    if (scrollRef.current) {
-      scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
-    }
-  }, [messages, sse.messages.length]);
-
-  // Focus input when panel opens
-  useEffect(() => {
-    if (open) {
-      setTimeout(() => inputRef.current?.focus(), 300);
-    }
-  }, [open]);
-
-  useEffect(() => {
-    if (!open) return;
-
-    let cancelled = false;
-    void fetchJson<{
-      session?: {
-        activeBookId?: string;
-        automationMode?: string;
-        creationDraft?: {
-          title?: string;
-        };
-        currentExecution?: {
-          status?: string;
-          stageLabel?: string;
-        };
-        pendingDecision?: {
-          summary?: string;
-        };
-        messages?: ReadonlyArray<{ role: "user" | "assistant" | "system"; content: string; timestamp: number }>;
-      };
-      activeBookId?: string;
-    }>("/interaction/session").then((data) => {
-      if (cancelled) return;
-      setSessionMeta({
-        activeBookId: data.activeBookId ?? data.session?.activeBookId,
-        draftTitle: data.session?.creationDraft?.title,
-        automationMode: data.session?.automationMode,
-        currentStage: data.session?.currentExecution?.stageLabel ?? data.session?.currentExecution?.status,
-        pendingSummary: data.session?.pendingDecision?.summary,
-      });
-      setMessages((current) => {
-        if (current.length > 0) return current;
-        return coerceSharedSessionMessages(data.session?.messages ?? []);
-      });
-    }).catch(() => {
-      // keep local empty state on session fetch failures
-    });
-
-    return () => {
-      cancelled = true;
-    };
-  }, [open]);
-
-  // SSE events → assistant messages
-  const loadingRef = useRef(false);
-  loadingRef.current = loading;
-
-  useEffect(() => {
-    const recent = sse.messages.slice(-1)[0];
-    if (!recent || recent.event === "ping") return;
-
-    const d = recent.data as Record<string, unknown>;
-
-    if (recent.event === "write:complete" || recent.event === "draft:complete") {
-      setLoading(false);
-      const title = d.title ?? `Chapter ${d.chapterNumber}`;
-      setMessages((prev) => [...prev, {
-        role: "assistant",
-        content: `✓ ${title} (${(d.wordCount as number)?.toLocaleString() ?? "?"} chars)`,
-        timestamp: Date.now(),
-      }]);
-    }
-    if (recent.event === "write:error" || recent.event === "draft:error") {
-      setLoading(false);
-      setMessages((prev) => [...prev, {
-        role: "assistant",
-        content: `✗ ${d.error ?? "Unknown error"}`,
-        timestamp: Date.now(),
-      }]);
-    }
-    if (recent.event === "log" && loadingRef.current) {
-      const msg = d.message as string;
-      if (msg && (msg.includes("Phase") || msg.includes("streaming") || msg.includes("Writing") || msg.includes("Audit") || msg.includes("Revis"))) {
-        setMessages((prev) => {
-          const last = prev[prev.length - 1];
-          if (last?.role === "assistant" && last.content.startsWith("⋯")) {
-            return [...prev.slice(0, -1), { role: "assistant", content: `⋯ ${msg}`, timestamp: Date.now() }];
-          }
-          return [...prev, { role: "assistant", content: `⋯ ${msg}`, timestamp: Date.now() }];
-        });
-      }
-    }
-  }, [sse.messages.length]);
-
-  // Current phase for status bar
-  const currentPhase = useMemo(() => {
-    const lastStatus = [...messages].reverse().find((m) => m.role === "assistant" && m.content.startsWith("⋯"));
-    return lastStatus?.content.replace("⋯ ", "") ?? "Initializing...";
-  }, [messages]);
-
-  const executeCommand = async (text: string) => {
-    const lower = text.toLowerCase();
-
-    try {
-      if (lower.match(/^(写下一章|write next)/)) {
-        const { books } = await fetchJson<{ books: ReadonlyArray<BookRef> }>("/books");
-        const target = resolveDirectWriteTarget(activeBookId, books);
-
-        if (target.bookId) {
-          setMessages((prev) => [...prev, {
-            role: "assistant",
-            content: isZh ? `⋯ 开始处理《${target.bookId}》...` : `⋯ Starting ${target.bookId}...`,
-            timestamp: Date.now(),
-          }]);
-          await postApi(`/books/${target.bookId}/write-next`, {});
-          return;
-        }
-
-        setLoading(false);
-        setMessages((prev) => [...prev, {
-          role: "assistant",
-          content:
-            target.reason === "missing"
-              ? (isZh ? "\u2717 \u8fd8\u6ca1\u6709\u4e66\uff0c\u5148\u521b\u5efa\u4e00\u672c\u518d\u5199\u3002" : "\u2717 No books yet. Create one first.")
-              : (isZh ? "\u2717 \u5f53\u524d\u6709\u591a\u672c\u4e66\uff0c\u8bf7\u5148\u6253\u5f00\u76ee\u6807\u4e66\u7c4d\u540e\u518d\u6267\u884c\u201c\u5199\u4e0b\u4e00\u7ae0\u201d\u3002" : '\u2717 Multiple books found. Open the target book first, then run "write next".'),
-          timestamp: Date.now(),
-        }]);
-        return;
-      }
-
-      const data = await fetchJson<{
-        response?: string;
-        error?: string;
-        session?: {
-          activeBookId?: string;
-          automationMode?: string;
-          creationDraft?: { title?: string };
-          currentExecution?: { status?: string; stageLabel?: string };
-          pendingDecision?: { summary?: string };
-          messages?: ReadonlyArray<{ role: "user" | "assistant" | "system"; content: string; timestamp: number }>;
-        };
-      }>("/agent", {
-        method: "POST",
-        headers: { "Content-Type": "application/json" },
-        body: JSON.stringify({ instruction: text, activeBookId }),
-      });
-      setLoading(false);
-      if (data.session) {
-        setSessionMeta({
-          activeBookId: data.session.activeBookId ?? activeBookId,
-          draftTitle: data.session.creationDraft?.title,
-          automationMode: data.session.automationMode,
-          currentStage: data.session.currentExecution?.stageLabel ?? data.session.currentExecution?.status,
-          pendingSummary: data.session.pendingDecision?.summary,
-        });
-      }
-      setMessages((prev) => [...prev, {
-        role: "assistant",
-        content: data.response ?? "Acknowledged.",
-        timestamp: Date.now(),
-      }]);
-    } catch (e) {
-      setLoading(false);
-      setMessages((prev) => [...prev, {
-        role: "assistant",
-        content: `✗ ${e instanceof Error ? e.message : String(e)}`,
-        timestamp: Date.now(),
-      }]);
-    }
-  };
-
-  const handleSubmit = async () => {
-    const text = input.trim();
-    if (!text || loading) return;
-    setInput("");
-    setMessages((prev) => [...prev, { role: "user", content: text, timestamp: Date.now() }]);
-    setLoading(true);
-    await executeCommand(text);
-  };
-
-  const handleQuickCommand = async (command: string) => {
-    if (loading) return;
-    setInput("");
-    setMessages((prev) => [...prev, { role: "user", content: command, timestamp: Date.now() }]);
-    setLoading(true);
-    await executeCommand(command);
-  };
-
-  const isZh = t("nav.connected") === "已连接";
-
-  // Rotating tips
-  const TIPS_ZH = [
-    "写下一章", "审计第5章", "帮我创建一本都市修仙小说",
-    "扫描市场趋势", "导出全书为 epub", "分析文风 → 导入到我的书",
-    "导入已有章节续写", "创建一个玄幻题材的同人", "修订第5章,spot-fix",
-  ];
-  const TIPS_EN = [
-    "write next chapter", "audit chapter 5", "create a LitRPG novel",
-    "scan market trends", "export book as epub", "analyze style → import",
-    "import chapters to continue", "create a progression fantasy fanfic", "revise chapter 5, spot-fix",
-  ];
-  const tips = isZh ? TIPS_ZH : TIPS_EN;
-  const [tipIndex, setTipIndex] = useState(() => Math.floor(Math.random() * tips.length));
-
-  useEffect(() => {
-    if (input) return;
-    const timer = setInterval(() => setTipIndex((i) => (i + 1) % tips.length), 8000);
-    return () => clearInterval(timer);
-  }, [input, tips.length]);
-
-  return (
-    <aside
-      className={cn(
-        "h-full flex flex-col border-l border-border/40 bg-background/80 backdrop-blur-md chat-panel-enter shrink-0 overflow-hidden",
-        open ? "w-[380px] opacity-100" : "w-0 opacity-0",
-      )}
-    >
-      {open && (
-        <>
-          {/* ── Section 1: Header ── */}
-          <div className="h-12 shrink-0 px-4 flex items-center justify-between border-b border-border/40">
-            <div className="flex items-center gap-2.5 min-w-0">
-              <div className="relative shrink-0">
-                <Sparkles size={15} className="text-primary chat-icon-glow" />
-                {loading && (
-                  <span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-primary rounded-full animate-ping" />
-                )}
-              </div>
-              <div className="min-w-0">
-                <div className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-foreground">
-                  InkOS Assistant
-                </div>
-                <div className="text-[10px] text-muted-foreground/60 truncate">
-                  {formatSharedSessionContext({
-                    activeBookId: sessionMeta.activeBookId ?? activeBookId,
-                    automationMode: sessionMeta.automationMode,
-                    currentStage: sessionMeta.currentStage,
-                  })}
-                </div>
-              </div>
-            </div>
-            <div className="flex items-center gap-1">
-              <button
-                onClick={() => setMessages([])}
-                className="p-1.5 rounded-md text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors group"
-                title="Clear conversation"
-              >
-                <Trash2 size={14} className="group-hover:animate-[shake_0.3s_ease-in-out]" />
-              </button>
-              <button
-                onClick={onClose}
-                className="p-1.5 rounded-md text-muted-foreground hover:bg-secondary transition-colors group"
-                title="Close panel"
-              >
-                <PanelRightClose size={14} className="group-hover:translate-x-0.5 transition-transform" />
-              </button>
-            </div>
-          </div>
-
-          {/* ── Section 2: Status Bar (when loading) ── */}
-          {loading && (
-            <div className="shrink-0 px-4 py-2 border-b border-border/30 bg-primary/[0.03] fade-in">
-              <div className="flex items-center gap-2.5">
-                <StatusIcon phase={currentPhase} />
-                <span className="text-xs font-medium text-primary truncate flex-1">
-                  {currentPhase}
-                </span>
-                <div className="flex gap-1">
-                  <span className="w-1 h-1 bg-primary/40 rounded-full chat-typing-dot" />
-                  <span className="w-1 h-1 bg-primary/40 rounded-full chat-typing-dot" />
-                  <span className="w-1 h-1 bg-primary/40 rounded-full chat-typing-dot" />
-                </div>
-              </div>
-            </div>
-          )}
-
-          {!loading && sessionMeta.pendingSummary && (
-            <div className="shrink-0 px-4 py-2 border-b border-border/30 bg-amber-500/[0.06] fade-in">
-              <div className="flex items-center gap-2 text-[11px] text-amber-700 dark:text-amber-300">
-                <AlertTriangle size={12} />
-                <span className="truncate">{sessionMeta.pendingSummary}</span>
-              </div>
-            </div>
-          )}
-
-          {/* ── Section 3: Messages ── */}
-          <div
-            ref={scrollRef}
-            className="flex-1 overflow-y-auto px-4 py-4 space-y-3"
-          >
-            {messages.length === 0 && !loading && <EmptyState />}
-
-            {messages.map((msg, i) => (
-              <MessageBubble key={`${msg.timestamp}-${i}`} msg={msg} />
-            ))}
-
-            {loading && !messages.some((m) => m.content.startsWith("⋯")) && (
-              <ThinkingBubble />
-            )}
-          </div>
-
-          {/* ── Section 4: Quick Commands ── */}
-          <div className="shrink-0 px-3 py-2 border-t border-border/30 flex gap-1.5 overflow-x-auto">
-            <QuickChip
-              icon={<Zap size={11} />}
-              label={t("dash.writeNext")}
-              onClick={() => void handleQuickCommand(isZh ? "写下一章" : "write next")}
-            />
-            <QuickChip
-              icon={<Search size={11} />}
-              label={t("book.audit")}
-              onClick={() => void handleQuickCommand(isZh ? "审计第1章" : "audit chapter 1")}
-            />
-            <QuickChip
-              icon={<FileOutput size={11} />}
-              label={t("book.export")}
-              onClick={() => void handleQuickCommand(isZh ? "导出全书" : "export book as epub")}
-            />
-            <QuickChip
-              icon={<TrendingUp size={11} />}
-              label={t("nav.radar")}
-              onClick={() => void handleQuickCommand(isZh ? "扫描市场趋势" : "scan market trends")}
-            />
-          </div>
-
-          {/* ── Section 5: Input ── */}
-          <div className="shrink-0 p-3 border-t border-border/40">
-            <div className="flex items-center gap-2 rounded-xl bg-secondary/30 border border-border/40 px-3 py-1.5 focus-within:border-primary/40 focus-within:ring-2 focus-within:ring-primary/10 transition-all">
-              <MessageSquare size={14} className="text-muted-foreground/50 shrink-0" />
-              <input
-                ref={inputRef}
-                type="text"
-                value={input}
-                onChange={(e) => setInput(e.target.value)}
-                onKeyDown={(e) => {
-                  if (e.key === "Enter" && !e.shiftKey) {
-                    e.preventDefault();
-                    handleSubmit();
-                  }
-                }}
-                placeholder={t("common.enterCommand")}
-                disabled={loading}
-                className="flex-1 bg-transparent text-sm placeholder:text-muted-foreground/50 outline-none ring-0 shadow-none disabled:opacity-50"
-                style={{ outline: "none", boxShadow: "none" }}
-              />
-              <button
-                onClick={handleSubmit}
-                disabled={!input.trim() || loading}
-                className="w-7 h-7 rounded-lg bg-primary text-primary-foreground flex items-center justify-center hover:scale-105 active:scale-95 transition-all disabled:opacity-20 disabled:scale-100 shadow-sm shadow-primary/20"
-              >
-                {loading ? (
-                  <Loader2 size={14} className="animate-spin" />
-                ) : (
-                  <ArrowUp size={14} strokeWidth={2.5} />
-                )}
-              </button>
-            </div>
-
-            {/* Rotating tip */}
-            {!input && (
-              <div className="mt-1.5 px-1 flex items-center gap-1.5">
-                <Lightbulb size={10} className="text-muted-foreground/30 shrink-0" />
-                <span className="text-[9px] text-muted-foreground/40 truncate fade-in" key={tipIndex}>
-                  {tips[tipIndex]}
-                </span>
-              </div>
-            )}
-          </div>
-        </>
-      )}
-    </aside>
-  );
-}

+ 20 - 18
packages/studio/src/components/Sidebar.tsx

@@ -29,7 +29,7 @@ interface Nav {
   toDashboard: () => void;
   toBook: (id: string) => void;
   toBookCreate: () => void;
-  toConfig: () => void;
+  toServices: () => void;
   toDaemon: () => void;
   toLogs: () => void;
   toGenres: () => void;
@@ -87,10 +87,10 @@ export function Sidebar({ nav, activePage, sse, t }: {
             </span>
             <button
               onClick={nav.toBookCreate}
-              className="p-1 rounded-md text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all group"
-              title={t("nav.newBook")}
+              className="flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] text-muted-foreground hover:text-primary hover:bg-primary/10 transition-all"
             >
-              <Plus size={14} className="group-hover:rotate-90 transition-transform duration-300" />
+              <Plus size={12} />
+              <span>{t("nav.newBook")}</span>
             </button>
           </div>
 
@@ -101,7 +101,7 @@ export function Sidebar({ nav, activePage, sse, t }: {
                 onClick={() => nav.toBook(book.id)}
                 className={`w-full group flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all duration-200 ${
                   activePage === `book:${book.id}`
-                    ? "bg-primary/10 text-primary font-semibold"
+                    ? "bg-primary/10 text-primary font-medium"
                     : "text-foreground font-medium hover:text-foreground hover:bg-secondary/50"
                 }`}
               >
@@ -140,17 +140,17 @@ export function Sidebar({ nav, activePage, sse, t }: {
             <SidebarItem
               label={t("nav.config")}
               icon={<Settings size={16} />}
-              active={activePage === "config"}
-              onClick={nav.toConfig}
+              active={activePage === "services"}
+              onClick={nav.toServices}
             />
-            <SidebarItem
+{/*            <SidebarItem
               label={t("nav.daemon")}
               icon={<Zap size={16} />}
               active={activePage === "daemon"}
               onClick={nav.toDaemon}
               badge={daemon?.running ? t("nav.running") : undefined}
               badgeColor={daemon?.running ? "bg-emerald-500/10 text-emerald-500" : "bg-muted text-muted-foreground"}
-            />
+            />*/}
             <SidebarItem
               label={t("nav.logs")}
               icon={<Terminal size={16} />}
@@ -196,15 +196,17 @@ export function Sidebar({ nav, activePage, sse, t }: {
         </div>
       </div>
 
-      {/* Footer / Status Area */}
-      <div className="p-4 border-t border-border bg-secondary/40">
-        <div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-card border border-border shadow-sm">
-          <div className={`w-2 h-2 rounded-full ${daemon?.running ? "bg-emerald-500 animate-pulse" : "bg-muted-foreground/40"}`} />
-          <span className="text-[11px] font-semibold text-foreground/80 uppercase tracking-wider">
-            {daemon?.running ? t("nav.agentOnline") : t("nav.agentOffline")}
-          </span>
+      {/* Footer / Status Area — only show when agent is online */}
+      {daemon?.running && (
+        <div className="p-4 border-t border-border bg-secondary/40">
+          <div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-card border border-border shadow-sm">
+            <div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
+            <span className="text-[11px] font-semibold text-foreground/80 uppercase tracking-wider">
+              {t("nav.agentOnline")}
+            </span>
+          </div>
         </div>
-      </div>
+      )}
     </aside>
   );
 }
@@ -222,7 +224,7 @@ function SidebarItem({ label, icon, active, onClick, badge, badgeColor }: {
       onClick={onClick}
       className={`w-full group flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-all duration-200 ${
         active
-          ? "bg-secondary text-foreground font-semibold shadow-sm border border-border"
+          ? "bg-secondary text-foreground font-medium shadow-sm border border-border"
           : "text-foreground font-medium hover:text-foreground hover:bg-secondary/50"
       }`}
     >

+ 562 - 0
packages/studio/src/components/ai-elements/code-block.tsx

@@ -0,0 +1,562 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { cn } from "@/lib/utils";
+import { CheckIcon, CopyIcon } from "lucide-react";
+import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
+import {
+  createContext,
+  memo,
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from "react";
+import type {
+  BundledLanguage,
+  BundledTheme,
+  HighlighterGeneric,
+  ThemedToken,
+} from "shiki";
+import { createHighlighter } from "shiki";
+
+// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
+// oxlint-disable-next-line eslint(no-bitwise)
+const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;
+// oxlint-disable-next-line eslint(no-bitwise)
+const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;
+const isUnderline = (fontStyle: number | undefined) =>
+  // oxlint-disable-next-line eslint(no-bitwise)
+  fontStyle && fontStyle & 4;
+
+// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
+interface KeyedToken {
+  token: ThemedToken;
+  key: string;
+}
+interface KeyedLine {
+  tokens: KeyedToken[];
+  key: string;
+}
+
+const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
+  lines.map((line, lineIdx) => ({
+    key: `line-${lineIdx}`,
+    tokens: line.map((token, tokenIdx) => ({
+      key: `line-${lineIdx}-${tokenIdx}`,
+      token,
+    })),
+  }));
+
+// Token rendering component
+const TokenSpan = ({ token }: { token: ThemedToken }) => (
+  <span
+    className="dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)]"
+    style={
+      {
+        backgroundColor: token.bgColor,
+        color: token.color,
+        fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
+        fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
+        textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
+        ...token.htmlStyle,
+      } as CSSProperties
+    }
+  >
+    {token.content}
+  </span>
+);
+
+// Line number styles using CSS counters
+const LINE_NUMBER_CLASSES = cn(
+  "block",
+  "before:content-[counter(line)]",
+  "before:inline-block",
+  "before:[counter-increment:line]",
+  "before:w-8",
+  "before:mr-4",
+  "before:text-right",
+  "before:text-muted-foreground/50",
+  "before:font-mono",
+  "before:select-none"
+);
+
+// Line rendering component
+const LineSpan = ({
+  keyedLine,
+  showLineNumbers,
+}: {
+  keyedLine: KeyedLine;
+  showLineNumbers: boolean;
+}) => (
+  <span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
+    {keyedLine.tokens.length === 0
+      ? "\n"
+      : keyedLine.tokens.map(({ token, key }) => (
+          <TokenSpan key={key} token={token} />
+        ))}
+  </span>
+);
+
+// Types
+type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
+  code: string;
+  language: BundledLanguage;
+  showLineNumbers?: boolean;
+};
+
+interface TokenizedCode {
+  tokens: ThemedToken[][];
+  fg: string;
+  bg: string;
+}
+
+interface CodeBlockContextType {
+  code: string;
+}
+
+// Context
+const CodeBlockContext = createContext<CodeBlockContextType>({
+  code: "",
+});
+
+// Highlighter cache (singleton per language)
+const highlighterCache = new Map<
+  string,
+  Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
+>();
+
+// Token cache
+const tokensCache = new Map<string, TokenizedCode>();
+
+// Subscribers for async token updates
+const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
+
+const getTokensCacheKey = (code: string, language: BundledLanguage) => {
+  const start = code.slice(0, 100);
+  const end = code.length > 100 ? code.slice(-100) : "";
+  return `${language}:${code.length}:${start}:${end}`;
+};
+
+const getHighlighter = (
+  language: BundledLanguage
+): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
+  const cached = highlighterCache.get(language);
+  if (cached) {
+    return cached;
+  }
+
+  const highlighterPromise = createHighlighter({
+    langs: [language],
+    themes: ["github-light", "github-dark"],
+  });
+
+  highlighterCache.set(language, highlighterPromise);
+  return highlighterPromise;
+};
+
+// Create raw tokens for immediate display while highlighting loads
+const createRawTokens = (code: string): TokenizedCode => ({
+  bg: "transparent",
+  fg: "inherit",
+  tokens: code.split("\n").map((line) =>
+    line === ""
+      ? []
+      : [
+          {
+            color: "inherit",
+            content: line,
+          } as ThemedToken,
+        ]
+  ),
+});
+
+// Synchronous highlight with callback for async results
+export const highlightCode = (
+  code: string,
+  language: BundledLanguage,
+  // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
+  callback?: (result: TokenizedCode) => void
+): TokenizedCode | null => {
+  const tokensCacheKey = getTokensCacheKey(code, language);
+
+  // Return cached result if available
+  const cached = tokensCache.get(tokensCacheKey);
+  if (cached) {
+    return cached;
+  }
+
+  // Subscribe callback if provided
+  if (callback) {
+    if (!subscribers.has(tokensCacheKey)) {
+      subscribers.set(tokensCacheKey, new Set());
+    }
+    subscribers.get(tokensCacheKey)?.add(callback);
+  }
+
+  // Start highlighting in background - fire-and-forget async pattern
+  getHighlighter(language)
+    // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
+    .then((highlighter) => {
+      const availableLangs = highlighter.getLoadedLanguages();
+      const langToUse = availableLangs.includes(language) ? language : "text";
+
+      const result = highlighter.codeToTokens(code, {
+        lang: langToUse,
+        themes: {
+          dark: "github-dark",
+          light: "github-light",
+        },
+      });
+
+      const tokenized: TokenizedCode = {
+        bg: result.bg ?? "transparent",
+        fg: result.fg ?? "inherit",
+        tokens: result.tokens,
+      };
+
+      // Cache the result
+      tokensCache.set(tokensCacheKey, tokenized);
+
+      // Notify all subscribers
+      const subs = subscribers.get(tokensCacheKey);
+      if (subs) {
+        for (const sub of subs) {
+          sub(tokenized);
+        }
+        subscribers.delete(tokensCacheKey);
+      }
+    })
+    // oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks)
+    .catch((error) => {
+      console.error("Failed to highlight code:", error);
+      subscribers.delete(tokensCacheKey);
+    });
+
+  return null;
+};
+
+const CodeBlockBody = memo(
+  ({
+    tokenized,
+    showLineNumbers,
+    className,
+  }: {
+    tokenized: TokenizedCode;
+    showLineNumbers: boolean;
+    className?: string;
+  }) => {
+    const preStyle = useMemo(
+      () => ({
+        backgroundColor: tokenized.bg,
+        color: tokenized.fg,
+      }),
+      [tokenized.bg, tokenized.fg]
+    );
+
+    const keyedLines = useMemo(
+      () => addKeysToTokens(tokenized.tokens),
+      [tokenized.tokens]
+    );
+
+    return (
+      <pre
+        className={cn(
+          "dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)] m-0 p-4 text-sm",
+          className
+        )}
+        style={preStyle}
+      >
+        <code
+          className={cn(
+            "font-mono text-sm",
+            showLineNumbers && "[counter-increment:line_0] [counter-reset:line]"
+          )}
+        >
+          {keyedLines.map((keyedLine) => (
+            <LineSpan
+              key={keyedLine.key}
+              keyedLine={keyedLine}
+              showLineNumbers={showLineNumbers}
+            />
+          ))}
+        </code>
+      </pre>
+    );
+  },
+  (prevProps, nextProps) =>
+    prevProps.tokenized === nextProps.tokenized &&
+    prevProps.showLineNumbers === nextProps.showLineNumbers &&
+    prevProps.className === nextProps.className
+);
+
+CodeBlockBody.displayName = "CodeBlockBody";
+
+export const CodeBlockContainer = ({
+  className,
+  language,
+  style,
+  ...props
+}: HTMLAttributes<HTMLDivElement> & { language: string }) => (
+  <div
+    className={cn(
+      "group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
+      className
+    )}
+    data-language={language}
+    style={{
+      containIntrinsicSize: "auto 200px",
+      contentVisibility: "auto",
+      ...style,
+    }}
+    {...props}
+  />
+);
+
+export const CodeBlockHeader = ({
+  children,
+  className,
+  ...props
+}: HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={cn(
+      "flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs",
+      className
+    )}
+    {...props}
+  >
+    {children}
+  </div>
+);
+
+export const CodeBlockTitle = ({
+  children,
+  className,
+  ...props
+}: HTMLAttributes<HTMLDivElement>) => (
+  <div className={cn("flex items-center gap-2", className)} {...props}>
+    {children}
+  </div>
+);
+
+export const CodeBlockFilename = ({
+  children,
+  className,
+  ...props
+}: HTMLAttributes<HTMLSpanElement>) => (
+  <span className={cn("font-mono", className)} {...props}>
+    {children}
+  </span>
+);
+
+export const CodeBlockActions = ({
+  children,
+  className,
+  ...props
+}: HTMLAttributes<HTMLDivElement>) => (
+  <div
+    className={cn("-my-1 -mr-1 flex items-center gap-2", className)}
+    {...props}
+  >
+    {children}
+  </div>
+);
+
+export const CodeBlockContent = ({
+  code,
+  language,
+  showLineNumbers = false,
+}: {
+  code: string;
+  language: BundledLanguage;
+  showLineNumbers?: boolean;
+}) => {
+  // Memoized raw tokens for immediate display
+  const rawTokens = useMemo(() => createRawTokens(code), [code]);
+
+  // Synchronous cache lookup — avoids setState in effect for cached results
+  const syncTokens = useMemo(
+    () => highlightCode(code, language) ?? rawTokens,
+    [code, language, rawTokens]
+  );
+
+  // Async highlighting result (populated after shiki loads)
+  const [asyncTokens, setAsyncTokens] = useState<TokenizedCode | null>(null);
+  const asyncKeyRef = useRef({ code, language });
+
+  // Invalidate stale async tokens synchronously during render
+  if (
+    asyncKeyRef.current.code !== code ||
+    asyncKeyRef.current.language !== language
+  ) {
+    asyncKeyRef.current = { code, language };
+    setAsyncTokens(null);
+  }
+
+  useEffect(() => {
+    let cancelled = false;
+
+    highlightCode(code, language, (result) => {
+      if (!cancelled) {
+        setAsyncTokens(result);
+      }
+    });
+
+    return () => {
+      cancelled = true;
+    };
+  }, [code, language]);
+
+  const tokenized = asyncTokens ?? syncTokens;
+
+  return (
+    <div className="relative overflow-auto">
+      <CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
+    </div>
+  );
+};
+
+export const CodeBlock = ({
+  code,
+  language,
+  showLineNumbers = false,
+  className,
+  children,
+  ...props
+}: CodeBlockProps) => {
+  const contextValue = useMemo(() => ({ code }), [code]);
+
+  return (
+    <CodeBlockContext.Provider value={contextValue}>
+      <CodeBlockContainer className={className} language={language} {...props}>
+        {children}
+        <CodeBlockContent
+          code={code}
+          language={language}
+          showLineNumbers={showLineNumbers}
+        />
+      </CodeBlockContainer>
+    </CodeBlockContext.Provider>
+  );
+};
+
+export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
+  onCopy?: () => void;
+  onError?: (error: Error) => void;
+  timeout?: number;
+};
+
+export const CodeBlockCopyButton = ({
+  onCopy,
+  onError,
+  timeout = 2000,
+  children,
+  className,
+  ...props
+}: CodeBlockCopyButtonProps) => {
+  const [isCopied, setIsCopied] = useState(false);
+  const timeoutRef = useRef<number>(0);
+  const { code } = useContext(CodeBlockContext);
+
+  const copyToClipboard = useCallback(async () => {
+    if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
+      onError?.(new Error("Clipboard API not available"));
+      return;
+    }
+
+    try {
+      if (!isCopied) {
+        await navigator.clipboard.writeText(code);
+        setIsCopied(true);
+        onCopy?.();
+        timeoutRef.current = window.setTimeout(
+          () => setIsCopied(false),
+          timeout
+        );
+      }
+    } catch (error) {
+      onError?.(error as Error);
+    }
+  }, [code, onCopy, onError, timeout, isCopied]);
+
+  useEffect(
+    () => () => {
+      window.clearTimeout(timeoutRef.current);
+    },
+    []
+  );
+
+  const Icon = isCopied ? CheckIcon : CopyIcon;
+
+  return (
+    <Button
+      className={cn("shrink-0", className)}
+      onClick={copyToClipboard}
+      size="icon"
+      variant="ghost"
+      {...props}
+    >
+      {children ?? <Icon size={14} />}
+    </Button>
+  );
+};
+
+export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
+
+export const CodeBlockLanguageSelector = (
+  props: CodeBlockLanguageSelectorProps
+) => <Select {...props} />;
+
+export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
+  typeof SelectTrigger
+>;
+
+export const CodeBlockLanguageSelectorTrigger = ({
+  className,
+  ...props
+}: CodeBlockLanguageSelectorTriggerProps) => (
+  <SelectTrigger
+    className={cn(
+      "h-7 border-none bg-transparent px-2 text-xs shadow-none",
+      className
+    )}
+    size="sm"
+    {...props}
+  />
+);
+
+export type CodeBlockLanguageSelectorValueProps = ComponentProps<
+  typeof SelectValue
+>;
+
+export const CodeBlockLanguageSelectorValue = (
+  props: CodeBlockLanguageSelectorValueProps
+) => <SelectValue {...props} />;
+
+export type CodeBlockLanguageSelectorContentProps = ComponentProps<
+  typeof SelectContent
+>;
+
+export const CodeBlockLanguageSelectorContent = ({
+  align = "end",
+  ...props
+}: CodeBlockLanguageSelectorContentProps) => (
+  <SelectContent align={align} {...props} />
+);
+
+export type CodeBlockLanguageSelectorItemProps = ComponentProps<
+  typeof SelectItem
+>;
+
+export const CodeBlockLanguageSelectorItem = (
+  props: CodeBlockLanguageSelectorItemProps
+) => <SelectItem {...props} />;

+ 174 - 0
packages/studio/src/components/ai-elements/confirmation.tsx

@@ -0,0 +1,174 @@
+"use client";
+
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import type { ToolUIPart } from "ai";
+import type { ComponentProps, ReactNode } from "react";
+import { createContext, useContext, useMemo } from "react";
+
+type ToolUIPartApproval =
+  | {
+      id: string;
+      approved?: never;
+      reason?: never;
+    }
+  | {
+      id: string;
+      approved: boolean;
+      reason?: string;
+    }
+  | {
+      id: string;
+      approved: true;
+      reason?: string;
+    }
+  | {
+      id: string;
+      approved: true;
+      reason?: string;
+    }
+  | {
+      id: string;
+      approved: false;
+      reason?: string;
+    }
+  | undefined;
+
+interface ConfirmationContextValue {
+  approval: ToolUIPartApproval;
+  state: ToolUIPart["state"];
+}
+
+const ConfirmationContext = createContext<ConfirmationContextValue | null>(
+  null
+);
+
+const useConfirmation = () => {
+  const context = useContext(ConfirmationContext);
+
+  if (!context) {
+    throw new Error("Confirmation components must be used within Confirmation");
+  }
+
+  return context;
+};
+
+export type ConfirmationProps = ComponentProps<typeof Alert> & {
+  approval?: ToolUIPartApproval;
+  state: ToolUIPart["state"];
+};
+
+export const Confirmation = ({
+  className,
+  approval,
+  state,
+  ...props
+}: ConfirmationProps) => {
+  const contextValue = useMemo(() => ({ approval, state }), [approval, state]);
+
+  if (!approval || state === "input-streaming" || state === "input-available") {
+    return null;
+  }
+
+  return (
+    <ConfirmationContext.Provider value={contextValue}>
+      <Alert className={cn("flex flex-col gap-2", className)} {...props} />
+    </ConfirmationContext.Provider>
+  );
+};
+
+export type ConfirmationTitleProps = ComponentProps<typeof AlertDescription>;
+
+export const ConfirmationTitle = ({
+  className,
+  ...props
+}: ConfirmationTitleProps) => (
+  <AlertDescription className={cn("inline", className)} {...props} />
+);
+
+export interface ConfirmationRequestProps {
+  children?: ReactNode;
+}
+
+export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => {
+  const { state } = useConfirmation();
+
+  // Only show when approval is requested
+  if (state !== "approval-requested") {
+    return null;
+  }
+
+  return children;
+};
+
+export interface ConfirmationAcceptedProps {
+  children?: ReactNode;
+}
+
+export const ConfirmationAccepted = ({
+  children,
+}: ConfirmationAcceptedProps) => {
+  const { approval, state } = useConfirmation();
+
+  // Only show when approved and in response states
+  if (
+    !approval?.approved ||
+    (state !== "approval-responded" &&
+      state !== "output-denied" &&
+      state !== "output-available")
+  ) {
+    return null;
+  }
+
+  return children;
+};
+
+export interface ConfirmationRejectedProps {
+  children?: ReactNode;
+}
+
+export const ConfirmationRejected = ({
+  children,
+}: ConfirmationRejectedProps) => {
+  const { approval, state } = useConfirmation();
+
+  // Only show when rejected and in response states
+  if (
+    approval?.approved !== false ||
+    (state !== "approval-responded" &&
+      state !== "output-denied" &&
+      state !== "output-available")
+  ) {
+    return null;
+  }
+
+  return children;
+};
+
+export type ConfirmationActionsProps = ComponentProps<"div">;
+
+export const ConfirmationActions = ({
+  className,
+  ...props
+}: ConfirmationActionsProps) => {
+  const { state } = useConfirmation();
+
+  // Only show when approval is requested
+  if (state !== "approval-requested") {
+    return null;
+  }
+
+  return (
+    <div
+      className={cn("flex items-center justify-end gap-2 self-end", className)}
+      {...props}
+    />
+  );
+};
+
+export type ConfirmationActionProps = ComponentProps<typeof Button>;
+
+export const ConfirmationAction = (props: ConfirmationActionProps) => (
+  <Button className="h-8 px-3 text-sm" type="button" {...props} />
+);

+ 360 - 0
packages/studio/src/components/ai-elements/message.tsx

@@ -0,0 +1,360 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import {
+  ButtonGroup,
+  ButtonGroupText,
+} from "@/components/ui/button-group";
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipProvider,
+  TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import { cjk } from "@streamdown/cjk";
+import { code } from "@streamdown/code";
+import { math } from "@streamdown/math";
+import { mermaid } from "@streamdown/mermaid";
+import type { UIMessage } from "ai";
+import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
+import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
+import {
+  createContext,
+  memo,
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useState,
+} from "react";
+import { Streamdown } from "streamdown";
+
+export type MessageProps = HTMLAttributes<HTMLDivElement> & {
+  from: UIMessage["role"];
+};
+
+export const Message = ({ className, from, ...props }: MessageProps) => (
+  <div
+    className={cn(
+      "group flex w-full max-w-[95%] flex-col gap-2",
+      from === "user" ? "is-user ml-auto items-end" : "is-assistant items-start",
+      className
+    )}
+    {...props}
+  />
+);
+
+export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
+
+export const MessageContent = ({
+  children,
+  className,
+  ...props
+}: MessageContentProps) => (
+  <div
+    className={cn(
+      "is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
+      "group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
+      "group-[.is-assistant]:text-foreground",
+      className
+    )}
+    {...props}
+  >
+    {children}
+  </div>
+);
+
+export type MessageActionsProps = ComponentProps<"div">;
+
+export const MessageActions = ({
+  className,
+  children,
+  ...props
+}: MessageActionsProps) => (
+  <div className={cn("flex items-center gap-1", className)} {...props}>
+    {children}
+  </div>
+);
+
+export type MessageActionProps = ComponentProps<typeof Button> & {
+  tooltip?: string;
+  label?: string;
+};
+
+export const MessageAction = ({
+  tooltip,
+  children,
+  label,
+  variant = "ghost",
+  size = "icon-sm",
+  ...props
+}: MessageActionProps) => {
+  const button = (
+    <Button size={size} type="button" variant={variant} {...props}>
+      {children}
+      <span className="sr-only">{label || tooltip}</span>
+    </Button>
+  );
+
+  if (tooltip) {
+    return (
+      <TooltipProvider>
+        <Tooltip>
+          <TooltipTrigger>{button}</TooltipTrigger>
+          <TooltipContent>
+            <p>{tooltip}</p>
+          </TooltipContent>
+        </Tooltip>
+      </TooltipProvider>
+    );
+  }
+
+  return button;
+};
+
+interface MessageBranchContextType {
+  currentBranch: number;
+  totalBranches: number;
+  goToPrevious: () => void;
+  goToNext: () => void;
+  branches: ReactElement[];
+  setBranches: (branches: ReactElement[]) => void;
+}
+
+const MessageBranchContext = createContext<MessageBranchContextType | null>(
+  null
+);
+
+const useMessageBranch = () => {
+  const context = useContext(MessageBranchContext);
+
+  if (!context) {
+    throw new Error(
+      "MessageBranch components must be used within MessageBranch"
+    );
+  }
+
+  return context;
+};
+
+export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
+  defaultBranch?: number;
+  onBranchChange?: (branchIndex: number) => void;
+};
+
+export const MessageBranch = ({
+  defaultBranch = 0,
+  onBranchChange,
+  className,
+  ...props
+}: MessageBranchProps) => {
+  const [currentBranch, setCurrentBranch] = useState(defaultBranch);
+  const [branches, setBranches] = useState<ReactElement[]>([]);
+
+  const handleBranchChange = useCallback(
+    (newBranch: number) => {
+      setCurrentBranch(newBranch);
+      onBranchChange?.(newBranch);
+    },
+    [onBranchChange]
+  );
+
+  const goToPrevious = useCallback(() => {
+    const newBranch =
+      currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
+    handleBranchChange(newBranch);
+  }, [currentBranch, branches.length, handleBranchChange]);
+
+  const goToNext = useCallback(() => {
+    const newBranch =
+      currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
+    handleBranchChange(newBranch);
+  }, [currentBranch, branches.length, handleBranchChange]);
+
+  const contextValue = useMemo<MessageBranchContextType>(
+    () => ({
+      branches,
+      currentBranch,
+      goToNext,
+      goToPrevious,
+      setBranches,
+      totalBranches: branches.length,
+    }),
+    [branches, currentBranch, goToNext, goToPrevious]
+  );
+
+  return (
+    <MessageBranchContext.Provider value={contextValue}>
+      <div
+        className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
+        {...props}
+      />
+    </MessageBranchContext.Provider>
+  );
+};
+
+export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
+
+export const MessageBranchContent = ({
+  children,
+  ...props
+}: MessageBranchContentProps) => {
+  const { currentBranch, setBranches, branches } = useMessageBranch();
+  const childrenArray = useMemo(
+    () => (Array.isArray(children) ? children : [children]),
+    [children]
+  );
+
+  // Use useEffect to update branches when they change
+  useEffect(() => {
+    if (branches.length !== childrenArray.length) {
+      setBranches(childrenArray);
+    }
+  }, [childrenArray, branches, setBranches]);
+
+  return childrenArray.map((branch, index) => (
+    <div
+      className={cn(
+        "grid gap-2 overflow-hidden [&>div]:pb-0",
+        index === currentBranch ? "block" : "hidden"
+      )}
+      key={branch.key}
+      {...props}
+    >
+      {branch}
+    </div>
+  ));
+};
+
+export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
+
+export const MessageBranchSelector = ({
+  className,
+  ...props
+}: MessageBranchSelectorProps) => {
+  const { totalBranches } = useMessageBranch();
+
+  // Don't render if there's only one branch
+  if (totalBranches <= 1) {
+    return null;
+  }
+
+  return (
+    <ButtonGroup
+      className={cn(
+        "[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
+        className
+      )}
+      orientation="horizontal"
+      {...props}
+    />
+  );
+};
+
+export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
+
+export const MessageBranchPrevious = ({
+  children,
+  ...props
+}: MessageBranchPreviousProps) => {
+  const { goToPrevious, totalBranches } = useMessageBranch();
+
+  return (
+    <Button
+      aria-label="Previous branch"
+      disabled={totalBranches <= 1}
+      onClick={goToPrevious}
+      size="icon-sm"
+      type="button"
+      variant="ghost"
+      {...props}
+    >
+      {children ?? <ChevronLeftIcon size={14} />}
+    </Button>
+  );
+};
+
+export type MessageBranchNextProps = ComponentProps<typeof Button>;
+
+export const MessageBranchNext = ({
+  children,
+  ...props
+}: MessageBranchNextProps) => {
+  const { goToNext, totalBranches } = useMessageBranch();
+
+  return (
+    <Button
+      aria-label="Next branch"
+      disabled={totalBranches <= 1}
+      onClick={goToNext}
+      size="icon-sm"
+      type="button"
+      variant="ghost"
+      {...props}
+    >
+      {children ?? <ChevronRightIcon size={14} />}
+    </Button>
+  );
+};
+
+export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
+
+export const MessageBranchPage = ({
+  className,
+  ...props
+}: MessageBranchPageProps) => {
+  const { currentBranch, totalBranches } = useMessageBranch();
+
+  return (
+    <ButtonGroupText
+      className={cn(
+        "border-none bg-transparent text-muted-foreground shadow-none",
+        className
+      )}
+      {...props}
+    >
+      {currentBranch + 1} of {totalBranches}
+    </ButtonGroupText>
+  );
+};
+
+export type MessageResponseProps = ComponentProps<typeof Streamdown>;
+
+const streamdownPlugins = { cjk, code, math, mermaid };
+
+export const MessageResponse = memo(
+  ({ className, ...props }: MessageResponseProps) => (
+    <Streamdown
+      className={cn(
+        "size-full text-[16px] leading-[1.6] font-['SimSun','Songti_SC','STSong',serif] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>p+p]:mt-4",
+        className
+      )}
+      plugins={streamdownPlugins}
+      {...props}
+    />
+  ),
+  (prevProps, nextProps) =>
+    prevProps.children === nextProps.children &&
+    nextProps.isAnimating === prevProps.isAnimating
+);
+
+MessageResponse.displayName = "MessageResponse";
+
+export type MessageToolbarProps = ComponentProps<"div">;
+
+export const MessageToolbar = ({
+  className,
+  children,
+  ...props
+}: MessageToolbarProps) => (
+  <div
+    className={cn(
+      "mt-4 flex w-full items-center justify-between gap-4",
+      className
+    )}
+    {...props}
+  >
+    {children}
+  </div>
+);

+ 1457 - 0
packages/studio/src/components/ai-elements/prompt-input.tsx

@@ -0,0 +1,1457 @@
+"use client";
+
+import {
+  Command,
+  CommandEmpty,
+  CommandGroup,
+  CommandInput,
+  CommandItem,
+  CommandList,
+  CommandSeparator,
+} from "@/components/ui/command";
+import {
+  DropdownMenu,
+  DropdownMenuContent,
+  DropdownMenuItem,
+  DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import {
+  HoverCard,
+  HoverCardContent,
+  HoverCardTrigger,
+} from "@/components/ui/hover-card";
+import {
+  InputGroup,
+  InputGroupAddon,
+  InputGroupButton,
+  InputGroupTextarea,
+} from "@/components/ui/input-group";
+import {
+  Select,
+  SelectContent,
+  SelectItem,
+  SelectTrigger,
+  SelectValue,
+} from "@/components/ui/select";
+import { Spinner } from "@/components/ui/spinner";
+import {
+  Tooltip,
+  TooltipContent,
+  TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from "ai";
+import {
+  CornerDownLeftIcon,
+  ImageIcon,
+  Monitor,
+  PlusIcon,
+  SquareIcon,
+  XIcon,
+} from "lucide-react";
+import { nanoid } from "nanoid";
+import type {
+  ChangeEvent,
+  ChangeEventHandler,
+  ClipboardEventHandler,
+  ComponentProps,
+  FormEvent,
+  FormEventHandler,
+  HTMLAttributes,
+  KeyboardEventHandler,
+  PropsWithChildren,
+  ReactNode,
+  RefObject,
+} from "react";
+import {
+  Children,
+  createContext,
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from "react";
+
+// ============================================================================
+// Helpers
+// ============================================================================
+
+const convertBlobUrlToDataUrl = async (url: string): Promise<string | null> => {
+  try {
+    const response = await fetch(url);
+    const blob = await response.blob();
+    // FileReader uses callback-based API, wrapping in Promise is necessary
+    // oxlint-disable-next-line eslint-plugin-promise(avoid-new)
+    return new Promise((resolve) => {
+      const reader = new FileReader();
+      // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
+      reader.onloadend = () => resolve(reader.result as string);
+      // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
+      reader.onerror = () => resolve(null);
+      reader.readAsDataURL(blob);
+    });
+  } catch {
+    return null;
+  }
+};
+
+const captureScreenshot = async (): Promise<File | null> => {
+  if (
+    typeof navigator === "undefined" ||
+    !navigator.mediaDevices?.getDisplayMedia
+  ) {
+    return null;
+  }
+
+  let stream: MediaStream | null = null;
+  const video = document.createElement("video");
+  video.muted = true;
+  video.playsInline = true;
+
+  try {
+    stream = await navigator.mediaDevices.getDisplayMedia({
+      audio: false,
+      video: true,
+    });
+
+    video.srcObject = stream;
+
+    // Video element uses callback-based API, wrapping in Promise is necessary
+    // oxlint-disable-next-line eslint-plugin-promise(avoid-new)
+    await new Promise<void>((resolve, reject) => {
+      // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
+      video.onloadedmetadata = () => resolve();
+      // oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
+      video.onerror = () => reject(new Error("Failed to load screen stream"));
+    });
+
+    await video.play();
+
+    const width = video.videoWidth;
+    const height = video.videoHeight;
+    if (!width || !height) {
+      return null;
+    }
+
+    const canvas = document.createElement("canvas");
+    canvas.width = width;
+    canvas.height = height;
+    const context = canvas.getContext("2d");
+    if (!context) {
+      return null;
+    }
+
+    context.drawImage(video, 0, 0, width, height);
+    // canvas.toBlob uses callback-based API, wrapping in Promise is necessary
+    // oxlint-disable-next-line eslint-plugin-promise(avoid-new)
+    const blob = await new Promise<Blob | null>((resolve) => {
+      canvas.toBlob(resolve, "image/png");
+    });
+    if (!blob) {
+      return null;
+    }
+
+    const timestamp = new Date()
+      .toISOString()
+      .replaceAll(/[:.]/g, "-")
+      .replace("T", "_")
+      .replace("Z", "");
+
+    return new File([blob], `screenshot-${timestamp}.png`, {
+      lastModified: Date.now(),
+      type: "image/png",
+    });
+  } finally {
+    if (stream) {
+      for (const track of stream.getTracks()) {
+        track.stop();
+      }
+    }
+    video.pause();
+    video.srcObject = null;
+  }
+};
+
+// ============================================================================
+// Provider Context & Types
+// ============================================================================
+
+export interface AttachmentsContext {
+  files: (FileUIPart & { id: string })[];
+  add: (files: File[] | FileList) => void;
+  remove: (id: string) => void;
+  clear: () => void;
+  openFileDialog: () => void;
+  fileInputRef: RefObject<HTMLInputElement | null>;
+}
+
+export interface TextInputContext {
+  value: string;
+  setInput: (v: string) => void;
+  clear: () => void;
+}
+
+export interface PromptInputControllerProps {
+  textInput: TextInputContext;
+  attachments: AttachmentsContext;
+  /** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
+  __registerFileInput: (
+    ref: RefObject<HTMLInputElement | null>,
+    open: () => void
+  ) => void;
+}
+
+const PromptInputController = createContext<PromptInputControllerProps | null>(
+  null
+);
+const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
+  null
+);
+
+export const usePromptInputController = () => {
+  const ctx = useContext(PromptInputController);
+  if (!ctx) {
+    throw new Error(
+      "Wrap your component inside <PromptInputProvider> to use usePromptInputController()."
+    );
+  }
+  return ctx;
+};
+
+// Optional variants (do NOT throw). Useful for dual-mode components.
+const useOptionalPromptInputController = () =>
+  useContext(PromptInputController);
+
+export const useProviderAttachments = () => {
+  const ctx = useContext(ProviderAttachmentsContext);
+  if (!ctx) {
+    throw new Error(
+      "Wrap your component inside <PromptInputProvider> to use useProviderAttachments()."
+    );
+  }
+  return ctx;
+};
+
+const useOptionalProviderAttachments = () =>
+  useContext(ProviderAttachmentsContext);
+
+export type PromptInputProviderProps = PropsWithChildren<{
+  initialInput?: string;
+}>;
+
+/**
+ * Optional global provider that lifts PromptInput state outside of PromptInput.
+ * If you don't use it, PromptInput stays fully self-managed.
+ */
+export const PromptInputProvider = ({
+  initialInput: initialTextInput = "",
+  children,
+}: PromptInputProviderProps) => {
+  // ----- textInput state
+  const [textInput, setTextInput] = useState(initialTextInput);
+  const clearInput = useCallback(() => setTextInput(""), []);
+
+  // ----- attachments state (global when wrapped)
+  const [attachmentFiles, setAttachmentFiles] = useState<
+    (FileUIPart & { id: string })[]
+  >([]);
+  const fileInputRef = useRef<HTMLInputElement | null>(null);
+  // oxlint-disable-next-line eslint(no-empty-function)
+  const openRef = useRef<() => void>(() => {});
+
+  const add = useCallback((files: File[] | FileList) => {
+    const incoming = [...files];
+    if (incoming.length === 0) {
+      return;
+    }
+
+    setAttachmentFiles((prev) => [
+      ...prev,
+      ...incoming.map((file) => ({
+        filename: file.name,
+        id: nanoid(),
+        mediaType: file.type,
+        type: "file" as const,
+        url: URL.createObjectURL(file),
+      })),
+    ]);
+  }, []);
+
+  const remove = useCallback((id: string) => {
+    setAttachmentFiles((prev) => {
+      const found = prev.find((f) => f.id === id);
+      if (found?.url) {
+        URL.revokeObjectURL(found.url);
+      }
+      return prev.filter((f) => f.id !== id);
+    });
+  }, []);
+
+  const clear = useCallback(() => {
+    setAttachmentFiles((prev) => {
+      for (const f of prev) {
+        if (f.url) {
+          URL.revokeObjectURL(f.url);
+        }
+      }
+      return [];
+    });
+  }, []);
+
+  // Keep a ref to attachments for cleanup on unmount (avoids stale closure)
+  const attachmentsRef = useRef(attachmentFiles);
+
+  useEffect(() => {
+    attachmentsRef.current = attachmentFiles;
+  }, [attachmentFiles]);
+
+  // Cleanup blob URLs on unmount to prevent memory leaks
+  useEffect(
+    () => () => {
+      for (const f of attachmentsRef.current) {
+        if (f.url) {
+          URL.revokeObjectURL(f.url);
+        }
+      }
+    },
+    []
+  );
+
+  const openFileDialog = useCallback(() => {
+    openRef.current?.();
+  }, []);
+
+  const attachments = useMemo<AttachmentsContext>(
+    () => ({
+      add,
+      clear,
+      fileInputRef,
+      files: attachmentFiles,
+      openFileDialog,
+      remove,
+    }),
+    [attachmentFiles, add, remove, clear, openFileDialog]
+  );
+
+  const __registerFileInput = useCallback(
+    (ref: RefObject<HTMLInputElement | null>, open: () => void) => {
+      fileInputRef.current = ref.current;
+      openRef.current = open;
+    },
+    []
+  );
+
+  const controller = useMemo<PromptInputControllerProps>(
+    () => ({
+      __registerFileInput,
+      attachments,
+      textInput: {
+        clear: clearInput,
+        setInput: setTextInput,
+        value: textInput,
+      },
+    }),
+    [textInput, clearInput, attachments, __registerFileInput]
+  );
+
+  return (
+    <PromptInputController.Provider value={controller}>
+      <ProviderAttachmentsContext.Provider value={attachments}>
+        {children}
+      </ProviderAttachmentsContext.Provider>
+    </PromptInputController.Provider>
+  );
+};
+
+// ============================================================================
+// Component Context & Hooks
+// ============================================================================
+
+const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);
+
+export const usePromptInputAttachments = () => {
+  // Prefer local context (inside PromptInput) as it has validation, fall back to provider
+  const provider = useOptionalProviderAttachments();
+  const local = useContext(LocalAttachmentsContext);
+  const context = local ?? provider;
+  if (!context) {
+    throw new Error(
+      "usePromptInputAttachments must be used within a PromptInput or PromptInputProvider"
+    );
+  }
+  return context;
+};
+
+// ============================================================================
+// Referenced Sources (Local to PromptInput)
+// ============================================================================
+
+export interface ReferencedSourcesContext {
+  sources: (SourceDocumentUIPart & { id: string })[];
+  add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void;
+  remove: (id: string) => void;
+  clear: () => void;
+}
+
+export const LocalReferencedSourcesContext =
+  createContext<ReferencedSourcesContext | null>(null);
+
+export const usePromptInputReferencedSources = () => {
+  const ctx = useContext(LocalReferencedSourcesContext);
+  if (!ctx) {
+    throw new Error(
+      "usePromptInputReferencedSources must be used within a LocalReferencedSourcesContext.Provider"
+    );
+  }
+  return ctx;
+};
+
+export type PromptInputActionAddAttachmentsProps = ComponentProps<
+  typeof DropdownMenuItem
+> & {
+  label?: string;
+};
+
+export const PromptInputActionAddAttachments = ({
+  label = "Add photos or files",
+  ...props
+}: PromptInputActionAddAttachmentsProps) => {
+  const attachments = usePromptInputAttachments();
+
+  const handleSelect = useCallback(
+    (e: { preventDefault: () => void }) => {
+      e.preventDefault();
+      attachments.openFileDialog();
+    },
+    [attachments]
+  );
+
+  return (
+    <DropdownMenuItem {...props} onSelect={handleSelect as any}>
+      <ImageIcon className="mr-2 size-4" /> {label}
+    </DropdownMenuItem>
+  );
+};
+
+export type PromptInputActionAddScreenshotProps = ComponentProps<
+  typeof DropdownMenuItem
+> & {
+  label?: string;
+};
+
+export const PromptInputActionAddScreenshot = ({
+  label = "Take screenshot",
+  onSelect,
+  ...props
+}: PromptInputActionAddScreenshotProps) => {
+  const attachments = usePromptInputAttachments();
+
+  const handleSelect = useCallback(
+    async (event: { preventDefault: () => void; defaultPrevented: boolean }) => {
+      onSelect?.(event as any);
+      if (event.defaultPrevented) {
+        return;
+      }
+
+      try {
+        const screenshot = await captureScreenshot();
+        if (screenshot) {
+          attachments.add([screenshot]);
+        }
+      } catch (error) {
+        if (
+          error instanceof DOMException &&
+          (error.name === "NotAllowedError" || error.name === "AbortError")
+        ) {
+          return;
+        }
+        throw error;
+      }
+    },
+    [onSelect, attachments]
+  );
+
+  return (
+    <DropdownMenuItem {...props} onSelect={handleSelect as any}>
+      <Monitor className="mr-2 size-4" />
+      {label}
+    </DropdownMenuItem>
+  );
+};
+
+export interface PromptInputMessage {
+  text: string;
+  files: FileUIPart[];
+}
+
+export type PromptInputProps = Omit<
+  HTMLAttributes<HTMLFormElement>,
+  "onSubmit" | "onError"
+> & {
+  // e.g., "image/*" or leave undefined for any
+  accept?: string;
+  multiple?: boolean;
+  // When true, accepts drops anywhere on document. Default false (opt-in).
+  globalDrop?: boolean;
+  // Render a hidden input with given name and keep it in sync for native form posts. Default false.
+  syncHiddenInput?: boolean;
+  // Minimal constraints
+  maxFiles?: number;
+  // bytes
+  maxFileSize?: number;
+  onError?: (err: {
+    code: "max_files" | "max_file_size" | "accept";
+    message: string;
+  }) => void;
+  onSubmit: (
+    message: PromptInputMessage,
+    event: FormEvent<HTMLFormElement>
+  ) => void | Promise<void>;
+};
+
+export const PromptInput = ({
+  className,
+  accept,
+  multiple,
+  globalDrop,
+  syncHiddenInput,
+  maxFiles,
+  maxFileSize,
+  onError,
+  onSubmit,
+  children,
+  ...props
+}: PromptInputProps) => {
+  // Try to use a provider controller if present
+  const controller = useOptionalPromptInputController();
+  const usingProvider = !!controller;
+
+  // Refs
+  const inputRef = useRef<HTMLInputElement | null>(null);
+  const formRef = useRef<HTMLFormElement | null>(null);
+
+  // ----- Local attachments (only used when no provider)
+  const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
+  const files = usingProvider ? controller.attachments.files : items;
+
+  // ----- Local referenced sources (always local to PromptInput)
+  const [referencedSources, setReferencedSources] = useState<
+    (SourceDocumentUIPart & { id: string })[]
+  >([]);
+
+  // Keep a ref to files for cleanup on unmount (avoids stale closure)
+  const filesRef = useRef(files);
+
+  useEffect(() => {
+    filesRef.current = files;
+  }, [files]);
+
+  const openFileDialogLocal = useCallback(() => {
+    inputRef.current?.click();
+  }, []);
+
+  const matchesAccept = useCallback(
+    (f: File) => {
+      if (!accept || accept.trim() === "") {
+        return true;
+      }
+
+      const patterns = accept
+        .split(",")
+        .map((s) => s.trim())
+        .filter(Boolean);
+
+      return patterns.some((pattern) => {
+        if (pattern.endsWith("/*")) {
+          // e.g: image/* -> image/
+          const prefix = pattern.slice(0, -1);
+          return f.type.startsWith(prefix);
+        }
+        return f.type === pattern;
+      });
+    },
+    [accept]
+  );
+
+  const addLocal = useCallback(
+    (fileList: File[] | FileList) => {
+      const incoming = [...fileList];
+      const accepted = incoming.filter((f) => matchesAccept(f));
+      if (incoming.length && accepted.length === 0) {
+        onError?.({
+          code: "accept",
+          message: "No files match the accepted types.",
+        });
+        return;
+      }
+      const withinSize = (f: File) =>
+        maxFileSize ? f.size <= maxFileSize : true;
+      const sized = accepted.filter(withinSize);
+      if (accepted.length > 0 && sized.length === 0) {
+        onError?.({
+          code: "max_file_size",
+          message: "All files exceed the maximum size.",
+        });
+        return;
+      }
+
+      setItems((prev) => {
+        const capacity =
+          typeof maxFiles === "number"
+            ? Math.max(0, maxFiles - prev.length)
+            : undefined;
+        const capped =
+          typeof capacity === "number" ? sized.slice(0, capacity) : sized;
+        if (typeof capacity === "number" && sized.length > capacity) {
+          onError?.({
+            code: "max_files",
+            message: "Too many files. Some were not added.",
+          });
+        }
+        const next: (FileUIPart & { id: string })[] = [];
+        for (const file of capped) {
+          next.push({
+            filename: file.name,
+            id: nanoid(),
+            mediaType: file.type,
+            type: "file",
+            url: URL.createObjectURL(file),
+          });
+        }
+        return [...prev, ...next];
+      });
+    },
+    [matchesAccept, maxFiles, maxFileSize, onError]
+  );
+
+  const removeLocal = useCallback(
+    (id: string) =>
+      setItems((prev) => {
+        const found = prev.find((file) => file.id === id);
+        if (found?.url) {
+          URL.revokeObjectURL(found.url);
+        }
+        return prev.filter((file) => file.id !== id);
+      }),
+    []
+  );
+
+  // Wrapper that validates files before calling provider's add
+  const addWithProviderValidation = useCallback(
+    (fileList: File[] | FileList) => {
+      const incoming = [...fileList];
+      const accepted = incoming.filter((f) => matchesAccept(f));
+      if (incoming.length && accepted.length === 0) {
+        onError?.({
+          code: "accept",
+          message: "No files match the accepted types.",
+        });
+        return;
+      }
+      const withinSize = (f: File) =>
+        maxFileSize ? f.size <= maxFileSize : true;
+      const sized = accepted.filter(withinSize);
+      if (accepted.length > 0 && sized.length === 0) {
+        onError?.({
+          code: "max_file_size",
+          message: "All files exceed the maximum size.",
+        });
+        return;
+      }
+
+      const currentCount = files.length;
+      const capacity =
+        typeof maxFiles === "number"
+          ? Math.max(0, maxFiles - currentCount)
+          : undefined;
+      const capped =
+        typeof capacity === "number" ? sized.slice(0, capacity) : sized;
+      if (typeof capacity === "number" && sized.length > capacity) {
+        onError?.({
+          code: "max_files",
+          message: "Too many files. Some were not added.",
+        });
+      }
+
+      if (capped.length > 0) {
+        controller?.attachments.add(capped);
+      }
+    },
+    [matchesAccept, maxFileSize, maxFiles, onError, files.length, controller]
+  );
+
+  const clearAttachments = useCallback(
+    () =>
+      usingProvider
+        ? controller?.attachments.clear()
+        : setItems((prev) => {
+            for (const file of prev) {
+              if (file.url) {
+                URL.revokeObjectURL(file.url);
+              }
+            }
+            return [];
+          }),
+    [usingProvider, controller]
+  );
+
+  const clearReferencedSources = useCallback(
+    () => setReferencedSources([]),
+    []
+  );
+
+  const add = usingProvider ? addWithProviderValidation : addLocal;
+  const remove = usingProvider ? controller.attachments.remove : removeLocal;
+  const openFileDialog = usingProvider
+    ? controller.attachments.openFileDialog
+    : openFileDialogLocal;
+
+  const clear = useCallback(() => {
+    clearAttachments();
+    clearReferencedSources();
+  }, [clearAttachments, clearReferencedSources]);
+
+  // Let provider know about our hidden file input so external menus can call openFileDialog()
+  useEffect(() => {
+    if (!usingProvider) {
+      return;
+    }
+    controller.__registerFileInput(inputRef, () => inputRef.current?.click());
+  }, [usingProvider, controller]);
+
+  // Note: File input cannot be programmatically set for security reasons
+  // The syncHiddenInput prop is no longer functional
+  useEffect(() => {
+    if (syncHiddenInput && inputRef.current && files.length === 0) {
+      inputRef.current.value = "";
+    }
+  }, [files, syncHiddenInput]);
+
+  // Attach drop handlers on nearest form and document (opt-in)
+  useEffect(() => {
+    const form = formRef.current;
+    if (!form) {
+      return;
+    }
+    if (globalDrop) {
+      // when global drop is on, let the document-level handler own drops
+      return;
+    }
+
+    const onDragOver = (e: DragEvent) => {
+      if (e.dataTransfer?.types?.includes("Files")) {
+        e.preventDefault();
+      }
+    };
+    const onDrop = (e: DragEvent) => {
+      if (e.dataTransfer?.types?.includes("Files")) {
+        e.preventDefault();
+      }
+      if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
+        add(e.dataTransfer.files);
+      }
+    };
+    form.addEventListener("dragover", onDragOver);
+    form.addEventListener("drop", onDrop);
+    return () => {
+      form.removeEventListener("dragover", onDragOver);
+      form.removeEventListener("drop", onDrop);
+    };
+  }, [add, globalDrop]);
+
+  useEffect(() => {
+    if (!globalDrop) {
+      return;
+    }
+
+    const onDragOver = (e: DragEvent) => {
+      if (e.dataTransfer?.types?.includes("Files")) {
+        e.preventDefault();
+      }
+    };
+    const onDrop = (e: DragEvent) => {
+      if (e.dataTransfer?.types?.includes("Files")) {
+        e.preventDefault();
+      }
+      if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
+        add(e.dataTransfer.files);
+      }
+    };
+    document.addEventListener("dragover", onDragOver);
+    document.addEventListener("drop", onDrop);
+    return () => {
+      document.removeEventListener("dragover", onDragOver);
+      document.removeEventListener("drop", onDrop);
+    };
+  }, [add, globalDrop]);
+
+  useEffect(
+    () => () => {
+      if (!usingProvider) {
+        for (const f of filesRef.current) {
+          if (f.url) {
+            URL.revokeObjectURL(f.url);
+          }
+        }
+      }
+    },
+    [usingProvider]
+  );
+
+  const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
+    (event) => {
+      if (event.currentTarget.files) {
+        add(event.currentTarget.files);
+      }
+      // Reset input value to allow selecting files that were previously removed
+      event.currentTarget.value = "";
+    },
+    [add]
+  );
+
+  const attachmentsCtx = useMemo<AttachmentsContext>(
+    () => ({
+      add,
+      clear: clearAttachments,
+      fileInputRef: inputRef,
+      files: files.map((item) => ({ ...item, id: item.id })),
+      openFileDialog,
+      remove,
+    }),
+    [files, add, remove, clearAttachments, openFileDialog]
+  );
+
+  const refsCtx = useMemo<ReferencedSourcesContext>(
+    () => ({
+      add: (incoming: SourceDocumentUIPart[] | SourceDocumentUIPart) => {
+        const array = Array.isArray(incoming) ? incoming : [incoming];
+        setReferencedSources((prev) => [
+          ...prev,
+          ...array.map((s) => ({ ...s, id: nanoid() })),
+        ]);
+      },
+      clear: clearReferencedSources,
+      remove: (id: string) => {
+        setReferencedSources((prev) => prev.filter((s) => s.id !== id));
+      },
+      sources: referencedSources,
+    }),
+    [referencedSources, clearReferencedSources]
+  );
+
+  const handleSubmit: FormEventHandler<HTMLFormElement> = useCallback(
+    async (event) => {
+      event.preventDefault();
+
+      const form = event.currentTarget;
+      const text = usingProvider
+        ? controller.textInput.value
+        : (() => {
+            const formData = new FormData(form);
+            return (formData.get("message") as string) || "";
+          })();
+
+      // Reset form immediately after capturing text to avoid race condition
+      // where user input during async blob conversion would be lost
+      if (!usingProvider) {
+        form.reset();
+      }
+
+      try {
+        // Convert blob URLs to data URLs asynchronously
+        const convertedFiles: FileUIPart[] = await Promise.all(
+          files.map(async ({ id: _id, ...item }) => {
+            if (item.url?.startsWith("blob:")) {
+              const dataUrl = await convertBlobUrlToDataUrl(item.url);
+              // If conversion failed, keep the original blob URL
+              return {
+                ...item,
+                url: dataUrl ?? item.url,
+              };
+            }
+            return item;
+          })
+        );
+
+        const result = onSubmit({ files: convertedFiles, text }, event);
+
+        // Handle both sync and async onSubmit
+        if (result instanceof Promise) {
+          try {
+            await result;
+            clear();
+            if (usingProvider) {
+              controller.textInput.clear();
+            }
+          } catch {
+            // Don't clear on error - user may want to retry
+          }
+        } else {
+          // Sync function completed without throwing, clear inputs
+          clear();
+          if (usingProvider) {
+            controller.textInput.clear();
+          }
+        }
+      } catch {
+        // Don't clear on error - user may want to retry
+      }
+    },
+    [usingProvider, controller, files, onSubmit, clear]
+  );
+
+  // Render with or without local provider
+  const inner = (
+    <>
+      <input
+        accept={accept}
+        aria-label="Upload files"
+        className="hidden"
+        multiple={multiple}
+        onChange={handleChange}
+        ref={inputRef}
+        title="Upload files"
+        type="file"
+      />
+      <form
+        className={cn("w-full", className)}
+        onSubmit={handleSubmit}
+        ref={formRef}
+        {...props}
+      >
+        <InputGroup className="overflow-hidden">{children}</InputGroup>
+      </form>
+    </>
+  );
+
+  const withReferencedSources = (
+    <LocalReferencedSourcesContext.Provider value={refsCtx}>
+      {inner}
+    </LocalReferencedSourcesContext.Provider>
+  );
+
+  // Always provide LocalAttachmentsContext so children get validated add function
+  return (
+    <LocalAttachmentsContext.Provider value={attachmentsCtx}>
+      {withReferencedSources}
+    </LocalAttachmentsContext.Provider>
+  );
+};
+
+export type PromptInputBodyProps = HTMLAttributes<HTMLDivElement>;
+
+export const PromptInputBody = ({
+  className,
+  ...props
+}: PromptInputBodyProps) => (
+  <div className={cn("contents", className)} {...props} />
+);
+
+export type PromptInputTextareaProps = ComponentProps<
+  typeof InputGroupTextarea
+>;
+
+export const PromptInputTextarea = ({
+  onChange,
+  onKeyDown,
+  className,
+  placeholder = "What would you like to know?",
+  ...props
+}: PromptInputTextareaProps) => {
+  const controller = useOptionalPromptInputController();
+  const attachments = usePromptInputAttachments();
+  const [isComposing, setIsComposing] = useState(false);
+
+  const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = useCallback(
+    (e) => {
+      // Call the external onKeyDown handler first
+      onKeyDown?.(e);
+
+      // If the external handler prevented default, don't run internal logic
+      if (e.defaultPrevented) {
+        return;
+      }
+
+      if (e.key === "Enter") {
+        if (isComposing || e.nativeEvent.isComposing) {
+          return;
+        }
+        if (e.shiftKey) {
+          return;
+        }
+        e.preventDefault();
+
+        // Check if the submit button is disabled before submitting
+        const { form } = e.currentTarget;
+        const submitButton = form?.querySelector(
+          'button[type="submit"]'
+        ) as HTMLButtonElement | null;
+        if (submitButton?.disabled) {
+          return;
+        }
+
+        form?.requestSubmit();
+      }
+
+      // Remove last attachment when Backspace is pressed and textarea is empty
+      if (
+        e.key === "Backspace" &&
+        e.currentTarget.value === "" &&
+        attachments.files.length > 0
+      ) {
+        e.preventDefault();
+        const lastAttachment = attachments.files.at(-1);
+        if (lastAttachment) {
+          attachments.remove(lastAttachment.id);
+        }
+      }
+    },
+    [onKeyDown, isComposing, attachments]
+  );
+
+  const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = useCallback(
+    (event) => {
+      const items = event.clipboardData?.items;
+
+      if (!items) {
+        return;
+      }
+
+      const files: File[] = [];
+
+      for (const item of items) {
+        if (item.kind === "file") {
+          const file = item.getAsFile();
+          if (file) {
+            files.push(file);
+          }
+        }
+      }
+
+      if (files.length > 0) {
+        event.preventDefault();
+        attachments.add(files);
+      }
+    },
+    [attachments]
+  );
+
+  const handleCompositionEnd = useCallback(() => setIsComposing(false), []);
+  const handleCompositionStart = useCallback(() => setIsComposing(true), []);
+
+  const controlledProps = controller
+    ? {
+        onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {
+          controller.textInput.setInput(e.currentTarget.value);
+          onChange?.(e);
+        },
+        value: controller.textInput.value,
+      }
+    : {
+        onChange,
+      };
+
+  return (
+    <InputGroupTextarea
+      className={cn("field-sizing-content max-h-48 min-h-16", className)}
+      name="message"
+      onCompositionEnd={handleCompositionEnd}
+      onCompositionStart={handleCompositionStart}
+      onKeyDown={handleKeyDown}
+      onPaste={handlePaste}
+      placeholder={placeholder}
+      {...props}
+      {...controlledProps}
+    />
+  );
+};
+
+export type PromptInputHeaderProps = Omit<
+  ComponentProps<typeof InputGroupAddon>,
+  "align"
+>;
+
+export const PromptInputHeader = ({
+  className,
+  ...props
+}: PromptInputHeaderProps) => (
+  <InputGroupAddon
+    align="block-end"
+    className={cn("order-first flex-wrap gap-1", className)}
+    {...props}
+  />
+);
+
+export type PromptInputFooterProps = Omit<
+  ComponentProps<typeof InputGroupAddon>,
+  "align"
+>;
+
+export const PromptInputFooter = ({
+  className,
+  ...props
+}: PromptInputFooterProps) => (
+  <InputGroupAddon
+    align="block-end"
+    className={cn("justify-between gap-1", className)}
+    {...props}
+  />
+);
+
+export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
+
+export const PromptInputTools = ({
+  className,
+  ...props
+}: PromptInputToolsProps) => (
+  <div
+    className={cn("flex min-w-0 items-center gap-1", className)}
+    {...props}
+  />
+);
+
+export type PromptInputButtonTooltip =
+  | string
+  | {
+      content: ReactNode;
+      shortcut?: string;
+      side?: ComponentProps<typeof TooltipContent>["side"];
+    };
+
+export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton> & {
+  tooltip?: PromptInputButtonTooltip;
+};
+
+export const PromptInputButton = ({
+  variant = "ghost",
+  className,
+  size,
+  tooltip,
+  ...props
+}: PromptInputButtonProps) => {
+  const newSize =
+    size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm");
+
+  const button = (
+    <InputGroupButton
+      className={cn(className)}
+      size={newSize}
+      type="button"
+      variant={variant}
+      {...props}
+    />
+  );
+
+  if (!tooltip) {
+    return button;
+  }
+
+  const tooltipContent =
+    typeof tooltip === "string" ? tooltip : tooltip.content;
+  const shortcut = typeof tooltip === "string" ? undefined : tooltip.shortcut;
+  const side = typeof tooltip === "string" ? "top" : (tooltip.side ?? "top");
+
+  return (
+    <Tooltip>
+      <TooltipTrigger>{button}</TooltipTrigger>
+      <TooltipContent side={side}>
+        {tooltipContent}
+        {shortcut && (
+          <span className="ml-2 text-muted-foreground">{shortcut}</span>
+        )}
+      </TooltipContent>
+    </Tooltip>
+  );
+};
+
+export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;
+export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
+  <DropdownMenu {...props} />
+);
+
+export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
+
+export const PromptInputActionMenuTrigger = ({
+  className,
+  children,
+  ...props
+}: PromptInputActionMenuTriggerProps) => (
+  <DropdownMenuTrigger render={<PromptInputButton className={className} {...props} />}>{children ?? <PlusIcon className="size-4" />}</DropdownMenuTrigger>
+);
+
+export type PromptInputActionMenuContentProps = ComponentProps<
+  typeof DropdownMenuContent
+>;
+export const PromptInputActionMenuContent = ({
+  className,
+  ...props
+}: PromptInputActionMenuContentProps) => (
+  <DropdownMenuContent align="start" className={cn(className)} {...props} />
+);
+
+export type PromptInputActionMenuItemProps = ComponentProps<
+  typeof DropdownMenuItem
+>;
+export const PromptInputActionMenuItem = ({
+  className,
+  ...props
+}: PromptInputActionMenuItemProps) => (
+  <DropdownMenuItem className={cn(className)} {...props} />
+);
+
+// Note: Actions that perform side-effects (like opening a file dialog)
+// are provided in opt-in modules (e.g., prompt-input-attachments).
+
+export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
+  status?: ChatStatus;
+  onStop?: () => void;
+};
+
+export const PromptInputSubmit = ({
+  className,
+  variant = "default",
+  size = "icon-sm",
+  status,
+  onStop,
+  onClick,
+  children,
+  ...props
+}: PromptInputSubmitProps) => {
+  const isGenerating = status === "submitted" || status === "streaming";
+
+  let Icon = <CornerDownLeftIcon className="size-4" />;
+
+  if (status === "submitted") {
+    Icon = <Spinner />;
+  } else if (status === "streaming") {
+    Icon = <SquareIcon className="size-4" />;
+  } else if (status === "error") {
+    Icon = <XIcon className="size-4" />;
+  }
+
+  const handleClick = useCallback(
+    (e: React.MouseEvent<HTMLButtonElement>) => {
+      if (isGenerating && onStop) {
+        e.preventDefault();
+        onStop();
+        return;
+      }
+      onClick?.(e as any);
+    },
+    [isGenerating, onStop, onClick]
+  );
+
+  return (
+    <InputGroupButton
+      aria-label={isGenerating ? "Stop" : "Submit"}
+      className={cn(className)}
+      onClick={handleClick}
+      size={size}
+      type={isGenerating && onStop ? "button" : "submit"}
+      variant={variant}
+      {...props}
+    >
+      {children ?? Icon}
+    </InputGroupButton>
+  );
+};
+
+export type PromptInputSelectProps = ComponentProps<typeof Select>;
+
+export const PromptInputSelect = (props: PromptInputSelectProps) => (
+  <Select {...props} />
+);
+
+export type PromptInputSelectTriggerProps = ComponentProps<
+  typeof SelectTrigger
+>;
+
+export const PromptInputSelectTrigger = ({
+  className,
+  ...props
+}: PromptInputSelectTriggerProps) => (
+  <SelectTrigger
+    className={cn(
+      "border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors",
+      "hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground",
+      className
+    )}
+    {...props}
+  />
+);
+
+export type PromptInputSelectContentProps = ComponentProps<
+  typeof SelectContent
+>;
+
+export const PromptInputSelectContent = ({
+  className,
+  ...props
+}: PromptInputSelectContentProps) => (
+  <SelectContent className={cn(className)} {...props} />
+);
+
+export type PromptInputSelectItemProps = ComponentProps<typeof SelectItem>;
+
+export const PromptInputSelectItem = ({
+  className,
+  ...props
+}: PromptInputSelectItemProps) => (
+  <SelectItem className={cn(className)} {...props} />
+);
+
+export type PromptInputSelectValueProps = ComponentProps<typeof SelectValue>;
+
+export const PromptInputSelectValue = ({
+  className,
+  ...props
+}: PromptInputSelectValueProps) => (
+  <SelectValue className={cn(className)} {...props} />
+);
+
+export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>;
+
+export const PromptInputHoverCard = ({
+  ...props
+}: PromptInputHoverCardProps) => (
+  <HoverCard {...props} />
+);
+
+export type PromptInputHoverCardTriggerProps = ComponentProps<
+  typeof HoverCardTrigger
+>;
+
+export const PromptInputHoverCardTrigger = (
+  props: PromptInputHoverCardTriggerProps
+) => <HoverCardTrigger {...props} />;
+
+export type PromptInputHoverCardContentProps = ComponentProps<
+  typeof HoverCardContent
+>;
+
+export const PromptInputHoverCardContent = ({
+  align = "start",
+  ...props
+}: PromptInputHoverCardContentProps) => (
+  <HoverCardContent align={align} {...props} />
+);
+
+export type PromptInputTabsListProps = HTMLAttributes<HTMLDivElement>;
+
+export const PromptInputTabsList = ({
+  className,
+  ...props
+}: PromptInputTabsListProps) => <div className={cn(className)} {...props} />;
+
+export type PromptInputTabProps = HTMLAttributes<HTMLDivElement>;
+
+export const PromptInputTab = ({
+  className,
+  ...props
+}: PromptInputTabProps) => <div className={cn(className)} {...props} />;
+
+export type PromptInputTabLabelProps = HTMLAttributes<HTMLHeadingElement>;
+
+export const PromptInputTabLabel = ({
+  className,
+  ...props
+}: PromptInputTabLabelProps) => (
+  // Content provided via children in props
+  // oxlint-disable-next-line eslint-plugin-jsx-a11y(heading-has-content)
+  <h3
+    className={cn(
+      "mb-2 px-3 font-medium text-muted-foreground text-xs",
+      className
+    )}
+    {...props}
+  />
+);
+
+export type PromptInputTabBodyProps = HTMLAttributes<HTMLDivElement>;
+
+export const PromptInputTabBody = ({
+  className,
+  ...props
+}: PromptInputTabBodyProps) => (
+  <div className={cn("space-y-1", className)} {...props} />
+);
+
+export type PromptInputTabItemProps = HTMLAttributes<HTMLDivElement>;
+
+export const PromptInputTabItem = ({
+  className,
+  ...props
+}: PromptInputTabItemProps) => (
+  <div
+    className={cn(
+      "flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent",
+      className
+    )}
+    {...props}
+  />
+);
+
+export type PromptInputCommandProps = ComponentProps<typeof Command>;
+
+export const PromptInputCommand = ({
+  className,
+  ...props
+}: PromptInputCommandProps) => <Command className={cn(className)} {...props} />;
+
+export type PromptInputCommandInputProps = ComponentProps<typeof CommandInput>;
+
+export const PromptInputCommandInput = ({
+  className,
+  ...props
+}: PromptInputCommandInputProps) => (
+  <CommandInput className={cn(className)} {...props} />
+);
+
+export type PromptInputCommandListProps = ComponentProps<typeof CommandList>;
+
+export const PromptInputCommandList = ({
+  className,
+  ...props
+}: PromptInputCommandListProps) => (
+  <CommandList className={cn(className)} {...props} />
+);
+
+export type PromptInputCommandEmptyProps = ComponentProps<typeof CommandEmpty>;
+
+export const PromptInputCommandEmpty = ({
+  className,
+  ...props
+}: PromptInputCommandEmptyProps) => (
+  <CommandEmpty className={cn(className)} {...props} />
+);
+
+export type PromptInputCommandGroupProps = ComponentProps<typeof CommandGroup>;
+
+export const PromptInputCommandGroup = ({
+  className,
+  ...props
+}: PromptInputCommandGroupProps) => (
+  <CommandGroup className={cn(className)} {...props} />
+);
+
+export type PromptInputCommandItemProps = ComponentProps<typeof CommandItem>;
+
+export const PromptInputCommandItem = ({
+  className,
+  ...props
+}: PromptInputCommandItemProps) => (
+  <CommandItem className={cn(className)} {...props} />
+);
+
+export type PromptInputCommandSeparatorProps = ComponentProps<
+  typeof CommandSeparator
+>;
+
+export const PromptInputCommandSeparator = ({
+  className,
+  ...props
+}: PromptInputCommandSeparatorProps) => (
+  <CommandSeparator className={cn(className)} {...props} />
+);

+ 226 - 0
packages/studio/src/components/ai-elements/reasoning.tsx

@@ -0,0 +1,226 @@
+"use client";
+
+import { useControllableState } from "@radix-ui/react-use-controllable-state";
+import {
+  Collapsible,
+  CollapsibleContent,
+  CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { cn } from "@/lib/utils";
+import { cjk } from "@streamdown/cjk";
+import { code } from "@streamdown/code";
+import { math } from "@streamdown/math";
+import { mermaid } from "@streamdown/mermaid";
+import { BrainIcon, ChevronDownIcon } from "lucide-react";
+import type { ComponentProps, ReactNode } from "react";
+import {
+  createContext,
+  memo,
+  useCallback,
+  useContext,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from "react";
+import { Streamdown } from "streamdown";
+
+import { Shimmer } from "./shimmer";
+
+interface ReasoningContextValue {
+  isStreaming: boolean;
+  isOpen: boolean;
+  setIsOpen: (open: boolean) => void;
+  duration: number | undefined;
+}
+
+const ReasoningContext = createContext<ReasoningContextValue | null>(null);
+
+export const useReasoning = () => {
+  const context = useContext(ReasoningContext);
+  if (!context) {
+    throw new Error("Reasoning components must be used within Reasoning");
+  }
+  return context;
+};
+
+export type ReasoningProps = ComponentProps<typeof Collapsible> & {
+  isStreaming?: boolean;
+  open?: boolean;
+  defaultOpen?: boolean;
+  onOpenChange?: (open: boolean) => void;
+  duration?: number;
+};
+
+const AUTO_CLOSE_DELAY = 1000;
+const MS_IN_S = 1000;
+
+export const Reasoning = memo(
+  ({
+    className,
+    isStreaming = false,
+    open,
+    defaultOpen,
+    onOpenChange,
+    duration: durationProp,
+    children,
+    ...props
+  }: ReasoningProps) => {
+    const resolvedDefaultOpen = defaultOpen ?? isStreaming;
+    // Track if defaultOpen was explicitly set to false (to prevent auto-open)
+    const isExplicitlyClosed = defaultOpen === false;
+
+    const [isOpen, setIsOpen] = useControllableState<boolean>({
+      defaultProp: resolvedDefaultOpen,
+      onChange: onOpenChange,
+      prop: open,
+    });
+    const [duration, setDuration] = useControllableState<number | undefined>({
+      defaultProp: undefined,
+      prop: durationProp,
+    });
+
+    const hasEverStreamedRef = useRef(isStreaming);
+    const [hasAutoClosed, setHasAutoClosed] = useState(false);
+    const startTimeRef = useRef<number | null>(null);
+
+    // Track when streaming starts and compute duration
+    useEffect(() => {
+      if (isStreaming) {
+        hasEverStreamedRef.current = true;
+        if (startTimeRef.current === null) {
+          startTimeRef.current = Date.now();
+        }
+      } else if (startTimeRef.current !== null) {
+        setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
+        startTimeRef.current = null;
+      }
+    }, [isStreaming, setDuration]);
+
+    // Auto-open when streaming starts (unless explicitly closed)
+    useEffect(() => {
+      if (isStreaming && !isOpen && !isExplicitlyClosed) {
+        setIsOpen(true);
+      }
+    }, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
+
+    // Auto-close when streaming ends (once only, and only if it ever streamed)
+    useEffect(() => {
+      if (
+        hasEverStreamedRef.current &&
+        !isStreaming &&
+        isOpen &&
+        !hasAutoClosed
+      ) {
+        const timer = setTimeout(() => {
+          setIsOpen(false);
+          setHasAutoClosed(true);
+        }, AUTO_CLOSE_DELAY);
+
+        return () => clearTimeout(timer);
+      }
+    }, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
+
+    const handleOpenChange = useCallback(
+      (newOpen: boolean) => {
+        setIsOpen(newOpen);
+      },
+      [setIsOpen]
+    );
+
+    const contextValue = useMemo(
+      () => ({ duration, isOpen, isStreaming, setIsOpen }),
+      [duration, isOpen, isStreaming, setIsOpen]
+    );
+
+    return (
+      <ReasoningContext.Provider value={contextValue}>
+        <Collapsible
+          className={cn("not-prose mb-4", className)}
+          onOpenChange={handleOpenChange}
+          open={isOpen}
+          {...props}
+        >
+          {children}
+        </Collapsible>
+      </ReasoningContext.Provider>
+    );
+  }
+);
+
+export type ReasoningTriggerProps = ComponentProps<
+  typeof CollapsibleTrigger
+> & {
+  getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
+};
+
+const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
+  if (isStreaming || duration === 0) {
+    return <Shimmer duration={1}>Thinking...</Shimmer>;
+  }
+  if (duration === undefined) {
+    return <p>Thought for a few seconds</p>;
+  }
+  return <p>Thought for {duration} seconds</p>;
+};
+
+export const ReasoningTrigger = memo(
+  ({
+    className,
+    children,
+    getThinkingMessage = defaultGetThinkingMessage,
+    ...props
+  }: ReasoningTriggerProps) => {
+    const { isStreaming, isOpen, duration } = useReasoning();
+
+    return (
+      <CollapsibleTrigger
+        className={cn(
+          "flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
+          className
+        )}
+        {...props}
+      >
+        {children ?? (
+          <>
+            <BrainIcon className="size-4" />
+            {getThinkingMessage(isStreaming, duration)}
+            <ChevronDownIcon
+              className={cn(
+                "size-4 transition-transform",
+                isOpen ? "rotate-180" : "rotate-0"
+              )}
+            />
+          </>
+        )}
+      </CollapsibleTrigger>
+    );
+  }
+);
+
+export type ReasoningContentProps = ComponentProps<
+  typeof CollapsibleContent
+> & {
+  children: string;
+};
+
+const streamdownPlugins = { cjk, code, math, mermaid };
+
+export const ReasoningContent = memo(
+  ({ className, children, ...props }: ReasoningContentProps) => (
+    <CollapsibleContent
+      className={cn(
+        "mt-4 text-sm",
+        "data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
+        className
+      )}
+      {...props}
+    >
+      <Streamdown plugins={streamdownPlugins}>{children}</Streamdown>
+    </CollapsibleContent>
+  )
+);
+
+Reasoning.displayName = "Reasoning";
+ReasoningTrigger.displayName = "ReasoningTrigger";
+ReasoningContent.displayName = "ReasoningContent";

+ 77 - 0
packages/studio/src/components/ai-elements/shimmer.tsx

@@ -0,0 +1,77 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import type { MotionProps } from "motion/react";
+import { motion } from "motion/react";
+import type { CSSProperties, ElementType, JSX } from "react";
+import { memo, useMemo } from "react";
+
+type MotionHTMLProps = MotionProps & Record<string, unknown>;
+
+// Cache motion components at module level to avoid creating during render
+const motionComponentCache = new Map<
+  keyof JSX.IntrinsicElements,
+  React.ComponentType<MotionHTMLProps>
+>();
+
+const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
+  let component = motionComponentCache.get(element);
+  if (!component) {
+    component = motion.create(element);
+    motionComponentCache.set(element, component);
+  }
+  return component;
+};
+
+export interface TextShimmerProps {
+  children: string;
+  as?: ElementType;
+  className?: string;
+  duration?: number;
+  spread?: number;
+}
+
+const ShimmerComponent = ({
+  children,
+  as: Component = "p",
+  className,
+  duration = 2,
+  spread = 2,
+}: TextShimmerProps) => {
+  const MotionComponent = getMotionComponent(
+    Component as keyof JSX.IntrinsicElements
+  );
+
+  const dynamicSpread = useMemo(
+    () => (children?.length ?? 0) * spread,
+    [children, spread]
+  );
+
+  return (
+    <MotionComponent
+      animate={{ backgroundPosition: "0% center" }}
+      className={cn(
+        "relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
+        "[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
+        className
+      )}
+      initial={{ backgroundPosition: "100% center" }}
+      style={
+        {
+          "--spread": `${dynamicSpread}px`,
+          backgroundImage:
+            "var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
+        } as CSSProperties
+      }
+      transition={{
+        duration,
+        ease: "linear",
+        repeat: Number.POSITIVE_INFINITY,
+      }}
+    >
+      {children}
+    </MotionComponent>
+  );
+};
+
+export const Shimmer = memo(ShimmerComponent);

+ 173 - 0
packages/studio/src/components/ai-elements/tool.tsx

@@ -0,0 +1,173 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import {
+  Collapsible,
+  CollapsibleContent,
+  CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import { cn } from "@/lib/utils";
+import type { DynamicToolUIPart, ToolUIPart } from "ai";
+import {
+  CheckCircleIcon,
+  ChevronDownIcon,
+  CircleIcon,
+  ClockIcon,
+  WrenchIcon,
+  XCircleIcon,
+} from "lucide-react";
+import type { ComponentProps, ReactNode } from "react";
+import { isValidElement } from "react";
+
+import { CodeBlock } from "./code-block";
+
+export type ToolProps = ComponentProps<typeof Collapsible>;
+
+export const Tool = ({ className, ...props }: ToolProps) => (
+  <Collapsible
+    className={cn("group not-prose mb-4 w-full rounded-md border", className)}
+    {...props}
+  />
+);
+
+export type ToolPart = ToolUIPart | DynamicToolUIPart;
+
+export type ToolHeaderProps = {
+  title?: string;
+  className?: string;
+} & (
+  | { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never }
+  | {
+      type: DynamicToolUIPart["type"];
+      state: DynamicToolUIPart["state"];
+      toolName: string;
+    }
+);
+
+const statusLabels: Record<ToolPart["state"], string> = {
+  "approval-requested": "等待确认",
+  "approval-responded": "已响应",
+  "input-available": "执行中",
+  "input-streaming": "处理中",
+  "output-available": "已完成",
+  "output-denied": "已拒绝",
+  "output-error": "出错",
+};
+
+const statusIcons: Record<ToolPart["state"], ReactNode> = {
+  "approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
+  "approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
+  "input-available": <ClockIcon className="size-4 animate-pulse" />,
+  "input-streaming": <CircleIcon className="size-4" />,
+  "output-available": <CheckCircleIcon className="size-4 text-green-600" />,
+  "output-denied": <XCircleIcon className="size-4 text-orange-600" />,
+  "output-error": <XCircleIcon className="size-4 text-red-600" />,
+};
+
+export const getStatusBadge = (status: ToolPart["state"]) => (
+  <Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
+    {statusIcons[status]}
+    {statusLabels[status]}
+  </Badge>
+);
+
+export const ToolHeader = ({
+  className,
+  title,
+  type,
+  state,
+  toolName,
+  ...props
+}: ToolHeaderProps) => {
+  const derivedName =
+    type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-");
+
+  return (
+    <CollapsibleTrigger
+      className={cn(
+        "flex w-full items-center justify-between gap-4 p-3",
+        className
+      )}
+      {...props}
+    >
+      <div className="flex items-center gap-2">
+        <WrenchIcon className="size-4 text-muted-foreground" />
+        <span className="font-medium text-sm">{title ?? derivedName}</span>
+        {getStatusBadge(state)}
+      </div>
+      <ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
+    </CollapsibleTrigger>
+  );
+};
+
+export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
+
+export const ToolContent = ({ className, ...props }: ToolContentProps) => (
+  <CollapsibleContent
+    className={cn(
+      "data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 space-y-4 p-4 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
+      className
+    )}
+    {...props}
+  />
+);
+
+export type ToolInputProps = ComponentProps<"div"> & {
+  input: ToolPart["input"];
+};
+
+export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
+  <div className={cn("space-y-2 overflow-hidden", className)} {...props}>
+    <h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
+      Parameters
+    </h4>
+    <div className="rounded-md bg-muted/50">
+      <CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
+    </div>
+  </div>
+);
+
+export type ToolOutputProps = ComponentProps<"div"> & {
+  output: ToolPart["output"];
+  errorText: ToolPart["errorText"];
+};
+
+export const ToolOutput = ({
+  className,
+  output,
+  errorText,
+  ...props
+}: ToolOutputProps) => {
+  if (!(output || errorText)) {
+    return null;
+  }
+
+  let Output = <div>{output as ReactNode}</div>;
+
+  if (typeof output === "object" && !isValidElement(output)) {
+    Output = (
+      <CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
+    );
+  } else if (typeof output === "string") {
+    Output = <CodeBlock code={output} language="json" />;
+  }
+
+  return (
+    <div className={cn("space-y-2", className)} {...props}>
+      <h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
+        {errorText ? "Error" : "Result"}
+      </h4>
+      <div
+        className={cn(
+          "overflow-x-auto rounded-md text-xs [&_table]:w-full",
+          errorText
+            ? "bg-destructive/10 text-destructive"
+            : "bg-muted/50 text-foreground"
+        )}
+      >
+        {errorText && <div>{errorText}</div>}
+        {Output}
+      </div>
+    </div>
+  );
+};

+ 56 - 0
packages/studio/src/components/chat-utils.ts

@@ -0,0 +1,56 @@
+// Pure helper functions extracted from ChatBar for reuse in ChatPage and tests.
+
+interface ChatMessage {
+  readonly role: "user" | "assistant";
+  readonly content: string;
+  readonly timestamp: number;
+}
+
+interface SharedSessionMeta {
+  readonly activeBookId?: string;
+  readonly automationMode?: string;
+  readonly currentStage?: string;
+  readonly pendingSummary?: string;
+  readonly draftTitle?: string;
+}
+
+interface BookRef {
+  readonly id: string;
+}
+
+export function coerceSharedSessionMessages(
+  messages: ReadonlyArray<{ role: "user" | "assistant" | "system"; content: string; timestamp: number }>,
+): ReadonlyArray<ChatMessage> {
+  return messages
+    .filter((message) => message.role === "user" || message.role === "assistant")
+    .map((message) => ({
+      role: message.role as "user" | "assistant",
+      content: message.content,
+      timestamp: message.timestamp,
+    }));
+}
+
+export function resolveDirectWriteTarget(
+  activeBookId: string | undefined,
+  books: ReadonlyArray<BookRef>,
+): { bookId: string | null; reason: "active" | "single" | "missing" | "ambiguous" } {
+  if (activeBookId && books.some((book) => book.id === activeBookId)) {
+    return { bookId: activeBookId, reason: "active" };
+  }
+  if (books.length === 1) {
+    return { bookId: books[0]!.id, reason: "single" };
+  }
+  if (books.length === 0) {
+    return { bookId: null, reason: "missing" };
+  }
+  return { bookId: null, reason: "ambiguous" };
+}
+
+export function formatSharedSessionContext(meta: SharedSessionMeta): string {
+  return [
+    meta.activeBookId ?? "no-book",
+    meta.draftTitle ? `draft:${meta.draftTitle}` : undefined,
+    meta.automationMode ?? "semi",
+    meta.currentStage,
+  ].filter(Boolean).join(" · ");
+}

+ 199 - 0
packages/studio/src/components/chat/BookFormCard.tsx

@@ -0,0 +1,199 @@
+import type { Theme } from "../../hooks/use-theme";
+import { cn } from "../../lib/utils";
+import { Tool, ToolHeader, ToolContent } from "../ai-elements/tool";
+import { Loader2 } from "lucide-react";
+
+export interface BookFormArgs {
+  title?: string;
+  genre?: string;
+  platform?: string;
+  targetChapters?: number;
+  chapterWordCount?: number;
+  language?: string;
+  brief?: string;
+}
+
+export interface BookFormCardProps {
+  readonly args: BookFormArgs;
+  readonly onArgsChange: (args: BookFormArgs) => void;
+  readonly onConfirm: () => void;
+  readonly confirming: boolean;
+  readonly theme: Theme;
+}
+
+const PLATFORM_OPTIONS = [
+  { label: "番茄小说", value: "tomato" },
+  { label: "起点中文网", value: "qidian" },
+  { label: "飞卢", value: "feilu" },
+  { label: "其他", value: "other" },
+] as const;
+
+const LANGUAGE_OPTIONS = [
+  { label: "中文", value: "zh" },
+  { label: "English", value: "en" },
+] as const;
+
+const labelClass = "text-[10px] uppercase tracking-[0.18em] text-muted-foreground font-semibold";
+
+const inputClass = cn(
+  "w-full rounded-lg border border-border/60 bg-background/80 px-3 py-1.5 text-sm",
+  "outline-none focus:border-primary/50 focus:ring-2 focus:ring-primary/10 transition-all",
+  "placeholder:text-muted-foreground/40",
+);
+
+function RadioGroup({
+  options,
+  value,
+  onChange,
+  disabled,
+}: {
+  readonly options: ReadonlyArray<{ readonly label: string; readonly value: string }>;
+  readonly value: string | undefined;
+  readonly onChange: (v: string) => void;
+  readonly disabled?: boolean;
+}) {
+  return (
+    <div className="flex flex-wrap gap-2">
+      {options.map((opt) => (
+        <button
+          key={opt.value}
+          type="button"
+          onClick={() => onChange(opt.value)}
+          disabled={disabled}
+          className={cn(
+            "px-3 py-1 rounded-lg text-xs font-medium border transition-all",
+            value === opt.value
+              ? "border-primary bg-primary/10 text-primary"
+              : "border-border/60 bg-background/60 text-muted-foreground hover:border-primary/30",
+            disabled && "opacity-50 cursor-not-allowed",
+          )}
+        >
+          {opt.label}
+        </button>
+      ))}
+    </div>
+  );
+}
+
+export function BookFormCard({ args, onArgsChange, onConfirm, confirming }: BookFormCardProps) {
+  const disabled = confirming;
+  const update = <K extends keyof BookFormArgs>(key: K, value: BookFormArgs[K]) => {
+    if (disabled) return;
+    onArgsChange({ ...args, [key]: value });
+  };
+
+  const disabledInput = disabled ? "opacity-60 cursor-not-allowed" : "";
+  const toolState = confirming ? "input-available" : "approval-requested";
+
+  return (
+    <Tool defaultOpen>
+      <ToolHeader
+        title="创建新书"
+        type="tool-invocation"
+        state={toolState}
+      />
+      <ToolContent>
+        <div className={cn("space-y-4", disabled && "pointer-events-none opacity-80")}>
+          {/* 书名 */}
+          <div className="space-y-1.5">
+            <label className={labelClass}>书名</label>
+            <input
+              type="text"
+              value={args.title ?? ""}
+              onChange={(e) => update("title", e.target.value)}
+              placeholder="输入书名"
+              disabled={disabled}
+              className={cn(inputClass, disabledInput)}
+            />
+          </div>
+
+          {/* 题材 */}
+          <div className="space-y-1.5">
+            <label className={labelClass}>题材</label>
+            <input
+              type="text"
+              value={args.genre ?? ""}
+              onChange={(e) => update("genre", e.target.value)}
+              placeholder="如 xuanhuan、urban、romance"
+              disabled={disabled}
+              className={cn(inputClass, disabledInput)}
+            />
+          </div>
+
+          {/* 目标平台 */}
+          <div className="space-y-1.5">
+            <label className={labelClass}>目标平台</label>
+            <RadioGroup
+              options={PLATFORM_OPTIONS}
+              value={args.platform}
+              onChange={(v) => update("platform", v)}
+              disabled={disabled}
+            />
+          </div>
+
+          {/* 目标章数 + 每章字数 */}
+          <div className="grid grid-cols-2 gap-3">
+            <div className="space-y-1.5">
+              <label className={labelClass}>目标章数</label>
+              <input
+                type="number"
+                value={args.targetChapters ?? ""}
+                onChange={(e) => update("targetChapters", e.target.value ? Number(e.target.value) : undefined)}
+                placeholder="如 200"
+                disabled={disabled}
+                className={cn(inputClass, disabledInput)}
+              />
+            </div>
+            <div className="space-y-1.5">
+              <label className={labelClass}>每章字数</label>
+              <input
+                type="number"
+                value={args.chapterWordCount ?? ""}
+                onChange={(e) => update("chapterWordCount", e.target.value ? Number(e.target.value) : undefined)}
+                placeholder="如 2000"
+                disabled={disabled}
+                className={cn(inputClass, disabledInput)}
+              />
+            </div>
+          </div>
+
+          {/* 写作语言 */}
+          <div className="space-y-1.5">
+            <label className={labelClass}>写作语言</label>
+            <RadioGroup
+              options={LANGUAGE_OPTIONS}
+              value={args.language}
+              onChange={(v) => update("language", v)}
+              disabled={disabled}
+            />
+          </div>
+
+          {/* 创意简述 */}
+          <div className="space-y-1.5">
+            <label className={labelClass}>创意简述</label>
+            <div className={cn(
+              "rounded-lg border border-border/60 bg-background/80 px-3 py-2 text-sm leading-7 whitespace-pre-wrap",
+              disabled ? "opacity-60" : "",
+            )}>
+              {args.brief || <span className="text-muted-foreground/40">AI 会根据你的描述自动生成</span>}
+            </div>
+          </div>
+        </div>
+
+        {/* 确认按钮 */}
+        <div className="flex items-center justify-between pt-4 border-t border-border/40 mt-4">
+          <p className="text-xs text-muted-foreground">确认后将由 Architect 生成完整 foundation</p>
+          <button
+            type="button"
+            onClick={onConfirm}
+            disabled={confirming}
+            className="flex items-center gap-1.5 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
+          >
+            {confirming && <Loader2 size={14} className="animate-spin" />}
+            {confirming ? "创建中…" : "开始写这本书"}
+          </button>
+        </div>
+      </ToolContent>
+    </Tool>
+  );
+}

+ 291 - 0
packages/studio/src/components/chat/BookSidebar.tsx

@@ -0,0 +1,291 @@
+import { useState, useEffect, useCallback, useRef } from "react";
+import type { Theme } from "../../hooks/use-theme";
+import type { TFunction } from "../../hooks/use-i18n";
+import type { SSEMessage } from "../../hooks/use-sse";
+import { useChatStore } from "../../store/chat";
+import { fetchJson } from "../../hooks/use-api";
+import { PanelRightClose, PanelRightOpen, ArrowLeft, Loader2, Pencil, Save, X } from "lucide-react";
+import { Streamdown } from "streamdown";
+import { cjk } from "@streamdown/cjk";
+import { ProgressSection } from "../sidebar/ProgressSection";
+import { FoundationSection } from "../sidebar/FoundationSection";
+import { SummarySection } from "../sidebar/SummarySection";
+import { ChaptersSection } from "../sidebar/ChaptersSection";
+import { CharacterSection } from "../sidebar/CharacterSection";
+
+export interface BookSidebarProps {
+  readonly bookId: string;
+  readonly theme: Theme;
+  readonly t: TFunction;
+  readonly sse: { messages: ReadonlyArray<SSEMessage>; connected: boolean };
+}
+
+const FOUNDATION_LABELS: Record<string, string> = {
+  "story_bible.md": "世界观设定",
+  "volume_outline.md": "卷纲规划",
+  "book_rules.md": "叙事规则",
+  "current_state.md": "状态卡",
+  "pending_hooks.md": "伏笔池",
+  "subplot_board.md": "支线进度",
+  "emotional_arcs.md": "感情线",
+  "character_matrix.md": "角色矩阵",
+};
+
+const streamdownPlugins = { cjk };
+
+function ArtifactView({ bookId }: { readonly bookId: string }) {
+  const artifactFile = useChatStore((s) => s.artifactFile);
+  const artifactChapter = useChatStore((s) => s.artifactChapter);
+  const closeArtifact = useChatStore((s) => s.closeArtifact);
+  const [content, setContent] = useState<string | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [editing, setEditing] = useState(false);
+  const [editContent, setEditContent] = useState("");
+  const [saving, setSaving] = useState(false);
+
+  const isChapter = artifactChapter !== null;
+  const label = isChapter
+    ? `第 ${artifactChapter} 章`
+    : artifactFile ? FOUNDATION_LABELS[artifactFile] ?? artifactFile : "";
+
+  useEffect(() => {
+    setEditing(false);
+    setLoading(true);
+    if (isChapter) {
+      fetchJson<{ content: string }>(`/books/${bookId}/chapters/${artifactChapter}`)
+        .then((data) => setContent(data.content ?? ""))
+        .catch(() => setContent(null))
+        .finally(() => setLoading(false));
+    } else if (artifactFile) {
+      fetchJson<{ content: string | null }>(`/books/${bookId}/truth/${artifactFile}`)
+        .then((data) => setContent(data.content ?? ""))
+        .catch(() => setContent(null))
+        .finally(() => setLoading(false));
+    }
+  }, [bookId, artifactFile, artifactChapter, isChapter]);
+
+  const handleEdit = useCallback(() => {
+    setEditContent(content ?? "");
+    setEditing(true);
+  }, [content]);
+
+  const handleSave = useCallback(async () => {
+    setSaving(true);
+    try {
+      if (isChapter) {
+        await fetchJson(`/books/${bookId}/chapters/${artifactChapter}`, {
+          method: "PUT",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({ content: editContent }),
+        });
+      } else if (artifactFile) {
+        await fetchJson(`/books/${bookId}/truth/${artifactFile}`, {
+          method: "PUT",
+          headers: { "Content-Type": "application/json" },
+          body: JSON.stringify({ content: editContent }),
+        });
+      }
+      setContent(editContent);
+      setEditing(false);
+    } catch {
+      // keep editing state on error
+    } finally {
+      setSaving(false);
+    }
+  }, [bookId, artifactFile, artifactChapter, isChapter, editContent]);
+
+  return (
+    <div className="flex flex-col h-full">
+      <div className="flex items-center gap-2 px-3 py-2.5 border-b border-border/20 shrink-0">
+        <button
+          onClick={closeArtifact}
+          className="w-6 h-6 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-secondary/50 transition-colors"
+        >
+          <ArrowLeft size={14} />
+        </button>
+        <span className="text-sm font-medium truncate flex-1">{label}</span>
+        {!loading && content !== null && !editing && (
+          <button
+            onClick={handleEdit}
+            className="w-6 h-6 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-secondary/50 transition-colors"
+          >
+            <Pencil size={12} />
+          </button>
+        )}
+        {editing && (
+          <div className="flex items-center gap-1">
+            <button
+              onClick={handleSave}
+              disabled={saving}
+              className="w-6 h-6 rounded-md flex items-center justify-center text-emerald-500 hover:bg-emerald-500/10 transition-colors"
+            >
+              {saving ? <Loader2 size={12} className="animate-spin" /> : <Save size={12} />}
+            </button>
+            <button
+              onClick={() => setEditing(false)}
+              className="w-6 h-6 rounded-md flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-secondary/50 transition-colors"
+            >
+              <X size={12} />
+            </button>
+          </div>
+        )}
+      </div>
+      <div className="flex-1 overflow-y-auto">
+        {loading ? (
+          <div className="flex items-center justify-center py-8">
+            <Loader2 size={16} className="text-muted-foreground animate-spin" />
+          </div>
+        ) : content === null ? (
+          <p className="text-xs text-muted-foreground/50 italic px-4 py-3">文件不存在</p>
+        ) : editing ? (
+          <textarea
+            value={editContent}
+            onChange={(e) => setEditContent(e.target.value)}
+            className="w-full h-full min-h-[300px] bg-transparent text-sm leading-7 px-4 py-3 resize-none outline-none border-0 font-mono"
+          />
+        ) : (
+          <div className="px-4 py-3 text-sm leading-7">
+            <Streamdown plugins={streamdownPlugins} mode="static">{content}</Streamdown>
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}
+
+function PanelView({ bookId, theme: _theme, t, sse }: BookSidebarProps) {
+  const isZh = t("nav.connected") === "\u5DF2\u8FDE\u63A5";
+
+  // Show writing indicator only during pipeline operations (write/audit/revise)
+  const [activeOp, setActiveOp] = useState<string | null>(null);
+  useEffect(() => {
+    const latest = sse.messages;
+    if (latest.length === 0) return;
+    const last = latest[latest.length - 1];
+    if (last.event === "write:start") setActiveOp("write");
+    else if (last.event === "tool:start") {
+      const data = last.data as { tool?: string; args?: { agent?: string } } | null;
+      if (data?.tool === "sub_agent") {
+        const agent = data.args?.agent;
+        if (agent === "writer") setActiveOp("write");
+        else if (agent === "auditor") setActiveOp("audit");
+        else if (agent === "reviser") setActiveOp("revise");
+      }
+    } else if (last.event === "write:complete" || last.event === "tool:end") {
+      setActiveOp(null);
+    }
+  }, [sse.messages]);
+
+  const OP_LABELS: Record<string, string> = {
+    write: isZh ? "正在写作中..." : "Writing...",
+    audit: isZh ? "正在审计中..." : "Auditing...",
+    revise: isZh ? "正在修订中..." : "Revising...",
+  };
+
+  return (
+    <div className="flex flex-col gap-2 p-3">
+      {activeOp && (
+        <div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-primary/5 border border-primary/10">
+          <Loader2 size={12} className="text-primary animate-spin shrink-0" />
+          <span className="text-xs text-primary font-medium">
+            {OP_LABELS[activeOp] ?? activeOp}
+          </span>
+        </div>
+      )}
+      <ProgressSection sse={sse} />
+      <ChaptersSection bookId={bookId} isZh={isZh} />
+      <CharacterSection bookId={bookId} />
+      <FoundationSection bookId={bookId} />
+      <SummarySection bookId={bookId} />
+    </div>
+  );
+}
+
+const SIDEBAR_RATIO = 0.4;
+const SIDEBAR_MIN = 280;
+const SIDEBAR_MAX = 700;
+
+function defaultSidebarWidth(): number {
+  return Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, Math.round(window.innerWidth * SIDEBAR_RATIO)));
+}
+
+export function BookSidebar({ bookId, theme, t, sse }: BookSidebarProps) {
+  const sidebarView = useChatStore((s) => s.sidebarView);
+  const [width, setWidth] = useState(defaultSidebarWidth);
+  const dragging = useRef(false);
+
+  const onMouseDown = useCallback((e: React.MouseEvent) => {
+    e.preventDefault();
+    dragging.current = true;
+    const startX = e.clientX;
+    const startW = width;
+    const onMove = (ev: MouseEvent) => {
+      if (!dragging.current) return;
+      const delta = startX - ev.clientX;
+      setWidth(Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, startW + delta)));
+    };
+    const onUp = () => {
+      dragging.current = false;
+      document.removeEventListener("mousemove", onMove);
+      document.removeEventListener("mouseup", onUp);
+    };
+    document.addEventListener("mousemove", onMove);
+    document.addEventListener("mouseup", onUp);
+  }, [width]);
+
+  return (
+    <aside
+      className="hidden lg:flex shrink-0 flex-col bg-background/30 backdrop-blur-sm overflow-y-auto relative"
+      style={{ width }}
+    >
+      {/* Resize handle */}
+      <div
+        onMouseDown={onMouseDown}
+        className="absolute left-0 top-0 h-full w-1 cursor-col-resize hover:bg-primary/20 active:bg-primary/30 transition-colors z-10"
+      />
+      {sidebarView === "artifact" ? (
+        <ArtifactView bookId={bookId} />
+      ) : (
+        <PanelView bookId={bookId} theme={theme} t={t} sse={sse} />
+      )}
+    </aside>
+  );
+}
+
+export function BookSidebarToggle({ bookId, theme, t, sse }: BookSidebarProps) {
+  const [open, setOpen] = useState(false);
+  const sidebarView = useChatStore((s) => s.sidebarView);
+
+  return (
+    <>
+      <button
+        onClick={() => setOpen(true)}
+        className="fixed right-3 top-[72px] z-20 lg:hidden w-8 h-8 rounded-lg bg-card border border-border/40 flex items-center justify-center text-muted-foreground hover:text-foreground transition-colors"
+      >
+        <PanelRightOpen size={14} />
+      </button>
+
+      {open && (
+        <div className="fixed inset-0 z-40 lg:hidden" onClick={() => setOpen(false)}>
+          <div className="absolute inset-0 bg-black/20 backdrop-blur-sm" />
+          <aside
+            className="absolute right-0 top-0 h-full w-[420px] max-w-[85vw] bg-background border-l border-border/20 overflow-y-auto"
+            onClick={(e) => e.stopPropagation()}
+          >
+            <div className="flex items-center justify-between px-3 py-2 border-b border-border/20">
+              <span className="text-xs font-medium text-muted-foreground">书籍信息</span>
+              <button onClick={() => setOpen(false)} className="text-muted-foreground hover:text-foreground">
+                <PanelRightClose size={14} />
+              </button>
+            </div>
+            {sidebarView === "artifact" ? (
+              <ArtifactView bookId={bookId} />
+            ) : (
+              <PanelView bookId={bookId} theme={theme} t={t} sse={sse} />
+            )}
+          </aside>
+        </div>
+      )}
+    </>
+  );
+}

+ 75 - 0
packages/studio/src/components/chat/ChatMessage.tsx

@@ -0,0 +1,75 @@
+import type { Theme } from "../../hooks/use-theme";
+import { cn } from "../../lib/utils";
+import {
+  Message,
+  MessageContent,
+  MessageResponse,
+} from "../ai-elements/message";
+import { BookFormCard } from "./BookFormCard";
+import type { BookFormArgs } from "./BookFormCard";
+import {
+  CheckCircle2,
+  XCircle,
+  Loader2,
+} from "lucide-react";
+
+export interface ToolCall {
+  readonly name: string;
+  readonly arguments: Record<string, unknown>;
+}
+
+export interface ChatMessageProps {
+  readonly role: "user" | "assistant";
+  readonly content: string;
+  readonly timestamp: number;
+  readonly theme: Theme;
+  readonly toolCall?: ToolCall;
+  readonly onArgsChange?: (args: Record<string, unknown>) => void;
+  readonly onConfirm?: () => void;
+  readonly confirming?: boolean;
+}
+
+export function ChatMessage({
+  role,
+  content,
+  timestamp,
+  theme,
+  toolCall,
+  onArgsChange,
+  onConfirm,
+  confirming,
+}: ChatMessageProps) {
+  const isUser = role === "user";
+  const isStatus = content.startsWith("\u22EF");
+  const isSuccess = content.startsWith("\u2713");
+  const isError = content.startsWith("\u2717");
+
+  const hasBookForm = toolCall?.name === "create_book" && onArgsChange && onConfirm;
+
+  return (
+    <Message from={role}>
+      <MessageContent>
+        {isUser ? (
+          <div className="text-sm leading-relaxed">{content}</div>
+        ) : isError ? (
+          <div className="flex items-center gap-2 text-sm text-destructive">
+            <XCircle size={14} className="shrink-0" />
+            <span>{content.replace(/^\u2717\s*/, "")}</span>
+          </div>
+        ) : (
+          <MessageResponse>{content}</MessageResponse>
+        )}
+      </MessageContent>
+
+      {hasBookForm && (
+        <BookFormCard
+          args={toolCall.arguments as BookFormArgs}
+          onArgsChange={(a) => onArgsChange(a as Record<string, unknown>)}
+          onConfirm={onConfirm}
+          confirming={confirming ?? false}
+          theme={theme}
+        />
+      )}
+    </Message>
+  );
+}

+ 73 - 0
packages/studio/src/components/chat/QuickActions.tsx

@@ -0,0 +1,73 @@
+import {
+  Zap,
+  Search,
+  FileOutput,
+  TrendingUp,
+} from "lucide-react";
+
+export interface QuickActionsProps {
+  readonly onAction: (command: string) => void;
+  readonly disabled: boolean;
+  readonly isZh: boolean;
+}
+
+interface ChipDef {
+  readonly icon: React.ReactNode;
+  readonly labelZh: string;
+  readonly labelEn: string;
+  readonly commandZh: string;
+  readonly commandEn: string;
+}
+
+const CHIPS: ReadonlyArray<ChipDef> = [
+  {
+    icon: <Zap size={12} />,
+    labelZh: "写下一章",
+    labelEn: "Write next",
+    commandZh: "写下一章",
+    commandEn: "write next",
+  },
+  {
+    icon: <Search size={12} />,
+    labelZh: "审计",
+    labelEn: "Audit",
+    commandZh: "审计",
+    commandEn: "audit",
+  },
+  {
+    icon: <FileOutput size={12} />,
+    labelZh: "导出",
+    labelEn: "Export",
+    commandZh: "导出全书",
+    commandEn: "export book",
+  },
+  {
+    icon: <TrendingUp size={12} />,
+    labelZh: "市场雷达",
+    labelEn: "Market radar",
+    commandZh: "扫描市场趋势",
+    commandEn: "scan market trends",
+  },
+];
+
+export function QuickActions({ onAction, disabled, isZh }: QuickActionsProps) {
+  return (
+    <div className="flex gap-2 overflow-x-auto px-1 py-1">
+      {CHIPS.map((chip) => {
+        const label = isZh ? chip.labelZh : chip.labelEn;
+        const command = isZh ? chip.commandZh : chip.commandEn;
+        return (
+          <button
+            key={label}
+            onClick={() => onAction(command)}
+            disabled={disabled}
+            className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-secondary/50 border border-border/30 text-xs font-medium text-muted-foreground hover:text-primary hover:border-primary/30 hover:bg-primary/5 transition-all disabled:opacity-40 disabled:pointer-events-none group"
+          >
+            <span className="group-hover:scale-110 transition-transform">{chip.icon}</span>
+            {label}
+          </button>
+        );
+      })}
+    </div>
+  );
+}

+ 236 - 0
packages/studio/src/components/chat/ToolExecutionSteps.tsx

@@ -0,0 +1,236 @@
+import { useMemo, useState, useEffect } from "react";
+import type { ToolExecution, PipelineStage } from "../../store/chat/types";
+import {
+  Collapsible,
+  CollapsibleTrigger,
+  CollapsibleContent,
+} from "../ui/collapsible";
+import {
+  Loader2,
+  CheckCircle2,
+  XCircle,
+  ChevronDown,
+  Wrench,
+} from "lucide-react";
+
+// -- Status rendering helpers --
+
+function ExecStatusBadge({ status }: { status: ToolExecution["status"] }) {
+  switch (status) {
+    case "running":
+      return (
+        <span className="inline-flex items-center gap-1 text-xs text-primary">
+          <Loader2 size={12} className="animate-spin" />
+          <span>执行中</span>
+        </span>
+      );
+    case "processing":
+      return (
+        <span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
+          <Loader2 size={12} className="animate-spin" style={{ animationDuration: "2s" }} />
+          <span>处理结果</span>
+        </span>
+      );
+    case "completed":
+      return (
+        <span className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
+          <CheckCircle2 size={12} />
+          <span>已完成</span>
+        </span>
+      );
+    case "error":
+      return (
+        <span className="inline-flex items-center gap-1 text-xs text-destructive">
+          <XCircle size={12} />
+          <span>失败</span>
+        </span>
+      );
+  }
+}
+
+function StageIcon({ status }: { status: PipelineStage["status"] }) {
+  switch (status) {
+    case "pending":
+      return <span className="w-4 h-4 rounded-full border border-border/60 flex items-center justify-center shrink-0 text-[8px] text-muted-foreground/40">○</span>;
+    case "active":
+      return <Loader2 size={14} className="text-primary animate-spin shrink-0" />;
+    case "completed":
+      return <CheckCircle2 size={14} className="text-green-600 dark:text-green-400 shrink-0" />;
+  }
+}
+
+function formatProgress(progress: NonNullable<PipelineStage["progress"]>): string {
+  const secs = Math.round(progress.elapsedMs / 1000);
+  const statusLabel = progress.status === "thinking" ? "思考中" : "";
+  const chars = progress.totalChars > 0
+    ? progress.chineseChars > 0 ? `${progress.totalChars}字` : `${progress.totalChars} chars`
+    : "";
+  const parts = [statusLabel, `${secs}s`, chars].filter(Boolean);
+  return parts.join(" · ");
+}
+
+function formatDuration(startedAt: number, completedAt?: number): string {
+  const ms = (completedAt ?? Date.now()) - startedAt;
+  const secs = Math.round(ms / 1000);
+  return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`;
+}
+
+// -- Live elapsed timer hook --
+
+function useElapsedTimer(startedAt: number, active: boolean): number {
+  const [elapsed, setElapsed] = useState(() => active ? Date.now() - startedAt : 0);
+  useEffect(() => {
+    if (!active) return;
+    setElapsed(Date.now() - startedAt);
+    const id = setInterval(() => setElapsed(Date.now() - startedAt), 1000);
+    return () => clearInterval(id);
+  }, [startedAt, active]);
+  return elapsed;
+}
+
+// -- Pipeline operation (sub_agent) --
+
+function PipelineExecution({ exec }: { exec: ToolExecution }) {
+  const isActive = exec.status === "running" || exec.status === "processing";
+  const [open, setOpen] = useState(isActive);
+  const elapsedMs = useElapsedTimer(exec.startedAt, isActive);
+
+  useEffect(() => {
+    if (exec.status === "running") setOpen(true);
+    if (exec.status === "completed") {
+      const timer = setTimeout(() => setOpen(false), 500);
+      return () => clearTimeout(timer);
+    }
+  }, [exec.status]);
+
+  const bookId = exec.args?.bookId as string | undefined;
+
+  return (
+    <Collapsible open={open} onOpenChange={setOpen} className="rounded-xl border border-border/40 bg-card/60">
+      <CollapsibleTrigger className="w-full flex items-center justify-between gap-2 px-3 py-2.5 rounded-xl hover:bg-card/80 transition-colors cursor-pointer">
+        <div className="flex items-center gap-2 min-w-0">
+          <span className="text-sm font-medium text-foreground truncate">
+            {exec.label}
+            {bookId && <span className="text-muted-foreground font-normal"> · {bookId}</span>}
+          </span>
+        </div>
+        <div className="flex items-center gap-2 shrink-0">
+          <span className="text-[10px] text-muted-foreground/60">
+            {isActive
+              ? formatDuration(exec.startedAt, exec.startedAt + elapsedMs)
+              : exec.completedAt ? formatDuration(exec.startedAt, exec.completedAt) : ""}
+          </span>
+          <ExecStatusBadge status={exec.status} />
+          <ChevronDown size={14} className={`text-muted-foreground transition-transform ${open ? "rotate-180" : ""}`} />
+        </div>
+      </CollapsibleTrigger>
+      <CollapsibleContent>
+        <div className="px-3 pb-3 pt-1">
+          {/* Real-time execution logs */}
+          {exec.logs && exec.logs.length > 0 && (
+            <ul className="space-y-0.5">
+              {exec.logs.map((log, i) => {
+                const isError = log.startsWith("[error]") || /error/i.test(log);
+                const isWarn = log.startsWith("[warning]") || /warning|警告/i.test(log);
+                return (
+                  <li key={i} className={`text-xs font-mono break-words ${isError ? "text-destructive" : isWarn ? "text-yellow-600 dark:text-yellow-400" : "text-muted-foreground"}`}>
+                    {log}
+                  </li>
+                );
+              })}
+            </ul>
+          )}
+          {exec.status === "error" && exec.error && (
+            <div className="mt-2 text-xs text-destructive bg-destructive/5 rounded-lg px-2.5 py-2">
+              {exec.error}
+            </div>
+          )}
+        </div>
+      </CollapsibleContent>
+    </Collapsible>
+  );
+}
+
+// -- Utility tools (read/edit/grep/ls) grouped --
+
+function UtilityToolsGroup({ execs }: { execs: ToolExecution[] }) {
+  const [open, setOpen] = useState(false);
+  const allDone = execs.every(e => e.status === "completed" || e.status === "error");
+  const hasError = execs.some(e => e.status === "error");
+
+  return (
+    <Collapsible open={open} onOpenChange={setOpen}>
+      <CollapsibleTrigger className="flex items-center gap-2 px-2 py-1 rounded-lg hover:bg-muted/50 transition-colors cursor-pointer text-xs text-muted-foreground">
+        <Wrench size={12} />
+        <span>{execs.length} 个文件操作</span>
+        {allDone && !hasError && <CheckCircle2 size={10} className="text-green-600 dark:text-green-400" />}
+        {hasError && <XCircle size={10} className="text-destructive" />}
+        {!allDone && <Loader2 size={10} className="animate-spin text-primary" />}
+        <ChevronDown size={10} className={`transition-transform ${open ? "rotate-180" : ""}`} />
+      </CollapsibleTrigger>
+      <CollapsibleContent>
+        <ul className="pl-6 space-y-0.5 py-1">
+          {execs.map((exec) => (
+            <li key={exec.id} className="flex items-center gap-2 text-xs text-muted-foreground">
+              <span className="font-mono truncate">{exec.tool} {String(exec.args?.path ?? exec.args?.pattern ?? "")}</span>
+              {exec.status === "completed" && <CheckCircle2 size={10} className="text-green-600 dark:text-green-400 shrink-0" />}
+              {exec.status === "error" && <XCircle size={10} className="text-destructive shrink-0" />}
+              {(exec.status === "running" || exec.status === "processing") && <Loader2 size={10} className="animate-spin text-primary shrink-0" />}
+            </li>
+          ))}
+        </ul>
+      </CollapsibleContent>
+    </Collapsible>
+  );
+}
+
+// -- Main component --
+
+export interface ToolExecutionStepsProps {
+  executions: ToolExecution[];
+}
+
+/**
+ * Group executions chronologically: pipeline ops render individually,
+ * consecutive utility tools are merged into a single collapsed group.
+ */
+type RenderGroup =
+  | { type: "pipeline"; exec: ToolExecution }
+  | { type: "utilities"; execs: ToolExecution[] };
+
+function groupChronologically(executions: ToolExecution[]): RenderGroup[] {
+  const groups: RenderGroup[] = [];
+  let utilBuf: ToolExecution[] = [];
+
+  const flushUtils = () => {
+    if (utilBuf.length > 0) {
+      groups.push({ type: "utilities", execs: utilBuf });
+      utilBuf = [];
+    }
+  };
+
+  for (const exec of executions) {
+    if (exec.tool === "sub_agent") {
+      flushUtils();
+      groups.push({ type: "pipeline", exec });
+    } else {
+      utilBuf.push(exec);
+    }
+  }
+  flushUtils();
+  return groups;
+}
+
+export function ToolExecutionSteps({ executions }: ToolExecutionStepsProps) {
+  const groups = useMemo(() => groupChronologically(executions), [executions]);
+
+  return (
+    <div className="space-y-2 mt-2">
+      {groups.map((g, i) =>
+        g.type === "pipeline"
+          ? <PipelineExecution key={g.exec.id} exec={g.exec} />
+          : <UtilityToolsGroup key={`utils-${i}`} execs={g.execs} />
+      )}
+    </div>
+  );
+}

+ 103 - 0
packages/studio/src/components/chat/__tests__/ToolExecutionSteps.test.ts

@@ -0,0 +1,103 @@
+import { describe, it, expect } from "vitest";
+import type { ToolExecution } from "../../../store/chat/types";
+
+// Import the grouping logic — we'll extract it as a named export
+// For now, inline the function to test the expected behavior
+function groupChronologically(executions: ToolExecution[]) {
+  type RenderGroup =
+    | { type: "pipeline"; exec: ToolExecution }
+    | { type: "utilities"; execs: ToolExecution[] };
+
+  const groups: RenderGroup[] = [];
+  let utilBuf: ToolExecution[] = [];
+
+  const flushUtils = () => {
+    if (utilBuf.length > 0) {
+      groups.push({ type: "utilities", execs: utilBuf });
+      utilBuf = [];
+    }
+  };
+
+  for (const exec of executions) {
+    if (exec.tool === "sub_agent") {
+      flushUtils();
+      groups.push({ type: "pipeline", exec });
+    } else {
+      utilBuf.push(exec);
+    }
+  }
+  flushUtils();
+  return groups;
+}
+
+const makeExec = (overrides: Partial<ToolExecution> & { id: string; tool: string }): ToolExecution => ({
+  label: "test",
+  status: "completed",
+  startedAt: Date.now(),
+  ...overrides,
+});
+
+describe("groupChronologically", () => {
+  it("keeps read before pipeline when read happened first", () => {
+    const execs: ToolExecution[] = [
+      makeExec({ id: "1", tool: "read", label: "读取文件" }),
+      makeExec({ id: "2", tool: "sub_agent", agent: "writer", label: "写作" }),
+    ];
+
+    const groups = groupChronologically(execs);
+
+    expect(groups).toHaveLength(2);
+    expect(groups[0].type).toBe("utilities");
+    expect(groups[1].type).toBe("pipeline");
+  });
+
+  it("groups consecutive utility tools together", () => {
+    const execs: ToolExecution[] = [
+      makeExec({ id: "1", tool: "read", label: "读取文件" }),
+      makeExec({ id: "2", tool: "grep", label: "搜索" }),
+      makeExec({ id: "3", tool: "read", label: "读取文件" }),
+    ];
+
+    const groups = groupChronologically(execs);
+
+    expect(groups).toHaveLength(1);
+    expect(groups[0].type).toBe("utilities");
+    if (groups[0].type === "utilities") {
+      expect(groups[0].execs).toHaveLength(3);
+    }
+  });
+
+  it("interleaves utility groups around pipeline ops", () => {
+    const execs: ToolExecution[] = [
+      makeExec({ id: "1", tool: "read", label: "读取文件" }),
+      makeExec({ id: "2", tool: "sub_agent", agent: "writer", label: "写作" }),
+      makeExec({ id: "3", tool: "read", label: "读取文件" }),
+      makeExec({ id: "4", tool: "grep", label: "搜索" }),
+    ];
+
+    const groups = groupChronologically(execs);
+
+    expect(groups).toHaveLength(3);
+    expect(groups[0].type).toBe("utilities");
+    expect(groups[1].type).toBe("pipeline");
+    expect(groups[2].type).toBe("utilities");
+    if (groups[2].type === "utilities") {
+      expect(groups[2].execs).toHaveLength(2);
+    }
+  });
+
+  it("handles pipeline-only executions", () => {
+    const execs: ToolExecution[] = [
+      makeExec({ id: "1", tool: "sub_agent", agent: "writer", label: "写作" }),
+    ];
+
+    const groups = groupChronologically(execs);
+
+    expect(groups).toHaveLength(1);
+    expect(groups[0].type).toBe("pipeline");
+  });
+
+  it("handles empty array", () => {
+    expect(groupChronologically([])).toHaveLength(0);
+  });
+});

+ 1 - 1
packages/studio/src/components/chatbar-state.test.ts

@@ -3,7 +3,7 @@ import {
   coerceSharedSessionMessages,
   formatSharedSessionContext,
   resolveDirectWriteTarget,
-} from "./ChatBar";
+} from "./chat-utils";
 
 describe("resolveDirectWriteTarget", () => {
   it("prefers the active book when the user is already inside a book flow", () => {

+ 66 - 0
packages/studio/src/components/sidebar/ChaptersSection.tsx

@@ -0,0 +1,66 @@
+import { useEffect, useState } from "react";
+import { fetchJson } from "../../hooks/use-api";
+import { useChatStore } from "../../store/chat";
+import { SidebarCard } from "./SidebarCard";
+import { cn } from "../../lib/utils";
+
+interface ChapterMeta {
+  number: number;
+  title: string;
+  status: string;
+  wordCount: number;
+}
+
+const STATUS_INDICATOR: Record<string, { symbol: string; color: string }> = {
+  approved: { symbol: "✓", color: "text-emerald-500" },
+  "ready-for-review": { symbol: "◆", color: "text-amber-500" },
+  drafted: { symbol: "○", color: "text-muted-foreground" },
+  "needs-revision": { symbol: "✕", color: "text-destructive" },
+  imported: { symbol: "◇", color: "text-blue-500" },
+};
+
+interface ChaptersSectionProps {
+  readonly bookId: string;
+  readonly isZh: boolean;
+}
+
+export function ChaptersSection({ bookId, isZh }: ChaptersSectionProps) {
+  const [chapters, setChapters] = useState<ReadonlyArray<ChapterMeta>>([]);
+  const bookDataVersion = useChatStore((s) => s.bookDataVersion);
+
+  useEffect(() => {
+    fetchJson<{ chapters: ChapterMeta[] }>(`/books/${bookId}`)
+      .then((data) => setChapters(data.chapters))
+      .catch(() => setChapters([]));
+  }, [bookId, bookDataVersion]);
+
+  return (
+    <SidebarCard title={isZh ? "章节" : "Chapters"}>
+      {chapters.length === 0 ? (
+        <p className="text-xs text-muted-foreground/50 italic">
+          {isZh ? "暂无章节" : "No chapters"}
+        </p>
+      ) : (
+        <ul className="space-y-1 max-h-52 overflow-y-auto overflow-x-hidden">
+          {chapters.map((ch) => {
+            const ind = STATUS_INDICATOR[ch.status] ?? { symbol: "○", color: "text-muted-foreground" };
+            return (
+              <li
+                key={`${ch.number}-${ch.title ?? ""}`}
+                onClick={() => useChatStore.getState().openChapterArtifact(ch.number)}
+                className="flex items-center gap-2 py-0.5 text-xs text-muted-foreground cursor-pointer hover:text-foreground transition-colors rounded px-1 -mx-1 hover:bg-secondary/50">
+                <span className={cn("text-[10px] shrink-0", ind.color)}>{ind.symbol}</span>
+                <span className="truncate flex-1">
+                  {String(ch.number).padStart(2, "0")} {ch.title || (isZh ? `第${ch.number}章` : `Chapter ${ch.number}`)}
+                </span>
+                <span className="tabular-nums text-[10px] text-muted-foreground/50 shrink-0">
+                  {(ch.wordCount ?? 0).toLocaleString()}
+                </span>
+              </li>
+            );
+          })}
+        </ul>
+      )}
+    </SidebarCard>
+  );
+}

+ 129 - 0
packages/studio/src/components/sidebar/CharacterSection.tsx

@@ -0,0 +1,129 @@
+import { useEffect, useState } from "react";
+import { Users, ChevronDown } from "lucide-react";
+import { useChatStore } from "../../store/chat";
+import { fetchJson } from "../../hooks/use-api";
+import { SidebarCard } from "./SidebarCard";
+import { cn } from "../../lib/utils";
+
+interface CharacterInfo {
+  name: string;
+  fields: Record<string, string>;
+}
+
+function parseCharacterMatrix(md: string): CharacterInfo[] {
+  const characters: CharacterInfo[] = [];
+  // Split by ## headings (level 2 only)
+  const sections = md.split(/^## /m).slice(1);
+  for (const section of sections) {
+    const lines = section.split("\n");
+    const name = lines[0].trim();
+    if (!name) continue;
+    const fields: Record<string, string> = {};
+    for (let i = 1; i < lines.length; i++) {
+      const match = lines[i].match(/^-\s+\*\*(.+?)\*\*:\s*(.+)/);
+      if (match) {
+        fields[match[1]] = match[2].trim();
+      }
+    }
+    characters.push({ name, fields });
+  }
+  return characters;
+}
+
+const ROLE_COLORS: Record<string, string> = {
+  "主角": "bg-amber-500/15 text-amber-600 dark:text-amber-400",
+  "反派": "bg-red-500/15 text-red-600 dark:text-red-400",
+  "盟友": "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
+  "配角": "bg-blue-500/15 text-blue-600 dark:text-blue-400",
+  "提及": "bg-zinc-500/15 text-zinc-600 dark:text-zinc-400",
+  "protagonist": "bg-amber-500/15 text-amber-600 dark:text-amber-400",
+  "antagonist": "bg-red-500/15 text-red-600 dark:text-red-400",
+  "ally": "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
+  "minor": "bg-blue-500/15 text-blue-600 dark:text-blue-400",
+  "mentioned": "bg-zinc-500/15 text-zinc-600 dark:text-zinc-400",
+};
+
+function getRoleColor(role: string): string {
+  const lower = role.toLowerCase().trim();
+  for (const [key, color] of Object.entries(ROLE_COLORS)) {
+    if (lower.includes(key)) return color;
+  }
+  return "bg-zinc-500/15 text-zinc-600 dark:text-zinc-400";
+}
+
+function CharacterCard({ char }: { readonly char: CharacterInfo }) {
+  const [expanded, setExpanded] = useState(false);
+  const role = char.fields["定位"] ?? char.fields["Role"] ?? "";
+  const tags = char.fields["标签"] ?? char.fields["Tags"] ?? "";
+  const current = char.fields["当前"] ?? char.fields["Current"] ?? "";
+
+  return (
+    <div className="rounded-lg bg-secondary/30 overflow-hidden">
+      <button
+        onClick={() => setExpanded(!expanded)}
+        className="w-full flex items-center gap-2 px-2.5 py-2 text-left"
+      >
+        <Users size={14} className="shrink-0 text-muted-foreground/60" />
+        <span className="text-sm font-medium text-foreground font-['SimSun','Songti_SC','STSong',serif] flex-1 truncate">
+          {char.name}
+        </span>
+        {role && (
+          <span className={cn("text-[10px] px-1.5 py-0.5 rounded-full shrink-0", getRoleColor(role))}>
+            {role.split("/")[0].trim()}
+          </span>
+        )}
+        <ChevronDown size={12} className={cn("text-muted-foreground/50 transition-transform shrink-0", expanded && "rotate-180")} />
+      </button>
+      {expanded && (
+        <div className="px-2.5 pb-2.5 space-y-1">
+          {tags && (
+            <p className="text-xs text-muted-foreground"><span className="text-muted-foreground/60">标签</span> {tags}</p>
+          )}
+          {current && (
+            <p className="text-xs text-muted-foreground"><span className="text-muted-foreground/60">当前</span> {current}</p>
+          )}
+          {Object.entries(char.fields)
+            .filter(([k]) => !["定位", "Role", "标签", "Tags", "当前", "Current"].includes(k))
+            .map(([key, val]) => (
+              <p key={key} className="text-xs text-muted-foreground">
+                <span className="text-muted-foreground/60">{key}</span> {val}
+              </p>
+            ))}
+        </div>
+      )}
+    </div>
+  );
+}
+
+interface CharacterSectionProps {
+  readonly bookId: string;
+}
+
+export function CharacterSection({ bookId }: CharacterSectionProps) {
+  const [characters, setCharacters] = useState<CharacterInfo[]>([]);
+  const bookDataVersion = useChatStore((s) => s.bookDataVersion);
+
+  useEffect(() => {
+    fetchJson<{ content: string | null }>(`/books/${bookId}/truth/character_matrix.md`)
+      .then((data) => {
+        if (data.content) {
+          setCharacters(parseCharacterMatrix(data.content));
+        } else {
+          setCharacters([]);
+        }
+      })
+      .catch(() => setCharacters([]));
+  }, [bookId, bookDataVersion]);
+
+  if (characters.length === 0) return null;
+
+  return (
+    <SidebarCard title="角色">
+      <div className="space-y-1.5">
+        {characters.map((char) => (
+          <CharacterCard key={char.name} char={char} />
+        ))}
+      </div>
+    </SidebarCard>
+  );
+}

+ 61 - 0
packages/studio/src/components/sidebar/FoundationSection.tsx

@@ -0,0 +1,61 @@
+import { useEffect, useState } from "react";
+import { FileText } from "lucide-react";
+import { useChatStore } from "../../store/chat";
+import { fetchJson } from "../../hooks/use-api";
+import { SidebarCard } from "./SidebarCard";
+
+const FOUNDATION_FILES: ReadonlyArray<{ file: string; label: string }> = [
+  { file: "story_bible.md", label: "世界观设定" },
+  { file: "volume_outline.md", label: "卷纲规划" },
+  { file: "book_rules.md", label: "叙事规则" },
+  { file: "current_state.md", label: "状态卡" },
+  { file: "pending_hooks.md", label: "伏笔池" },
+  { file: "subplot_board.md", label: "支线进度" },
+  { file: "emotional_arcs.md", label: "感情线" },
+  { file: "character_matrix.md", label: "角色矩阵" },
+];
+
+interface TruthFileInfo {
+  name: string;
+  size: number;
+}
+
+interface FoundationSectionProps {
+  readonly bookId: string;
+}
+
+export function FoundationSection({ bookId }: FoundationSectionProps) {
+  const [files, setFiles] = useState<ReadonlyArray<TruthFileInfo>>([]);
+  const openArtifact = useChatStore((s) => s.openArtifact);
+  const bookDataVersion = useChatStore((s) => s.bookDataVersion);
+
+  useEffect(() => {
+    fetchJson<{ files: TruthFileInfo[] }>(`/books/${bookId}/truth`)
+      .then((data) => setFiles(data.files))
+      .catch(() => setFiles([]));
+  }, [bookId, bookDataVersion]);
+
+  const available = FOUNDATION_FILES.filter((f) =>
+    files.some((tf) => tf.name === f.file),
+  );
+
+  if (available.length === 0) return null;
+
+  return (
+    <SidebarCard title="核心文件">
+      <ul className="space-y-1">
+        {available.map((item) => (
+          <li key={item.file}>
+            <button
+              onClick={() => openArtifact(item.file)}
+              className="w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-secondary/50 transition-colors font-['SimSun','Songti_SC','STSong',serif]"
+            >
+              <FileText size={14} className="shrink-0 text-muted-foreground/60" />
+              <span className="truncate">{item.label}</span>
+            </button>
+          </li>
+        ))}
+      </ul>
+    </SidebarCard>
+  );
+}

+ 124 - 0
packages/studio/src/components/sidebar/ProgressSection.tsx

@@ -0,0 +1,124 @@
+import { useEffect, useState } from "react";
+import type { SSEMessage } from "../../hooks/use-sse";
+import { Loader2, Check } from "lucide-react";
+import { cn } from "../../lib/utils";
+import { SidebarCard } from "./SidebarCard";
+
+const INIT_BOOK_STEPS = [
+  "生成基础设定",
+  "保存书籍配置",
+  "写入基础设定文件",
+  "初始化控制文档",
+  "创建初始快照",
+] as const;
+
+const WRITE_CHAPTER_STEPS = [
+  "准备章节输入",
+  "撰写章节草稿",
+  "落盘最终章节",
+  "生成最终真相文件",
+  "校验真相文件变更",
+  "同步记忆索引",
+  "更新章节索引与快照",
+] as const;
+
+type StepStatus = "pending" | "active" | "done";
+
+interface ProgressSectionProps {
+  readonly sse: { messages: ReadonlyArray<SSEMessage>; connected: boolean };
+}
+
+export function ProgressSection({ sse }: ProgressSectionProps) {
+  const [operation, setOperation] = useState<"idle" | "init" | "write">("idle");
+  const [completedSteps, setCompletedSteps] = useState<Set<string>>(new Set());
+  const [activeStep, setActiveStep] = useState<string | null>(null);
+
+  useEffect(() => {
+    const latest = sse.messages;
+    if (latest.length === 0) return;
+    const last = latest[latest.length - 1];
+
+    if (last.event === "book:creating") {
+      setOperation("init");
+      setCompletedSteps(new Set());
+      setActiveStep(null);
+    } else if (last.event === "write:start") {
+      setOperation("write");
+      setCompletedSteps(new Set());
+      setActiveStep(null);
+    } else if (last.event === "book:created" || last.event === "write:complete") {
+      // Mark all steps done
+      const steps = operation === "init" ? INIT_BOOK_STEPS : WRITE_CHAPTER_STEPS;
+      setCompletedSteps(new Set(steps));
+      setActiveStep(null);
+    } else if (last.event === "log") {
+      const data = last.data as { message?: string } | null;
+      const message = data?.message;
+      if (message && operation !== "idle") {
+        // Mark previous active step as done, set new active
+        setCompletedSteps((prev) => {
+          if (activeStep) {
+            const next = new Set(prev);
+            next.add(activeStep);
+            return next;
+          }
+          return prev;
+        });
+        setActiveStep(message);
+      }
+    }
+  }, [sse.messages]);
+
+  const steps = operation === "init" ? INIT_BOOK_STEPS
+    : operation === "write" ? WRITE_CHAPTER_STEPS
+    : null;
+
+  if (!steps) return null;
+
+  return (
+    <SidebarCard title="执行">
+      <ul className="space-y-2">
+        {steps.map((step, i) => {
+          const status: StepStatus = completedSteps.has(step) ? "done"
+            : activeStep === step ? "active"
+            : "pending";
+          return (
+            <li key={step} className="flex items-center gap-2.5">
+              <StepIndicator index={i + 1} status={status} />
+              <span className={cn(
+                "text-xs",
+                status === "done" && "text-muted-foreground",
+                status === "active" && "text-foreground font-medium",
+                status === "pending" && "text-muted-foreground/50",
+              )}>
+                {step}
+              </span>
+            </li>
+          );
+        })}
+      </ul>
+    </SidebarCard>
+  );
+}
+
+function StepIndicator({ index, status }: { readonly index: number; readonly status: StepStatus }) {
+  if (status === "done") {
+    return (
+      <div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center shrink-0">
+        <Check size={12} className="text-primary-foreground" strokeWidth={2.5} />
+      </div>
+    );
+  }
+  if (status === "active") {
+    return (
+      <div className="w-5 h-5 rounded-full border-2 border-primary flex items-center justify-center shrink-0">
+        <Loader2 size={10} className="text-primary animate-spin" />
+      </div>
+    );
+  }
+  return (
+    <div className="w-5 h-5 rounded-full border border-border/60 flex items-center justify-center shrink-0">
+      <span className="text-[10px] text-muted-foreground/50">{index}</span>
+    </div>
+  );
+}

+ 30 - 0
packages/studio/src/components/sidebar/SidebarCard.tsx

@@ -0,0 +1,30 @@
+import { useState } from "react";
+import { ChevronDown } from "lucide-react";
+import { cn } from "../../lib/utils";
+
+interface SidebarCardProps {
+  readonly title: string;
+  readonly defaultOpen?: boolean;
+  readonly children: React.ReactNode;
+  readonly actions?: React.ReactNode;
+}
+
+export function SidebarCard({ title, defaultOpen = true, children, actions }: SidebarCardProps) {
+  const [open, setOpen] = useState(defaultOpen);
+
+  return (
+    <div className="rounded-xl bg-card/60">
+      <button
+        onClick={() => setOpen(!open)}
+        className="w-full flex items-center justify-between px-3 py-2.5"
+      >
+        <span className="text-base font-medium text-foreground font-['SimSun','Songti_SC','STSong',serif]">{title}</span>
+        <div className="flex items-center gap-1.5">
+          {actions}
+          <ChevronDown className={cn("w-4 h-4 text-muted-foreground transition-transform", open && "rotate-180")} />
+        </div>
+      </button>
+      {open && <div className="px-3 pb-3">{children}</div>}
+    </div>
+  );
+}

+ 71 - 0
packages/studio/src/components/sidebar/SummarySection.tsx

@@ -0,0 +1,71 @@
+import { useEffect } from "react";
+import { useChatStore } from "../../store/chat";
+import type { BookSummary } from "../../store/chat";
+import { fetchJson } from "../../hooks/use-api";
+import { SidebarCard } from "./SidebarCard";
+
+function parseStoryBible(content: string): BookSummary {
+  const sections = content.split(/^##\s+/m);
+  let world = "";
+  let protagonist = "";
+  let cast = "";
+
+  for (const section of sections) {
+    if (/^0?1[_\s]|世界观|world/i.test(section)) {
+      world = section.replace(/^[^\n]+\n/, "").trim().split("\n\n")[0] ?? "";
+    } else if (/^0?2[_\s]|主角|protagonist/i.test(section)) {
+      protagonist = section.replace(/^[^\n]+\n/, "").trim().split("\n\n")[0] ?? "";
+    } else if (/^0?3[_\s]|配角|supporting|cast/i.test(section)) {
+      cast = section.replace(/^[^\n]+\n/, "").trim().split("\n\n")[0] ?? "";
+    }
+  }
+
+  return { world, protagonist, cast };
+}
+
+interface SummarySectionProps {
+  readonly bookId: string;
+}
+
+export function SummarySection({ bookId }: SummarySectionProps) {
+  const summary = useChatStore((s) => s.bookSummary);
+  const setBookSummary = useChatStore((s) => s.setBookSummary);
+  const bookDataVersion = useChatStore((s) => s.bookDataVersion);
+
+  useEffect(() => {
+    setBookSummary(null);
+    fetchJson<{ content: string | null }>(`/books/${bookId}/truth/story_bible.md`)
+      .then((data) => {
+        if (data.content) setBookSummary(parseStoryBible(data.content));
+      })
+      .catch(() => {});
+  }, [bookId, bookDataVersion, setBookSummary]);
+
+  if (!summary) return null;
+
+  return (
+    <>
+      {summary.world && (
+        <SidebarCard title="世界观">
+          <p className="text-xs text-muted-foreground leading-relaxed line-clamp-4">
+            {summary.world}
+          </p>
+        </SidebarCard>
+      )}
+      {(summary.protagonist || summary.cast) && (
+        <SidebarCard title="角色">
+          {summary.protagonist && (
+            <p className="text-xs text-muted-foreground leading-relaxed line-clamp-3">
+              {summary.protagonist}
+            </p>
+          )}
+          {summary.cast && (
+            <p className="text-xs text-muted-foreground leading-relaxed line-clamp-3 mt-2">
+              {summary.cast}
+            </p>
+          )}
+        </SidebarCard>
+      )}
+    </>
+  );
+}

+ 76 - 0
packages/studio/src/components/ui/alert.tsx

@@ -0,0 +1,76 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+  "group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
+  {
+    variants: {
+      variant: {
+        default: "bg-card text-card-foreground",
+        destructive:
+          "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  }
+)
+
+function Alert({
+  className,
+  variant,
+  ...props
+}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
+  return (
+    <div
+      data-slot="alert"
+      role="alert"
+      className={cn(alertVariants({ variant }), className)}
+      {...props}
+    />
+  )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="alert-title"
+      className={cn(
+        "font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function AlertDescription({
+  className,
+  ...props
+}: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="alert-description"
+      className={cn(
+        "text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="alert-action"
+      className={cn("absolute top-2 right-2", className)}
+      {...props}
+    />
+  )
+}
+
+export { Alert, AlertTitle, AlertDescription, AlertAction }

+ 52 - 0
packages/studio/src/components/ui/badge.tsx

@@ -0,0 +1,52 @@
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const badgeVariants = cva(
+  "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
+  {
+    variants: {
+      variant: {
+        default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+        secondary:
+          "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
+        destructive:
+          "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
+        outline:
+          "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
+        ghost:
+          "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
+        link: "text-primary underline-offset-4 hover:underline",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+    },
+  }
+)
+
+function Badge({
+  className,
+  variant = "default",
+  render,
+  ...props
+}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
+  return useRender({
+    defaultTagName: "span",
+    props: mergeProps<"span">(
+      {
+        className: cn(badgeVariants({ variant }), className),
+      },
+      props
+    ),
+    render,
+    state: {
+      slot: "badge",
+      variant,
+    },
+  })
+}
+
+export { Badge, badgeVariants }

+ 87 - 0
packages/studio/src/components/ui/button-group.tsx

@@ -0,0 +1,87 @@
+import { mergeProps } from "@base-ui/react/merge-props"
+import { useRender } from "@base-ui/react/use-render"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+import { Separator } from "@/components/ui/separator"
+
+const buttonGroupVariants = cva(
+  "flex w-fit items-stretch *:focus-visible:relative *:focus-visible:z-10 has-[>[data-slot=button-group]]:gap-2 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-lg [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
+  {
+    variants: {
+      orientation: {
+        horizontal:
+          "*:data-slot:rounded-r-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-r-lg! [&>[data-slot]~[data-slot]]:rounded-l-none [&>[data-slot]~[data-slot]]:border-l-0",
+        vertical:
+          "flex-col *:data-slot:rounded-b-none [&>[data-slot]:not(:has(~[data-slot]))]:rounded-b-lg! [&>[data-slot]~[data-slot]]:rounded-t-none [&>[data-slot]~[data-slot]]:border-t-0",
+      },
+    },
+    defaultVariants: {
+      orientation: "horizontal",
+    },
+  }
+)
+
+function ButtonGroup({
+  className,
+  orientation,
+  ...props
+}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
+  return (
+    <div
+      role="group"
+      data-slot="button-group"
+      data-orientation={orientation}
+      className={cn(buttonGroupVariants({ orientation }), className)}
+      {...props}
+    />
+  )
+}
+
+function ButtonGroupText({
+  className,
+  render,
+  ...props
+}: useRender.ComponentProps<"div">) {
+  return useRender({
+    defaultTagName: "div",
+    props: mergeProps<"div">(
+      {
+        className: cn(
+          "flex items-center gap-2 rounded-lg border bg-muted px-2.5 text-sm font-medium [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
+          className
+        ),
+      },
+      props
+    ),
+    render,
+    state: {
+      slot: "button-group-text",
+    },
+  })
+}
+
+function ButtonGroupSeparator({
+  className,
+  orientation = "vertical",
+  ...props
+}: React.ComponentProps<typeof Separator>) {
+  return (
+    <Separator
+      data-slot="button-group-separator"
+      orientation={orientation}
+      className={cn(
+        "relative self-stretch bg-input data-horizontal:mx-px data-horizontal:w-auto data-vertical:my-px data-vertical:h-auto",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export {
+  ButtonGroup,
+  ButtonGroupSeparator,
+  ButtonGroupText,
+  buttonGroupVariants,
+}

+ 58 - 0
packages/studio/src/components/ui/button.tsx

@@ -0,0 +1,58 @@
+import { Button as ButtonPrimitive } from "@base-ui/react/button"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+
+const buttonVariants = cva(
+  "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+  {
+    variants: {
+      variant: {
+        default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
+        outline:
+          "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
+        secondary:
+          "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
+        ghost:
+          "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
+        destructive:
+          "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
+        link: "text-primary underline-offset-4 hover:underline",
+      },
+      size: {
+        default:
+          "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+        xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
+        sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
+        lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
+        icon: "size-8",
+        "icon-xs":
+          "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
+        "icon-sm":
+          "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
+        "icon-lg": "size-9",
+      },
+    },
+    defaultVariants: {
+      variant: "default",
+      size: "default",
+    },
+  }
+)
+
+function Button({
+  className,
+  variant = "default",
+  size = "default",
+  ...props
+}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
+  return (
+    <ButtonPrimitive
+      data-slot="button"
+      className={cn(buttonVariants({ variant, size, className }))}
+      {...props}
+    />
+  )
+}
+
+export { Button, buttonVariants }

+ 19 - 0
packages/studio/src/components/ui/collapsible.tsx

@@ -0,0 +1,19 @@
+import { Collapsible as CollapsiblePrimitive } from "@base-ui/react/collapsible"
+
+function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) {
+  return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
+}
+
+function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) {
+  return (
+    <CollapsiblePrimitive.Trigger data-slot="collapsible-trigger" {...props} />
+  )
+}
+
+function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) {
+  return (
+    <CollapsiblePrimitive.Panel data-slot="collapsible-content" {...props} />
+  )
+}
+
+export { Collapsible, CollapsibleTrigger, CollapsibleContent }

+ 194 - 0
packages/studio/src/components/ui/command.tsx

@@ -0,0 +1,194 @@
+import * as React from "react"
+import { Command as CommandPrimitive } from "cmdk"
+
+import { cn } from "@/lib/utils"
+import {
+  Dialog,
+  DialogContent,
+  DialogDescription,
+  DialogHeader,
+  DialogTitle,
+} from "@/components/ui/dialog"
+import {
+  InputGroup,
+  InputGroupAddon,
+} from "@/components/ui/input-group"
+import { SearchIcon, CheckIcon } from "lucide-react"
+
+function Command({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive>) {
+  return (
+    <CommandPrimitive
+      data-slot="command"
+      className={cn(
+        "flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function CommandDialog({
+  title = "Command Palette",
+  description = "Search for a command to run...",
+  children,
+  className,
+  showCloseButton = false,
+  ...props
+}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
+  title?: string
+  description?: string
+  className?: string
+  showCloseButton?: boolean
+  children: React.ReactNode
+}) {
+  return (
+    <Dialog {...props}>
+      <DialogHeader className="sr-only">
+        <DialogTitle>{title}</DialogTitle>
+        <DialogDescription>{description}</DialogDescription>
+      </DialogHeader>
+      <DialogContent
+        className={cn(
+          "top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
+          className
+        )}
+        showCloseButton={showCloseButton}
+      >
+        {children}
+      </DialogContent>
+    </Dialog>
+  )
+}
+
+function CommandInput({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.Input>) {
+  return (
+    <div data-slot="command-input-wrapper" className="p-1 pb-0">
+      <InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
+        <CommandPrimitive.Input
+          data-slot="command-input"
+          className={cn(
+            "w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
+            className
+          )}
+          {...props}
+        />
+        <InputGroupAddon>
+          <SearchIcon className="size-4 shrink-0 opacity-50" />
+        </InputGroupAddon>
+      </InputGroup>
+    </div>
+  )
+}
+
+function CommandList({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.List>) {
+  return (
+    <CommandPrimitive.List
+      data-slot="command-list"
+      className={cn(
+        "no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function CommandEmpty({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
+  return (
+    <CommandPrimitive.Empty
+      data-slot="command-empty"
+      className={cn("py-6 text-center text-sm", className)}
+      {...props}
+    />
+  )
+}
+
+function CommandGroup({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.Group>) {
+  return (
+    <CommandPrimitive.Group
+      data-slot="command-group"
+      className={cn(
+        "overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function CommandSeparator({
+  className,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
+  return (
+    <CommandPrimitive.Separator
+      data-slot="command-separator"
+      className={cn("-mx-1 h-px bg-border", className)}
+      {...props}
+    />
+  )
+}
+
+function CommandItem({
+  className,
+  children,
+  ...props
+}: React.ComponentProps<typeof CommandPrimitive.Item>) {
+  return (
+    <CommandPrimitive.Item
+      data-slot="command-item"
+      className={cn(
+        "group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
+        className
+      )}
+      {...props}
+    >
+      {children}
+      <CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
+    </CommandPrimitive.Item>
+  )
+}
+
+function CommandShortcut({
+  className,
+  ...props
+}: React.ComponentProps<"span">) {
+  return (
+    <span
+      data-slot="command-shortcut"
+      className={cn(
+        "ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export {
+  Command,
+  CommandDialog,
+  CommandInput,
+  CommandList,
+  CommandEmpty,
+  CommandGroup,
+  CommandItem,
+  CommandShortcut,
+  CommandSeparator,
+}

+ 158 - 0
packages/studio/src/components/ui/dialog.tsx

@@ -0,0 +1,158 @@
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+  return <DialogPrimitive.Root data-slot="dialog" {...props} />
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+  return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+  return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+  return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
+}
+
+function DialogOverlay({
+  className,
+  ...props
+}: DialogPrimitive.Backdrop.Props) {
+  return (
+    <DialogPrimitive.Backdrop
+      data-slot="dialog-overlay"
+      className={cn(
+        "fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function DialogContent({
+  className,
+  children,
+  showCloseButton = true,
+  ...props
+}: DialogPrimitive.Popup.Props & {
+  showCloseButton?: boolean
+}) {
+  return (
+    <DialogPortal>
+      <DialogOverlay />
+      <DialogPrimitive.Popup
+        data-slot="dialog-content"
+        className={cn(
+          "fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
+          className
+        )}
+        {...props}
+      >
+        {children}
+        {showCloseButton && (
+          <DialogPrimitive.Close
+            data-slot="dialog-close"
+            render={
+              <Button
+                variant="ghost"
+                className="absolute top-2 right-2"
+                size="icon-sm"
+              />
+            }
+          >
+            <XIcon
+            />
+            <span className="sr-only">Close</span>
+          </DialogPrimitive.Close>
+        )}
+      </DialogPrimitive.Popup>
+    </DialogPortal>
+  )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="dialog-header"
+      className={cn("flex flex-col gap-2", className)}
+      {...props}
+    />
+  )
+}
+
+function DialogFooter({
+  className,
+  showCloseButton = false,
+  children,
+  ...props
+}: React.ComponentProps<"div"> & {
+  showCloseButton?: boolean
+}) {
+  return (
+    <div
+      data-slot="dialog-footer"
+      className={cn(
+        "-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
+        className
+      )}
+      {...props}
+    >
+      {children}
+      {showCloseButton && (
+        <DialogPrimitive.Close render={<Button variant="outline" />}>
+          Close
+        </DialogPrimitive.Close>
+      )}
+    </div>
+  )
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+  return (
+    <DialogPrimitive.Title
+      data-slot="dialog-title"
+      className={cn(
+        "text-base leading-none font-medium",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function DialogDescription({
+  className,
+  ...props
+}: DialogPrimitive.Description.Props) {
+  return (
+    <DialogPrimitive.Description
+      data-slot="dialog-description"
+      className={cn(
+        "text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export {
+  Dialog,
+  DialogClose,
+  DialogContent,
+  DialogDescription,
+  DialogFooter,
+  DialogHeader,
+  DialogOverlay,
+  DialogPortal,
+  DialogTitle,
+  DialogTrigger,
+}

+ 266 - 0
packages/studio/src/components/ui/dropdown-menu.tsx

@@ -0,0 +1,266 @@
+import * as React from "react"
+import { Menu as MenuPrimitive } from "@base-ui/react/menu"
+
+import { cn } from "@/lib/utils"
+import { ChevronRightIcon, CheckIcon } from "lucide-react"
+
+function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
+  return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
+}
+
+function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
+  return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
+}
+
+function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
+  return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
+}
+
+function DropdownMenuContent({
+  align = "start",
+  alignOffset = 0,
+  side = "bottom",
+  sideOffset = 4,
+  className,
+  ...props
+}: MenuPrimitive.Popup.Props &
+  Pick<
+    MenuPrimitive.Positioner.Props,
+    "align" | "alignOffset" | "side" | "sideOffset"
+  >) {
+  return (
+    <MenuPrimitive.Portal>
+      <MenuPrimitive.Positioner
+        className="isolate z-50 outline-none"
+        align={align}
+        alignOffset={alignOffset}
+        side={side}
+        sideOffset={sideOffset}
+      >
+        <MenuPrimitive.Popup
+          data-slot="dropdown-menu-content"
+          className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
+          {...props}
+        />
+      </MenuPrimitive.Positioner>
+    </MenuPrimitive.Portal>
+  )
+}
+
+function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
+  return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
+}
+
+function DropdownMenuLabel({
+  className,
+  inset,
+  ...props
+}: MenuPrimitive.GroupLabel.Props & {
+  inset?: boolean
+}) {
+  return (
+    <MenuPrimitive.GroupLabel
+      data-slot="dropdown-menu-label"
+      data-inset={inset}
+      className={cn(
+        "px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function DropdownMenuItem({
+  className,
+  inset,
+  variant = "default",
+  ...props
+}: MenuPrimitive.Item.Props & {
+  inset?: boolean
+  variant?: "default" | "destructive"
+}) {
+  return (
+    <MenuPrimitive.Item
+      data-slot="dropdown-menu-item"
+      data-inset={inset}
+      data-variant={variant}
+      className={cn(
+        "group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
+  return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
+}
+
+function DropdownMenuSubTrigger({
+  className,
+  inset,
+  children,
+  ...props
+}: MenuPrimitive.SubmenuTrigger.Props & {
+  inset?: boolean
+}) {
+  return (
+    <MenuPrimitive.SubmenuTrigger
+      data-slot="dropdown-menu-sub-trigger"
+      data-inset={inset}
+      className={cn(
+        "flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className
+      )}
+      {...props}
+    >
+      {children}
+      <ChevronRightIcon className="ml-auto" />
+    </MenuPrimitive.SubmenuTrigger>
+  )
+}
+
+function DropdownMenuSubContent({
+  align = "start",
+  alignOffset = -3,
+  side = "right",
+  sideOffset = 0,
+  className,
+  ...props
+}: React.ComponentProps<typeof DropdownMenuContent>) {
+  return (
+    <DropdownMenuContent
+      data-slot="dropdown-menu-sub-content"
+      className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
+      align={align}
+      alignOffset={alignOffset}
+      side={side}
+      sideOffset={sideOffset}
+      {...props}
+    />
+  )
+}
+
+function DropdownMenuCheckboxItem({
+  className,
+  children,
+  checked,
+  inset,
+  ...props
+}: MenuPrimitive.CheckboxItem.Props & {
+  inset?: boolean
+}) {
+  return (
+    <MenuPrimitive.CheckboxItem
+      data-slot="dropdown-menu-checkbox-item"
+      data-inset={inset}
+      className={cn(
+        "relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className
+      )}
+      checked={checked}
+      {...props}
+    >
+      <span
+        className="pointer-events-none absolute right-2 flex items-center justify-center"
+        data-slot="dropdown-menu-checkbox-item-indicator"
+      >
+        <MenuPrimitive.CheckboxItemIndicator>
+          <CheckIcon
+          />
+        </MenuPrimitive.CheckboxItemIndicator>
+      </span>
+      {children}
+    </MenuPrimitive.CheckboxItem>
+  )
+}
+
+function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
+  return (
+    <MenuPrimitive.RadioGroup
+      data-slot="dropdown-menu-radio-group"
+      {...props}
+    />
+  )
+}
+
+function DropdownMenuRadioItem({
+  className,
+  children,
+  inset,
+  ...props
+}: MenuPrimitive.RadioItem.Props & {
+  inset?: boolean
+}) {
+  return (
+    <MenuPrimitive.RadioItem
+      data-slot="dropdown-menu-radio-item"
+      data-inset={inset}
+      className={cn(
+        "relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className
+      )}
+      {...props}
+    >
+      <span
+        className="pointer-events-none absolute right-2 flex items-center justify-center"
+        data-slot="dropdown-menu-radio-item-indicator"
+      >
+        <MenuPrimitive.RadioItemIndicator>
+          <CheckIcon
+          />
+        </MenuPrimitive.RadioItemIndicator>
+      </span>
+      {children}
+    </MenuPrimitive.RadioItem>
+  )
+}
+
+function DropdownMenuSeparator({
+  className,
+  ...props
+}: MenuPrimitive.Separator.Props) {
+  return (
+    <MenuPrimitive.Separator
+      data-slot="dropdown-menu-separator"
+      className={cn("-mx-1 my-1 h-px bg-border", className)}
+      {...props}
+    />
+  )
+}
+
+function DropdownMenuShortcut({
+  className,
+  ...props
+}: React.ComponentProps<"span">) {
+  return (
+    <span
+      data-slot="dropdown-menu-shortcut"
+      className={cn(
+        "ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export {
+  DropdownMenu,
+  DropdownMenuPortal,
+  DropdownMenuTrigger,
+  DropdownMenuContent,
+  DropdownMenuGroup,
+  DropdownMenuLabel,
+  DropdownMenuItem,
+  DropdownMenuCheckboxItem,
+  DropdownMenuRadioGroup,
+  DropdownMenuRadioItem,
+  DropdownMenuSeparator,
+  DropdownMenuShortcut,
+  DropdownMenuSub,
+  DropdownMenuSubTrigger,
+  DropdownMenuSubContent,
+}

+ 51 - 0
packages/studio/src/components/ui/hover-card.tsx

@@ -0,0 +1,51 @@
+"use client"
+
+import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"
+
+import { cn } from "@/lib/utils"
+
+function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) {
+  return <PreviewCardPrimitive.Root data-slot="hover-card" {...props} />
+}
+
+function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) {
+  return (
+    <PreviewCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
+  )
+}
+
+function HoverCardContent({
+  className,
+  side = "bottom",
+  sideOffset = 4,
+  align = "center",
+  alignOffset = 4,
+  ...props
+}: PreviewCardPrimitive.Popup.Props &
+  Pick<
+    PreviewCardPrimitive.Positioner.Props,
+    "align" | "alignOffset" | "side" | "sideOffset"
+  >) {
+  return (
+    <PreviewCardPrimitive.Portal data-slot="hover-card-portal">
+      <PreviewCardPrimitive.Positioner
+        align={align}
+        alignOffset={alignOffset}
+        side={side}
+        sideOffset={sideOffset}
+        className="isolate z-50"
+      >
+        <PreviewCardPrimitive.Popup
+          data-slot="hover-card-content"
+          className={cn(
+            "z-50 w-64 origin-(--transform-origin) rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
+            className
+          )}
+          {...props}
+        />
+      </PreviewCardPrimitive.Positioner>
+    </PreviewCardPrimitive.Portal>
+  )
+}
+
+export { HoverCard, HoverCardTrigger, HoverCardContent }

+ 158 - 0
packages/studio/src/components/ui/input-group.tsx

@@ -0,0 +1,158 @@
+"use client"
+
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+
+function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
+  return (
+    <div
+      data-slot="input-group"
+      role="group"
+      className={cn(
+        "group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-border/40 transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-primary/40 has-[[data-slot=input-group-control]:focus-visible]:ring-2 has-[[data-slot=input-group-control]:focus-visible]:ring-primary/10 has-[[data-slot][aria-invalid=true]]:border-border/40 has-[[data-slot][aria-invalid=true]]:ring-0 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-0 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+const inputGroupAddonVariants = cva(
+  "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
+  {
+    variants: {
+      align: {
+        "inline-start":
+          "order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]",
+        "inline-end":
+          "order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]",
+        "block-start":
+          "order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2",
+        "block-end":
+          "order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2",
+      },
+    },
+    defaultVariants: {
+      align: "inline-start",
+    },
+  }
+)
+
+function InputGroupAddon({
+  className,
+  align = "inline-start",
+  ...props
+}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
+  return (
+    <div
+      role="group"
+      data-slot="input-group-addon"
+      data-align={align}
+      className={cn(inputGroupAddonVariants({ align }), className)}
+      onClick={(e) => {
+        if ((e.target as HTMLElement).closest("button")) {
+          return
+        }
+        e.currentTarget.parentElement?.querySelector("input")?.focus()
+      }}
+      {...props}
+    />
+  )
+}
+
+const inputGroupButtonVariants = cva(
+  "flex items-center gap-2 text-sm shadow-none",
+  {
+    variants: {
+      size: {
+        xs: "h-6 gap-1 rounded-[calc(var(--radius)-3px)] px-1.5 [&>svg:not([class*='size-'])]:size-3.5",
+        sm: "",
+        "icon-xs":
+          "size-6 rounded-[calc(var(--radius)-3px)] p-0 has-[>svg]:p-0",
+        "icon-sm": "size-8 p-0 has-[>svg]:p-0",
+      },
+    },
+    defaultVariants: {
+      size: "xs",
+    },
+  }
+)
+
+function InputGroupButton({
+  className,
+  type = "button",
+  variant = "ghost",
+  size = "xs",
+  ...props
+}: Omit<React.ComponentProps<typeof Button>, "size" | "type"> &
+  VariantProps<typeof inputGroupButtonVariants> & {
+    type?: "button" | "submit" | "reset"
+  }) {
+  return (
+    <Button
+      type={type}
+      data-size={size}
+      variant={variant}
+      className={cn(inputGroupButtonVariants({ size }), className)}
+      {...props}
+    />
+  )
+}
+
+function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
+  return (
+    <span
+      className={cn(
+        "flex items-center gap-2 text-sm text-muted-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function InputGroupInput({
+  className,
+  ...props
+}: React.ComponentProps<"input">) {
+  return (
+    <Input
+      data-slot="input-group-control"
+      className={cn(
+        "flex-1 rounded-none border-0 bg-transparent shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+function InputGroupTextarea({
+  className,
+  ...props
+}: React.ComponentProps<"textarea">) {
+  return (
+    <Textarea
+      data-slot="input-group-control"
+      className={cn(
+        "flex-1 resize-none rounded-none border-0 bg-transparent py-2 shadow-none ring-0 focus-visible:ring-0 disabled:bg-transparent aria-invalid:ring-0 dark:bg-transparent dark:disabled:bg-transparent",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export {
+  InputGroup,
+  InputGroupAddon,
+  InputGroupButton,
+  InputGroupText,
+  InputGroupInput,
+  InputGroupTextarea,
+}

+ 20 - 0
packages/studio/src/components/ui/input.tsx

@@ -0,0 +1,20 @@
+import * as React from "react"
+import { Input as InputPrimitive } from "@base-ui/react/input"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+  return (
+    <InputPrimitive
+      type={type}
+      data-slot="input"
+      className={cn(
+        "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export { Input }

+ 199 - 0
packages/studio/src/components/ui/select.tsx

@@ -0,0 +1,199 @@
+import * as React from "react"
+import { Select as SelectPrimitive } from "@base-ui/react/select"
+
+import { cn } from "@/lib/utils"
+import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
+
+const Select = SelectPrimitive.Root
+
+function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
+  return (
+    <SelectPrimitive.Group
+      data-slot="select-group"
+      className={cn("scroll-my-1 p-1", className)}
+      {...props}
+    />
+  )
+}
+
+function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
+  return (
+    <SelectPrimitive.Value
+      data-slot="select-value"
+      className={cn("flex flex-1 text-left", className)}
+      {...props}
+    />
+  )
+}
+
+function SelectTrigger({
+  className,
+  size = "default",
+  children,
+  ...props
+}: SelectPrimitive.Trigger.Props & {
+  size?: "sm" | "default"
+}) {
+  return (
+    <SelectPrimitive.Trigger
+      data-slot="select-trigger"
+      data-size={size}
+      className={cn(
+        "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
+        className
+      )}
+      {...props}
+    >
+      {children}
+      <SelectPrimitive.Icon
+        render={
+          <ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
+        }
+      />
+    </SelectPrimitive.Trigger>
+  )
+}
+
+function SelectContent({
+  className,
+  children,
+  side = "bottom",
+  sideOffset = 4,
+  align = "center",
+  alignOffset = 0,
+  alignItemWithTrigger = true,
+  ...props
+}: SelectPrimitive.Popup.Props &
+  Pick<
+    SelectPrimitive.Positioner.Props,
+    "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
+  >) {
+  return (
+    <SelectPrimitive.Portal>
+      <SelectPrimitive.Positioner
+        side={side}
+        sideOffset={sideOffset}
+        align={align}
+        alignOffset={alignOffset}
+        alignItemWithTrigger={alignItemWithTrigger}
+        className="isolate z-50"
+      >
+        <SelectPrimitive.Popup
+          data-slot="select-content"
+          data-align-trigger={alignItemWithTrigger}
+          className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
+          {...props}
+        >
+          <SelectScrollUpButton />
+          <SelectPrimitive.List>{children}</SelectPrimitive.List>
+          <SelectScrollDownButton />
+        </SelectPrimitive.Popup>
+      </SelectPrimitive.Positioner>
+    </SelectPrimitive.Portal>
+  )
+}
+
+function SelectLabel({
+  className,
+  ...props
+}: SelectPrimitive.GroupLabel.Props) {
+  return (
+    <SelectPrimitive.GroupLabel
+      data-slot="select-label"
+      className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
+      {...props}
+    />
+  )
+}
+
+function SelectItem({
+  className,
+  children,
+  ...props
+}: SelectPrimitive.Item.Props) {
+  return (
+    <SelectPrimitive.Item
+      data-slot="select-item"
+      className={cn(
+        "relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
+        className
+      )}
+      {...props}
+    >
+      <SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
+        {children}
+      </SelectPrimitive.ItemText>
+      <SelectPrimitive.ItemIndicator
+        render={
+          <span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
+        }
+      >
+        <CheckIcon className="pointer-events-none" />
+      </SelectPrimitive.ItemIndicator>
+    </SelectPrimitive.Item>
+  )
+}
+
+function SelectSeparator({
+  className,
+  ...props
+}: SelectPrimitive.Separator.Props) {
+  return (
+    <SelectPrimitive.Separator
+      data-slot="select-separator"
+      className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
+      {...props}
+    />
+  )
+}
+
+function SelectScrollUpButton({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
+  return (
+    <SelectPrimitive.ScrollUpArrow
+      data-slot="select-scroll-up-button"
+      className={cn(
+        "top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
+        className
+      )}
+      {...props}
+    >
+      <ChevronUpIcon
+      />
+    </SelectPrimitive.ScrollUpArrow>
+  )
+}
+
+function SelectScrollDownButton({
+  className,
+  ...props
+}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
+  return (
+    <SelectPrimitive.ScrollDownArrow
+      data-slot="select-scroll-down-button"
+      className={cn(
+        "bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
+        className
+      )}
+      {...props}
+    >
+      <ChevronDownIcon
+      />
+    </SelectPrimitive.ScrollDownArrow>
+  )
+}
+
+export {
+  Select,
+  SelectContent,
+  SelectGroup,
+  SelectItem,
+  SelectLabel,
+  SelectScrollDownButton,
+  SelectScrollUpButton,
+  SelectSeparator,
+  SelectTrigger,
+  SelectValue,
+}

+ 23 - 0
packages/studio/src/components/ui/separator.tsx

@@ -0,0 +1,23 @@
+import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
+
+import { cn } from "@/lib/utils"
+
+function Separator({
+  className,
+  orientation = "horizontal",
+  ...props
+}: SeparatorPrimitive.Props) {
+  return (
+    <SeparatorPrimitive
+      data-slot="separator"
+      orientation={orientation}
+      className={cn(
+        "shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export { Separator }

+ 10 - 0
packages/studio/src/components/ui/spinner.tsx

@@ -0,0 +1,10 @@
+import { cn } from "@/lib/utils"
+import { Loader2Icon } from "lucide-react"
+
+function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
+  return (
+    <Loader2Icon role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props} />
+  )
+}
+
+export { Spinner }

+ 18 - 0
packages/studio/src/components/ui/textarea.tsx

@@ -0,0 +1,18 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
+  return (
+    <textarea
+      data-slot="textarea"
+      className={cn(
+        "flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
+        className
+      )}
+      {...props}
+    />
+  )
+}
+
+export { Textarea }

+ 66 - 0
packages/studio/src/components/ui/tooltip.tsx

@@ -0,0 +1,66 @@
+"use client"
+
+import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+  delay = 0,
+  ...props
+}: TooltipPrimitive.Provider.Props) {
+  return (
+    <TooltipPrimitive.Provider
+      data-slot="tooltip-provider"
+      delay={delay}
+      {...props}
+    />
+  )
+}
+
+function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
+  return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
+}
+
+function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
+  return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
+}
+
+function TooltipContent({
+  className,
+  side = "top",
+  sideOffset = 4,
+  align = "center",
+  alignOffset = 0,
+  children,
+  ...props
+}: TooltipPrimitive.Popup.Props &
+  Pick<
+    TooltipPrimitive.Positioner.Props,
+    "align" | "alignOffset" | "side" | "sideOffset"
+  >) {
+  return (
+    <TooltipPrimitive.Portal>
+      <TooltipPrimitive.Positioner
+        align={align}
+        alignOffset={alignOffset}
+        side={side}
+        sideOffset={sideOffset}
+        className="isolate z-50"
+      >
+        <TooltipPrimitive.Popup
+          data-slot="tooltip-content"
+          className={cn(
+            "z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
+            className
+          )}
+          {...props}
+        >
+          {children}
+          <TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
+        </TooltipPrimitive.Popup>
+      </TooltipPrimitive.Positioner>
+    </TooltipPrimitive.Portal>
+  )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

+ 12 - 12
packages/studio/src/hooks/use-api.test.ts

@@ -8,9 +8,9 @@ describe("buildApiUrl", () => {
   });
 
   it("prefixes api paths once", () => {
-    expect(buildApiUrl("/books")).toBe("/api/books");
-    expect(buildApiUrl("books")).toBe("/api/books");
-    expect(buildApiUrl("/api/books")).toBe("/api/books");
+    expect(buildApiUrl("/books")).toBe("/api/v1/books");
+    expect(buildApiUrl("books")).toBe("/api/v1/books");
+    expect(buildApiUrl("/api/v1/books")).toBe("/api/v1/books");
   });
 });
 
@@ -52,27 +52,27 @@ describe("fetchJson", () => {
 
 describe("deriveInvalidationPaths", () => {
   it("refreshes book collections after creating a book", () => {
-    expect(deriveInvalidationPaths("/books/create")).toEqual(["/api/books"]);
+    expect(deriveInvalidationPaths("/books/create")).toEqual(["/api/v1/books"]);
   });
 
   it("refreshes both collections and the current book after book mutations", () => {
     expect(deriveInvalidationPaths("/books/demo/write-next")).toEqual([
-      "/api/books",
-      "/api/books/demo",
+      "/api/v1/books",
+      "/api/v1/books/demo",
     ]);
     expect(deriveInvalidationPaths("/books/demo/chapters/3/approve")).toEqual([
-      "/api/books",
-      "/api/books/demo",
+      "/api/v1/books",
+      "/api/v1/books/demo",
     ]);
   });
 
   it("refreshes daemon state after daemon mutations", () => {
-    expect(deriveInvalidationPaths("/daemon/start")).toEqual(["/api/daemon"]);
-    expect(deriveInvalidationPaths("/daemon/stop")).toEqual(["/api/daemon"]);
+    expect(deriveInvalidationPaths("/daemon/start")).toEqual(["/api/v1/daemon"]);
+    expect(deriveInvalidationPaths("/daemon/stop")).toEqual(["/api/v1/daemon"]);
   });
 
   it("refreshes project data after project mutations", () => {
-    expect(deriveInvalidationPaths("/project")).toEqual(["/api/project"]);
-    expect(deriveInvalidationPaths("/project/language")).toEqual(["/api/project", "/api/project/language"]);
+    expect(deriveInvalidationPaths("/project")).toEqual(["/api/v1/project"]);
+    expect(deriveInvalidationPaths("/project/language")).toEqual(["/api/v1/project", "/api/v1/project/language"]);
   });
 });

+ 13 - 13
packages/studio/src/hooks/use-api.ts

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback } from "react";
 
-const BASE = "/api";
+const BASE = "/api/v1";
 const API_INVALIDATE_EVENT = "inkos:api-invalidate";
 
 interface ApiInvalidateDetail {
@@ -20,30 +20,30 @@ export function deriveInvalidationPaths(path: string): ReadonlyArray<string> {
   const normalized = buildApiUrl(path);
   if (!normalized) return [];
 
-  if (normalized === "/api/books/create") {
-    return ["/api/books"];
+  if (normalized === "/api/v1/books/create") {
+    return ["/api/v1/books"];
   }
 
-  if (normalized === "/api/project") {
-    return ["/api/project"];
+  if (normalized === "/api/v1/project") {
+    return ["/api/v1/project"];
   }
 
-  if (normalized.startsWith("/api/project/")) {
-    return ["/api/project", normalized];
+  if (normalized.startsWith("/api/v1/project/")) {
+    return ["/api/v1/project", normalized];
   }
 
-  const bookAction = normalized.match(/^\/api\/books\/([^/]+)\/(write-next|draft)$/);
+  const bookAction = normalized.match(/^\/api\/v1\/books\/([^/]+)\/(write-next|draft)$/);
   if (bookAction) {
-    return ["/api/books", `/api/books/${bookAction[1]}`];
+    return ["/api/v1/books", `/api/v1/books/${bookAction[1]}`];
   }
 
-  const chapterAction = normalized.match(/^\/api\/books\/([^/]+)\/chapters\/\d+\/(approve|reject)$/);
+  const chapterAction = normalized.match(/^\/api\/v1\/books\/([^/]+)\/chapters\/\d+\/(approve|reject)$/);
   if (chapterAction) {
-    return ["/api/books", `/api/books/${chapterAction[1]}`];
+    return ["/api/v1/books", `/api/v1/books/${chapterAction[1]}`];
   }
 
-  if (/^\/api\/daemon\/(start|stop)$/.test(normalized)) {
-    return ["/api/daemon"];
+  if (/^\/api\/v1\/daemon\/(start|stop)$/.test(normalized)) {
+    return ["/api/v1/daemon"];
   }
 
   return [];

+ 85 - 0
packages/studio/src/hooks/use-hash-route.test.ts

@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+import { parseHash, routeToHash } from "./use-hash-route";
+
+describe("hash route", () => {
+  describe("parseHash", () => {
+    it("parses empty hash as dashboard", () => {
+      expect(parseHash("")).toEqual({ page: "dashboard" });
+    });
+
+    it("parses #/ as dashboard", () => {
+      expect(parseHash("#/")).toEqual({ page: "dashboard" });
+    });
+
+    it("parses book route", () => {
+      expect(parseHash("#/book/my-novel")).toEqual({ page: "book", bookId: "my-novel" });
+    });
+
+    it("decodes encoded bookId", () => {
+      expect(parseHash("#/book/%E4%B9%9D%E9%BE%99")).toEqual({ page: "book", bookId: "九龙" });
+    });
+
+    it("parses book/new as book-create", () => {
+      expect(parseHash("#/book/new")).toEqual({ page: "book-create" });
+    });
+
+    it("parses config as services (redirect)", () => {
+      expect(parseHash("#/config")).toEqual({ page: "services" });
+    });
+
+    it("parses services", () => {
+      expect(parseHash("#/services")).toEqual({ page: "services" });
+    });
+
+    it("parses service-detail", () => {
+      expect(parseHash("#/services/openai")).toEqual({ page: "service-detail", serviceId: "openai" });
+    });
+
+    it("decodes encoded serviceId", () => {
+      expect(parseHash("#/services/%E8%87%AA%E5%AE%9A%E4%B9%89")).toEqual({ page: "service-detail", serviceId: "自定义" });
+    });
+
+    it("falls back to dashboard for unknown hash", () => {
+      expect(parseHash("#/unknown/route")).toEqual({ page: "dashboard" });
+    });
+  });
+
+  describe("routeToHash", () => {
+    it("dashboard -> #/", () => {
+      expect(routeToHash({ page: "dashboard" })).toBe("#/");
+    });
+
+    it("book -> #/book/{id}", () => {
+      expect(routeToHash({ page: "book", bookId: "novel-1" })).toBe("#/book/novel-1");
+    });
+
+    it("encodes Chinese bookId", () => {
+      const hash = routeToHash({ page: "book", bookId: "九龙城夜行" });
+      expect(hash).toContain("#/book/");
+      expect(decodeURIComponent(hash)).toContain("九龙城夜行");
+    });
+
+    it("book-create -> #/book/new", () => {
+      expect(routeToHash({ page: "book-create" })).toBe("#/book/new");
+    });
+
+    it("services -> #/services", () => {
+      expect(routeToHash({ page: "services" })).toBe("#/services");
+    });
+
+    it("service-detail -> #/services/{id}", () => {
+      expect(routeToHash({ page: "service-detail", serviceId: "openai" })).toBe("#/services/openai");
+    });
+
+    it("encodes Chinese serviceId", () => {
+      const hash = routeToHash({ page: "service-detail", serviceId: "自定义" });
+      expect(hash).toContain("#/services/");
+      expect(decodeURIComponent(hash)).toContain("自定义");
+    });
+
+    it("non-hash pages return empty string", () => {
+      expect(routeToHash({ page: "daemon" })).toBe("");
+      expect(routeToHash({ page: "logs" })).toBe("");
+    });
+  });
+});

+ 77 - 0
packages/studio/src/hooks/use-hash-route.ts

@@ -0,0 +1,77 @@
+import { useState, useEffect, useCallback } from "react";
+
+export type HashRoute =
+  | { page: "dashboard" }
+  | { page: "book"; bookId: string }
+  | { page: "book-create" }
+  | { page: "services" }
+  | { page: "service-detail"; serviceId: string }
+  | { page: "chapter"; bookId: string; chapterNumber: number }
+  | { page: "analytics"; bookId: string }
+  | { page: "truth"; bookId: string }
+  | { page: "daemon" }
+  | { page: "logs" }
+  | { page: "genres" }
+  | { page: "style" }
+  | { page: "import" }
+  | { page: "radar" }
+  | { page: "doctor" };
+
+function parseHash(hash: string): HashRoute {
+  const path = hash.replace(/^#\/?/, "");
+
+  if (!path || path === "/") return { page: "dashboard" };
+  if (path === "config" || path === "services") return { page: "services" };
+  if (path === "book/new") return { page: "book-create" };
+
+  const serviceMatch = path.match(/^services\/([^/]+)$/);
+  if (serviceMatch) return { page: "service-detail", serviceId: decodeURIComponent(serviceMatch[1]) };
+
+  const bookMatch = path.match(/^book\/([^/]+)$/);
+  if (bookMatch) return { page: "book", bookId: decodeURIComponent(bookMatch[1]) };
+
+  return { page: "dashboard" };
+}
+
+function routeToHash(route: HashRoute): string {
+  switch (route.page) {
+    case "dashboard": return "#/";
+    case "book": return `#/book/${encodeURIComponent(route.bookId)}`;
+    case "book-create": return "#/book/new";
+    case "services": return "#/services";
+    case "service-detail": return `#/services/${encodeURIComponent(route.serviceId)}`;
+    default: return "";
+  }
+}
+
+export { parseHash, routeToHash }; // for testing
+
+const HASH_PAGES = new Set(["dashboard", "book", "book-create", "services", "service-detail"]);
+
+export function useHashRoute() {
+  const [route, setRouteState] = useState<HashRoute>(() => parseHash(window.location.hash));
+
+  useEffect(() => {
+    const onHashChange = () => setRouteState(parseHash(window.location.hash));
+    window.addEventListener("hashchange", onHashChange);
+    return () => window.removeEventListener("hashchange", onHashChange);
+  }, []);
+
+  const setRoute = useCallback((newRoute: HashRoute) => {
+    if (HASH_PAGES.has(newRoute.page)) {
+      const hash = routeToHash(newRoute);
+      if (hash) {
+        window.location.hash = hash;
+        return;
+      }
+    }
+    setRouteState(newRoute);
+  }, []);
+
+  const nav = {
+    toServices: () => setRoute({ page: "services" }),
+    toServiceDetail: (id: string) => setRoute({ page: "service-detail", serviceId: id }),
+  };
+
+  return { route, setRoute, nav };
+}

+ 1 - 1
packages/studio/src/hooks/use-i18n.ts

@@ -6,7 +6,7 @@ const strings = {
   // Header
   "nav.books": { zh: "书籍", en: "Books" },
   "nav.newBook": { zh: "新建书籍", en: "New Book" },
-  "nav.config": { zh: "配置", en: "Config" },
+  "nav.config": { zh: "模型配置", en: "Model Config" },
   "nav.connected": { zh: "已连接", en: "Connected" },
   "nav.disconnected": { zh: "未连接", en: "Disconnected" },
 

+ 2 - 1
packages/studio/src/hooks/use-sse.ts

@@ -45,6 +45,7 @@ export const STUDIO_SSE_EVENTS = [
   "fanfic:refresh:start",
   "fanfic:refresh:complete",
   "fanfic:refresh:error",
+  "draft:delta",
   "radar:start",
   "radar:complete",
   "radar:error",
@@ -53,7 +54,7 @@ export const STUDIO_SSE_EVENTS = [
   "ping",
 ] as const;
 
-export function useSSE(url = "/api/events") {
+export function useSSE(url = "/api/v1/events") {
   const [messages, setMessages] = useState<ReadonlyArray<SSEMessage>>([]);
   const [connected, setConnected] = useState(false);
   const esRef = useRef<EventSource | null>(null);

+ 15 - 0
packages/studio/src/index.css

@@ -6,6 +6,11 @@
 
 @import "tailwindcss";
 
+@import "tw-animate-css";
+@import "shadcn/tailwind.css";
+
+@custom-variant dark (&:is(.dark *));
+
 @theme inline {
   --color-background: var(--background);
   --color-foreground: var(--foreground);
@@ -306,3 +311,13 @@ h3 { font-size: 1.25rem; }
   transition: width 300ms cubic-bezier(0.16, 1, 0.3, 1),
               opacity 200ms ease-out;
 }
+
+/* ── Streamdown overrides ── */
+[data-streamdown="table-wrapper"] {
+  border: none;
+  background: transparent;
+  padding: 0;
+}
+[data-streamdown="table-wrapper"] > .flex.items-center.justify-end {
+  display: none;
+}

+ 2 - 2
packages/studio/src/pages/BookDetail.tsx

@@ -161,7 +161,7 @@ export function BookDetail({
     setConfirmDeleteOpen(false);
     setDeleting(true);
     try {
-      const res = await fetch(`/api/books/${bookId}`, { method: "DELETE" });
+      const res = await fetch(`/api/v1/books/${bookId}`, { method: "DELETE" });
       if (!res.ok) {
         const json = await res.json().catch(() => ({}));
         throw new Error((json as { error?: string }).error ?? `${res.status}`);
@@ -299,7 +299,7 @@ export function BookDetail({
   const currentTargetChapters = settingsTargetChapters ?? book.targetChapters ?? 0;
   const currentStatus = settingsStatus ?? (book.status as BookStatus);
 
-  const exportHref = `/api/books/${bookId}/export?format=${exportFormat}${exportApprovedOnly ? "&approvedOnly=true" : ""}`;
+  const exportHref = `/api/v1/books/${bookId}/export?format=${exportFormat}${exportApprovedOnly ? "&approvedOnly=true" : ""}`;
 
   return (
     <div className="space-y-8 fade-in">

+ 446 - 0
packages/studio/src/pages/ChatPage.tsx

@@ -0,0 +1,446 @@
+import { useRef, useEffect, useMemo } from "react";
+import type { Theme } from "../hooks/use-theme";
+import type { TFunction } from "../hooks/use-i18n";
+import type { SSEMessage } from "../hooks/use-sse";
+import { useChatStore } from "../store/chat";
+import { useServiceStore } from "../store/service";
+import {
+  DropdownMenu,
+  DropdownMenuTrigger,
+  DropdownMenuContent,
+  DropdownMenuItem,
+} from "../components/ui/dropdown-menu";
+import {
+  Reasoning,
+  ReasoningTrigger,
+  ReasoningContent,
+} from "../components/ai-elements/reasoning";
+import { ChatMessage } from "../components/chat/ChatMessage";
+import { QuickActions } from "../components/chat/QuickActions";
+import { ToolExecutionSteps } from "../components/chat/ToolExecutionSteps";
+import {
+  Loader2,
+  BotMessageSquare,
+  ArrowUp,
+  ChevronDown,
+  Check,
+} from "lucide-react";
+import { Shimmer } from "../components/ai-elements/shimmer";
+import {
+  Message,
+  MessageContent,
+} from "../components/ai-elements/message";
+
+// -- Types --
+
+interface Nav {
+  toDashboard: () => void;
+  toBook: (id: string) => void;
+  toServices: () => void;
+}
+
+export interface ChatPageProps {
+  readonly activeBookId?: string;
+  readonly nav: Nav;
+  readonly theme: Theme;
+  readonly t: TFunction;
+  readonly sse: { messages: ReadonlyArray<SSEMessage>; connected: boolean };
+}
+
+// -- Component --
+
+export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPageProps) {
+  // -- Store selectors --
+  const messages = useChatStore((s) => s.messages);
+  const input = useChatStore((s) => s.input);
+  const loading = useChatStore((s) => s.loading);
+  const pendingBookArgs = useChatStore((s) => s.pendingBookArgs);
+  const bookCreating = useChatStore((s) => s.bookCreating);
+  const createProgress = useChatStore((s) => s.createProgress);
+  const selectedModel = useChatStore((s) => s.selectedModel);
+  const selectedService = useChatStore((s) => s.selectedService);
+  // -- Store actions --
+  const setInput = useChatStore((s) => s.setInput);
+  const sendMessage = useChatStore((s) => s.sendMessage);
+  const setPendingBookArgs = useChatStore((s) => s.setPendingBookArgs);
+  const handleCreateBook = useChatStore((s) => s.handleCreateBook);
+  const setCreateProgress = useChatStore((s) => s.setCreateProgress);
+  const setSelectedModel = useChatStore((s) => s.setSelectedModel);
+
+  const scrollRef = useRef<HTMLDivElement>(null);
+  const textareaRef = useRef<HTMLTextAreaElement>(null);
+
+  const isZh = t("nav.connected") === "\u5DF2\u8FDE\u63A5";
+  const hasBook = Boolean(activeBookId);
+
+  // Derived: is the assistant currently streaming/thinking/executing tools?
+  const isStreaming = useMemo(() => {
+    const last = messages[messages.length - 1];
+    if (!last || last.role !== "assistant") return false;
+    return last.thinkingStreaming === true
+      || !last.content
+      || (last.toolExecutions?.some(t => t.status === "running" || t.status === "processing") ?? false);
+  }, [messages]);
+
+  // -- Model picker: read raw state, derive with useMemo (stable refs) --
+  const services = useServiceStore((s) => s.services);
+  const servicesLoading = useServiceStore((s) => s.servicesLoading);
+  const modelsByService = useServiceStore((s) => s.modelsByService);
+  const fetchServices = useServiceStore((s) => s.fetchServices);
+  const fetchModels = useServiceStore((s) => s.fetchModels);
+
+  useEffect(() => { void fetchServices(); }, [fetchServices]);
+  useEffect(() => {
+    for (const svc of services) {
+      if (svc.connected) void fetchModels(svc.service);
+    }
+  }, [services, fetchModels]);
+
+  const modelPickerStatus = useMemo(() => {
+    if (servicesLoading || services.length === 0) return "loading" as const;
+    const connected = services.filter((s) => s.connected);
+    if (connected.length === 0) return "no-models" as const;
+    if (connected.some((s) => modelsByService[s.service]?.loading)) return "loading" as const;
+    return connected.some((s) => (modelsByService[s.service]?.models.length ?? 0) > 0)
+      ? "ready" as const : "no-models" as const;
+  }, [services, servicesLoading, modelsByService]);
+
+  const groupedModels = useMemo(() => {
+    return services
+      .filter((s) => s.connected && (modelsByService[s.service]?.models.length ?? 0) > 0)
+      .map((s) => ({ service: s.service, label: s.label, models: modelsByService[s.service]!.models }));
+  }, [services, modelsByService]);
+
+  // Auto-select first model when models load and none selected
+  useEffect(() => {
+    if (!selectedModel && groupedModels.length > 0) {
+      const first = groupedModels[0];
+      if (first.models.length > 0) {
+        setSelectedModel(first.models[0].id, first.service);
+      }
+    }
+  }, [groupedModels, selectedModel, setSelectedModel]);
+
+  // Auto-resize textarea
+  useEffect(() => {
+    const el = textareaRef.current;
+    if (!el) return;
+    el.style.height = "auto";
+    el.style.height = `${Math.min(el.scrollHeight, 200)}px`;
+  }, [input]);
+
+  // Auto-scroll on new messages or progress updates
+  useEffect(() => {
+    if (scrollRef.current) {
+      scrollRef.current.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" });
+    }
+  }, [messages, createProgress]);
+
+  // Listen for pipeline log events during book creation
+  useEffect(() => {
+    if (!bookCreating) {
+      setCreateProgress("");
+      return;
+    }
+    const es = new EventSource("/api/v1/events");
+    es.addEventListener("log", (e: MessageEvent) => {
+      try {
+        const data = e.data ? JSON.parse(e.data) : null;
+        const msg = data?.message as string | undefined;
+        if (msg) setCreateProgress(msg);
+      } catch { /* ignore */ }
+    });
+    return () => { es.close(); };
+  }, [bookCreating, setCreateProgress]);
+
+  // Load session messages on mount or when activeBookId changes
+  useEffect(() => {
+    useChatStore.getState().loadSession(activeBookId);
+  }, [activeBookId]);
+
+  const onSend = (text: string) => {
+    void sendMessage(text, activeBookId);
+  };
+
+  const onCreateBook = async () => {
+    const newBookId = await handleCreateBook(activeBookId);
+    if (newBookId) nav.toBook(newBookId);
+  };
+
+  const handleQuickAction = (command: string) => {
+    void sendMessage(command, activeBookId);
+  };
+
+  const emptyGuidance = isZh
+    ? "\u544A\u8BC9\u6211\u4F60\u60F3\u5199\u4EC0\u4E48\u2014\u2014\u9898\u6750\u3001\u4E16\u754C\u89C2\u3001\u4E3B\u89D2\u3001\u6838\u5FC3\u51B2\u7A81"
+    : "Tell me what you want to write \u2014 genre, world, protagonist, core conflict";
+
+  return (
+    <div className="flex flex-col h-full flex-1 min-w-0">
+      {/* Message scroll area */}
+      <div
+        ref={scrollRef}
+        className="flex-1 overflow-y-auto px-4 py-6"
+      >
+        {messages.length === 0 && !loading ? (
+          <div className="h-full flex flex-col items-center justify-center text-center select-none">
+            <div className="w-14 h-14 rounded-2xl border border-dashed border-border flex items-center justify-center mb-4 bg-secondary/30 opacity-40">
+              <BotMessageSquare size={24} className="text-muted-foreground" />
+            </div>
+            <p className="text-sm text-muted-foreground/70 max-w-md leading-7">
+              {emptyGuidance}
+            </p>
+          </div>
+        ) : (
+          <div className="max-w-3xl mx-auto space-y-4">
+            {messages.map((msg, i) => (
+              <div key={`${msg.timestamp}-${i}`}>
+                {msg.role === "user" ? (
+                  /* User message */
+                  <ChatMessage role="user" content={msg.content} timestamp={msg.timestamp} theme={theme} />
+                ) : msg.parts && msg.parts.length > 0 ? (
+                  /* Assistant message — parts-based rendering (chronological) */
+                  /* Merge consecutive utility tool parts into one group */
+                  <>
+                    {(() => {
+                      type RenderItem =
+                        | { kind: "thinking"; pi: number; part: Extract<typeof msg.parts[0], { type: "thinking" }> }
+                        | { kind: "text"; pi: number; part: Extract<typeof msg.parts[0], { type: "text" }> }
+                        | { kind: "tools"; parts: Array<Extract<typeof msg.parts[0], { type: "tool" }>>; startIdx: number };
+
+                      const items: RenderItem[] = [];
+                      for (let pi = 0; pi < msg.parts!.length; pi++) {
+                        const part = msg.parts![pi];
+                        if (part.type === "thinking") {
+                          items.push({ kind: "thinking", pi, part });
+                        } else if (part.type === "text") {
+                          items.push({ kind: "text", pi, part });
+                        } else if (part.type === "tool") {
+                          // Merge consecutive tool parts into one group
+                          const last = items[items.length - 1];
+                          if (last?.kind === "tools") {
+                            last.parts.push(part);
+                          } else {
+                            items.push({ kind: "tools", parts: [part], startIdx: pi });
+                          }
+                        }
+                      }
+
+                      return items.map((item) => {
+                        if (item.kind === "thinking") {
+                          return (
+                            <div key={`t-${item.pi}`} className="mb-2">
+                              <Reasoning isStreaming={item.part.streaming}>
+                                <ReasoningTrigger />
+                                <ReasoningContent>{item.part.content}</ReasoningContent>
+                              </Reasoning>
+                            </div>
+                          );
+                        }
+                        if (item.kind === "tools") {
+                          return <ToolExecutionSteps key={`x-${item.startIdx}`} executions={item.parts.map(p => p.execution)} />;
+                        }
+                        if (item.kind === "text" && item.part.content) {
+                          return (
+                            <ChatMessage
+                              key={`c-${item.pi}`}
+                              role="assistant"
+                              content={item.part.content}
+                              timestamp={msg.timestamp}
+                              theme={theme}
+                              toolCall={msg.toolCall?.name === "create_book" && pendingBookArgs
+                                ? { name: msg.toolCall.name, arguments: pendingBookArgs }
+                                : msg.toolCall}
+                              onArgsChange={msg.toolCall?.name === "create_book"
+                                ? (args) => setPendingBookArgs(args)
+                                : undefined}
+                              onConfirm={msg.toolCall?.name === "create_book"
+                                ? () => void onCreateBook()
+                                : undefined}
+                              confirming={msg.toolCall?.name === "create_book" ? bookCreating : undefined}
+                            />
+                          );
+                        }
+                        return null;
+                      });
+                    })()}
+                  </>
+                ) : (
+                  /* Assistant message — fallback (no parts, e.g. error messages) */
+                  <ChatMessage
+                    role={msg.role}
+                    content={msg.content}
+                    timestamp={msg.timestamp}
+                    theme={theme}
+                    toolCall={msg.toolCall?.name === "create_book" && pendingBookArgs
+                      ? { name: msg.toolCall.name, arguments: pendingBookArgs }
+                      : msg.toolCall}
+                    onArgsChange={msg.toolCall?.name === "create_book"
+                      ? (args) => setPendingBookArgs(args)
+                      : undefined}
+                    onConfirm={msg.toolCall?.name === "create_book"
+                      ? () => void onCreateBook()
+                      : undefined}
+                    confirming={msg.toolCall?.name === "create_book" ? bookCreating : undefined}
+                  />
+                )}
+              </div>
+            ))}
+
+            {/* Loading indicator — only when loading and no streaming activity */}
+            {loading && !isStreaming && (
+              <Message from="assistant">
+                <MessageContent>
+                  <Shimmer className="text-sm" duration={1.5}>
+                    {isZh ? "思考中..." : "Thinking..."}
+                  </Shimmer>
+                </MessageContent>
+              </Message>
+            )}
+
+            {/* Book creation progress */}
+            {bookCreating && (
+              <div className="flex gap-3 items-start">
+                <div className="w-7 h-7 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
+                  <Loader2 size={14} className="text-primary animate-spin" />
+                </div>
+                <div className="bg-card border border-border/50 px-4 py-3 rounded-2xl rounded-tl-sm text-sm space-y-1">
+                  <div className="font-medium text-foreground">{isZh ? "\u6B63\u5728\u521B\u5EFA\u4E66\u7C4D..." : "Creating book..."}</div>
+                  {createProgress && (
+                    <div className="text-xs text-muted-foreground font-mono truncate max-w-md">
+                      {createProgress}
+                    </div>
+                  )}
+                </div>
+              </div>
+            )}
+          </div>
+        )}
+      </div>
+
+      {/* Quick actions (only when a book is active) */}
+      {hasBook && (
+        <div className="shrink-0 max-w-3xl mx-auto w-full px-4">
+          <QuickActions
+            onAction={handleQuickAction}
+            disabled={loading}
+            isZh={isZh}
+          />
+        </div>
+      )}
+
+      {/* Input area */}
+      <div className="shrink-0 border-t border-border/40 px-4 py-3">
+        <div className="max-w-3xl mx-auto">
+          {pendingBookArgs && !loading ? (
+            /* create_book tool call pending — show action buttons */
+            <div className="flex items-center gap-3">
+              <button
+                type="button"
+                onClick={() => void onCreateBook()}
+                disabled={bookCreating}
+                className="flex items-center gap-1.5 px-5 py-2.5 rounded-xl bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 active:scale-[0.98] transition-all disabled:opacity-50"
+              >
+                {bookCreating && <Loader2 size={14} className="animate-spin" />}
+                {bookCreating ? "创建中…" : "开始写这本书"}
+              </button>
+              <div className="flex-1 flex items-center gap-2 rounded-xl border border-border/40 bg-secondary/30 px-3 py-2">
+                <input
+                  type="text"
+                  value={input}
+                  onChange={(e) => setInput(e.target.value)}
+                  onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onSend(input); } }}
+                  placeholder={isZh ? "或输入修改要求…" : "Or type changes..."}
+                  className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/50"
+                />
+                {input.trim() && (
+                  <button
+                    type="button"
+                    onClick={() => onSend(input)}
+                    className="w-7 h-7 rounded-lg bg-primary text-primary-foreground flex items-center justify-center shrink-0 hover:scale-105 active:scale-95 transition-all"
+                  >
+                    <ArrowUp size={12} strokeWidth={2.5} />
+                  </button>
+                )}
+              </div>
+            </div>
+          ) : (
+            /* Normal input */
+            <div className="rounded-xl bg-secondary/30 transition-all">
+              <div className="flex items-center gap-2 px-3 py-2">
+                <textarea
+                  ref={textareaRef}
+                  value={input}
+                  onChange={(e) => setInput(e.target.value)}
+                  onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onSend(input); } }}
+                  placeholder={isZh ? "输入指令..." : "Enter command..."}
+                  disabled={loading}
+                  rows={1}
+                  className="flex-1 bg-transparent text-sm leading-6 placeholder:text-muted-foreground/50 outline-none! border-none! ring-0! shadow-none focus:outline-none! focus:ring-0! focus:border-none! resize-none disabled:opacity-50 max-h-[200px] overflow-y-auto"
+                />
+                <button
+                  type="button"
+                  onClick={() => onSend(input)}
+                  disabled={!input.trim() || loading}
+                  className="w-8 h-8 rounded-lg bg-primary text-primary-foreground flex items-center justify-center shrink-0 hover:scale-105 active:scale-95 transition-all disabled:opacity-20 disabled:scale-100 shadow-sm shadow-primary/20"
+                >
+                  {loading ? <Loader2 size={14} className="animate-spin" /> : <ArrowUp size={14} strokeWidth={2.5} />}
+                </button>
+              </div>
+              <div className="flex items-center gap-2 px-3 pb-2 border-t border-border/20 pt-1.5">
+                {modelPickerStatus === "loading" ? (
+                  <span className="text-xs text-muted-foreground/40 animate-pulse">加载模型...</span>
+                ) : modelPickerStatus === "ready" ? (
+                  <DropdownMenu>
+                    <DropdownMenuTrigger className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted text-sm transition-colors cursor-pointer">
+                      <span className="font-medium text-xs truncate max-w-[140px]">
+                        {selectedModel ?? "选择模型"}
+                      </span>
+                      <ChevronDown size={14} className="text-muted-foreground" />
+                    </DropdownMenuTrigger>
+                    <DropdownMenuContent side="top" align="start" className="w-60 max-h-72 overflow-y-auto">
+                      {groupedModels.map((group) => (
+                        <div key={group.service}>
+                          <div className="px-2 py-1.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
+                            {group.label}
+                          </div>
+                          {group.models.map((m) => {
+                            const isSelected = selectedModel === m.id && selectedService === group.service;
+                            return (
+                              <DropdownMenuItem
+                                key={`${group.service}:${m.id}`}
+                                onClick={() => setSelectedModel(m.id, group.service)}
+                                className={isSelected ? "bg-muted/50" : ""}
+                              >
+                                <div className="flex flex-1 items-center justify-between">
+                                  <span className="text-sm">{m.name ?? m.id}</span>
+                                  {isSelected && <Check size={14} className="text-primary shrink-0" />}
+                                </div>
+                              </DropdownMenuItem>
+                            );
+                          })}
+                        </div>
+                      ))}
+                      <div className="border-t border-border/30 mt-1">
+                        <DropdownMenuItem onClick={() => nav.toServices()} className="text-primary">
+                          管理服务商
+                        </DropdownMenuItem>
+                      </div>
+                    </DropdownMenuContent>
+                  </DropdownMenu>
+                ) : (
+                  <button
+                    onClick={() => nav.toServices()}
+                    className="text-xs text-muted-foreground/50 hover:text-primary transition-colors"
+                  >
+                    配置模型 →
+                  </button>
+                )}
+              </div>
+            </div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}

+ 0 - 317
packages/studio/src/pages/ConfigView.tsx

@@ -1,317 +0,0 @@
-import { fetchJson, putApi, useApi } from "../hooks/use-api";
-import { useEffect, useState } from "react";
-import type { Theme } from "../hooks/use-theme";
-import type { TFunction } from "../hooks/use-i18n";
-import { useColors } from "../hooks/use-colors";
-
-const ROUTING_AGENTS = [
-  "writer",
-  "auditor",
-  "reviser",
-  "architect",
-  "radar",
-  "chapter-analyzer",
-] as const;
-
-interface AgentOverride {
-  readonly model: string;
-  readonly provider: string;
-  readonly baseUrl: string;
-}
-
-type OverridesMap = Record<string, AgentOverride>;
-
-interface ProjectInfo {
-  readonly name: string;
-  readonly language: string;
-  readonly model: string;
-  readonly provider: string;
-  readonly baseUrl: string;
-  readonly stream: boolean;
-  readonly temperature: number;
-  readonly maxTokens: number;
-}
-
-interface Nav {
-  toDashboard: () => void;
-}
-
-interface SaveProjectConfigOptions {
-  readonly putApiImpl?: typeof putApi;
-}
-
-export async function saveProjectConfig(
-  form: Record<string, unknown>,
-  options: SaveProjectConfigOptions = {},
-): Promise<void> {
-  const putApiImpl = options.putApiImpl ?? putApi;
-  await putApiImpl("/project", form);
-}
-
-export function normalizeOverridesDraft(
-  data?: { readonly overrides?: OverridesMap } | null,
-): OverridesMap {
-  return Object.fromEntries(
-    Object.entries(data?.overrides ?? {}).map(([agent, override]) => [
-      agent,
-      { ...override },
-    ]),
-  ) as OverridesMap;
-}
-
-export function ConfigView({ nav, theme, t }: { nav: Nav; theme: Theme; t: TFunction }) {
-  const c = useColors(theme);
-  const { data, loading, error, refetch } = useApi<ProjectInfo>("/project");
-  const [editing, setEditing] = useState(false);
-  const [saving, setSaving] = useState(false);
-  const [form, setForm] = useState<Record<string, unknown>>({});
-
-  if (loading) return <div className="text-muted-foreground py-20 text-center text-sm">Loading...</div>;
-  if (error) return <div className="text-destructive py-20 text-center">Error: {error}</div>;
-  if (!data) return null;
-
-  const startEdit = () => {
-    setForm({
-      temperature: data.temperature,
-      maxTokens: data.maxTokens,
-      stream: data.stream,
-      language: data.language,
-    });
-    setEditing(true);
-  };
-
-  const handleSave = async () => {
-    setSaving(true);
-    try {
-      await saveProjectConfig(form);
-      setEditing(false);
-      refetch();
-    } catch (e) {
-      alert(e instanceof Error ? e.message : "Failed");
-    } finally {
-      setSaving(false);
-    }
-  };
-
-  return (
-    <div className="max-w-xl mx-auto space-y-8">
-      <div className="flex items-center gap-2 text-sm text-muted-foreground">
-        <button onClick={nav.toDashboard} className={c.link}>{t("bread.home")}</button>
-        <span className="text-border">/</span>
-        <span className="text-foreground">{t("bread.config")}</span>
-      </div>
-
-      <div className="flex items-baseline justify-between">
-        <h1 className="font-serif text-3xl">{t("config.title")}</h1>
-        {!editing && (
-          <button onClick={startEdit} className={`px-3 py-2 text-xs rounded-md ${c.btnSecondary}`}>
-            Edit
-          </button>
-        )}
-      </div>
-
-      <div className={`border ${c.cardStatic} rounded-lg divide-y divide-border/40`}>
-        <Row label={t("config.project")} value={data.name} />
-        <Row label={t("config.provider")} value={data.provider} />
-        <Row label={t("config.model")} value={data.model} />
-        <Row label={t("config.baseUrl")} value={data.baseUrl} mono />
-
-        {editing ? (
-          <>
-            <EditRow
-              label={t("config.language")}
-              value={form.language as string}
-              onChange={(v) => setForm({ ...form, language: v })}
-              type="select"
-              options={[{ value: "zh", label: t("config.chinese") }, { value: "en", label: t("config.english") }]}
-              c={c}
-            />
-            <EditRow
-              label={t("config.temperature")}
-              value={String(form.temperature)}
-              onChange={(v) => setForm({ ...form, temperature: parseFloat(v) })}
-              type="number"
-              c={c}
-            />
-            <EditRow
-              label={t("config.maxTokens")}
-              value={String(form.maxTokens)}
-              onChange={(v) => setForm({ ...form, maxTokens: parseInt(v, 10) })}
-              type="number"
-              c={c}
-            />
-            <EditRow
-              label={t("config.stream")}
-              value={String(form.stream)}
-              onChange={(v) => setForm({ ...form, stream: v === "true" })}
-              type="select"
-              options={[{ value: "true", label: t("config.enabled") }, { value: "false", label: t("config.disabled") }]}
-              c={c}
-            />
-          </>
-        ) : (
-          <>
-            <Row label={t("config.language")} value={data.language === "en" ? t("config.english") : t("config.chinese")} />
-            <Row label={t("config.temperature")} value={String(data.temperature)} mono />
-            <Row label={t("config.maxTokens")} value={String(data.maxTokens)} mono />
-            <Row label={t("config.stream")} value={data.stream ? t("config.enabled") : t("config.disabled")} />
-          </>
-        )}
-      </div>
-
-      {editing && (
-        <div className="flex gap-2 justify-end">
-          <button onClick={() => setEditing(false)} className={`px-4 py-2.5 text-sm rounded-md ${c.btnSecondary}`}>
-            {t("config.cancel")}
-          </button>
-          <button onClick={handleSave} disabled={saving} className={`px-4 py-2.5 text-sm rounded-md ${c.btnPrimary} disabled:opacity-50`}>
-            {saving ? t("config.saving") : t("config.save")}
-          </button>
-        </div>
-      )}
-
-      <ModelRoutingSection theme={theme} t={t} />
-    </div>
-  );
-}
-
-function emptyOverride(): AgentOverride {
-  return { model: "", provider: "", baseUrl: "" };
-}
-
-function ModelRoutingSection({ theme, t }: { theme: Theme; t: TFunction }) {
-  const c = useColors(theme);
-  const { data, loading, error, refetch } = useApi<{ overrides: OverridesMap }>(
-    "/project/model-overrides",
-  );
-  const [overrides, setOverrides] = useState<OverridesMap>({});
-  const [saving, setSaving] = useState(false);
-
-  useEffect(() => {
-    setOverrides(normalizeOverridesDraft(data));
-  }, [data]);
-
-  if (loading) return <div className="text-muted-foreground py-8 text-center text-sm">Loading model overrides...</div>;
-  if (error) return <div className="text-destructive py-8 text-center text-sm">Error: {error}</div>;
-
-  const updateAgent = (agent: string, field: keyof AgentOverride, value: string) => {
-    const current = overrides[agent] ?? emptyOverride();
-    setOverrides({
-      ...overrides,
-      [agent]: { ...current, [field]: value },
-    });
-  };
-
-  const handleSave = async () => {
-    setSaving(true);
-    try {
-      await fetchJson("/project/model-overrides", {
-        method: "PUT",
-        headers: { "Content-Type": "application/json" },
-        body: JSON.stringify({ overrides }),
-      });
-      refetch();
-    } catch (e) {
-      alert(e instanceof Error ? e.message : "Failed to save model overrides");
-    } finally {
-      setSaving(false);
-    }
-  };
-
-  return (
-    <>
-      <h2 className="font-serif text-xl mt-4">{t("config.modelRouting")}</h2>
-
-      <div className={`border ${c.cardStatic} rounded-lg overflow-hidden`}>
-        <table className="w-full text-sm">
-          <thead>
-            <tr className="border-b border-border/40 text-muted-foreground text-left">
-              <th className="px-4 py-2.5 font-medium">{t("config.agent")}</th>
-              <th className="px-4 py-2.5 font-medium">{t("config.model")}</th>
-              <th className="px-4 py-2.5 font-medium">{t("config.provider")}</th>
-              <th className="px-4 py-2.5 font-medium">{t("config.baseUrl")}</th>
-            </tr>
-          </thead>
-          <tbody>
-            {ROUTING_AGENTS.map((agent) => {
-              const row = overrides[agent] ?? emptyOverride();
-              return (
-                <tr key={agent} className="border-b border-border/40 last:border-b-0">
-                  <td className="px-4 py-2 font-mono text-foreground/80">{agent}</td>
-                  <td className="px-4 py-2">
-                    <input
-                      type="text"
-                      value={row.model}
-                      onChange={(e) => updateAgent(agent, "model", e.target.value)}
-                      placeholder={t("config.default")}
-                      className={`${c.input} rounded px-2 py-1 text-sm w-full`}
-                    />
-                  </td>
-                  <td className="px-4 py-2">
-                    <input
-                      type="text"
-                      value={row.provider}
-                      onChange={(e) => updateAgent(agent, "provider", e.target.value)}
-                      placeholder={t("config.optional")}
-                      className={`${c.input} rounded px-2 py-1 text-sm w-full`}
-                    />
-                  </td>
-                  <td className="px-4 py-2">
-                    <input
-                      type="text"
-                      value={row.baseUrl}
-                      onChange={(e) => updateAgent(agent, "baseUrl", e.target.value)}
-                      placeholder={t("config.optional")}
-                      className={`${c.input} rounded px-2 py-1 text-sm w-full`}
-                    />
-                  </td>
-                </tr>
-              );
-            })}
-          </tbody>
-        </table>
-      </div>
-
-      <div className="flex justify-end">
-        <button
-          onClick={handleSave}
-          disabled={saving}
-          className={`px-4 py-2.5 text-sm rounded-md ${c.btnPrimary} disabled:opacity-50`}
-        >
-          {saving ? t("config.saving") : t("config.saveOverrides")}
-        </button>
-      </div>
-    </>
-  );
-}
-
-function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
-  return (
-    <div className="flex justify-between px-4 py-3">
-      <span className="text-muted-foreground text-sm">{label}</span>
-      <span className={mono ? "font-mono text-sm" : "text-sm"}>{value}</span>
-    </div>
-  );
-}
-
-function EditRow({ label, value, onChange, type, options, c }: {
-  label: string;
-  value: string;
-  onChange: (v: string) => void;
-  type: "number" | "select";
-  options?: ReadonlyArray<{ value: string; label: string }>;
-  c: ReturnType<typeof useColors>;
-}) {
-  return (
-    <div className="flex justify-between items-center px-4 py-2.5">
-      <span className="text-muted-foreground text-sm">{label}</span>
-      {type === "select" && options ? (
-        <select value={value} onChange={(e) => onChange(e.target.value)} className={`${c.input} rounded px-2 py-1 text-sm w-32`}>
-          {options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
-        </select>
-      ) : (
-        <input type="number" value={value} onChange={(e) => onChange(e.target.value)} className={`${c.input} rounded px-2 py-1 text-sm w-32 text-right`} />
-      )}
-    </div>
-  );
-}

+ 21 - 1
packages/studio/src/pages/Dashboard.tsx

@@ -1,5 +1,6 @@
 import { fetchJson, useApi, postApi } from "../hooks/use-api";
 import { useEffect, useMemo, useState, useRef } from "react";
+import { useServiceStore } from "../store/service";
 import type { SSEMessage } from "../hooks/use-sse";
 import type { Theme } from "../hooks/use-theme";
 import type { TFunction } from "../hooks/use-i18n";
@@ -37,6 +38,7 @@ interface Nav {
   toBook: (id: string) => void;
   toAnalytics: (id: string) => void;
   toBookCreate: () => void;
+  toServices: () => void;
 }
 
 function BookMenu({ bookId, bookTitle, nav, t, onDelete, onOpenChange }: {
@@ -92,7 +94,7 @@ function BookMenu({ bookId, bookTitle, nav, t, onDelete, onOpenChange }: {
             {t("book.settings")}
           </button>
           <a
-            href={`/api/books/${bookId}/export?format=txt`}
+            href={`/api/v1/books/${bookId}/export?format=txt`}
             download
             onClick={() => setOpen(false)}
             className="w-full flex items-center gap-2.5 px-4 py-2.5 text-sm text-foreground hover:bg-secondary/50 transition-colors cursor-pointer"
@@ -129,6 +131,10 @@ export function Dashboard({ nav, sse, theme, t }: { nav: Nav; sse: { messages: R
   const [menuOpenBookId, setMenuOpenBookId] = useState<string | null>(null);
   const { data, loading, error, refetch } = useApi<{ books: ReadonlyArray<BookSummary> }>("/books");
   const writingBooks = useMemo(() => deriveActiveBookIds(sse.messages), [sse.messages]);
+  const serviceStoreServices = useServiceStore((s) => s.services);
+  const fetchServices = useServiceStore((s) => s.fetchServices);
+  useEffect(() => { void fetchServices(); }, [fetchServices]);
+  const hasServices = serviceStoreServices.some((s) => s.connected);
 
   const logEvents = sse.messages.filter((m) => m.event === "log").slice(-8);
   const progressEvent = sse.messages.filter((m) => m.event === "llm:progress").slice(-1)[0];
@@ -179,6 +185,20 @@ export function Dashboard({ nav, sse, theme, t }: { nav: Nav; sse: { messages: R
 
   return (
     <div className="space-y-12">
+      {!hasServices && (
+        <div className="rounded-lg border border-border/60 bg-card px-5 py-4 mb-8 flex items-center justify-between gap-4">
+          <div>
+            <div className="text-sm font-medium">还没有配置 AI 模型</div>
+            <div className="text-xs text-muted-foreground mt-0.5">配好一个服务商才能开始创作</div>
+          </div>
+          <button
+            onClick={nav.toServices}
+            className="px-4 py-2 text-xs rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors shrink-0"
+          >
+            去配置
+          </button>
+        </div>
+      )}
       <div className="flex items-end justify-between border-b border-border/40 pb-8">
         <div>
           <h1 className="font-serif text-4xl mb-2">{t("dash.title")}</h1>

+ 282 - 0
packages/studio/src/pages/ServiceDetailPage.tsx

@@ -0,0 +1,282 @@
+import { useState, useEffect } from "react";
+import { fetchJson } from "../hooks/use-api";
+import { useServiceStore } from "../store/service";
+import { Eye, EyeOff, Loader2, ArrowLeft } from "lucide-react";
+
+interface Nav {
+  toServices: () => void;
+}
+
+interface ModelInfo {
+  readonly id: string;
+  readonly name?: string;
+}
+
+// Unified page state
+type ConnectionStatus =
+  | { state: "idle" }               // No action taken yet
+  | { state: "testing" }            // Test in progress
+  | { state: "connected"; models: ModelInfo[] }  // Test succeeded
+  | { state: "error"; message: string }          // Test failed
+  | { state: "saving" }             // Save in progress
+  | { state: "saved" }              // Save succeeded
+
+function DetailSkeleton() {
+  return (
+    <div className="max-w-xl mx-auto space-y-6 animate-pulse">
+      <div className="h-4 w-16 bg-muted rounded" />
+      <div className="h-7 w-40 bg-muted rounded" />
+      <div className="space-y-2"><div className="h-3 w-16 bg-muted/60 rounded" /><div className="h-10 w-full bg-muted/40 rounded-lg" /></div>
+      <div className="h-9 w-24 bg-muted/40 rounded-lg" />
+    </div>
+  );
+}
+
+export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav: Nav }) {
+  // -- Service store --
+  const services = useServiceStore((s) => s.services);
+  const loading = useServiceStore((s) => s.servicesLoading);
+  const fetchServices = useServiceStore((s) => s.fetchServices);
+  const refreshServices = useServiceStore((s) => s.refreshServices);
+  const setStoreModels = useServiceStore((s) => s.setModels);
+  const clearStoreModels = useServiceStore((s) => s.clearModels);
+
+  useEffect(() => { void fetchServices(); }, [fetchServices]);
+
+  const svc = services.find((s) => s.service === serviceId);
+  const isCustom = serviceId === "custom";
+
+  // -- Local form state --
+  const [apiKey, setApiKey] = useState("");
+  const [showKey, setShowKey] = useState(false);
+  const [customName, setCustomName] = useState("");
+  const [baseUrl, setBaseUrl] = useState("");
+  const [temperature, setTemperature] = useState("0.7");
+  const [maxTokens, setMaxTokens] = useState("4096");
+
+  // -- Unified connection status --
+  const [status, setStatus] = useState<ConnectionStatus>({ state: "idle" });
+
+  // Load models on mount if connected
+  useEffect(() => {
+    if (svc?.connected) {
+      setStatus({ state: "testing" });
+      fetchJson<{ models: ModelInfo[] }>(`/services/${encodeURIComponent(serviceId)}/models`)
+        .then((data) => {
+          const models = data.models ?? [];
+          setStatus(models.length > 0
+            ? { state: "connected", models }
+            : { state: "idle" });
+        })
+        .catch(() => setStatus({ state: "idle" }));
+    }
+  }, [svc?.connected, serviceId]);
+
+  const label = isCustom ? (customName || "自定义服务") : (svc?.label ?? serviceId);
+
+  if (loading) return <DetailSkeleton />;
+
+  // -- Derived state --
+  const isConnected = status.state === "connected";
+  const models = status.state === "connected" ? status.models : [];
+  const isBusy = status.state === "testing" || status.state === "saving";
+
+  // -- Handlers --
+  const handleTest = async () => {
+    const trimmedKey = apiKey.trim();
+    if (!trimmedKey) {
+      setStatus({ state: "error", message: "请先输入 API Key" });
+      return;
+    }
+    setApiKey(trimmedKey);
+    setStatus({ state: "testing" });
+    try {
+      const result = await fetchJson<{ ok: boolean; models?: ModelInfo[]; modelCount?: number; error?: string }>(
+        `/services/${encodeURIComponent(serviceId)}/test`,
+        { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ apiKey: trimmedKey }) },
+      );
+      if (result.ok) {
+        const models = result.models ?? [];
+        setStatus({ state: "connected", models });
+        setStoreModels(serviceId, models); // Write to global store
+      } else {
+        setStatus({ state: "error", message: result.error ?? "连接失败" });
+        clearStoreModels(serviceId);
+      }
+    } catch (e) {
+      setStatus({ state: "error", message: e instanceof Error ? e.message : "连接失败" });
+    }
+  };
+
+  const handleSave = async () => {
+    const trimmedKey = apiKey.trim();
+    setApiKey(trimmedKey);
+    setStatus({ state: "saving" });
+    try {
+      // Save key (empty = delete)
+      await fetchJson(`/services/${encodeURIComponent(serviceId)}/secret`, {
+        method: "PUT",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ apiKey: trimmedKey }),
+      });
+      // Save config (temperature, maxTokens, etc.)
+      await fetchJson("/services/config", {
+        method: "PUT",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({
+          services: {
+            [serviceId]: {
+              temperature: parseFloat(temperature),
+              maxTokens: parseInt(maxTokens, 10),
+              ...(isCustom ? { name: customName, baseUrl } : {}),
+            },
+          },
+        }),
+      });
+      setStatus({ state: "saved" });
+      void refreshServices();
+      // After 1.5s, transition to correct state
+      setTimeout(() => {
+        if (trimmedKey) {
+          setStatus({ state: "testing" });
+          fetchJson<{ models: ModelInfo[] }>(`/services/${encodeURIComponent(serviceId)}/models`)
+            .then((data) => {
+              const m = data.models ?? [];
+              if (m.length > 0) {
+                setStatus({ state: "connected", models: m });
+                setStoreModels(serviceId, m); // Cache for chat picker
+              } else {
+                setStatus({ state: "idle" });
+              }
+            })
+            .catch(() => setStatus({ state: "idle" }));
+        } else {
+          clearStoreModels(serviceId);
+          setStatus({ state: "idle" });
+        }
+      }, 1500);
+    } catch (e) {
+      setStatus({ state: "error", message: e instanceof Error ? e.message : "保存失败" });
+    }
+  };
+
+  return (
+    <div className="max-w-xl mx-auto space-y-6">
+      {/* Back */}
+      <button onClick={nav.toServices} className="flex items-center gap-1.5 text-sm text-muted-foreground/70 hover:text-foreground transition-colors">
+        <ArrowLeft size={14} /> 返回
+      </button>
+
+      {/* Title + status */}
+      <div className="flex items-center gap-3">
+        <h1 className="font-serif text-2xl">{label}</h1>
+        {isConnected && (
+          <span className="text-[10px] px-2 py-0.5 rounded-full bg-emerald-500/10 text-emerald-500 font-medium">
+            已连接
+          </span>
+        )}
+      </div>
+
+      <div className="space-y-5">
+        {/* Custom fields */}
+        {isCustom && (
+          <div className="grid grid-cols-2 gap-4">
+            <Field label="服务名称">
+              <input type="text" value={customName} onChange={(e) => setCustomName(e.target.value)}
+                placeholder="例如:本地 Ollama" className="w-full rounded-lg border border-border/60 bg-background px-3 py-2 text-sm" />
+            </Field>
+            <Field label="Base URL">
+              <input type="text" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)}
+                placeholder="https://api.example.com/v1" className="w-full rounded-lg border border-border/60 bg-background px-3 py-2 text-sm font-mono" />
+            </Field>
+          </div>
+        )}
+
+        {/* API Key */}
+        <Field label="API Key">
+          <div className="relative">
+            <input
+              type={showKey ? "text" : "password"} value={apiKey}
+              onChange={(e) => setApiKey(e.target.value)} placeholder="sk-..."
+              className="w-full rounded-lg border border-border/60 bg-background px-3 py-2 pr-10 text-sm font-mono"
+            />
+            <button type="button" onClick={() => setShowKey((v) => !v)}
+              className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-muted-foreground transition-colors">
+              {showKey ? <EyeOff size={14} /> : <Eye size={14} />}
+            </button>
+          </div>
+        </Field>
+
+        {/* Actions + feedback */}
+        <div className="flex items-center gap-2">
+          <button onClick={handleTest} disabled={isBusy}
+            className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-lg border border-border/60 hover:bg-secondary/50 transition-colors disabled:opacity-50">
+            {status.state === "testing" && <Loader2 size={12} className="animate-spin" />}
+            测试连接
+          </button>
+          <button onClick={handleSave} disabled={isBusy}
+            className="flex items-center gap-1.5 px-3.5 py-2 text-xs rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50">
+            {status.state === "saving" && <Loader2 size={12} className="animate-spin" />}
+            保存
+          </button>
+          {/* Status feedback */}
+          {status.state === "connected" && (
+            <span className="text-xs text-emerald-500">连接成功,{models.length} 个模型</span>
+          )}
+          {status.state === "error" && (
+            <span className="text-xs text-destructive">{status.message}</span>
+          )}
+          {status.state === "saved" && (
+            <span className="text-xs text-emerald-500">已保存</span>
+          )}
+        </div>
+
+        {/* Models */}
+        {models.length > 0 && (
+          <div className="space-y-2">
+            <p className="text-xs text-muted-foreground/70 font-medium uppercase tracking-wider">
+              可用模型({models.length})
+            </p>
+            <div className="flex gap-1.5 flex-wrap">
+              {models.map((m) => (
+                <span key={m.id} className="text-[11px] px-2.5 py-1 rounded-md bg-emerald-500/[0.06] text-emerald-600 dark:text-emerald-400 border border-emerald-500/15">
+                  {m.name ?? m.id}
+                </span>
+              ))}
+            </div>
+          </div>
+        )}
+
+        {/* Advanced params */}
+        <details className="group pt-2 border-t border-border/20">
+          <summary className="text-xs text-muted-foreground/60 cursor-pointer select-none hover:text-muted-foreground transition-colors py-2">
+            高级参数
+          </summary>
+          <div className="space-y-4 pt-2">
+            <Field label="temperature">
+              <div className="flex items-center gap-3">
+                <input type="range" min="0" max="2" step="0.05" value={temperature}
+                  onChange={(e) => setTemperature(e.target.value)} className="flex-1 accent-primary h-1" />
+                <input type="number" value={temperature} onChange={(e) => setTemperature(e.target.value)}
+                  min="0" max="2" step="0.05" className="w-16 rounded-md border border-border/60 bg-background px-2 py-1 text-xs text-right font-mono" />
+              </div>
+            </Field>
+            <Field label="maxTokens">
+              <input type="number" value={maxTokens} onChange={(e) => setMaxTokens(e.target.value)}
+                min="256" max="200000" step="256" className="w-full rounded-md border border-border/60 bg-background px-3 py-1.5 text-xs font-mono" />
+            </Field>
+          </div>
+        </details>
+      </div>
+    </div>
+  );
+}
+
+function Field({ label, children }: { label: string; children: React.ReactNode }) {
+  return (
+    <div className="space-y-1.5">
+      <label className="block text-xs text-muted-foreground/70 font-medium">{label}</label>
+      {children}
+    </div>
+  );
+}

+ 86 - 0
packages/studio/src/pages/ServiceListPage.tsx

@@ -0,0 +1,86 @@
+import { useEffect } from "react";
+import { useServiceStore } from "../store/service";
+import type { ServiceInfo } from "../store/service";
+
+interface Nav {
+  toDashboard: () => void;
+  toServiceDetail: (id: string) => void;
+}
+
+function SkeletonCard() {
+  return (
+    <div className="rounded-xl border border-border/30 p-5 animate-pulse">
+      <div className="flex items-center justify-between mb-3">
+        <div className="h-4 w-24 bg-muted rounded" />
+        <div className="w-2 h-2 rounded-full bg-muted" />
+      </div>
+      <div className="h-3 w-16 bg-muted/60 rounded" />
+    </div>
+  );
+}
+
+export function ServiceListPage({ nav }: { nav: Nav }) {
+  const services = useServiceStore((s) => s.services);
+  const loading = useServiceStore((s) => s.servicesLoading);
+  const fetchServices = useServiceStore((s) => s.fetchServices);
+
+  useEffect(() => { void fetchServices(); }, [fetchServices]);
+
+  return (
+    <div className="max-w-2xl mx-auto space-y-8">
+      <div className="flex items-center gap-2 text-sm text-muted-foreground">
+        <button onClick={nav.toDashboard} className="hover:text-foreground transition-colors">
+          首页
+        </button>
+        <span className="text-border">/</span>
+        <span className="text-foreground">服务商管理</span>
+      </div>
+
+      <h1 className="font-serif text-2xl">服务商管理</h1>
+
+      <div className="grid grid-cols-2 gap-3">
+        {loading
+          ? Array.from({ length: 6 }, (_, i) => <SkeletonCard key={i} />)
+          : services.map((svc) => (
+              <ServiceCard
+                key={svc.service}
+                svc={svc}
+                onClick={() => nav.toServiceDetail(svc.service)}
+              />
+            ))
+        }
+        {!loading && (
+          <button
+            onClick={() => nav.toServiceDetail("custom")}
+            className="flex flex-col items-center justify-center gap-1.5 rounded-xl border border-dashed border-border/40 p-5 text-muted-foreground/60 hover:border-primary/30 hover:text-muted-foreground transition-all"
+          >
+            <span className="text-lg leading-none">+</span>
+            <span className="text-xs">自定义服务</span>
+          </button>
+        )}
+      </div>
+    </div>
+  );
+}
+
+function ServiceCard({ svc, onClick }: { svc: ServiceInfo; onClick: () => void }) {
+  return (
+    <button
+      onClick={onClick}
+      className={[
+        "flex flex-col gap-2 rounded-xl border p-5 text-left transition-all hover:shadow-sm",
+        svc.connected
+          ? "border-emerald-500/30 bg-emerald-500/[0.03]"
+          : "border-dashed border-border/40",
+      ].join(" ")}
+    >
+      <div className="flex items-center justify-between">
+        <span className="font-medium text-sm">{svc.label}</span>
+        <span className={`w-1.5 h-1.5 rounded-full shrink-0 ${svc.connected ? "bg-emerald-500" : "bg-muted-foreground/30"}`} />
+      </div>
+      <span className="text-xs text-muted-foreground/60">
+        {svc.connected ? "已连接" : "未配置"}
+      </span>
+    </button>
+  );
+}

+ 0 - 20
packages/studio/src/pages/config-view.test.ts

@@ -1,20 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import { saveProjectConfig } from "./ConfigView";
-
-type PutApiLike = <T>(path: string, body?: unknown) => Promise<T>;
-
-describe("saveProjectConfig", () => {
-  it("persists project settings through putApi so project listeners invalidate immediately", async () => {
-    const putApiMock = vi.fn(async () => undefined);
-    const draft = {
-      language: "en",
-      temperature: 0.2,
-      maxTokens: 2048,
-      stream: true,
-    };
-
-    await saveProjectConfig(draft, { putApiImpl: putApiMock as PutApiLike });
-
-    expect(putApiMock).toHaveBeenCalledWith("/project", draft);
-  });
-});

+ 136 - 0
packages/studio/src/store/chat/__tests__/message-parts.test.ts

@@ -0,0 +1,136 @@
+import { describe, it, expect } from "vitest";
+import { buildPartsFromEvents, type StreamEvent } from "../parts-builder";
+
+describe("buildPartsFromEvents", () => {
+  it("produces thinking → text parts from basic conversation", () => {
+    const parts = buildPartsFromEvents([
+      { type: "thinking:start" },
+      { type: "thinking:delta", text: "Let me think..." },
+      { type: "thinking:end" },
+      { type: "draft:delta", text: "Here is " },
+      { type: "draft:delta", text: "the answer." },
+    ]);
+
+    expect(parts).toEqual([
+      { type: "thinking", content: "Let me think...", streaming: false },
+      { type: "text", content: "Here is the answer." },
+    ]);
+  });
+
+  it("interleaves text and tool calls chronologically", () => {
+    const parts = buildPartsFromEvents([
+      { type: "draft:delta", text: "Let me read the file..." },
+      { type: "tool:start", id: "t1", tool: "read" },
+      { type: "tool:end", id: "t1", result: "file content" },
+      { type: "draft:delta", text: "Here is the analysis." },
+    ]);
+
+    expect(parts).toHaveLength(3);
+    expect(parts[0]).toEqual({ type: "text", content: "Let me read the file..." });
+    expect(parts[1].type).toBe("tool");
+    if (parts[1].type === "tool") {
+      expect(parts[1].execution.tool).toBe("read");
+      expect(parts[1].execution.status).toBe("completed");
+    }
+    expect(parts[2]).toEqual({ type: "text", content: "Here is the analysis." });
+  });
+
+  it("moves pre-tool text to thinking when tool starts", () => {
+    const parts = buildPartsFromEvents([
+      { type: "thinking:start" },
+      { type: "thinking:delta", text: "Reasoning here" },
+      { type: "thinking:end" },
+      { type: "draft:delta", text: "I will call writer now" },
+      { type: "tool:start", id: "t1", tool: "sub_agent", agent: "writer", stages: ["准备章节输入", "撰写章节草稿"] },
+      { type: "log:stage", stageName: "准备章节输入" },
+      { type: "log:stage", stageName: "撰写章节草稿" },
+      { type: "tool:end", id: "t1" },
+      { type: "draft:delta", text: "Chapter written." },
+    ]);
+
+    // Pre-tool text "I will call writer now" should become thinking, not a text part
+    expect(parts[0].type).toBe("thinking");
+    if (parts[0].type === "thinking") {
+      expect(parts[0].content).toContain("Reasoning here");
+      expect(parts[0].content).toContain("I will call writer now");
+    }
+    expect(parts[1].type).toBe("tool");
+    if (parts[1].type === "tool") {
+      expect(parts[1].execution.agent).toBe("writer");
+      expect(parts[1].execution.stages).toHaveLength(2);
+      expect(parts[1].execution.stages![0].status).toBe("completed");
+      expect(parts[1].execution.stages![1].status).toBe("completed");
+    }
+    expect(parts[2]).toEqual({ type: "text", content: "Chapter written." });
+  });
+
+  it("handles multiple tool calls in sequence", () => {
+    const parts = buildPartsFromEvents([
+      { type: "tool:start", id: "t1", tool: "read" },
+      { type: "tool:end", id: "t1" },
+      { type: "tool:start", id: "t2", tool: "grep" },
+      { type: "tool:end", id: "t2" },
+      { type: "tool:start", id: "t3", tool: "sub_agent", agent: "writer", stages: ["准备章节输入"] },
+      { type: "tool:end", id: "t3" },
+      { type: "draft:delta", text: "Done." },
+    ]);
+
+    expect(parts).toHaveLength(4);
+    expect(parts[0].type).toBe("tool"); // read
+    expect(parts[1].type).toBe("tool"); // grep
+    expect(parts[2].type).toBe("tool"); // writer
+    expect(parts[3]).toEqual({ type: "text", content: "Done." });
+  });
+
+  it("tracks pipeline stages and progress on running tool", () => {
+    const parts = buildPartsFromEvents([
+      { type: "tool:start", id: "t1", tool: "sub_agent", agent: "writer", stages: ["步骤1", "步骤2"] },
+      { type: "log:stage", stageName: "步骤1" },
+      { type: "llm:progress", status: "thinking", elapsedMs: 5000, totalChars: 0, chineseChars: 0 },
+    ]);
+
+    expect(parts).toHaveLength(1);
+    expect(parts[0].type).toBe("tool");
+    if (parts[0].type === "tool") {
+      const exec = parts[0].execution;
+      expect(exec.status).toBe("running");
+      expect(exec.stages![0].status).toBe("active");
+      expect(exec.stages![0].progress?.status).toBe("thinking");
+      expect(exec.stages![1].status).toBe("pending");
+    }
+  });
+
+  it("handles multi-turn thinking (append, not overwrite)", () => {
+    const parts = buildPartsFromEvents([
+      { type: "thinking:start" },
+      { type: "thinking:delta", text: "First thought" },
+      { type: "thinking:end" },
+      { type: "tool:start", id: "t1", tool: "read" },
+      { type: "tool:end", id: "t1" },
+      { type: "thinking:start" },
+      { type: "thinking:delta", text: "Second thought" },
+      { type: "thinking:end" },
+      { type: "draft:delta", text: "Final answer" },
+    ]);
+
+    // Two thinking parts, tool in between, then text
+    const thinkingParts = parts.filter(p => p.type === "thinking");
+    expect(thinkingParts).toHaveLength(2);
+    expect(thinkingParts[0].type === "thinking" && thinkingParts[0].content).toBe("First thought");
+    expect(thinkingParts[1].type === "thinking" && thinkingParts[1].content).toBe("Second thought");
+    expect(parts.map(p => p.type)).toEqual(["thinking", "tool", "thinking", "text"]);
+  });
+
+  it("marks tool error correctly", () => {
+    const parts = buildPartsFromEvents([
+      { type: "tool:start", id: "t1", tool: "sub_agent", agent: "writer" },
+      { type: "tool:end", id: "t1", isError: true, result: "timeout" },
+    ]);
+
+    expect(parts[0].type).toBe("tool");
+    if (parts[0].type === "tool") {
+      expect(parts[0].execution.status).toBe("error");
+      expect(parts[0].execution.error).toBe("timeout");
+    }
+  });
+});

+ 3 - 0
packages/studio/src/store/chat/index.ts

@@ -0,0 +1,3 @@
+export { useChatStore } from "./store";
+export { chatSelectors } from "./selectors";
+export type { ChatStore, Message, ToolCall, BookSummary } from "./types";

+ 8 - 0
packages/studio/src/store/chat/initialState.ts

@@ -0,0 +1,8 @@
+import type { ChatState } from "./types";
+import { initialMessageState } from "./slices/message/initialState";
+import { initialCreateState } from "./slices/create/initialState";
+
+export const initialChatState: ChatState = {
+  ...initialMessageState,
+  ...initialCreateState,
+};

+ 164 - 0
packages/studio/src/store/chat/parts-builder.ts

@@ -0,0 +1,164 @@
+import type { MessagePart, ToolExecution, PipelineStage } from "./types";
+
+// -- Event types for the builder --
+
+export type StreamEvent =
+  | { type: "thinking:start" }
+  | { type: "thinking:delta"; text: string }
+  | { type: "thinking:end" }
+  | { type: "draft:delta"; text: string }
+  | { type: "tool:start"; id: string; tool: string; agent?: string; stages?: string[] }
+  | { type: "tool:end"; id: string; isError?: boolean; result?: string }
+  | { type: "log:stage"; stageName: string }
+  | { type: "llm:progress"; status: string; elapsedMs: number; totalChars: number; chineseChars: number };
+
+// -- Label helpers --
+
+const AGENT_LABELS: Record<string, string> = {
+  architect: "建书", writer: "写作", auditor: "审计",
+  reviser: "修订", exporter: "导出",
+};
+const TOOL_LABELS: Record<string, string> = {
+  read: "读取文件", edit: "编辑文件", grep: "搜索", ls: "列目录",
+};
+
+function resolveToolLabel(tool: string, agent?: string): string {
+  if (tool === "sub_agent" && agent) return AGENT_LABELS[agent] ?? agent;
+  return TOOL_LABELS[tool] ?? tool;
+}
+
+// -- Builder --
+
+export function buildPartsFromEvents(events: StreamEvent[]): MessagePart[] {
+  const parts: MessagePart[] = [];
+
+  /** Find the last tool part that is still "running". */
+  function findRunningTool(): ToolExecution | undefined {
+    for (let i = parts.length - 1; i >= 0; i--) {
+      const p = parts[i];
+      if (p.type === "tool" && p.execution.status === "running") return p.execution;
+    }
+    return undefined;
+  }
+
+  for (const event of events) {
+    switch (event.type) {
+      case "thinking:start": {
+        parts.push({ type: "thinking", content: "", streaming: true });
+        break;
+      }
+
+      case "thinking:delta": {
+        // Append to last thinking part
+        const last = parts[parts.length - 1];
+        if (last?.type === "thinking") {
+          last.content += event.text;
+        }
+        break;
+      }
+
+      case "thinking:end": {
+        const last = parts[parts.length - 1];
+        if (last?.type === "thinking") {
+          last.streaming = false;
+        }
+        break;
+      }
+
+      case "draft:delta": {
+        // Append to last text part, or create a new one
+        const last = parts[parts.length - 1];
+        if (last?.type === "text") {
+          last.content += event.text;
+        } else {
+          parts.push({ type: "text", content: event.text });
+        }
+        break;
+      }
+
+      case "tool:start": {
+        // For pipeline operations (sub_agent), move trailing text to thinking
+        // (it's the agent's reasoning before calling the tool, not user-facing content).
+        // For utility tools (read/grep/edit/ls), keep text as-is.
+        if (event.tool === "sub_agent") {
+          const last = parts[parts.length - 1];
+          if (last?.type === "text" && last.content) {
+            parts.pop();
+            const prevPart = parts[parts.length - 1];
+            if (prevPart?.type === "thinking") {
+              prevPart.content += (prevPart.content ? "\n\n" : "") + last.content;
+            } else {
+              parts.push({ type: "thinking", content: last.content, streaming: false });
+            }
+          }
+        }
+
+        const stages: PipelineStage[] | undefined = event.stages?.length
+          ? event.stages.map((label) => ({ label, status: "pending" as const }))
+          : undefined;
+
+        const exec: ToolExecution = {
+          id: event.id,
+          tool: event.tool,
+          agent: event.agent,
+          label: resolveToolLabel(event.tool, event.agent),
+          status: "running",
+          stages,
+          startedAt: Date.now(),
+        };
+
+        parts.push({ type: "tool", execution: exec });
+        break;
+      }
+
+      case "tool:end": {
+        // Find matching tool part by id
+        for (const p of parts) {
+          if (p.type === "tool" && p.execution.id === event.id) {
+            const exec = p.execution;
+            exec.status = event.isError ? "error" : "completed";
+            exec.completedAt = Date.now();
+            if (event.isError) exec.error = event.result;
+            else exec.result = event.result;
+            // Mark all remaining stages as completed
+            exec.stages = exec.stages?.map((s) =>
+              s.status !== "completed" ? { ...s, status: "completed" as const, progress: undefined } : s
+            );
+            break;
+          }
+        }
+        break;
+      }
+
+      case "log:stage": {
+        const exec = findRunningTool();
+        if (!exec?.stages) break;
+        let found = false;
+        exec.stages = exec.stages.map((stage) => {
+          if (stage.label === event.stageName) {
+            found = true;
+            return { ...stage, status: "active" as const };
+          }
+          if (!found && stage.status === "active") {
+            return { ...stage, status: "completed" as const, progress: undefined };
+          }
+          return stage;
+        });
+        break;
+      }
+
+      case "llm:progress": {
+        const exec = findRunningTool();
+        if (!exec?.stages) break;
+        exec.stages = exec.stages.map((stage) =>
+          stage.status === "active"
+            ? { ...stage, progress: { status: event.status, elapsedMs: event.elapsedMs, totalChars: event.totalChars, chineseChars: event.chineseChars } }
+            : stage
+        );
+        break;
+      }
+    }
+  }
+
+  return parts;
+}

+ 7 - 0
packages/studio/src/store/chat/selectors.ts

@@ -0,0 +1,7 @@
+import type { ChatState } from "./types";
+
+export const chatSelectors = {
+  hasPendingTool: (s: ChatState) => s.pendingBookArgs !== null,
+  isCreating: (s: ChatState) => s.bookCreating,
+  isEmpty: (s: ChatState) => s.messages.length === 0 && !s.loading,
+};

+ 36 - 0
packages/studio/src/store/chat/slices/create/action.ts

@@ -0,0 +1,36 @@
+import type { StateCreator } from "zustand";
+import type { ChatStore, CreateActions, AgentResponse } from "../../types";
+import { fetchJson } from "../../../../hooks/use-api";
+
+export const createCreateSlice: StateCreator<ChatStore, [], [], CreateActions> = (set, get) => ({
+  setPendingBookArgs: (args) => set({ pendingBookArgs: args }),
+  setBookCreating: (creating) => set({ bookCreating: creating }),
+  setCreateProgress: (progress) => set({ createProgress: progress }),
+
+  bumpBookDataVersion: () => set((s) => ({ bookDataVersion: s.bookDataVersion + 1 })),
+  openArtifact: (file) => set({ sidebarView: "artifact", artifactFile: file, artifactChapter: null }),
+  openChapterArtifact: (chapterNum) => set({ sidebarView: "artifact", artifactFile: null, artifactChapter: chapterNum }),
+  closeArtifact: () => set({ sidebarView: "panel", artifactFile: null, artifactChapter: null }),
+  setBookSummary: (summary) => set({ bookSummary: summary }),
+
+  handleCreateBook: async (activeBookId) => {
+    if (!get().pendingBookArgs) return null;
+
+    set({ bookCreating: true });
+    try {
+      const data = await fetchJson<AgentResponse>("/agent", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ instruction: "/create", activeBookId }),
+      });
+      const newBookId = data.session?.activeBookId ?? null;
+      if (newBookId) get().bumpBookDataVersion();
+      return newBookId;
+    } catch (e) {
+      get().addErrorMessage(e instanceof Error ? e.message : String(e));
+      return null;
+    } finally {
+      set({ bookCreating: false });
+    }
+  },
+});

+ 12 - 0
packages/studio/src/store/chat/slices/create/initialState.ts

@@ -0,0 +1,12 @@
+import type { CreateState } from "../../types";
+
+export const initialCreateState: CreateState = {
+  pendingBookArgs: null,
+  bookCreating: false,
+  createProgress: "",
+  bookDataVersion: 0,
+  sidebarView: "panel",
+  artifactFile: null,
+  artifactChapter: null,
+  bookSummary: null,
+};

+ 454 - 0
packages/studio/src/store/chat/slices/message/action.ts

@@ -0,0 +1,454 @@
+import type { StateCreator } from "zustand";
+import type { ChatStore, MessageActions, AgentResponse, SessionMessage, Message, MessagePart, ToolExecution, PipelineStage } from "../../types";
+import { fetchJson } from "../../../../hooks/use-api";
+
+function extractErrorMessage(error: string | { code?: string; message?: string }): string {
+  if (typeof error === "string") return error;
+  return error.message ?? "Unknown error";
+}
+
+// -- Tool label helpers --
+
+const AGENT_LABELS: Record<string, string> = {
+  architect: "建书", writer: "写作", auditor: "审计",
+  reviser: "修订", exporter: "导出",
+};
+const TOOL_LABELS: Record<string, string> = {
+  read: "读取文件", edit: "编辑文件", grep: "搜索", ls: "列目录",
+};
+
+function resolveToolLabel(tool: string, agent?: string): string {
+  if (tool === "sub_agent" && agent) return AGENT_LABELS[agent] ?? agent;
+  return TOOL_LABELS[tool] ?? tool;
+}
+
+function summarizeResult(result: unknown): string {
+  if (typeof result === "string") return result.slice(0, 200);
+  if (result && typeof result === "object") {
+    const r = result as Record<string, unknown>;
+    if (typeof r.content === "string") return r.content.slice(0, 200);
+  }
+  return String(result).slice(0, 200);
+}
+
+function extractToolError(result: unknown): string {
+  if (typeof result === "string") return result.slice(0, 500);
+  if (result && typeof result === "object") {
+    const r = result as Record<string, unknown>;
+    if (typeof r.content === "string") return r.content.slice(0, 500);
+    if (r.content && Array.isArray(r.content)) {
+      const textPart = r.content.find((c: any) => c.type === "text");
+      if (textPart) return (textPart as any).text?.slice(0, 500) ?? "";
+    }
+  }
+  return String(result).slice(0, 500);
+}
+
+// -- Parts helpers --
+
+/** Get or create the streaming assistant message, returning [updatedMessages, streamMsg]. */
+function getOrCreateStream(messages: ReadonlyArray<Message>, streamTs: number): [ReadonlyArray<Message>, Message] {
+  const last = messages[messages.length - 1];
+  if (last?.timestamp === streamTs && last.role === "assistant") {
+    return [messages, last];
+  }
+  const newMsg: Message = { role: "assistant", content: "", timestamp: streamTs, parts: [] };
+  return [[...messages, newMsg], newMsg];
+}
+
+/** Replace the last message with an updated version. */
+function replaceLast(messages: ReadonlyArray<Message>, updated: Message): ReadonlyArray<Message> {
+  return [...messages.slice(0, -1), updated];
+}
+
+/** Find the last tool part that is "running" in a parts array. */
+function findRunningToolPart(parts: MessagePart[]): (MessagePart & { type: "tool" }) | undefined {
+  for (let i = parts.length - 1; i >= 0; i--) {
+    const p = parts[i];
+    if (p.type === "tool" && p.execution.status === "running") return p as MessagePart & { type: "tool" };
+  }
+  return undefined;
+}
+
+/** Derive flat fields (content, thinking, toolExecutions) from parts for persistence compatibility. */
+function deriveFlat(parts: MessagePart[]): { content: string; thinking?: string; thinkingStreaming?: boolean; toolExecutions?: ToolExecution[] } {
+  let content = "";
+  let thinking = "";
+  let thinkingStreaming = false;
+  const toolExecutions: ToolExecution[] = [];
+
+  for (const p of parts) {
+    if (p.type === "thinking") {
+      if (thinking) thinking += "\n\n---\n\n";
+      thinking += p.content;
+      if (p.streaming) thinkingStreaming = true;
+    } else if (p.type === "text") {
+      content += p.content;
+    } else if (p.type === "tool") {
+      toolExecutions.push(p.execution);
+    }
+  }
+
+  return {
+    content,
+    ...(thinking ? { thinking } : {}),
+    ...(thinkingStreaming ? { thinkingStreaming: true } : {}),
+    ...(toolExecutions.length > 0 ? { toolExecutions } : {}),
+  };
+}
+
+export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions> = (set, get) => ({
+  setInput: (text) => set({ input: text }),
+
+  addUserMessage: (content) => set((s) => ({
+    messages: [...s.messages, { role: "user" as const, content, timestamp: Date.now() }],
+  })),
+
+  appendStreamChunk: (text, streamTs) => set((s) => {
+    const last = s.messages[s.messages.length - 1];
+    if (last?.timestamp === streamTs && last.role === "assistant") {
+      return { messages: [...s.messages.slice(0, -1), { ...last, content: last.content + text }] };
+    }
+    return { messages: [...s.messages, { role: "assistant" as const, content: text, timestamp: streamTs }] };
+  }),
+
+  finalizeStream: (streamTs, content, toolCall) => set((s) => ({
+    messages: s.messages.map((m) => {
+      if (m.timestamp !== streamTs || m.role !== "assistant") return m;
+      // Update the last text part in parts, or add one
+      const parts = [...(m.parts ?? [])];
+      const lastPart = parts[parts.length - 1];
+      if (lastPart?.type === "text") {
+        parts[parts.length - 1] = { ...lastPart, content };
+      } else if (content) {
+        parts.push({ type: "text", content });
+      }
+      return { ...m, content, toolCall, parts };
+    }),
+  })),
+
+  replaceStreamWithError: (streamTs, errorMsg) => set((s) => ({
+    messages: [
+      ...s.messages.filter((m) => !(m.timestamp === streamTs && m.role === "assistant")),
+      { role: "assistant" as const, content: `\u2717 ${errorMsg}`, timestamp: Date.now() },
+    ],
+  })),
+
+  addErrorMessage: (errorMsg) => set((s) => ({
+    messages: [...s.messages, { role: "assistant" as const, content: `\u2717 ${errorMsg}`, timestamp: Date.now() }],
+  })),
+
+  setLoading: (loading) => set({ loading }),
+
+  setSelectedModel: (model, service) => set({ selectedModel: model, selectedService: service }),
+
+  loadSessionMessages: (msgs) => set((s) => {
+    if (s.messages.length > 0) return s;
+    return {
+      messages: msgs
+        .filter((m) => m.role === "user" || m.role === "assistant")
+        .map((m) => {
+          const toolExecs = (m as any).toolExecutions as ToolExecution[] | undefined;
+          // Rebuild parts from flat fields for historical messages
+          const parts: MessagePart[] = [];
+          if (m.thinking) parts.push({ type: "thinking", content: m.thinking, streaming: false });
+          if (toolExecs) {
+            for (const exec of toolExecs) parts.push({ type: "tool", execution: exec });
+          }
+          if (m.content) parts.push({ type: "text", content: m.content });
+          return {
+            role: m.role as "user" | "assistant",
+            content: m.content,
+            thinking: m.thinking,
+            toolExecutions: toolExecs,
+            timestamp: m.timestamp,
+            parts: parts.length > 0 ? parts : undefined,
+          };
+        }),
+    };
+  }),
+
+  loadSession: async (bookId) => {
+    try {
+      const data = await fetchJson<{ session: { sessionId: string; messages?: SessionMessage[] } }>("/sessions", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ bookId: bookId ?? null }),
+      });
+      const session = data.session;
+      const prevSessionId = get().currentSessionId;
+
+      if (prevSessionId === session.sessionId && get().messages.length > 0) {
+        return;
+      }
+
+      get()._activeStream?.close();
+      set({ currentSessionId: session.sessionId, messages: [], loading: false, _activeStream: null });
+      if (session.messages && session.messages.length > 0) {
+        get().loadSessionMessages(session.messages);
+      }
+    } catch {
+      set({ currentSessionId: null, messages: [], loading: false });
+    }
+  },
+
+  sendMessage: async (text, activeBookId) => {
+    const trimmed = text.trim();
+    if (!trimmed || get().loading) return;
+
+    if (!get().selectedModel) {
+      get().addUserMessage(trimmed);
+      get().addErrorMessage("请先选择一个模型");
+      return;
+    }
+
+    const hasBook = Boolean(activeBookId);
+    const instruction = hasBook ? trimmed : `/new ${trimmed}`;
+    const streamTs = Date.now() + 1;
+
+    set({ input: "", loading: true });
+    get().addUserMessage(trimmed);
+
+    get()._activeStream?.close();
+    const streamEs = new EventSource("/api/v1/events");
+    set({ _activeStream: streamEs });
+
+    // ---------------------------------------------------------------
+    // SSE listeners — each event updates `parts` as source of truth,
+    // then derives flat fields (content/thinking/toolExecutions) for
+    // persistence compatibility.
+    // ---------------------------------------------------------------
+
+    streamEs.addEventListener("thinking:start", () => {
+      set((s) => {
+        const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
+        const parts = [...(stream.parts ?? [])];
+        parts.push({ type: "thinking", content: "", streaming: true });
+        const flat = deriveFlat(parts);
+        return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
+      });
+    });
+
+    streamEs.addEventListener("thinking:delta", (e: MessageEvent) => {
+      try {
+        const d = e.data ? JSON.parse(e.data) : null;
+        if (!d?.text) return;
+        set((s) => {
+          const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
+          const parts = [...(stream.parts ?? [])];
+          const last = parts[parts.length - 1];
+          if (last?.type === "thinking") {
+            parts[parts.length - 1] = { ...last, content: last.content + d.text };
+          }
+          const flat = deriveFlat(parts);
+          return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
+        });
+      } catch { /* ignore */ }
+    });
+
+    streamEs.addEventListener("thinking:end", () => {
+      set((s) => {
+        const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
+        const parts = [...(stream.parts ?? [])];
+        const last = parts[parts.length - 1];
+        if (last?.type === "thinking") {
+          parts[parts.length - 1] = { ...last, streaming: false };
+        }
+        const flat = deriveFlat(parts);
+        return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
+      });
+    });
+
+    streamEs.addEventListener("draft:delta", (e: MessageEvent) => {
+      try {
+        const d = e.data ? JSON.parse(e.data) : null;
+        if (!d?.text) return;
+        set((s) => {
+          const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
+          const parts = [...(stream.parts ?? [])];
+          const last = parts[parts.length - 1];
+          if (last?.type === "text") {
+            parts[parts.length - 1] = { ...last, content: last.content + d.text };
+          } else {
+            parts.push({ type: "text", content: d.text });
+          }
+          const flat = deriveFlat(parts);
+          return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
+        });
+      } catch { /* ignore */ }
+    });
+
+    streamEs.addEventListener("tool:start", (e: MessageEvent) => {
+      try {
+        const d = e.data ? JSON.parse(e.data) : null;
+        if (!d?.tool) return;
+
+        set((s) => {
+          const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
+          const parts = [...(stream.parts ?? [])];
+
+          // For pipeline ops (sub_agent), move trailing text to thinking
+          if (d.tool === "sub_agent") {
+            const last = parts[parts.length - 1];
+            if (last?.type === "text" && last.content) {
+              parts.pop();
+              const prev = parts[parts.length - 1];
+              if (prev?.type === "thinking") {
+                parts[parts.length - 1] = { ...prev, content: prev.content + (prev.content ? "\n\n" : "") + last.content };
+              } else {
+                parts.push({ type: "thinking", content: last.content, streaming: false });
+              }
+            }
+          }
+
+          const agent = d.tool === "sub_agent" ? (d.args?.agent as string | undefined) : undefined;
+          const stages: PipelineStage[] | undefined = (d.stages as string[] | undefined)?.length
+            ? (d.stages as string[]).map((label) => ({ label, status: "pending" as const }))
+            : undefined;
+
+          const exec: ToolExecution = {
+            id: d.id as string,
+            tool: d.tool as string,
+            agent,
+            label: resolveToolLabel(d.tool, agent),
+            status: "running",
+            args: d.args as Record<string, unknown> | undefined,
+            stages,
+            startedAt: Date.now(),
+          };
+
+          parts.push({ type: "tool", execution: exec });
+          const flat = deriveFlat(parts);
+          return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
+        });
+      } catch { /* ignore */ }
+    });
+
+    streamEs.addEventListener("tool:end", (e: MessageEvent) => {
+      try {
+        const d = e.data ? JSON.parse(e.data) : null;
+        if (!d?.tool) return;
+
+        set((s) => {
+          const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
+          const parts = (stream.parts ?? []).map((p) => {
+            if (p.type !== "tool" || p.execution.id !== d.id) return p;
+            const exec = { ...p.execution };
+            exec.status = d.isError ? "error" : "completed";
+            exec.completedAt = Date.now();
+            if (d.isError) exec.error = extractToolError(d.result);
+            else exec.result = summarizeResult(d.result);
+            exec.stages = exec.stages?.map((s) =>
+              s.status !== "completed" ? { ...s, status: "completed" as const, progress: undefined } : s
+            );
+            return { type: "tool" as const, execution: exec };
+          });
+          const flat = deriveFlat(parts);
+          return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
+        });
+
+        get().bumpBookDataVersion();
+      } catch { /* ignore */ }
+    });
+
+    streamEs.addEventListener("log", (e: MessageEvent) => {
+      try {
+        const d = e.data ? JSON.parse(e.data) : null;
+        const msg = d?.message as string | undefined;
+        if (!msg) return;
+
+        set((s) => {
+          const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
+          const runningTool = findRunningToolPart([...(stream.parts ?? [])]);
+          if (!runningTool) return s;
+
+          const parts = (stream.parts ?? []).map((p) => {
+            if (p.type !== "tool" || p.execution.id !== runningTool.execution.id) return p;
+            return { type: "tool" as const, execution: { ...p.execution, logs: [...(p.execution.logs ?? []), msg] } };
+          });
+          const flat = deriveFlat(parts);
+          return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
+        });
+      } catch { /* ignore */ }
+    });
+
+    streamEs.addEventListener("llm:progress", (e: MessageEvent) => {
+      try {
+        const d = e.data ? JSON.parse(e.data) : null;
+        if (!d) return;
+
+        set((s) => {
+          const [msgs, stream] = getOrCreateStream(s.messages, streamTs);
+          const runningTool = findRunningToolPart([...(stream.parts ?? [])]);
+          if (!runningTool?.execution.stages) return s;
+
+          const parts = (stream.parts ?? []).map((p) => {
+            if (p.type !== "tool" || p.execution.id !== runningTool.execution.id) return p;
+            const stages = p.execution.stages!.map((stage) =>
+              stage.status === "active"
+                ? { ...stage, progress: { status: d.status, elapsedMs: d.elapsedMs, totalChars: d.totalChars, chineseChars: d.chineseChars } }
+                : stage
+            );
+            return { type: "tool" as const, execution: { ...p.execution, stages } };
+          });
+          const flat = deriveFlat(parts);
+          return { messages: replaceLast(msgs, { ...stream, ...flat, parts }) };
+        });
+      } catch { /* ignore */ }
+    });
+
+    // -- API call + finalize --
+
+    try {
+      const data = await fetchJson<AgentResponse>("/agent", {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({
+          instruction,
+          activeBookId,
+          sessionId: get().currentSessionId,
+          model: get().selectedModel ?? undefined,
+          service: get().selectedService ?? undefined,
+        }),
+      });
+
+      streamEs.close();
+
+      const finalContent = data.details?.draftRaw || data.response || "Acknowledged.";
+      const toolCall = data.details?.toolCall ?? undefined;
+      const hasStream = get().messages.some((m) => m.timestamp === streamTs);
+
+      if (data.error) {
+        if (hasStream) {
+          get().replaceStreamWithError(streamTs, extractErrorMessage(data.error));
+        } else {
+          get().addErrorMessage(extractErrorMessage(data.error));
+        }
+      } else {
+        if (hasStream) {
+          get().finalizeStream(streamTs, finalContent, toolCall);
+        } else {
+          set((s) => ({
+            messages: [...s.messages, {
+              role: "assistant" as const, content: finalContent, timestamp: Date.now(), toolCall,
+            }],
+          }));
+        }
+        if (toolCall?.name === "create_book") {
+          get().setPendingBookArgs({ ...toolCall.arguments });
+        }
+      }
+    } catch (e) {
+      streamEs.close();
+      const errorMsg = e instanceof Error ? e.message : String(e);
+      const hasStream = get().messages.some((m) => m.timestamp === streamTs);
+      if (hasStream) {
+        get().replaceStreamWithError(streamTs, errorMsg);
+      } else {
+        get().addErrorMessage(errorMsg);
+      }
+    } finally {
+      set({ loading: false, _activeStream: null });
+    }
+  },
+});

+ 11 - 0
packages/studio/src/store/chat/slices/message/initialState.ts

@@ -0,0 +1,11 @@
+import type { MessageState } from "../../types";
+
+export const initialMessageState: MessageState = {
+  messages: [],
+  input: "",
+  loading: false,
+  currentSessionId: null,
+  selectedModel: null,
+  selectedService: null,
+  _activeStream: null,
+};

+ 11 - 0
packages/studio/src/store/chat/store.ts

@@ -0,0 +1,11 @@
+import { create } from "zustand";
+import type { ChatStore } from "./types";
+import { initialChatState } from "./initialState";
+import { createMessageSlice } from "./slices/message/action";
+import { createCreateSlice } from "./slices/create/action";
+
+export const useChatStore = create<ChatStore>()((...a) => ({
+  ...initialChatState,
+  ...createMessageSlice(...a),
+  ...createCreateSlice(...a),
+}));

+ 145 - 0
packages/studio/src/store/chat/types.ts

@@ -0,0 +1,145 @@
+// -- Data types --
+
+export interface ToolCall {
+  readonly name: string;
+  readonly arguments: Record<string, unknown>;
+}
+
+export interface PipelineStage {
+  label: string;
+  status: "pending" | "active" | "completed";
+  progress?: {
+    status?: string;          // "thinking" | "streaming" | ...
+    elapsedMs: number;
+    totalChars: number;
+    chineseChars: number;
+  };
+}
+
+export interface ToolExecution {
+  id: string;
+  tool: string;
+  agent?: string;
+  label: string;
+  status: "running" | "processing" | "completed" | "error";
+  args?: Record<string, unknown>;
+  result?: string;
+  error?: string;
+  stages?: PipelineStage[];
+  logs?: string[];
+  startedAt: number;
+  completedAt?: number;
+}
+
+// -- Message parts (chronologically ordered for rendering) --
+
+export type MessagePart =
+  | { type: "thinking"; content: string; streaming: boolean }
+  | { type: "text"; content: string }
+  | { type: "tool"; execution: ToolExecution };
+
+export interface Message {
+  readonly role: "user" | "assistant";
+  readonly content: string;
+  readonly thinking?: string;
+  readonly thinkingStreaming?: boolean;
+  readonly timestamp: number;
+  readonly toolCall?: ToolCall;
+  readonly toolExecutions?: ToolExecution[];
+  readonly parts?: MessagePart[];              // chronological parts for interleaved rendering
+}
+
+export interface SessionMessage {
+  readonly role: "user" | "assistant" | "system";
+  readonly content: string;
+  readonly thinking?: string;
+  readonly timestamp: number;
+}
+
+export interface AgentResponse {
+  readonly response?: string;
+  readonly error?: string | { code?: string; message?: string };
+  readonly details?: {
+    readonly draftRaw?: string;
+    readonly toolCall?: ToolCall;
+  };
+  readonly session?: {
+    readonly activeBookId?: string;
+    readonly creationDraft?: unknown;
+    readonly messages?: ReadonlyArray<SessionMessage>;
+  };
+  readonly request?: unknown;
+}
+
+export interface SessionResponse {
+  readonly session?: {
+    readonly sessionId?: string;
+    readonly activeBookId?: string;
+    readonly messages?: ReadonlyArray<SessionMessage>;
+  };
+  readonly activeBookId?: string;
+}
+
+// -- State interfaces --
+
+export interface BookSummary {
+  world: string;
+  protagonist: string;
+  cast: string;
+}
+
+export interface MessageState {
+  messages: ReadonlyArray<Message>;
+  input: string;
+  loading: boolean;
+  currentSessionId: string | null;
+  selectedModel: string | null;
+  selectedService: string | null;
+  /** Active EventSource ref — closed on session switch */
+  _activeStream: EventSource | null;
+}
+
+export interface CreateState {
+  pendingBookArgs: Record<string, unknown> | null;
+  bookCreating: boolean;
+  createProgress: string;
+  bookDataVersion: number;
+  sidebarView: "panel" | "artifact";
+  artifactFile: string | null;         // foundation file name, e.g. "story_bible.md"
+  artifactChapter: number | null;      // chapter number, e.g. 1
+  bookSummary: BookSummary | null;
+}
+
+export type ChatState = MessageState & CreateState;
+
+// -- Action interfaces --
+
+export interface MessageActions {
+  setInput: (text: string) => void;
+  addUserMessage: (content: string) => void;
+  appendStreamChunk: (text: string, streamTs: number) => void;
+  finalizeStream: (streamTs: number, content: string, toolCall?: ToolCall) => void;
+  replaceStreamWithError: (streamTs: number, errorMsg: string) => void;
+  addErrorMessage: (errorMsg: string) => void;
+  setLoading: (loading: boolean) => void;
+  loadSessionMessages: (msgs: ReadonlyArray<SessionMessage>) => void;
+  loadSession: (bookId?: string) => Promise<void>;
+  sendMessage: (text: string, activeBookId?: string) => Promise<void>;
+  setSelectedModel: (model: string, service: string) => void;
+}
+
+export interface CreateActions {
+  setPendingBookArgs: (args: Record<string, unknown> | null) => void;
+  setBookCreating: (creating: boolean) => void;
+  setCreateProgress: (progress: string) => void;
+  handleCreateBook: (activeBookId?: string) => Promise<string | null>;
+  bumpBookDataVersion: () => void;
+  openArtifact: (file: string) => void;
+  openChapterArtifact: (chapterNum: number) => void;
+  closeArtifact: () => void;
+  setBookSummary: (summary: BookSummary | null) => void;
+}
+
+// -- Composed store type --
+
+export type ChatStore = ChatState & MessageActions & CreateActions;

+ 2 - 0
packages/studio/src/store/service/index.ts

@@ -0,0 +1,2 @@
+export { useServiceStore } from "./store";
+export type { ServiceStore, ServiceInfo, ModelInfo, ModelGroup, ModelPickerStatus } from "./types";

+ 107 - 0
packages/studio/src/store/service/store.ts

@@ -0,0 +1,107 @@
+import { create } from "zustand";
+import type { ServiceStore } from "./types";
+import { fetchJson } from "../../hooks/use-api";
+
+export const useServiceStore = create<ServiceStore>()((set, get) => ({
+  // -- State --
+  services: [],
+  servicesLoading: false,
+  modelsByService: {},
+
+  // -- Actions --
+
+  fetchServices: async () => {
+    // Skip if already loaded
+    if (get().services.length > 0 || get().servicesLoading) return;
+    set({ servicesLoading: true });
+    try {
+      const data = await fetchJson<{ services: any[] }>("/services");
+      set({ services: data.services ?? [], servicesLoading: false });
+    } catch {
+      set({ servicesLoading: false });
+    }
+  },
+
+  fetchModels: async (service: string) => {
+    const existing = get().modelsByService[service];
+    // Skip if already loaded or loading
+    if (existing?.models.length || existing?.loading) return;
+
+    set((s) => ({
+      modelsByService: {
+        ...s.modelsByService,
+        [service]: { models: [], loading: true, error: null },
+      },
+    }));
+
+    try {
+      const data = await fetchJson<{ models: any[] }>(
+        `/services/${encodeURIComponent(service)}/models`,
+      );
+      set((s) => ({
+        modelsByService: {
+          ...s.modelsByService,
+          [service]: { models: data.models ?? [], loading: false, error: null },
+        },
+      }));
+    } catch (e) {
+      set((s) => ({
+        modelsByService: {
+          ...s.modelsByService,
+          [service]: {
+            models: [],
+            loading: false,
+            error: e instanceof Error ? e.message : "Failed",
+          },
+        },
+      }));
+    }
+  },
+
+  setModels: (service, models) => {
+    set((s) => ({
+      modelsByService: {
+        ...s.modelsByService,
+        [service]: { models, loading: false, error: null },
+      },
+    }));
+  },
+
+  clearModels: (service) => {
+    set((s) => {
+      const next = { ...s.modelsByService };
+      delete next[service];
+      return { modelsByService: next };
+    });
+  },
+
+  refreshServices: async () => {
+    set({ services: [], servicesLoading: false });
+    await get().fetchServices();
+  },
+
+  // -- Selectors --
+
+  getModelPickerStatus: () => {
+    const { services, servicesLoading, modelsByService } = get();
+    if (servicesLoading || services.length === 0) return "loading";
+    const connected = services.filter((s) => s.connected);
+    if (connected.length === 0) return "no-models";
+    const anyLoading = connected.some((s) => modelsByService[s.service]?.loading);
+    if (anyLoading) return "loading";
+    const anyModels = connected.some((s) => (modelsByService[s.service]?.models.length ?? 0) > 0);
+    return anyModels ? "ready" : "no-models";
+  },
+
+  getGroupedModels: () => {
+    const { services, modelsByService } = get();
+    const groups: Array<{ service: string; label: string; models: ReadonlyArray<{ id: string; name?: string }> }> = [];
+    for (const svc of services.filter((s) => s.connected)) {
+      const entry = modelsByService[svc.service];
+      if (entry?.models.length) {
+        groups.push({ service: svc.service, label: svc.label, models: entry.models });
+      }
+    }
+    return groups;
+  },
+}));

+ 62 - 0
packages/studio/src/store/service/types.ts

@@ -0,0 +1,62 @@
+export interface ServiceInfo {
+  readonly service: string;
+  readonly label: string;
+  readonly connected: boolean;
+}
+
+export interface ModelInfo {
+  readonly id: string;
+  readonly name?: string;
+}
+
+export interface ServiceModelsEntry {
+  readonly models: ReadonlyArray<ModelInfo>;
+  readonly loading: boolean;
+  readonly error: string | null;
+}
+
+// -- State --
+
+export interface ServiceState {
+  /** All known services with connection status */
+  services: ReadonlyArray<ServiceInfo>;
+  servicesLoading: boolean;
+  /** Models keyed by service id, fetched on demand */
+  modelsByService: Record<string, ServiceModelsEntry>;
+}
+
+// -- Actions --
+
+export interface ServiceActions {
+  /** Fetch service list (fast — only reads secrets, no external API) */
+  fetchServices: () => Promise<void>;
+  /** Fetch models for a specific service (calls external API) */
+  fetchModels: (service: string) => Promise<void>;
+  /** Write models directly (from test connection result) */
+  setModels: (service: string, models: ReadonlyArray<ModelInfo>) => void;
+  /** Clear models for a service (key removed) */
+  clearModels: (service: string) => void;
+  /** Invalidate and re-fetch services (after saving a key) */
+  refreshServices: () => Promise<void>;
+}
+
+// -- Derived (selectors) --
+
+export interface ModelGroup {
+  readonly service: string;
+  readonly label: string;
+  readonly models: ReadonlyArray<ModelInfo>;
+}
+
+export type ModelPickerStatus = "loading" | "no-models" | "ready";
+
+export interface ServiceSelectors {
+  /** Model picker status: loading → no-models → ready */
+  getModelPickerStatus: () => ModelPickerStatus;
+  /** Grouped models for dropdown, derived from store state */
+  getGroupedModels: () => ReadonlyArray<ModelGroup>;
+}
+
+// -- Composed --
+
+export type ServiceStore = ServiceState & ServiceActions & ServiceSelectors;

+ 12 - 0
packages/studio/vite.config.ts

@@ -13,6 +13,18 @@ export default defineConfig({
   server: {
     port: 4567,
     proxy: {
+      "/api/v1/events": {
+        target: `http://localhost:${process.env.INKOS_STUDIO_PORT ?? "4569"}`,
+        changeOrigin: true,
+        // SSE needs unbuffered streaming — bypass http-proxy response handling
+        selfHandleResponse: true,
+        configure: (proxy) => {
+          proxy.on("proxyRes", (proxyRes, _req, res) => {
+            res.writeHead(proxyRes.statusCode ?? 200, proxyRes.headers);
+            proxyRes.pipe(res);
+          });
+        },
+      },
       "/api": {
         target: `http://localhost:${process.env.INKOS_STUDIO_PORT ?? "4569"}`,
         changeOrigin: true,

+ 6 - 0
packages/studio/vitest.config.ts

@@ -1,6 +1,12 @@
 import { defineConfig } from "vitest/config";
+import { resolve } from "node:path";
 
 export default defineConfig({
+  resolve: {
+    alias: {
+      "@": resolve(__dirname, "src"),
+    },
+  },
   test: {
     include: ["src/**/*.test.ts"],
   },