@markbrutx/promptbook-viewer 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/server/api.d.ts +4 -5
  4. package/dist/server/api.d.ts.map +1 -1
  5. package/dist/server/api.js +49 -12
  6. package/dist/server/api.js.map +1 -1
  7. package/dist/server/book-source.d.ts +24 -0
  8. package/dist/server/book-source.d.ts.map +1 -1
  9. package/dist/server/book-source.js +39 -0
  10. package/dist/server/book-source.js.map +1 -1
  11. package/dist/server/server.d.ts +1 -1
  12. package/dist/server/server.d.ts.map +1 -1
  13. package/dist/server/server.js +21 -8
  14. package/dist/server/server.js.map +1 -1
  15. package/dist/server/workspace.d.ts +13 -0
  16. package/dist/server/workspace.d.ts.map +1 -0
  17. package/dist/server/workspace.js +55 -0
  18. package/dist/server/workspace.js.map +1 -0
  19. package/dist/shared/types.d.ts +9 -0
  20. package/dist/shared/types.d.ts.map +1 -1
  21. package/dist/web/assets/index-BCBuW76o.css +1 -0
  22. package/dist/web/assets/index-BwIAKPNq.js +51 -0
  23. package/dist/web/favicon.png +0 -0
  24. package/dist/web/index.html +3 -2
  25. package/dist/web/promptbook-logo.png +0 -0
  26. package/package.json +19 -2
  27. package/src/index.ts +2 -0
  28. package/src/server/api.ts +57 -18
  29. package/src/server/book-source.ts +67 -0
  30. package/src/server/server.ts +22 -9
  31. package/src/server/workspace.ts +64 -0
  32. package/src/shared/types.ts +11 -0
  33. package/src/web/App.tsx +91 -28
  34. package/src/web/api.ts +26 -10
  35. package/src/web/components/Sidebar.tsx +37 -0
  36. package/src/web/index.html +1 -0
  37. package/src/web/public/favicon.png +0 -0
  38. package/src/web/public/promptbook-logo.png +0 -0
  39. package/src/web/styles.css +35 -0
  40. package/src/web/types.ts +2 -0
  41. package/dist/web/assets/index-B2Wxtb-f.css +0 -1
  42. package/dist/web/assets/index-C8f_6lr_.js +0 -51
Binary file
@@ -3,9 +3,10 @@
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <link rel="icon" type="image/png" href="/favicon.png" />
6
7
  <title>promptbook viewer</title>
7
- <script type="module" crossorigin src="/assets/index-C8f_6lr_.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-B2Wxtb-f.css">
8
+ <script type="module" crossorigin src="/assets/index-BwIAKPNq.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-BCBuW76o.css">
9
10
  </head>
10
11
  <body>
11
12
  <div id="root"></div>
