Explorar el Código

fix(studio): restore reachable navigation and book refresh

Ma hace 7 meses
padre
commit
e1af64054c

+ 7 - 3
packages/studio/src/App.tsx

@@ -11,6 +11,7 @@ import { TruthFiles } from "./pages/TruthFiles";
 import { DaemonControl } from "./pages/DaemonControl";
 import { LogViewer } from "./pages/LogViewer";
 import { GenreManager } from "./pages/GenreManager";
+import { StyleManager } from "./pages/StyleManager";
 import { LanguageSelector } from "./pages/LanguageSelector";
 import { useSSE } from "./hooks/use-sse";
 import { useTheme } from "./hooks/use-theme";
@@ -27,7 +28,8 @@ type Route =
   | { page: "truth"; bookId: string }
   | { page: "daemon" }
   | { page: "logs" }
-  | { page: "genres" };
+  | { page: "genres" }
+  | { page: "style" };
 
 export function App() {
   const [route, setRoute] = useState<Route>({ page: "dashboard" });
@@ -66,6 +68,7 @@ export function App() {
     toDaemon: () => setRoute({ page: "daemon" }),
     toLogs: () => setRoute({ page: "logs" }),
     toGenres: () => setRoute({ page: "genres" }),
+    toStyle: () => setRoute({ page: "style" }),
   };
 
   const activePage =
@@ -95,7 +98,7 @@ export function App() {
 
   return (
     <div className="h-screen bg-background text-foreground flex overflow-hidden">
-      <Sidebar nav={nav} activePage={activePage} t={t} />
+      <Sidebar nav={nav} activePage={activePage} t={t} sse={sse} />
 
       <div className="flex-1 flex flex-col min-w-0">
         {/* Thin utility strip — pushed inward */}
@@ -117,7 +120,7 @@ export function App() {
         <div className="flex-1 overflow-y-auto">
           <div className="max-w-3xl mx-auto px-10 py-12">
             {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} />}
+            {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} />}
@@ -126,6 +129,7 @@ export function App() {
             {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} />}
           </div>
         </div>
 

+ 24 - 3
packages/studio/src/components/Sidebar.tsx

@@ -1,5 +1,8 @@
+import { useEffect } from "react";
 import { useApi } from "../hooks/use-api";
 import type { TFunction } from "../hooks/use-i18n";
+import type { SSEMessage } from "../hooks/use-sse";
+import { shouldRefetchBookCollections, shouldRefetchDaemonStatus } from "../hooks/use-book-activity";
 
 interface BookSummary {
   readonly id: string;
@@ -17,15 +20,27 @@ interface Nav {
   toDaemon: () => void;
   toLogs: () => void;
   toGenres: () => void;
+  toStyle: () => void;
 }
 
-export function Sidebar({ nav, activePage, t }: {
+export function Sidebar({ nav, activePage, t, sse }: {
   nav: Nav;
   activePage: string;
   t: TFunction;
+  sse: { messages: ReadonlyArray<SSEMessage> };
 }) {
-  const { data } = useApi<{ books: ReadonlyArray<BookSummary> }>("/books");
-  const { data: daemon } = useApi<{ running: boolean }>("/daemon");
+  const { data, refetch } = useApi<{ books: ReadonlyArray<BookSummary> }>("/books");
+  const { data: daemon, refetch: refetchDaemon } = useApi<{ running: boolean }>("/daemon");
+
+  useEffect(() => {
+    const recent = sse.messages.at(-1);
+    if (shouldRefetchBookCollections(recent)) {
+      void refetch();
+    }
+    if (shouldRefetchDaemonStatus(recent)) {
+      void refetchDaemon();
+    }
+  }, [refetch, refetchDaemon, sse.messages]);
 
   return (
     <aside className="w-[240px] shrink-0 border-r border-border bg-card/40 flex flex-col h-full overflow-y-auto">
@@ -80,6 +95,12 @@ export function Sidebar({ nav, activePage, t }: {
           active={activePage === "genres"}
           onClick={nav.toGenres}
         />
+        <SidebarItem
+          label={t("nav.style")}
+          icon="✦"
+          active={activePage === "style"}
+          onClick={nav.toStyle}
+        />
         <SidebarItem
           label={t("nav.config")}
           icon="⚙"

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

@@ -1,5 +1,5 @@
 import { describe, expect, it, vi } from "vitest";
-import { buildApiUrl, fetchJson } from "./use-api";
+import { buildApiUrl, deriveInvalidationPaths, fetchJson } from "./use-api";
 
 describe("buildApiUrl", () => {
   it("returns null for blank paths so callers can skip requests", () => {
@@ -38,3 +38,25 @@ describe("fetchJson", () => {
     await expect(fetchJson("/books", {}, { fetchImpl })).rejects.toThrow("500 Internal Server Error");
   });
 });
+
+describe("deriveInvalidationPaths", () => {
+  it("refreshes book collections after creating a book", () => {
+    expect(deriveInvalidationPaths("/books/create")).toEqual(["/api/books"]);
+  });
+
+  it("refreshes both collections and the current book after book mutations", () => {
+    expect(deriveInvalidationPaths("/books/demo/write-next")).toEqual([
+      "/api/books",
+      "/api/books/demo",
+    ]);
+    expect(deriveInvalidationPaths("/books/demo/chapters/3/approve")).toEqual([
+      "/api/books",
+      "/api/books/demo",
+    ]);
+  });
+
+  it("refreshes daemon state after daemon mutations", () => {
+    expect(deriveInvalidationPaths("/daemon/start")).toEqual(["/api/daemon"]);
+    expect(deriveInvalidationPaths("/daemon/stop")).toEqual(["/api/daemon"]);
+  });
+});

+ 61 - 1
packages/studio/src/hooks/use-api.ts

@@ -1,6 +1,11 @@
 import { useState, useEffect, useCallback } from "react";
 
 const BASE = "/api";
+const API_INVALIDATE_EVENT = "inkos:api-invalidate";
+
+interface ApiInvalidateDetail {
+  readonly paths: ReadonlyArray<string>;
+}
 
 export function buildApiUrl(path: string): string | null {
   const normalized = String(path ?? "").trim();
@@ -11,6 +16,41 @@ export function buildApiUrl(path: string): string | null {
   return normalized.startsWith("/") ? `${BASE}${normalized}` : `${BASE}/${normalized}`;
 }
 
+export function deriveInvalidationPaths(path: string): ReadonlyArray<string> {
+  const normalized = buildApiUrl(path);
+  if (!normalized) return [];
+
+  if (normalized === "/api/books/create") {
+    return ["/api/books"];
+  }
+
+  const bookAction = normalized.match(/^\/api\/books\/([^/]+)\/(write-next|draft)$/);
+  if (bookAction) {
+    return ["/api/books", `/api/books/${bookAction[1]}`];
+  }
+
+  const chapterAction = normalized.match(/^\/api\/books\/([^/]+)\/chapters\/\d+\/(approve|reject)$/);
+  if (chapterAction) {
+    return ["/api/books", `/api/books/${chapterAction[1]}`];
+  }
+
+  if (/^\/api\/daemon\/(start|stop)$/.test(normalized)) {
+    return ["/api/daemon"];
+  }
+
+  return [];
+}
+
+export function invalidateApiPaths(paths: ReadonlyArray<string>): void {
+  if (!paths.length || typeof window === "undefined") {
+    return;
+  }
+
+  window.dispatchEvent(new CustomEvent<ApiInvalidateDetail>(API_INVALIDATE_EVENT, {
+    detail: { paths: [...new Set(paths)] },
+  }));
+}
+
 async function readErrorMessage(res: Response): Promise<string> {
   const contentType = res.headers.get("content-type") ?? "";
   if (contentType.includes("application/json")) {
@@ -91,13 +131,33 @@ export function useApi<T>(path: string) {
     refetch();
   }, [refetch]);
 
+  useEffect(() => {
+    const url = buildApiUrl(path);
+    if (!url || typeof window === "undefined") {
+      return;
+    }
+
+    const handleInvalidate = (event: Event) => {
+      const detail = (event as CustomEvent<ApiInvalidateDetail>).detail;
+      if (!detail?.paths.includes(url)) return;
+      void refetch();
+    };
+
+    window.addEventListener(API_INVALIDATE_EVENT, handleInvalidate);
+    return () => {
+      window.removeEventListener(API_INVALIDATE_EVENT, handleInvalidate);
+    };
+  }, [path, refetch]);
+
   return { data, loading, error, refetch };
 }
 
 export async function postApi<T>(path: string, body?: unknown): Promise<T> {
-  return fetchJson<T>(path, {
+  const result = await fetchJson<T>(path, {
     method: "POST",
     headers: { "Content-Type": "application/json" },
     body: body ? JSON.stringify(body) : undefined,
   });
+  invalidateApiPaths(deriveInvalidationPaths(path));
+  return result;
 }

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

@@ -1,8 +1,10 @@
 import { useApi, postApi } from "../hooks/use-api";
-import { useState } from "react";
+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";
+import type { SSEMessage } from "../hooks/use-sse";
+import { shouldRefetchBookView } from "../hooks/use-book-activity";
 
 interface ChapterMeta {
   readonly number: number;
@@ -39,12 +41,18 @@ const STATUS_COLORS: Record<string, string> = {
   imported: "text-blue-400",
 };
 
-export function BookDetail({ bookId, nav, theme, t }: { bookId: string; nav: Nav; theme: Theme; t: TFunction }) {
+export function BookDetail({ bookId, nav, theme, t, sse }: { bookId: string; nav: Nav; theme: Theme; t: TFunction; sse: { messages: ReadonlyArray<SSEMessage> } }) {
   const c = useColors(theme);
   const { data, loading, error, refetch } = useApi<BookData>(`/books/${bookId}`);
   const [writing, setWriting] = useState(false);
   const [drafting, setDrafting] = useState(false);
 
+  useEffect(() => {
+    const recent = sse.messages.at(-1);
+    if (!recent || !shouldRefetchBookView(recent, bookId)) return;
+    void refetch();
+  }, [bookId, refetch, sse.messages]);
+
   const handleWriteNext = async () => {
     setWriting(true);
     try {

+ 10 - 18
packages/studio/src/pages/Dashboard.tsx

@@ -1,9 +1,10 @@
 import { useApi, postApi } from "../hooks/use-api";
-import { useState, useMemo } from "react";
+import { useEffect, useMemo } from "react";
 import type { SSEMessage } from "../hooks/use-sse";
 import type { Theme } from "../hooks/use-theme";
 import type { TFunction } from "../hooks/use-i18n";
 import { useColors } from "../hooks/use-colors";
+import { deriveActiveBookIds, shouldRefetchBookCollections } from "../hooks/use-book-activity";
 
 interface BookSummary {
   readonly id: string;
@@ -24,25 +25,16 @@ interface Nav {
 export function Dashboard({ nav, sse, theme, t }: { nav: Nav; sse: { messages: ReadonlyArray<SSEMessage> }; theme: Theme; t: TFunction }) {
   const c = useColors(theme);
   const { data, loading, error, refetch } = useApi<{ books: ReadonlyArray<BookSummary> }>("/books");
-  const [writingBooks, setWritingBooks] = useState<Set<string>>(new Set());
 
   const logEvents = sse.messages.filter((m) => m.event === "log").slice(-8);
   const progressEvent = sse.messages.filter((m) => m.event === "llm:progress").slice(-1)[0];
+  const activeBookIds = useMemo(() => deriveActiveBookIds(sse.messages), [sse.messages]);
 
-  useMemo(() => {
-    for (const msg of sse.messages) {
-      const bookId = (msg.data as { bookId?: string })?.bookId;
-      if (!bookId) continue;
-      if (msg.event === "write:start" || msg.event === "draft:start") {
-        setWritingBooks((prev) => new Set([...prev, bookId]));
-      }
-      if (msg.event === "write:complete" || msg.event === "write:error" ||
-          msg.event === "draft:complete" || msg.event === "draft:error") {
-        setWritingBooks((prev) => { const next = new Set(prev); next.delete(bookId); return next; });
-        refetch();
-      }
-    }
-  }, [sse.messages.length]);
+  useEffect(() => {
+    const recent = sse.messages.at(-1);
+    if (!shouldRefetchBookCollections(recent)) return;
+    void refetch();
+  }, [refetch, sse.messages]);
 
   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>;
@@ -79,7 +71,7 @@ export function Dashboard({ nav, sse, theme, t }: { nav: Nav; sse: { messages: R
 
       <div className="space-y-3">
         {data.books.map((book) => {
-          const isWriting = writingBooks.has(book.id);
+          const isWriting = activeBookIds.has(book.id);
           return (
             <div
               key={book.id}
@@ -141,7 +133,7 @@ export function Dashboard({ nav, sse, theme, t }: { nav: Nav; sse: { messages: R
       </div>
 
       {/* Live writing progress panel */}
-      {writingBooks.size > 0 && logEvents.length > 0 && (
+      {activeBookIds.size > 0 && logEvents.length > 0 && (
         <div className="border border-primary/30 bg-primary/5 rounded-lg p-6">
           <h3 className="text-sm uppercase tracking-wide text-primary font-medium mb-4">{t("dash.writingProgress")}</h3>
           <div className="space-y-1.5 text-sm font-mono text-muted-foreground">