plotlink-ows 0.1.15 → 1.0.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 (40) hide show
  1. package/README.md +185 -93
  2. package/app/db.ts +1 -1
  3. package/app/lib/paths.ts +0 -2
  4. package/app/lib/publish.ts +257 -44
  5. package/app/prisma/schema.prisma +0 -36
  6. package/app/routes/dashboard.ts +105 -57
  7. package/app/routes/publish.ts +107 -25
  8. package/app/routes/settings.ts +194 -0
  9. package/app/routes/stories.ts +223 -0
  10. package/app/routes/terminal.ts +258 -0
  11. package/app/routes/wallet.ts +40 -10
  12. package/app/server.ts +35 -81
  13. package/app/web/App.tsx +4 -6
  14. package/app/web/components/Dashboard.tsx +98 -79
  15. package/app/web/components/Layout.tsx +70 -103
  16. package/app/web/components/PreviewPanel.tsx +388 -0
  17. package/app/web/components/Settings.tsx +210 -67
  18. package/app/web/components/StoriesPage.tsx +270 -0
  19. package/app/web/components/StoryBrowser.tsx +161 -0
  20. package/app/web/components/TerminalPanel.tsx +428 -0
  21. package/app/web/components/WalletCard.tsx +14 -8
  22. package/app/web/dist/assets/index-BuOxhUWG.css +32 -0
  23. package/app/web/dist/assets/index-De8CpT47.js +129 -0
  24. package/app/web/dist/index.html +3 -3
  25. package/app/web/dist/plotlink-logo.svg +5 -0
  26. package/app/web/public/plotlink-logo.svg +5 -0
  27. package/app/web/styles.css +18 -0
  28. package/bin/plotlink-ows.js +18 -62
  29. package/package.json +15 -6
  30. package/scripts/fix-index-status.ts +93 -0
  31. package/app/lib/llm-client.ts +0 -265
  32. package/app/lib/writer-prompt.ts +0 -44
  33. package/app/routes/chat.ts +0 -135
  34. package/app/routes/config.ts +0 -210
  35. package/app/routes/oauth.ts +0 -150
  36. package/app/web/components/Chat.tsx +0 -272
  37. package/app/web/components/LLMSetup.tsx +0 -291
  38. package/app/web/components/Publish.tsx +0 -245
  39. package/app/web/dist/assets/index-C9kXlYO_.css +0 -2
  40. package/app/web/dist/assets/index-CJiiaLHs.js +0 -9