Binary file
package/package.json CHANGED
@@ -1,9 +1,26 @@
1
1
  {
2
2
  "name": "@markbrutx/promptbook-viewer",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Storybook-for-prompts: a local web app that renders a prompts folder via @markbrutx/promptbook-core.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
+ "keywords": [
8
+ "prompt",
9
+ "prompt-engineering",
10
+ "prompt-management",
11
+ "llm",
12
+ "ai",
13
+ "viewer",
14
+ "storybook",
15
+ "composition"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/markbrutx/promptbook.git",
20
+ "directory": "packages/viewer"
21
+ },
22
+ "homepage": "https://github.com/markbrutx/promptbook#readme",
23
+ "bugs": "https://github.com/markbrutx/promptbook/issues",
7
24
  "exports": {
8
25
  ".": {
9
26
  "types": "./dist/index.d.ts",
@@ -26,7 +43,7 @@
26
43
  "check": "biome check ."
27
44
  },
28
45
  "dependencies": {
29
- "@markbrutx/promptbook-core": "^0.1.0"
46
+ "@markbrutx/promptbook-core": "^0.3.0"
30
47
  },
31
48
  "devDependencies": {
32
49
  "@biomejs/biome": "latest",
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export type {
7
7
  Annotation,
8
8
  AnnotationsResponse,
9
9
  BookResponse,
10
+ BooksResponse,
10
11
  CompositionSummary,
11
12
  FragmentSummary,
12
13
  LintResponse,
@@ -16,4 +17,5 @@ export type {
16
17
  UsedInReference,
17
18
  UsedInResponse,
18
19
  VariantSummary,
20
+ WorkspaceBook,
19
21
  } from "./shared/types.js";
package/src/server/api.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { Context } from "@markbrutx/promptbook-core";
3
3
  import type { AnnotateRequest, ResolveRequest } from "../shared/types.js";
4
- import { type AnnotateInput, createAnnotationStore } from "./annotations.js";
5
- import type { BookSource } from "./book-source.js";
4
+ import { type AnnotateInput, type AnnotationStore, createAnnotationStore } from "./annotations.js";
5
+ import type { ResolvedBook, WorkspaceSource } from "./book-source.js";
6
6
  import {
7
7
  buildBookResponse,
8
8
  buildLintResponse,
@@ -12,8 +12,7 @@ import {
12
12
  import { serveStatic } from "./static.js";
13
13
 
14
14
  export interface RequestHandlerOptions {
15
- source: BookSource;
16
- promptsDir: string;
15
+ workspace: WorkspaceSource;
17
16
  /** Directory of the built web bundle (dist/web). */
18
17
  webRoot: string;
19
18
  }
@@ -21,8 +20,8 @@ export interface RequestHandlerOptions {
21
20
  /** A request handler plus the hook the folder watcher uses to push reloads. */
22
21
  export interface RequestHandler {
23
22
  handle(req: IncomingMessage, res: ServerResponse): void;
24
- /** Invalidate the cached book and notify connected clients to refetch. */
25
- notifyReload(): void;
23
+ /** Invalidate a changed book (or all) and notify clients to refetch. */
24
+ notifyReload(book?: string): void;
26
25
  }
27
26
 
28
27
  function sendJson(res: ServerResponse, status: number, payload: unknown): void {
@@ -80,48 +79,87 @@ function asAnnotateRequest(body: unknown): AnnotateInput {
80
79
  return input;
81
80
  }
82
81
 
82
+ const EMPTY_BOOK = { compositions: [], codePrompts: [], fragments: [], warnings: [] } as const;
83
+
83
84
  export function createRequestHandler(options: RequestHandlerOptions): RequestHandler {
84
- const { source, promptsDir, webRoot } = options;
85
+ const { workspace, webRoot } = options;
85
86
  const sseClients = new Set<ServerResponse>();
86
- const annotations = createAnnotationStore(promptsDir);
87
+ const annotationStores = new Map<string, AnnotationStore>();
88
+
89
+ /** Per-book annotation queue, keyed by directory; bookless roots write at root. */
90
+ const annotationsFor = async (bookName: string | undefined): Promise<AnnotationStore> => {
91
+ const resolved = await workspace.resolve(bookName);
92
+ const dir = resolved?.book.dir ?? workspace.root;
93
+ let store = annotationStores.get(dir);
94
+ if (store === undefined) {
95
+ store = createAnnotationStore(dir);
96
+ annotationStores.set(dir, store);
97
+ }
98
+ return store;
99
+ };
100
+
101
+ /** Resolve the active book or throw a 400-worthy error when the workspace is empty. */
102
+ const requireBook = async (bookName: string | undefined): Promise<ResolvedBook> => {
103
+ const resolved = await workspace.resolve(bookName);
104
+ if (resolved === undefined) {
105
+ throw new Error("no book found in the workspace");
106
+ }
107
+ return resolved;
108
+ };
87
109
 
88
110
  const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
89
111
  const url = new URL(req.url ?? "/", "http://localhost");
90
112
  const path = url.pathname;
91
113
  const method = req.method ?? "GET";
114
+ const bookName = url.searchParams.get("book") ?? undefined;
92
115
 
93
116
  try {
117
+ if (path === "/api/books" && method === "GET") {
118
+ sendJson(res, 200, { books: await workspace.books() });
119
+ return;
120
+ }
94
121
  if (path === "/api/book" && method === "GET") {
95
- sendJson(res, 200, buildBookResponse(await source.get(), promptsDir));
122
+ const resolved = await workspace.resolve(bookName);
123
+ if (resolved === undefined) {
124
+ sendJson(res, 200, EMPTY_BOOK);
125
+ return;
126
+ }
127
+ sendJson(res, 200, buildBookResponse(resolved.folder, resolved.book.dir));
96
128
  return;
97
129
  }
98
130
  if (path === "/api/resolve" && method === "POST") {
99
131
  const { prompt, context } = asResolveRequest(await readJsonBody(req));
100
- sendJson(res, 200, buildResolveResponse(await source.get(), prompt, context));
132
+ const { folder } = await requireBook(bookName);
133
+ sendJson(res, 200, buildResolveResponse(folder, prompt, context));
101
134
  return;
102
135
  }
103
136
  if (path === "/api/lint" && method === "POST") {
104
137
  const { prompt, context } = asResolveRequest(await readJsonBody(req));
105
- sendJson(res, 200, buildLintResponse(await source.get(), prompt, context));
138
+ const { folder } = await requireBook(bookName);
139
+ sendJson(res, 200, buildLintResponse(folder, prompt, context));
106
140
  return;
107
141
  }
108
142
  if (path.startsWith("/api/used-in/") && method === "GET") {
109
143
  const id = decodeURIComponent(path.slice("/api/used-in/".length));
110
- sendJson(res, 200, buildUsedInResponse(await source.get(), id));
144
+ const { folder } = await requireBook(bookName);
145
+ sendJson(res, 200, buildUsedInResponse(folder, id));
111
146
  return;
112
147
  }
113
148
  if (path === "/api/annotate" && method === "POST") {
114
- const annotation = await annotations.append(asAnnotateRequest(await readJsonBody(req)));
149
+ const store = await annotationsFor(bookName);
150
+ const annotation = await store.append(asAnnotateRequest(await readJsonBody(req)));
115
151
  sendJson(res, 200, annotation);
116
152
  return;
117
153
  }
118
154
  if (path === "/api/annotations" && method === "GET") {
119
- sendJson(res, 200, { annotations: await annotations.list() });
155
+ const store = await annotationsFor(bookName);
156
+ sendJson(res, 200, { annotations: await store.list() });
120
157
  return;
121
158
  }
122
159
  if (path.startsWith("/api/annotations/") && method === "DELETE") {
123
160
  const id = decodeURIComponent(path.slice("/api/annotations/".length));
124
- const removed = await annotations.remove(id);
161
+ const store = await annotationsFor(bookName);
162
+ const removed = await store.remove(id);
125
163
  sendJson(res, removed ? 200 : 404, { id, removed });
126
164
  return;
127
165
  }
@@ -154,10 +192,11 @@ export function createRequestHandler(options: RequestHandlerOptions): RequestHan
154
192
  handle(req, res) {
155
193
  void handle(req, res);
156
194
  },
157
- notifyReload() {
158
- source.invalidate();
195
+ notifyReload(book) {
196
+ workspace.invalidate(book);
197
+ const data = book !== undefined ? JSON.stringify({ book }) : "{}";
159
198
  for (const client of sseClients) {
160
- client.write("event: reload\ndata: {}\n\n");
199
+ client.write(`event: reload\ndata: ${data}\n\n`);
161
200
  }
162
201
  },
163
202
  };
@@ -1,5 +1,6 @@
1
1
  import type { Fixture, FsAdapter, PromptBook } from "@markbrutx/promptbook-core";
2
2
  import { loadFixtures, loadPrompts } from "@markbrutx/promptbook-core";
3
+ import { type Book, loadWorkspaceBooks } from "./workspace.js";
3
4
 
4
5
  /** A loaded prompts folder plus the fixtures that supply named variants. */
5
6
  export interface LoadedFolder {
@@ -52,3 +53,69 @@ export function createBookSource(promptsDir: string, fs?: FsAdapter): BookSource
52
53
  },
53
54
  };
54
55
  }
56
+
57
+ /** A book resolved for a request: its identity plus its loaded folder. */
58
+ export interface ResolvedBook {
59
+ book: Book;
60
+ folder: LoadedFolder;
61
+ }
62
+
63
+ /**
64
+ * Caches a {@link BookSource} per book across a whole workspace, discovering the
65
+ * books under the root lazily. One viewer serves every book through a single
66
+ * switcher: `books()` lists them, `resolve(name)` loads one (defaulting to the
67
+ * first), and `invalidate(name)` drops one book's cache while always re-scanning
68
+ * the book set (so a new folder appears without a restart).
69
+ */
70
+ export interface WorkspaceSource {
71
+ /** Root the workspace was discovered from (annotation fallback when bookless). */
72
+ root: string;
73
+ /** Discovered books (name + dir), re-scanned after {@link invalidate}. */
74
+ books(): Promise<Book[]>;
75
+ /** Load a book by name, or the first book when the name is missing/unknown. */
76
+ resolve(name?: string): Promise<ResolvedBook | undefined>;
77
+ /** Drop one book's cache (or none) and re-scan the book set. */
78
+ invalidate(name?: string): void;
79
+ }
80
+
81
+ export function createWorkspaceSource(root: string, fs?: FsAdapter): WorkspaceSource {
82
+ let bookList: Promise<Book[]> | undefined;
83
+ const sources = new Map<string, BookSource>();
84
+
85
+ const discover = (): Promise<Book[]> => {
86
+ if (bookList === undefined) {
87
+ bookList = loadWorkspaceBooks(root, fs);
88
+ }
89
+ return bookList;
90
+ };
91
+
92
+ const sourceFor = (book: Book): BookSource => {
93
+ let source = sources.get(book.name);
94
+ if (source === undefined) {
95
+ source = createBookSource(book.dir, fs);
96
+ sources.set(book.name, source);
97
+ }
98
+ return source;
99
+ };
100
+
101
+ return {
102
+ root,
103
+ books() {
104
+ return discover();
105
+ },
106
+ async resolve(name) {
107
+ const books = await discover();
108
+ if (books.length === 0) {
109
+ return undefined;
110
+ }
111
+ const book = books.find((b) => b.name === name) ?? (books[0] as Book);
112
+ return { book, folder: await sourceFor(book).get() };
113
+ },
114
+ invalidate(name) {
115
+ bookList = undefined;
116
+ if (name !== undefined) {
117
+ sources.get(name)?.invalidate();
118
+ }
119
+ },
120
+ };
121
+ }
@@ -1,13 +1,14 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { type FSWatcher, watch } from "node:fs";
3
3
  import { createServer } from "node:http";
4
+ import { sep } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
  import type { FsAdapter } from "@markbrutx/promptbook-core";
6
7
  import { createRequestHandler } from "./api.js";
7
- import { createBookSource } from "./book-source.js";
8
+ import { createWorkspaceSource } from "./book-source.js";
8
9
 
9
10
  export interface ViewerOptions {
10
- /** Folder containing `fragments/`, `rules/` and optional `fixtures/`. */
11
+ /** Workspace root (a single book, or a folder of sibling books). */
11
12
  promptsDir: string;
12
13
  /** Port to listen on; 0 (default) picks a free port. */
13
14
  port?: number;
@@ -41,15 +42,27 @@ function openBrowser(url: string): void {
41
42
  }
42
43
  }
43
44
 
44
- /** Watch the prompts folder and call `onChange` (debounced) on any edit. */
45
- function watchFolder(promptsDir: string, onChange: () => void): FSWatcher | undefined {
45
+ /**
46
+ * Watch the workspace root and call `onChange` (debounced) with the changed
47
+ * book. A change path's first segment names its top-level book; when several
48
+ * books or unknown paths change in one window, `undefined` is passed so the
49
+ * client refetches the active book regardless.
50
+ */
51
+ function watchFolder(rootDir: string, onChange: (book: string | undefined) => void): FSWatcher | undefined {
46
52
  try {
47
53
  let timer: NodeJS.Timeout | undefined;
48
- const watcher = watch(promptsDir, { recursive: true }, () => {
54
+ let changed = new Set<string | undefined>();
55
+ const watcher = watch(rootDir, { recursive: true }, (_event, filename) => {
56
+ const name = typeof filename === "string" && filename.length > 0 ? filename : undefined;
57
+ changed.add(name === undefined ? undefined : name.split(sep)[0]);
49
58
  if (timer !== undefined) {
50
59
  clearTimeout(timer);
51
60
  }
52
- timer = setTimeout(onChange, 50);
61
+ timer = setTimeout(() => {
62
+ const books = [...changed];
63
+ changed = new Set();
64
+ onChange(books.length === 1 ? books[0] : undefined);
65
+ }, 50);
53
66
  });
54
67
  watcher.on("error", () => {});
55
68
  return watcher;
@@ -66,8 +79,8 @@ function watchFolder(promptsDir: string, onChange: () => void): FSWatcher | unde
66
79
  export async function startViewer(options: ViewerOptions): Promise<Viewer> {
67
80
  const { promptsDir, port = 0, open = false, fs } = options;
68
81
  const webRoot = fileURLToPath(new URL("../web", import.meta.url));
69
- const source = createBookSource(promptsDir, fs);
70
- const handler = createRequestHandler({ source, promptsDir, webRoot });
82
+ const workspace = createWorkspaceSource(promptsDir, fs);
83
+ const handler = createRequestHandler({ workspace, webRoot });
71
84
 
72
85
  const server = createServer((req, res) => handler.handle(req, res));
73
86
  await new Promise<void>((resolve, reject) => {
@@ -79,7 +92,7 @@ export async function startViewer(options: ViewerOptions): Promise<Viewer> {
79
92
  const boundPort = typeof address === "object" && address !== null ? address.port : port;
80
93
  const url = `http://localhost:${boundPort}`;
81
94
 
82
- const watcher = watchFolder(promptsDir, () => handler.notifyReload());
95
+ const watcher = watchFolder(promptsDir, (book) => handler.notifyReload(book));
83
96
 
84
97
  if (open) {
85
98
  openBrowser(url);
@@ -0,0 +1,64 @@
1
+ import { basename, join } from "node:path";
2
+ import type { FsAdapter } from "@markbrutx/promptbook-core";
3
+ import { nodeFs } from "@markbrutx/promptbook-core";
4
+
5
+ /** A discovered prompts book: its folder name and absolute directory. */
6
+ export interface Book {
7
+ name: string;
8
+ dir: string;
9
+ }
10
+
11
+ /** Folder names that are book internals or noise, never workspace sub-books. */
12
+ const NON_BOOK_DIRS = new Set(["node_modules", "fragments", "rules", "code-prompts"]);
13
+
14
+ /** Markers that make a folder a loadable book (a config or a loadable form). */
15
+ const BOOK_MARKERS = ["promptbook.json", "rules", "code-prompts"];
16
+
17
+ /** True when `dir` looks like a prompts book (has a config or a loadable form). */
18
+ async function isBook(fs: FsAdapter, dir: string): Promise<boolean> {
19
+ let entries: string[];
20
+ try {
21
+ entries = await fs.readDir(dir);
22
+ } catch {
23
+ return false;
24
+ }
25
+ return BOOK_MARKERS.some((marker) => entries.includes(marker));
26
+ }
27
+
28
+ /**
29
+ * Find the books directly under `rootDir`: each non-internal subfolder that is
30
+ * itself a book. One level deep, sorted by name. This mirrors the CLI's
31
+ * discovery (kept as a server-local copy so the viewer never depends on the
32
+ * CLI), so `view` and `ls --all` see the same workspace.
33
+ */
34
+ async function discoverBooks(rootDir: string, fs: FsAdapter = nodeFs()): Promise<Book[]> {
35
+ let entries: string[];
36
+ try {
37
+ entries = await fs.readDir(rootDir);
38
+ } catch {
39
+ return [];
40
+ }
41
+ const books: Book[] = [];
42
+ for (const name of [...entries].sort()) {
43
+ if (name.startsWith(".") || name.startsWith("_") || NON_BOOK_DIRS.has(name)) {
44
+ continue;
45
+ }
46
+ const dir = join(rootDir, name);
47
+ if (await isBook(fs, dir)) {
48
+ books.push({ name, dir });
49
+ }
50
+ }
51
+ return books;
52
+ }
53
+
54
+ /**
55
+ * Read a workspace from `rootDir`. When the root is itself a book it is the only
56
+ * book (back-compat single-book view, named after the folder); otherwise the
57
+ * root is a workspace of its sub-books.
58
+ */
59
+ export async function loadWorkspaceBooks(rootDir: string, fs: FsAdapter = nodeFs()): Promise<Book[]> {
60
+ if (await isBook(fs, rootDir)) {
61
+ return [{ name: basename(rootDir), dir: rootDir }];
62
+ }
63
+ return discoverBooks(rootDir, fs);
64
+ }
@@ -68,6 +68,17 @@ export interface CodePromptSummary {
68
68
  sourceFile: string;
69
69
  }
70
70
 
71
+ /** One book in the workspace, as `GET /api/books` lists it. */
72
+ export interface WorkspaceBook {
73
+ name: string;
74
+ dir: string;
75
+ }
76
+
77
+ /** Response of `GET /api/books`: the switcher menu. */
78
+ export interface BooksResponse {
79
+ books: WorkspaceBook[];
80
+ }
81
+
71
82
  /** Response of `GET /api/book`. */
72
83
  export interface BookResponse {
73
84
  compositions: CompositionSummary[];
package/src/web/App.tsx CHANGED
@@ -17,6 +17,7 @@ import type {
17
17
  LintResponse,
18
18
  ResolveResponse,
19
19
  UsedInResponse,
20
+ WorkspaceBook,
20
21
  } from "./types.js";
21
22
 
22
23
  /** Order-independent key for comparing two contexts (variant identity). */
@@ -47,6 +48,8 @@ function controlKeys(composition: CompositionSummary | undefined): string[] {
47
48
  }
48
49
 
49
50
  export function App() {
51
+ const [books, setBooks] = useState<WorkspaceBook[]>([]);
52
+ const [activeBook, setActiveBook] = useState<string | null>(null);
50
53
  const [book, setBook] = useState<BookResponse | null>(null);
51
54
  const [error, setError] = useState<string | null>(null);
52
55
  const [selection, setSelection] = useState<Selection | null>(null);
@@ -58,19 +61,34 @@ export function App() {
58
61
  const [compareResolved, setCompareResolved] = useState<ResolveResponse | null>(null);
59
62
  const [annotations, setAnnotations] = useState<Annotation[]>([]);
60
63
  const requestId = useRef(0);
64
+ // Mirror activeBook into a ref so the (book-independent) SSE subscription can
65
+ // read the current book without re-subscribing on every switch.
66
+ const activeBookRef = useRef<string | null>(null);
67
+ activeBookRef.current = activeBook;
61
68
 
62
- const loadAnnotations = useCallback(async () => {
69
+ const loadBooks = useCallback(async () => {
63
70
  try {
64
- const { annotations: next } = await api.annotations();
71
+ const { books: next } = await api.books();
72
+ setBooks(next);
73
+ return next;
74
+ } catch {
75
+ setBooks([]);
76
+ return [];
77
+ }
78
+ }, []);
79
+
80
+ const loadAnnotations = useCallback(async (which: string | null) => {
81
+ try {
82
+ const { annotations: next } = await api.annotations(which);
65
83
  setAnnotations(next);
66
84
  } catch {
67
85
  setAnnotations([]);
68
86
  }
69
87
  }, []);
70
88
 
71
- const loadBook = useCallback(async () => {
89
+ const loadBook = useCallback(async (which: string | null) => {
72
90
  try {
73
- const next = await api.book();
91
+ const next = await api.book(which);
74
92
  setBook(next);
75
93
  setError(null);
76
94
  return next;
@@ -80,25 +98,54 @@ export function App() {
80
98
  }
81
99
  }, []);
82
100
 
83
- // Initial load + pick the first composition's Default variant.
101
+ // Discover the workspace's books, then activate the first one.
102
+ useEffect(() => {
103
+ void loadBooks().then((list) => {
104
+ setActiveBook((current) => current ?? list[0]?.name ?? null);
105
+ });
106
+ }, [loadBooks]);
107
+
108
+ // Load the active book's tree + annotations and reset the selection to its
109
+ // first composition. Re-runs on every book switch (a fresh menu each time).
84
110
  useEffect(() => {
85
- void loadBook().then((next) => {
111
+ if (activeBook === null) {
112
+ setBook(null);
113
+ setSelection(null);
114
+ return;
115
+ }
116
+ void loadBook(activeBook).then((next) => {
86
117
  const first = next?.compositions[0];
87
- if (first !== undefined) {
88
- setSelection({ kind: "variant", composition: first.name, variant: DEFAULT_VARIANT.name });
89
- setContext({});
90
- }
118
+ setSelection(
119
+ first !== undefined
120
+ ? { kind: "variant", composition: first.name, variant: DEFAULT_VARIANT.name }
121
+ : null,
122
+ );
123
+ setContext({});
124
+ setCompareVariant("");
91
125
  });
92
- void loadAnnotations();
93
- }, [loadBook, loadAnnotations]);
126
+ void loadAnnotations(activeBook);
127
+ }, [activeBook, loadBook, loadAnnotations]);
94
128
 
95
- // Hot-reload: refetch the book on folder changes. The new book object is a
96
- // fresh reference, so the resolve/used-in effects below re-run automatically.
129
+ // Hot-reload: refetch the book list, and the active book's tree when it (or
130
+ // an unknown path) changed. The new book object is a fresh reference, so the
131
+ // resolve/used-in effects below re-run automatically; the selection persists.
97
132
  useEffect(() => {
98
133
  const source = new EventSource("/api/events");
99
- source.addEventListener("reload", () => void loadBook());
134
+ source.addEventListener("reload", (event) => {
135
+ let changed: string | undefined;
136
+ try {
137
+ changed = (JSON.parse((event as MessageEvent).data) as { book?: string }).book;
138
+ } catch {
139
+ changed = undefined;
140
+ }
141
+ void loadBooks();
142
+ const active = activeBookRef.current;
143
+ if (active !== null && (changed === undefined || changed === active)) {
144
+ void loadBook(active);
145
+ }
146
+ });
100
147
  return () => source.close();
101
- }, [loadBook]);
148
+ }, [loadBook, loadBooks]);
102
149
 
103
150
  const compositions = book?.compositions ?? [];
104
151
  const selectedComposition =
@@ -113,7 +160,10 @@ export function App() {
113
160
  requestId.current += 1;
114
161
  const id = requestId.current;
115
162
  const { composition } = selection;
116
- void Promise.all([api.resolve(composition, context), api.lint(composition, context)])
163
+ void Promise.all([
164
+ api.resolve(activeBook, composition, context),
165
+ api.lint(activeBook, composition, context),
166
+ ])
117
167
  .then(([r, l]) => {
118
168
  if (requestId.current === id) {
119
169
  setResolved(r);
@@ -126,7 +176,7 @@ export function App() {
126
176
  setError((e as Error).message);
127
177
  }
128
178
  });
129
- }, [book, selection, context]);
179
+ }, [book, selection, context, activeBook]);
130
180
 
131
181
  useEffect(() => {
132
182
  if (book === null || selection?.kind !== "fragment") {
@@ -134,10 +184,10 @@ export function App() {
134
184
  return;
135
185
  }
136
186
  void api
137
- .usedIn(selection.id)
187
+ .usedIn(activeBook, selection.id)
138
188
  .then(setUsedIn)
139
189
  .catch(() => setUsedIn(null));
140
- }, [book, selection]);
190
+ }, [book, selection, activeBook]);
141
191
 
142
192
  // Resolve the comparison variant for the Diff panel.
143
193
  useEffect(() => {
@@ -147,10 +197,14 @@ export function App() {
147
197
  }
148
198
  const ctx = variantContext(selectedComposition, compareVariant);
149
199
  void api
150
- .resolve(selection.composition, ctx)
200
+ .resolve(activeBook, selection.composition, ctx)
151
201
  .then(setCompareResolved)
152
202
  .catch(() => setCompareResolved(null));
153
- }, [selection, compareVariant, selectedComposition]);
203
+ }, [selection, compareVariant, selectedComposition, activeBook]);
204
+
205
+ const selectBook = useCallback((name: string) => {
206
+ setActiveBook(name);
207
+ }, []);
154
208
 
155
209
  const selectVariant = useCallback(
156
210
  (composition: string, variant: string) => {
@@ -175,18 +229,24 @@ export function App() {
175
229
  if (selection?.kind !== "variant") {
176
230
  return;
177
231
  }
178
- await api.annotate({ prompt: selection.composition, context, fragmentId, anchorText, comment });
179
- await loadAnnotations();
232
+ await api.annotate(activeBook, {
233
+ prompt: selection.composition,
234
+ context,
235
+ fragmentId,
236
+ anchorText,
237
+ comment,
238
+ });
239
+ await loadAnnotations(activeBook);
180
240
  },
181
- [selection, context, loadAnnotations],
241
+ [selection, context, activeBook, loadAnnotations],
182
242
  );
183
243
 
184
244
  const resolveAnnotation = useCallback(
185
245
  async (id: string) => {
186
- await api.resolveAnnotation(id);
187
- await loadAnnotations();
246
+ await api.resolveAnnotation(activeBook, id);
247
+ await loadAnnotations(activeBook);
188
248
  },
189
- [loadAnnotations],
249
+ [activeBook, loadAnnotations],
190
250
  );
191
251
 
192
252
  // Annotations belonging to exactly the variant on screen (composition + context).
@@ -211,6 +271,9 @@ export function App() {
211
271
  return (
212
272
  <div className="layout">
213
273
  <Sidebar
274
+ books={books}
275
+ activeBook={activeBook}
276
+ onSelectBook={selectBook}
214
277
  tree={tree}
215
278
  fragmentGroups={fragmentGroups}
216
279
  selection={selection}