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.
- package/README.md +185 -93
- package/app/db.ts +1 -1
- package/app/lib/paths.ts +0 -2
- package/app/lib/publish.ts +257 -44
- package/app/prisma/schema.prisma +0 -36
- package/app/routes/dashboard.ts +105 -57
- package/app/routes/publish.ts +107 -25
- package/app/routes/settings.ts +194 -0
- package/app/routes/stories.ts +223 -0
- package/app/routes/terminal.ts +258 -0
- package/app/routes/wallet.ts +40 -10
- package/app/server.ts +35 -81
- package/app/web/App.tsx +4 -6
- package/app/web/components/Dashboard.tsx +98 -79
- package/app/web/components/Layout.tsx +70 -103
- package/app/web/components/PreviewPanel.tsx +388 -0
- package/app/web/components/Settings.tsx +210 -67
- package/app/web/components/StoriesPage.tsx +270 -0
- package/app/web/components/StoryBrowser.tsx +161 -0
- package/app/web/components/TerminalPanel.tsx +428 -0
- package/app/web/components/WalletCard.tsx +14 -8
- package/app/web/dist/assets/index-BuOxhUWG.css +32 -0
- package/app/web/dist/assets/index-De8CpT47.js +129 -0
- package/app/web/dist/index.html +3 -3
- package/app/web/dist/plotlink-logo.svg +5 -0
- package/app/web/public/plotlink-logo.svg +5 -0
- package/app/web/styles.css +18 -0
- package/bin/plotlink-ows.js +18 -62
- package/package.json +15 -6
- package/scripts/fix-index-status.ts +93 -0
- package/app/lib/llm-client.ts +0 -265
- package/app/lib/writer-prompt.ts +0 -44
- package/app/routes/chat.ts +0 -135
- package/app/routes/config.ts +0 -210
- package/app/routes/oauth.ts +0 -150
- package/app/web/components/Chat.tsx +0 -272
- package/app/web/components/LLMSetup.tsx +0 -291
- package/app/web/components/Publish.tsx +0 -245
- package/app/web/dist/assets/index-C9kXlYO_.css +0 -2
- 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.
|
|
81
|
-
{wallet.
|
|
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">
|
|
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">
|
|
99
|
-
<span className="text-foreground">
|
|
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">
|
|
103
|
-
<span className="text-foreground
|
|
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
|
|
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>
|