@@ -0,0 +1,161 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+
3
+ interface FileStatus {
4
+ file: string;
5
+ status: "published" | "published-not-indexed" | "pending" | "draft";
6
+ txHash?: string;
7
+ storylineId?: number;
8
+ }
9
+
10
+ interface StoryInfo {
11
+ name: string;
12
+ files: FileStatus[];
13
+ hasStructure: boolean;
14
+ hasGenesis: boolean;
15
+ plotCount: number;
16
+ publishedCount: number;
17
+ }
18
+
19
+ interface StoryBrowserProps {
20
+ authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
21
+ selectedStory: string | null;
22
+ selectedFile: string | null;
23
+ onSelectFile: (storyName: string, fileName: string) => void;
24
+ }
25
+
26
+ const STATUS_ICON: Record<string, string> = {
27
+ "published": "\u2713",
28
+ "published-not-indexed": "\u26A0",
29
+ "pending": "\u23F3",
30
+ "draft": "\uD83D\uDCDD",
31
+ };
32
+
33
+ const STATUS_COLOR: Record<string, string> = {
34
+ "published": "text-green-700",
35
+ "published-not-indexed": "text-amber-700",
36
+ "pending": "text-amber-700",
37
+ "draft": "text-muted",
38
+ };
39
+
40
+ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectFile }: StoryBrowserProps) {
41
+ const [stories, setStories] = useState<StoryInfo[]>([]);
42
+ const [expanded, setExpanded] = useState<Set<string>>(new Set());
43
+
44
+ const loadStories = useCallback(async () => {
45
+ try {
46
+ const res = await authFetch("/api/stories");
47
+ if (res.ok) {
48
+ const data = await res.json();
49
+ setStories(data.stories);
50
+ }
51
+ } catch { /* ignore */ }
52
+ }, [authFetch]);
53
+
54
+ useEffect(() => {
55
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- initial load + polling
56
+ loadStories();
57
+ const interval = setInterval(loadStories, 5000);
58
+ return () => clearInterval(interval);
59
+ }, [loadStories]);
60
+
61
+ // Auto-expand selected story
62
+ useEffect(() => {
63
+ if (selectedStory) {
64
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- derived from prop
65
+ setExpanded((prev) => new Set(prev).add(selectedStory));
66
+ }
67
+ }, [selectedStory]);
68
+
69
+ const getLatestFile = (files: FileStatus[]): string | null => {
70
+ // Latest plot by highest number
71
+ const plots = files
72
+ .map((f) => ({ file: f.file, num: f.file.match(/^plot-(\d+)\.md$/)?.[1] }))
73
+ .filter((p) => p.num != null)
74
+ .sort((a, b) => parseInt(b.num!) - parseInt(a.num!));
75
+ if (plots.length > 0) return plots[0].file;
76
+ // Fallback: genesis, then structure
77
+ if (files.some((f) => f.file === "genesis.md")) return "genesis.md";
78
+ if (files.some((f) => f.file === "structure.md")) return "structure.md";
79
+ return files[0]?.file ?? null;
80
+ };
81
+
82
+ const toggleExpand = (name: string) => {
83
+ setExpanded((prev) => {
84
+ const next = new Set(prev);
85
+ if (next.has(name)) next.delete(name);
86
+ else next.add(name);
87
+ return next;
88
+ });
89
+ };
90
+
91
+ const handleStoryClick = (story: StoryInfo) => {
92
+ toggleExpand(story.name);
93
+ // Auto-select latest file when expanding (not when collapsing)
94
+ if (!expanded.has(story.name)) {
95
+ const latest = getLatestFile(story.files);
96
+ if (latest) onSelectFile(story.name, latest);
97
+ }
98
+ };
99
+
100
+ // Sort files: structure first, genesis, then plots in order
101
+ const sortFiles = (files: FileStatus[]) => {
102
+ const order = (f: string) => {
103
+ if (f === "structure.md") return 0;
104
+ if (f === "genesis.md") return 1;
105
+ const m = f.match(/^plot-(\d+)\.md$/);
106
+ return m ? 2 + parseInt(m[1]) : 100;
107
+ };
108
+ return [...files].sort((a, b) => order(a.file) - order(b.file));
109
+ };
110
+
111
+ return (
112
+ <div className="h-full flex flex-col">
113
+ <div className="px-3 py-1.5 border-b border-border flex items-center justify-between">
114
+ <span className="text-xs font-mono text-muted">Stories</span>
115
+ <span className="text-xs text-muted">{stories.length}</span>
116
+ </div>
117
+ <div className="flex-1 min-h-0 overflow-y-auto">
118
+ {stories.length === 0 ? (
119
+ <div className="p-3 text-sm text-muted">
120
+ <p>No stories yet.</p>
121
+ <p className="mt-1 text-xs">Use the terminal to start writing with Claude.</p>
122
+ </div>
123
+ ) : (
124
+ stories.filter((s) => s.name !== "_example").map((story) => (
125
+ <div key={story.name}>
126
+ <button
127
+ onClick={() => handleStoryClick(story)}
128
+ className="w-full px-3 py-2 text-left flex items-center gap-2 hover:bg-surface text-sm"
129
+ >
130
+ <span className="text-xs text-muted">{expanded.has(story.name) ? "\u25BC" : "\u25B6"}</span>
131
+ <span className="font-medium truncate">{story.name}</span>
132
+ <span className="ml-auto text-xs text-muted">
133
+ {story.publishedCount}/{story.files.length}
134
+ </span>
135
+ </button>
136
+ {expanded.has(story.name) && (
137
+ <div className="pl-4">
138
+ {sortFiles(story.files).map((f) => {
139
+ const isSelected = selectedStory === story.name && selectedFile === f.file;
140
+ return (
141
+ <button
142
+ key={f.file}
143
+ onClick={() => onSelectFile(story.name, f.file)}
144
+ className={`w-full px-3 py-1.5 text-left flex items-center gap-2 text-xs hover:bg-surface ${
145
+ isSelected ? "bg-surface font-medium" : ""
146
+ }`}
147
+ >
148
+ <span className={STATUS_COLOR[f.status]}>{STATUS_ICON[f.status]}</span>
149
+ <span className="truncate font-mono">{f.file}</span>
150
+ </button>
151
+ );
152
+ })}
153
+ </div>
154
+ )}
155
+ </div>
156
+ ))
157
+ )}
158
+ </div>
159
+ </div>
160
+ );
161
+ }
@@ -0,0 +1,428 @@
1
+ import { useRef, useEffect, useCallback, useState } from "react";
2
+ import { Terminal } from "@xterm/xterm";
3
+ import { FitAddon } from "@xterm/addon-fit";
4
+ import { SerializeAddon } from "@xterm/addon-serialize";
5
+ import "@xterm/xterm/css/xterm.css";
6
+
7
+ interface TerminalPanelProps {
8
+ token: string;
9
+ storyName: string | null;
10
+ authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
11
+ onSelectStory?: (storyName: string) => void;
12
+ }
13
+
14
+ interface TerminalSession {
15
+ term: Terminal;
16
+ fit: FitAddon;
17
+ serialize: SerializeAddon;
18
+ ws: WebSocket | null;
19
+ container: HTMLDivElement;
20
+ observer: ResizeObserver;
21
+ connected: boolean;
22
+ _retried?: boolean;
23
+ }
24
+
25
+ const THEME = {
26
+ background: "#F0EBE1",
27
+ foreground: "#2C1810",
28
+ cursor: "#8B4513",
29
+ cursorAccent: "#F0EBE1",
30
+ selectionBackground: "#D4C5B0",
31
+ selectionForeground: "#2C1810",
32
+ black: "#2C1810",
33
+ red: "#A63D40",
34
+ green: "#4A7A4A",
35
+ yellow: "#8B6914",
36
+ blue: "#4A6FA5",
37
+ magenta: "#7B4B8A",
38
+ cyan: "#3D7A7A",
39
+ white: "#3A2A1E",
40
+ brightBlack: "#8B7355",
41
+ brightRed: "#B85C5C", // muted red — readable as text, soft as diff bg
42
+ brightGreen: "#5A8A5A", // muted green — readable as text, soft as diff bg
43
+ brightYellow: "#A07D1C",
44
+ brightBlue: "#5A82BA",
45
+ brightMagenta: "#8E5D9F",
46
+ brightCyan: "#5A8F8F",
47
+ brightWhite: "#4A3728",
48
+ };
49
+
50
+ const DB_NAME = "plotlink-terminal";
51
+ const DB_VERSION = 1;
52
+ const STORE_NAME = "scrollback";
53
+ const MAX_SCROLLBACK_BYTES = 10 * 1024 * 1024; // 10MB per story
54
+
55
+ // ---- IndexedDB helpers ----
56
+ function openDb(): Promise<IDBDatabase> {
57
+ return new Promise((resolve, reject) => {
58
+ const req = indexedDB.open(DB_NAME, DB_VERSION);
59
+ req.onupgradeneeded = () => {
60
+ const db = req.result;
61
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
62
+ db.createObjectStore(STORE_NAME);
63
+ }
64
+ };
65
+ req.onsuccess = () => resolve(req.result);
66
+ req.onerror = () => reject(req.error);
67
+ });
68
+ }
69
+
70
+ async function saveScrollback(storyName: string, data: string): Promise<void> {
71
+ // Enforce size limit
72
+ const trimmed = data.length > MAX_SCROLLBACK_BYTES ? data.slice(-MAX_SCROLLBACK_BYTES) : data;
73
+ const db = await openDb();
74
+ return new Promise((resolve, reject) => {
75
+ const tx = db.transaction(STORE_NAME, "readwrite");
76
+ tx.objectStore(STORE_NAME).put(trimmed, storyName);
77
+ tx.oncomplete = () => { db.close(); resolve(); };
78
+ tx.onerror = () => { db.close(); reject(tx.error); };
79
+ });
80
+ }
81
+
82
+ async function loadScrollback(storyName: string): Promise<string | null> {
83
+ const db = await openDb();
84
+ return new Promise((resolve, reject) => {
85
+ const tx = db.transaction(STORE_NAME, "readonly");
86
+ const req = tx.objectStore(STORE_NAME).get(storyName);
87
+ req.onsuccess = () => { db.close(); resolve(req.result ?? null); };
88
+ req.onerror = () => { db.close(); reject(req.error); };
89
+ });
90
+ }
91
+
92
+ // Sessions live outside React state to avoid ref-in-effect lint issues
93
+ const sessions = new Map<string, TerminalSession>();
94
+
95
+ export function TerminalPanel({ token, storyName, authFetch, onSelectStory }: TerminalPanelProps) {
96
+ const wrapperRef = useRef<HTMLDivElement>(null);
97
+ const authFetchRef = useRef(authFetch);
98
+ const [sessionList, setSessionList] = useState<string[]>([]);
99
+ const [disconnected, setDisconnected] = useState<Set<string>>(new Set());
100
+
101
+ const connectWsRef = useRef<(name: string, session: TerminalSession, resume: boolean) => void>(() => {});
102
+
103
+ useEffect(() => { authFetchRef.current = authFetch; }, [authFetch]);
104
+
105
+ const safeFit = useCallback((session: TerminalSession) => {
106
+ const { width } = session.container.getBoundingClientRect();
107
+ if (width < 50) return; // Skip fit if container has no real dimensions
108
+ try {
109
+ session.fit.fit();
110
+ if (session.ws?.readyState === WebSocket.OPEN) {
111
+ session.ws.send(JSON.stringify({ type: "resize", cols: session.term.cols, rows: session.term.rows }));
112
+ }
113
+ } catch { /* ignore */ }
114
+ }, []);
115
+
116
+ const showSession = useCallback((name: string | null) => {
117
+ for (const [key, session] of sessions) {
118
+ session.container.style.display = key === name ? "block" : "none";
119
+ }
120
+ if (name) {
121
+ const active = sessions.get(name);
122
+ if (active) {
123
+ // setTimeout gives browser time to compute layout after display change
124
+ setTimeout(() => safeFit(active), 50);
125
+ }
126
+ }
127
+ }, [safeFit]);
128
+
129
+ const connectWs = useCallback((name: string, session: TerminalSession, resume: boolean) => {
130
+ const wsProto = window.location.protocol === "https:" ? "wss:" : "ws:";
131
+ const ws = new WebSocket(
132
+ `${wsProto}//${window.location.host}/ws/terminal?story=${encodeURIComponent(name)}&token=${token}&resume=${resume}`
133
+ );
134
+
135
+ ws.onopen = () => {
136
+ session.connected = true;
137
+ session._retried = false;
138
+ setDisconnected((prev) => { const next = new Set(prev); next.delete(name); return next; });
139
+ ws.send(JSON.stringify({ type: "resize", cols: session.term.cols, rows: session.term.rows }));
140
+ };
141
+
142
+ ws.onmessage = (e) => {
143
+ session.term.write(e.data);
144
+ };
145
+
146
+ ws.onclose = (event) => {
147
+ session.connected = false;
148
+ if (session.ws === ws) {
149
+ session.ws = null;
150
+ // Save scrollback before marking disconnected
151
+ try {
152
+ const data = session.serialize.serialize();
153
+ saveScrollback(name, data).catch(() => {});
154
+ } catch { /* ignore */ }
155
+
156
+ // Code 4000 = resume failed, auto-reconnect fresh (once only)
157
+ if (event.code === 4000 && !session._retried) {
158
+ session._retried = true;
159
+ session.term.write("\r\n\x1b[33m[Resume failed — starting fresh session...]\x1b[0m\r\n");
160
+ connectWsRef.current(name, session, false);
161
+ return;
162
+ }
163
+
164
+ setDisconnected((prev) => new Set(prev).add(name));
165
+ }
166
+ };
167
+
168
+ session.term.onData((data) => {
169
+ if (ws.readyState === WebSocket.OPEN) {
170
+ ws.send(data);
171
+ }
172
+ });
173
+
174
+ session.ws = ws;
175
+ }, [token]);
176
+
177
+ useEffect(() => { connectWsRef.current = connectWs; }, [connectWs]);
178
+
179
+ const createSession = useCallback(async (name: string, opts?: { resume?: boolean; autoConnect?: boolean }) => {
180
+ if (!wrapperRef.current || sessions.has(name)) return;
181
+ const { resume = false, autoConnect = true } = opts ?? {};
182
+
183
+ const container = document.createElement("div");
184
+ container.style.width = "100%";
185
+ container.style.height = "100%";
186
+ container.style.display = "none";
187
+ wrapperRef.current.appendChild(container);
188
+
189
+ const term = new Terminal({
190
+ cols: 80, // Fallback minimum until FitAddon computes actual size
191
+ scrollback: 5000,
192
+ fontSize: 13,
193
+ fontFamily: '"Geist Mono", ui-monospace, monospace',
194
+ lineHeight: 1.05,
195
+ letterSpacing: 0,
196
+ cursorBlink: true,
197
+ cursorStyle: "block",
198
+ theme: THEME,
199
+ allowTransparency: false,
200
+ drawBoldTextInBrightColors: false,
201
+ minimumContrastRatio: 7, // High contrast — compensates for dim text halving
202
+ });
203
+
204
+ const fit = new FitAddon();
205
+ const serialize = new SerializeAddon();
206
+ term.loadAddon(fit);
207
+ term.loadAddon(serialize);
208
+ term.open(container);
209
+
210
+ // Apply padding to term.element so FitAddon measures correctly
211
+ if (term.element) {
212
+ term.element.style.paddingLeft = "10px";
213
+ }
214
+
215
+ const session: TerminalSession = { term, fit, serialize, ws: null, container, observer: null as unknown as ResizeObserver, connected: false };
216
+
217
+ const observer = new ResizeObserver(() => {
218
+ const { width } = container.getBoundingClientRect();
219
+ if (width < 50) return; // Skip if container not yet laid out
220
+ try {
221
+ fit.fit();
222
+ if (session.ws?.readyState === WebSocket.OPEN) {
223
+ session.ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows }));
224
+ }
225
+ } catch { /* ignore */ }
226
+ });
227
+ observer.observe(container);
228
+ session.observer = observer;
229
+ sessions.set(name, session);
230
+ setSessionList((prev) => [...prev, name]);
231
+
232
+ // Restore scrollback from IndexedDB
233
+ try {
234
+ const saved = await loadScrollback(name);
235
+ if (saved) {
236
+ term.write(saved);
237
+ }
238
+ } catch { /* ignore */ }
239
+
240
+ if (autoConnect) {
241
+ connectWs(name, session, resume);
242
+ } else {
243
+ // Show as disconnected so overlay appears
244
+ setDisconnected((prev) => new Set(prev).add(name));
245
+ }
246
+
247
+ // Defer initial fit — container may still be display:none
248
+ setTimeout(() => safeFit(session), 50);
249
+ }, [connectWs, safeFit]);
250
+
251
+ const reconnectSession = useCallback(async (name: string, resume: boolean) => {
252
+ const session = sessions.get(name);
253
+ if (!session) return;
254
+
255
+ // Close existing WS if any
256
+ if (session.ws) {
257
+ session.ws.close();
258
+ session.ws = null;
259
+ }
260
+
261
+ if (!resume) {
262
+ // Kill old server PTY so a fresh one spawns on reconnect
263
+ await authFetchRef.current(`/api/terminal/${encodeURIComponent(name)}`, { method: "DELETE" }).catch(() => {});
264
+ session.term.clear();
265
+ }
266
+
267
+ connectWs(name, session, resume);
268
+ }, [connectWs]);
269
+
270
+ const destroySession = useCallback((name: string) => {
271
+ const session = sessions.get(name);
272
+ if (!session) return;
273
+
274
+ // Save scrollback before destroying
275
+ try {
276
+ const data = session.serialize.serialize();
277
+ saveScrollback(name, data).catch(() => {});
278
+ } catch { /* ignore */ }
279
+
280
+ session.observer.disconnect();
281
+ if (session.ws) session.ws.close();
282
+ session.term.dispose();
283
+ session.container.remove();
284
+ sessions.delete(name);
285
+ setSessionList((prev) => prev.filter((s) => s !== name));
286
+ setDisconnected((prev) => { const next = new Set(prev); next.delete(name); return next; });
287
+
288
+ authFetch(`/api/terminal/${encodeURIComponent(name)}`, { method: "DELETE" }).catch(() => {});
289
+ }, [authFetch]);
290
+
291
+ // Auto-spawn + show/hide when story changes
292
+ useEffect(() => {
293
+ if (!storyName) return;
294
+ if (!sessions.has(storyName)) {
295
+ // Check if a previous session exists — if so, show overlay instead of auto-connecting
296
+ authFetchRef.current(`/api/terminal/session/${encodeURIComponent(storyName)}`)
297
+ .then((res) => res.ok ? res.json() : null)
298
+ .then((data) => {
299
+ if (!sessions.has(storyName)) { // guard against race
300
+ const hasStoredSession = data?.sessionId && !data?.running;
301
+ createSession(storyName, { autoConnect: !hasStoredSession });
302
+ showSession(storyName);
303
+ }
304
+ })
305
+ .catch(() => {
306
+ if (!sessions.has(storyName)) {
307
+ createSession(storyName);
308
+ showSession(storyName);
309
+ }
310
+ });
311
+ } else {
312
+ showSession(storyName);
313
+ }
314
+ }, [storyName, createSession, showSession]);
315
+
316
+ // Periodic scrollback save (every 30s for active session)
317
+ useEffect(() => {
318
+ const interval = setInterval(() => {
319
+ for (const [name, session] of sessions) {
320
+ if (session.connected) {
321
+ try {
322
+ const data = session.serialize.serialize();
323
+ saveScrollback(name, data).catch(() => {});
324
+ } catch { /* ignore */ }
325
+ }
326
+ }
327
+ }, 30000);
328
+ return () => clearInterval(interval);
329
+ }, []);
330
+
331
+ // Cleanup all sessions on unmount
332
+ useEffect(() => {
333
+ return () => {
334
+ for (const [name, session] of sessions) {
335
+ // Save scrollback before cleanup
336
+ try {
337
+ const data = session.serialize.serialize();
338
+ saveScrollback(name, data).catch(() => {});
339
+ } catch { /* ignore */ }
340
+ session.observer.disconnect();
341
+ if (session.ws) session.ws.close();
342
+ session.term.dispose();
343
+ session.container.remove();
344
+ authFetchRef.current(`/api/terminal/${encodeURIComponent(name)}`, { method: "DELETE" }).catch(() => {});
345
+ }
346
+ sessions.clear();
347
+ };
348
+ }, []);
349
+
350
+ const isDisconnected = storyName ? disconnected.has(storyName) : false;
351
+ const isEmpty = sessionList.length === 0;
352
+
353
+ return (
354
+ <div className="h-full flex flex-col">
355
+ {/* Session tabs — hidden when no sessions */}
356
+ {!isEmpty && (
357
+ <div className="px-2 py-1 border-b border-border flex items-center gap-1 overflow-x-auto">
358
+ {sessionList.map((name) => (
359
+ <div
360
+ key={name}
361
+ onClick={() => onSelectStory?.(name)}
362
+ className={`flex items-center gap-1 px-2 py-0.5 rounded text-xs font-mono cursor-pointer ${
363
+ name === storyName
364
+ ? "bg-accent/10 text-accent"
365
+ : "text-muted hover:text-foreground"
366
+ }`}
367
+ >
368
+ <span className={`w-1.5 h-1.5 rounded-full ${
369
+ disconnected.has(name) ? "bg-amber-500" : name === storyName ? "bg-green-600" : "bg-muted/50"
370
+ }`} />
371
+ <span className="truncate max-w-[120px]">{name}</span>
372
+ <button
373
+ onClick={(e) => {
374
+ e.stopPropagation();
375
+ destroySession(name);
376
+ }}
377
+ className="ml-0.5 text-muted hover:text-error text-[10px] leading-none"
378
+ title="Close terminal"
379
+ >
380
+ ×
381
+ </button>
382
+ </div>
383
+ ))
384
+ }
385
+ </div>
386
+ )}
387
+
388
+ {/* Terminal containers — always rendered so wrapperRef is available */}
389
+ <div className="relative flex-1 min-h-0">
390
+ <div ref={wrapperRef} className="h-full" />
391
+
392
+ {/* Empty state overlay */}
393
+ {isEmpty && (
394
+ <div className="absolute inset-0 flex items-center justify-center text-muted">
395
+ <div className="text-center">
396
+ <p className="text-lg font-serif">Select a story on the left menu</p>
397
+ <p className="text-sm mt-1">to start an AI Writer session</p>
398
+ </div>
399
+ </div>
400
+ )}
401
+
402
+ {/* Reconnect overlay */}
403
+ {isDisconnected && storyName && (
404
+ <div className="absolute inset-0 flex items-center justify-center" style={{ background: "rgba(240, 235, 225, 0.9)" }}>
405
+ <div className="text-center space-y-3">
406
+ <p className="text-sm font-serif text-foreground">Terminal disconnected</p>
407
+ <div className="flex items-center gap-2">
408
+ <button
409
+ onClick={() => reconnectSession(storyName, true)}
410
+ className="px-4 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent-dim"
411
+ >
412
+ Resume Session
413
+ </button>
414
+ <button
415
+ onClick={() => reconnectSession(storyName, false)}
416
+ className="px-4 py-1.5 border border-border text-sm rounded hover:bg-surface"
417
+ >
418
+ Start Fresh
419
+ </button>
420
+ </div>
421
+ <p className="text-xs text-muted">Resume continues your previous Claude conversation</p>
422
+ </div>
423
+ </div>
424
+ )}
425
+ </div>
426
+ </div>
427
+ );
428
+ }
@@ -7,7 +7,9 @@ interface WalletInfo {
7
7
  walletId?: string;
8
8
  name?: string;
9
9
  address?: string;
10
+ ethBalance?: string;
10
11
  usdcBalance?: string;
12
+ plotBalance?: string;
11
13
  error?: string;
12
14
  }
13
15
 
@@ -77,8 +79,8 @@ export function WalletCard({ token }: { token: string }) {
77
79
  <div className="space-y-3">
78
80
  <div className="flex items-center justify-between">
79
81
  <span className="text-muted text-[10px] uppercase tracking-wider">Address (Base)</span>
80
- <span className={`rounded border px-1.5 py-0.5 text-[9px] ${wallet.usdcBalance && parseFloat(wallet.usdcBalance) > 0 ? "border-accent/30 text-accent" : "border-accent-dim/30 text-accent-dim"}`}>
81
- {wallet.usdcBalance && parseFloat(wallet.usdcBalance) > 0 ? "active" : "no balance"}
82
+ <span className={`rounded border px-1.5 py-0.5 text-[9px] ${wallet.ethBalance && parseFloat(wallet.ethBalance) > 0 ? "border-accent/30 text-accent" : "border-accent-dim/30 text-accent-dim"}`}>
83
+ {wallet.ethBalance && parseFloat(wallet.ethBalance) > 0 ? "active" : "no balance"}
82
84
  </span>
83
85
  </div>
84
86
 
@@ -91,23 +93,27 @@ export function WalletCard({ token }: { token: string }) {
91
93
 
92
94
  <div className="border-border space-y-1 border-t pt-3">
93
95
  <div className="flex justify-between text-xs">
94
- <span className="text-muted">USDC Balance</span>
96
+ <span className="text-muted">ETH</span>
97
+ <span className="text-foreground font-medium">{wallet.ethBalance || "0.000000"} ETH</span>
98
+ </div>
99
+ <div className="flex justify-between text-xs">
100
+ <span className="text-muted">USDC</span>
95
101
  <span className="text-foreground font-medium">${wallet.usdcBalance || "0.00"}</span>
96
102
  </div>
97
103
  <div className="flex justify-between text-xs">
98
- <span className="text-muted">Network</span>
99
- <span className="text-foreground">Base</span>
104
+ <span className="text-muted">PLOT</span>
105
+ <span className="text-foreground font-medium">{wallet.plotBalance || "0.0000"} PLOT</span>
100
106
  </div>
101
107
  <div className="flex justify-between text-xs">
102
- <span className="text-muted">Wallet ID</span>
103
- <span className="text-foreground font-mono text-[10px]">{wallet.walletId?.slice(0, 12)}...</span>
108
+ <span className="text-muted">Network</span>
109
+ <span className="text-foreground">Base</span>
104
110
  </div>
105
111
  </div>
106
112
 
107
113
  {/* Fund wallet */}
108
114
  <div className="border-border border-t pt-3">
109
115
  <p className="text-muted mb-2 text-[10px] font-medium uppercase tracking-wider">Fund Wallet</p>
110
- <p className="text-muted text-[10px]">Send USDC on Base to:</p>
116
+ <p className="text-muted text-[10px]">Send ETH on Base for gas (~$0.01 per publish):</p>
111
117
  <code className="text-foreground bg-surface mt-1 block break-all rounded px-2 py-1.5 text-[10px] font-mono">{wallet.address}</code>
112
118
  </div>
113
119
  </div>