plotlink-ows 1.0.8 → 1.0.13
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/app/routes/stories.ts +71 -1
- package/app/routes/terminal.ts +29 -1
- package/app/web/components/StoriesPage.tsx +97 -1
- package/app/web/components/StoryBrowser.tsx +113 -4
- package/app/web/components/TerminalPanel.tsx +138 -4
- package/app/web/dist/assets/index-9QhAhFuv.js +130 -0
- package/app/web/dist/assets/index-DHjiVVCV.css +32 -0
- package/app/web/dist/index.html +2 -2
- package/package.json +2 -1
- package/app/web/dist/assets/index-BuOxhUWG.css +0 -32
- package/app/web/dist/assets/index-De8CpT47.js +0 -129
package/app/routes/stories.ts
CHANGED
|
@@ -27,6 +27,7 @@ interface FileStatus {
|
|
|
27
27
|
|
|
28
28
|
interface StoryInfo {
|
|
29
29
|
name: string;
|
|
30
|
+
title: string | null;
|
|
30
31
|
files: FileStatus[];
|
|
31
32
|
hasStructure: boolean;
|
|
32
33
|
hasGenesis: boolean;
|
|
@@ -66,7 +67,23 @@ function scanStory(storyDir: string, name: string): StoryInfo {
|
|
|
66
67
|
const plotCount = entries.filter((f) => f.match(/^plot-\d+\.md$/)).length;
|
|
67
68
|
const publishedCount = files.filter((f) => f.status === "published" || f.status === "published-not-indexed").length;
|
|
68
69
|
|
|
69
|
-
|
|
70
|
+
// Extract title from structure.md or genesis.md
|
|
71
|
+
let title: string | null = null;
|
|
72
|
+
try {
|
|
73
|
+
const structPath = path.join(storyDir, "structure.md");
|
|
74
|
+
const genesisPath = path.join(storyDir, "genesis.md");
|
|
75
|
+
if (fs.existsSync(structPath)) {
|
|
76
|
+
const content = fs.readFileSync(structPath, "utf-8");
|
|
77
|
+
const match = content.match(/^#\s+(.+)$/m);
|
|
78
|
+
if (match) title = match[1];
|
|
79
|
+
} else if (fs.existsSync(genesisPath)) {
|
|
80
|
+
const content = fs.readFileSync(genesisPath, "utf-8");
|
|
81
|
+
const match = content.match(/^#\s+(.+)$/m);
|
|
82
|
+
if (match) title = match[1];
|
|
83
|
+
}
|
|
84
|
+
} catch { /* best effort */ }
|
|
85
|
+
|
|
86
|
+
return { name, title, files, hasStructure, hasGenesis, plotCount, publishedCount };
|
|
70
87
|
}
|
|
71
88
|
|
|
72
89
|
/** GET /api/stories — list all stories */
|
|
@@ -84,6 +101,59 @@ stories.get("/", (c) => {
|
|
|
84
101
|
return c.json({ stories: result });
|
|
85
102
|
});
|
|
86
103
|
|
|
104
|
+
const ARCHIVED_DIR = path.join(STORIES_DIR, ".archived");
|
|
105
|
+
|
|
106
|
+
/** GET /api/stories/archived — list archived stories */
|
|
107
|
+
stories.get("/archived", (c) => {
|
|
108
|
+
if (!fs.existsSync(ARCHIVED_DIR)) {
|
|
109
|
+
return c.json({ stories: [] });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const dirs = fs.readdirSync(ARCHIVED_DIR, { withFileTypes: true })
|
|
113
|
+
.filter((d) => d.isDirectory() && !d.name.startsWith("."))
|
|
114
|
+
.map((d) => d.name)
|
|
115
|
+
.sort();
|
|
116
|
+
|
|
117
|
+
const result = dirs.map((name) => scanStory(path.join(ARCHIVED_DIR, name), name));
|
|
118
|
+
return c.json({ stories: result });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/** POST /api/stories/archive — move story to .archived/ */
|
|
122
|
+
stories.post("/archive", async (c) => {
|
|
123
|
+
const body = await c.req.json<{ name: string }>();
|
|
124
|
+
const name = safeName(body.name);
|
|
125
|
+
if (!name) return c.json({ error: "Invalid story name" }, 400);
|
|
126
|
+
|
|
127
|
+
const src = path.join(STORIES_DIR, name);
|
|
128
|
+
if (!fs.existsSync(src)) return c.json({ error: "Story not found" }, 404);
|
|
129
|
+
if (!fs.existsSync(path.join(src, "structure.md"))) {
|
|
130
|
+
return c.json({ error: "Only stories with structure.md can be archived" }, 400);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
fs.mkdirSync(ARCHIVED_DIR, { recursive: true });
|
|
134
|
+
const dest = path.join(ARCHIVED_DIR, name);
|
|
135
|
+
if (fs.existsSync(dest)) return c.json({ error: "Already archived" }, 409);
|
|
136
|
+
|
|
137
|
+
fs.renameSync(src, dest);
|
|
138
|
+
return c.json({ ok: true });
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/** POST /api/stories/restore — move story back from .archived/ */
|
|
142
|
+
stories.post("/restore", async (c) => {
|
|
143
|
+
const body = await c.req.json<{ name: string }>();
|
|
144
|
+
const name = safeName(body.name);
|
|
145
|
+
if (!name) return c.json({ error: "Invalid story name" }, 400);
|
|
146
|
+
|
|
147
|
+
const src = path.join(ARCHIVED_DIR, name);
|
|
148
|
+
if (!fs.existsSync(src)) return c.json({ error: "Archived story not found" }, 404);
|
|
149
|
+
|
|
150
|
+
const dest = path.join(STORIES_DIR, name);
|
|
151
|
+
if (fs.existsSync(dest)) return c.json({ error: "Story already exists" }, 409);
|
|
152
|
+
|
|
153
|
+
fs.renameSync(src, dest);
|
|
154
|
+
return c.json({ ok: true });
|
|
155
|
+
});
|
|
156
|
+
|
|
87
157
|
/** GET /api/stories/:name — single story detail */
|
|
88
158
|
stories.get("/:name", (c) => {
|
|
89
159
|
const name = safeName(c.req.param("name"));
|
package/app/routes/terminal.ts
CHANGED
|
@@ -43,7 +43,9 @@ function saveSessionMap(map: Record<string, string>) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
function spawnPty(storyName: string, opts?: { sessionId?: string; resume?: boolean }) {
|
|
46
|
-
|
|
46
|
+
// New story sessions spawn in the stories root so Claude can create any folder
|
|
47
|
+
const isNewStory = storyName.startsWith("_new_");
|
|
48
|
+
const storyDir = isNewStory ? STORIES_DIR : path.join(STORIES_DIR, storyName);
|
|
47
49
|
if (!fs.existsSync(storyDir)) fs.mkdirSync(storyDir, { recursive: true });
|
|
48
50
|
const shell = process.env.SHELL || "/bin/zsh";
|
|
49
51
|
|
|
@@ -165,6 +167,32 @@ terminal.delete("/:storyName", (c) => {
|
|
|
165
167
|
return c.json({ ok: true, message: "not running" });
|
|
166
168
|
});
|
|
167
169
|
|
|
170
|
+
/** DELETE /api/terminal/:storyName/discard — discard session, kill PTY, clean up metadata */
|
|
171
|
+
terminal.delete("/:storyName/discard", (c) => {
|
|
172
|
+
const storyName = safeName(c.req.param("storyName"));
|
|
173
|
+
if (!storyName) return c.json({ error: "Invalid story name" }, 400);
|
|
174
|
+
|
|
175
|
+
const session = ptySessions.get(storyName);
|
|
176
|
+
if (session?.term && session.state === "running") {
|
|
177
|
+
// Send exit gracefully, then kill
|
|
178
|
+
try { session.term.write("exit\n"); } catch { /* ignore */ }
|
|
179
|
+
setTimeout(() => {
|
|
180
|
+
try { session.term.kill(); } catch { /* ignore */ }
|
|
181
|
+
}, 500);
|
|
182
|
+
session.state = "stopped";
|
|
183
|
+
}
|
|
184
|
+
ptySessions.delete(storyName);
|
|
185
|
+
|
|
186
|
+
// Remove session metadata from terminal-sessions.json
|
|
187
|
+
const sessionMap = loadSessionMap();
|
|
188
|
+
if (sessionMap[storyName]) {
|
|
189
|
+
delete sessionMap[storyName];
|
|
190
|
+
saveSessionMap(sessionMap);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return c.json({ ok: true });
|
|
194
|
+
});
|
|
195
|
+
|
|
168
196
|
/** POST /api/terminal/stop — kill PTY (legacy, kills default) */
|
|
169
197
|
terminal.post("/stop", (c) => {
|
|
170
198
|
const session = ptySessions.get("default");
|
|
@@ -39,6 +39,8 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
39
39
|
const [publishingFile, setPublishingFile] = useState<string | null>(null);
|
|
40
40
|
const [publishProgress, setPublishProgress] = useState<string>("");
|
|
41
41
|
const [ratio, setRatio] = useState(loadRatio);
|
|
42
|
+
const [untitledSessions, setUntitledSessions] = useState<string[]>([]);
|
|
43
|
+
const knownStoriesRef = useRef<Set<string>>(new Set());
|
|
42
44
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
43
45
|
const dragging = useRef(false);
|
|
44
46
|
|
|
@@ -60,6 +62,56 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
60
62
|
return () => window.removeEventListener("resize", onResize);
|
|
61
63
|
}, []);
|
|
62
64
|
|
|
65
|
+
const handleNewStory = useCallback(() => {
|
|
66
|
+
const id = `_new_${Date.now()}`;
|
|
67
|
+
setUntitledSessions((prev) => [...prev, id]);
|
|
68
|
+
setSelectedStory(id);
|
|
69
|
+
setSelectedFile(null);
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
// Poll for new stories and auto-transition untitled sessions
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (untitledSessions.length === 0) return;
|
|
75
|
+
const interval = setInterval(async () => {
|
|
76
|
+
try {
|
|
77
|
+
const res = await authFetch("/api/stories");
|
|
78
|
+
if (!res.ok) return;
|
|
79
|
+
const data = await res.json();
|
|
80
|
+
const currentNames = new Set<string>(
|
|
81
|
+
(data.stories as { name: string }[])
|
|
82
|
+
.filter((s) => s.name !== "_example")
|
|
83
|
+
.map((s) => s.name)
|
|
84
|
+
);
|
|
85
|
+
// Detect newly appeared stories
|
|
86
|
+
for (const name of currentNames) {
|
|
87
|
+
if (!knownStoriesRef.current.has(name) && untitledSessions.length > 0) {
|
|
88
|
+
// New story appeared — transition the oldest untitled session
|
|
89
|
+
setUntitledSessions((prev) => prev.slice(1));
|
|
90
|
+
setSelectedStory(name);
|
|
91
|
+
setSelectedFile(null);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
knownStoriesRef.current = currentNames;
|
|
95
|
+
} catch { /* ignore */ }
|
|
96
|
+
}, 3000);
|
|
97
|
+
return () => clearInterval(interval);
|
|
98
|
+
}, [authFetch, untitledSessions]);
|
|
99
|
+
|
|
100
|
+
// Initialize known stories on mount
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
authFetch("/api/stories").then((res) => {
|
|
103
|
+
if (res.ok) return res.json();
|
|
104
|
+
}).then((data) => {
|
|
105
|
+
if (data?.stories) {
|
|
106
|
+
knownStoriesRef.current = new Set(
|
|
107
|
+
(data.stories as { name: string }[])
|
|
108
|
+
.filter((s) => s.name !== "_example")
|
|
109
|
+
.map((s) => s.name)
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
}).catch(() => {});
|
|
113
|
+
}, [authFetch]);
|
|
114
|
+
|
|
63
115
|
const handleSelectFile = useCallback((storyName: string, fileName: string) => {
|
|
64
116
|
setSelectedStory(storyName);
|
|
65
117
|
setSelectedFile(fileName);
|
|
@@ -220,6 +272,48 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
220
272
|
}
|
|
221
273
|
}, [authFetch]);
|
|
222
274
|
|
|
275
|
+
const handleDestroySession = useCallback((name: string) => {
|
|
276
|
+
if (name.startsWith("_new_")) {
|
|
277
|
+
setUntitledSessions((prev) => prev.filter((id) => id !== name));
|
|
278
|
+
}
|
|
279
|
+
}, []);
|
|
280
|
+
|
|
281
|
+
// Track confirmed stories (those with structure.md) for Archive gating
|
|
282
|
+
const [confirmedStories, setConfirmedStories] = useState<Set<string>>(new Set());
|
|
283
|
+
useEffect(() => {
|
|
284
|
+
authFetch("/api/stories").then((res) => res.ok ? res.json() : null).then((data) => {
|
|
285
|
+
if (data?.stories) {
|
|
286
|
+
setConfirmedStories(new Set(
|
|
287
|
+
(data.stories as { name: string; hasStructure: boolean }[])
|
|
288
|
+
.filter((s) => s.hasStructure)
|
|
289
|
+
.map((s) => s.name)
|
|
290
|
+
));
|
|
291
|
+
}
|
|
292
|
+
}).catch(() => {});
|
|
293
|
+
const interval = setInterval(async () => {
|
|
294
|
+
try {
|
|
295
|
+
const res = await authFetch("/api/stories");
|
|
296
|
+
if (res.ok) {
|
|
297
|
+
const data = await res.json();
|
|
298
|
+
setConfirmedStories(new Set(
|
|
299
|
+
(data.stories as { name: string; hasStructure: boolean }[])
|
|
300
|
+
.filter((s) => s.hasStructure)
|
|
301
|
+
.map((s) => s.name)
|
|
302
|
+
));
|
|
303
|
+
}
|
|
304
|
+
} catch { /* ignore */ }
|
|
305
|
+
}, 5000);
|
|
306
|
+
return () => clearInterval(interval);
|
|
307
|
+
}, [authFetch]);
|
|
308
|
+
|
|
309
|
+
const handleArchiveStory = useCallback((name: string) => {
|
|
310
|
+
// Archive API already called by TerminalPanel — just clear selection
|
|
311
|
+
if (selectedStory === name) {
|
|
312
|
+
setSelectedStory(null);
|
|
313
|
+
setSelectedFile(null);
|
|
314
|
+
}
|
|
315
|
+
}, [selectedStory]);
|
|
316
|
+
|
|
223
317
|
return (
|
|
224
318
|
<div ref={containerRef} className="h-[calc(100vh-3.5rem)] flex">
|
|
225
319
|
{/* Story Browser Sidebar */}
|
|
@@ -229,12 +323,14 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
229
323
|
selectedStory={selectedStory}
|
|
230
324
|
selectedFile={selectedFile}
|
|
231
325
|
onSelectFile={handleSelectFile}
|
|
326
|
+
onNewStory={handleNewStory}
|
|
327
|
+
untitledSessions={untitledSessions}
|
|
232
328
|
/>
|
|
233
329
|
</div>
|
|
234
330
|
|
|
235
331
|
{/* Terminal — sized by ratio of available space */}
|
|
236
332
|
<div className="min-w-0 border-r border-border" style={{ flex: `${ratio} 0 0` }}>
|
|
237
|
-
<TerminalPanel token={token} storyName={selectedStory} authFetch={authFetch} onSelectStory={handleSelectStory} />
|
|
333
|
+
<TerminalPanel token={token} storyName={selectedStory} authFetch={authFetch} onSelectStory={handleSelectStory} onDestroySession={handleDestroySession} onArchiveStory={handleArchiveStory} confirmedStories={confirmedStories} />
|
|
238
334
|
</div>
|
|
239
335
|
|
|
240
336
|
{/* Drag Handle */}
|
|
@@ -9,6 +9,7 @@ interface FileStatus {
|
|
|
9
9
|
|
|
10
10
|
interface StoryInfo {
|
|
11
11
|
name: string;
|
|
12
|
+
title: string | null;
|
|
12
13
|
files: FileStatus[];
|
|
13
14
|
hasStructure: boolean;
|
|
14
15
|
hasGenesis: boolean;
|
|
@@ -21,6 +22,8 @@ interface StoryBrowserProps {
|
|
|
21
22
|
selectedStory: string | null;
|
|
22
23
|
selectedFile: string | null;
|
|
23
24
|
onSelectFile: (storyName: string, fileName: string) => void;
|
|
25
|
+
onNewStory?: () => void;
|
|
26
|
+
untitledSessions?: string[];
|
|
24
27
|
}
|
|
25
28
|
|
|
26
29
|
const STATUS_ICON: Record<string, string> = {
|
|
@@ -37,9 +40,11 @@ const STATUS_COLOR: Record<string, string> = {
|
|
|
37
40
|
"draft": "text-muted",
|
|
38
41
|
};
|
|
39
42
|
|
|
40
|
-
export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectFile }: StoryBrowserProps) {
|
|
43
|
+
export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectFile, onNewStory, untitledSessions = [] }: StoryBrowserProps) {
|
|
41
44
|
const [stories, setStories] = useState<StoryInfo[]>([]);
|
|
45
|
+
const [archivedStories, setArchivedStories] = useState<StoryInfo[]>([]);
|
|
42
46
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
47
|
+
const [showArchives, setShowArchives] = useState(false);
|
|
43
48
|
|
|
44
49
|
const loadStories = useCallback(async () => {
|
|
45
50
|
try {
|
|
@@ -51,6 +56,30 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
|
|
|
51
56
|
} catch { /* ignore */ }
|
|
52
57
|
}, [authFetch]);
|
|
53
58
|
|
|
59
|
+
const loadArchivedStories = useCallback(async () => {
|
|
60
|
+
try {
|
|
61
|
+
const res = await authFetch("/api/stories/archived");
|
|
62
|
+
if (res.ok) {
|
|
63
|
+
const data = await res.json();
|
|
64
|
+
setArchivedStories(data.stories);
|
|
65
|
+
}
|
|
66
|
+
} catch { /* ignore */ }
|
|
67
|
+
}, [authFetch]);
|
|
68
|
+
|
|
69
|
+
const handleRestore = useCallback(async (name: string) => {
|
|
70
|
+
try {
|
|
71
|
+
const res = await authFetch("/api/stories/restore", {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: { "Content-Type": "application/json" },
|
|
74
|
+
body: JSON.stringify({ name }),
|
|
75
|
+
});
|
|
76
|
+
if (res.ok) {
|
|
77
|
+
loadArchivedStories();
|
|
78
|
+
loadStories();
|
|
79
|
+
}
|
|
80
|
+
} catch { /* ignore */ }
|
|
81
|
+
}, [authFetch, loadArchivedStories, loadStories]);
|
|
82
|
+
|
|
54
83
|
useEffect(() => {
|
|
55
84
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial load + polling
|
|
56
85
|
loadStories();
|
|
@@ -58,6 +87,14 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
|
|
|
58
87
|
return () => clearInterval(interval);
|
|
59
88
|
}, [loadStories]);
|
|
60
89
|
|
|
90
|
+
// Load archived stories when archives view is shown
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (showArchives) {
|
|
93
|
+
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial load for archives
|
|
94
|
+
loadArchivedStories();
|
|
95
|
+
}
|
|
96
|
+
}, [showArchives, loadArchivedStories]);
|
|
97
|
+
|
|
61
98
|
// Auto-expand selected story
|
|
62
99
|
useEffect(() => {
|
|
63
100
|
if (selectedStory) {
|
|
@@ -108,17 +145,81 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
|
|
|
108
145
|
return [...files].sort((a, b) => order(a.file) - order(b.file));
|
|
109
146
|
};
|
|
110
147
|
|
|
148
|
+
if (showArchives) {
|
|
149
|
+
return (
|
|
150
|
+
<div className="h-full flex flex-col">
|
|
151
|
+
<div className="px-3 py-1.5 border-b border-border flex items-center justify-between">
|
|
152
|
+
<span className="text-xs font-mono text-muted">Archives</span>
|
|
153
|
+
<span className="text-xs text-muted">{archivedStories.length}</span>
|
|
154
|
+
</div>
|
|
155
|
+
<div className="px-3 py-2 border-b border-border">
|
|
156
|
+
<button
|
|
157
|
+
onClick={() => setShowArchives(false)}
|
|
158
|
+
className="w-full px-3 py-1.5 text-sm text-muted hover:text-foreground hover:bg-surface rounded flex items-center gap-1.5"
|
|
159
|
+
>
|
|
160
|
+
<span>←</span>
|
|
161
|
+
<span>Back</span>
|
|
162
|
+
</button>
|
|
163
|
+
</div>
|
|
164
|
+
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
165
|
+
{archivedStories.length === 0 ? (
|
|
166
|
+
<div className="p-3 text-sm text-muted">
|
|
167
|
+
<p>No archived stories.</p>
|
|
168
|
+
</div>
|
|
169
|
+
) : (
|
|
170
|
+
archivedStories.map((story) => (
|
|
171
|
+
<div key={story.name} className="px-3 py-2 flex items-center justify-between hover:bg-surface">
|
|
172
|
+
<span className="text-sm font-medium truncate" title={story.name}>{story.title || story.name}</span>
|
|
173
|
+
<button
|
|
174
|
+
onClick={() => handleRestore(story.name)}
|
|
175
|
+
className="text-xs text-accent hover:text-accent-dim flex-shrink-0 ml-2"
|
|
176
|
+
>
|
|
177
|
+
Restore
|
|
178
|
+
</button>
|
|
179
|
+
</div>
|
|
180
|
+
))
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
111
187
|
return (
|
|
112
188
|
<div className="h-full flex flex-col">
|
|
113
189
|
<div className="px-3 py-1.5 border-b border-border flex items-center justify-between">
|
|
114
190
|
<span className="text-xs font-mono text-muted">Stories</span>
|
|
115
191
|
<span className="text-xs text-muted">{stories.length}</span>
|
|
116
192
|
</div>
|
|
193
|
+
{onNewStory && (
|
|
194
|
+
<div className="px-3 py-2 border-b border-border">
|
|
195
|
+
<button
|
|
196
|
+
onClick={onNewStory}
|
|
197
|
+
className="w-full px-3 py-1.5 text-sm bg-accent text-white rounded hover:bg-accent-dim flex items-center justify-center gap-1.5"
|
|
198
|
+
>
|
|
199
|
+
<span>+</span>
|
|
200
|
+
<span>New Story</span>
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
117
204
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
118
|
-
{
|
|
205
|
+
{/* Untitled new story sessions */}
|
|
206
|
+
{untitledSessions.map((id) => (
|
|
207
|
+
<div key={id}>
|
|
208
|
+
<button
|
|
209
|
+
onClick={() => onSelectFile(id, "")}
|
|
210
|
+
className={`w-full px-3 py-2 text-left flex items-center gap-2 hover:bg-surface text-sm ${
|
|
211
|
+
selectedStory === id ? "bg-surface" : ""
|
|
212
|
+
}`}
|
|
213
|
+
>
|
|
214
|
+
<span className="w-1.5 h-1.5 rounded-full bg-green-600 flex-shrink-0" />
|
|
215
|
+
<span className="font-medium italic text-muted">Untitled</span>
|
|
216
|
+
</button>
|
|
217
|
+
</div>
|
|
218
|
+
))}
|
|
219
|
+
{stories.length === 0 && untitledSessions.length === 0 ? (
|
|
119
220
|
<div className="p-3 text-sm text-muted">
|
|
120
221
|
<p>No stories yet.</p>
|
|
121
|
-
<p className="mt-1 text-xs">
|
|
222
|
+
<p className="mt-1 text-xs">Click "+ New Story" above to start writing.</p>
|
|
122
223
|
</div>
|
|
123
224
|
) : (
|
|
124
225
|
stories.filter((s) => s.name !== "_example").map((story) => (
|
|
@@ -128,7 +229,7 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
|
|
|
128
229
|
className="w-full px-3 py-2 text-left flex items-center gap-2 hover:bg-surface text-sm"
|
|
129
230
|
>
|
|
130
231
|
<span className="text-xs text-muted">{expanded.has(story.name) ? "\u25BC" : "\u25B6"}</span>
|
|
131
|
-
<span className="font-medium truncate">{story.name}</span>
|
|
232
|
+
<span className="font-medium truncate" title={story.name}>{story.title || story.name}</span>
|
|
132
233
|
<span className="ml-auto text-xs text-muted">
|
|
133
234
|
{story.publishedCount}/{story.files.length}
|
|
134
235
|
</span>
|
|
@@ -156,6 +257,14 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
|
|
|
156
257
|
))
|
|
157
258
|
)}
|
|
158
259
|
</div>
|
|
260
|
+
<div className="px-3 py-2 border-t border-border">
|
|
261
|
+
<button
|
|
262
|
+
onClick={() => setShowArchives(true)}
|
|
263
|
+
className="w-full px-3 py-1.5 text-xs text-muted hover:text-foreground hover:bg-surface rounded flex items-center justify-center gap-1.5"
|
|
264
|
+
>
|
|
265
|
+
<span>Archives</span>
|
|
266
|
+
</button>
|
|
267
|
+
</div>
|
|
159
268
|
</div>
|
|
160
269
|
);
|
|
161
270
|
}
|
|
@@ -9,6 +9,9 @@ interface TerminalPanelProps {
|
|
|
9
9
|
storyName: string | null;
|
|
10
10
|
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
|
|
11
11
|
onSelectStory?: (storyName: string) => void;
|
|
12
|
+
onDestroySession?: (storyName: string) => void;
|
|
13
|
+
onArchiveStory?: (storyName: string) => void;
|
|
14
|
+
confirmedStories?: Set<string>;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
interface TerminalSession {
|
|
@@ -89,14 +92,26 @@ async function loadScrollback(storyName: string): Promise<string | null> {
|
|
|
89
92
|
});
|
|
90
93
|
}
|
|
91
94
|
|
|
95
|
+
async function deleteScrollback(storyName: string): Promise<void> {
|
|
96
|
+
const db = await openDb();
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const tx = db.transaction(STORE_NAME, "readwrite");
|
|
99
|
+
tx.objectStore(STORE_NAME).delete(storyName);
|
|
100
|
+
tx.oncomplete = () => { db.close(); resolve(); };
|
|
101
|
+
tx.onerror = () => { db.close(); reject(tx.error); };
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
92
105
|
// Sessions live outside React state to avoid ref-in-effect lint issues
|
|
93
106
|
const sessions = new Map<string, TerminalSession>();
|
|
94
107
|
|
|
95
|
-
export function TerminalPanel({ token, storyName, authFetch, onSelectStory }: TerminalPanelProps) {
|
|
108
|
+
export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDestroySession, onArchiveStory, confirmedStories }: TerminalPanelProps) {
|
|
96
109
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
97
110
|
const authFetchRef = useRef(authFetch);
|
|
98
111
|
const [sessionList, setSessionList] = useState<string[]>([]);
|
|
99
112
|
const [disconnected, setDisconnected] = useState<Set<string>>(new Set());
|
|
113
|
+
const [confirmingDiscard, setConfirmingDiscard] = useState<string | null>(null);
|
|
114
|
+
const [confirmingArchive, setConfirmingArchive] = useState<string | null>(null);
|
|
100
115
|
|
|
101
116
|
const connectWsRef = useRef<(name: string, session: TerminalSession, resume: boolean) => void>(() => {});
|
|
102
117
|
|
|
@@ -283,7 +298,34 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory }: Te
|
|
|
283
298
|
setDisconnected((prev) => { const next = new Set(prev); next.delete(name); return next; });
|
|
284
299
|
|
|
285
300
|
authFetch(`/api/terminal/${encodeURIComponent(name)}`, { method: "DELETE" }).catch(() => {});
|
|
286
|
-
|
|
301
|
+
onDestroySession?.(name);
|
|
302
|
+
}, [authFetch, onDestroySession]);
|
|
303
|
+
|
|
304
|
+
/** Discard an untitled session: send exit, kill PTY, delete scrollback & session metadata */
|
|
305
|
+
const discardSession = useCallback((name: string) => {
|
|
306
|
+
const session = sessions.get(name);
|
|
307
|
+
if (!session) return;
|
|
308
|
+
|
|
309
|
+
// Send exit command gracefully before killing
|
|
310
|
+
if (session.ws?.readyState === WebSocket.OPEN) {
|
|
311
|
+
session.ws.send("exit\n");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Delete scrollback instead of saving
|
|
315
|
+
deleteScrollback(name).catch(() => {});
|
|
316
|
+
|
|
317
|
+
session.observer.disconnect();
|
|
318
|
+
if (session.ws) session.ws.close();
|
|
319
|
+
session.term.dispose();
|
|
320
|
+
session.container.remove();
|
|
321
|
+
sessions.delete(name);
|
|
322
|
+
setSessionList((prev) => prev.filter((s) => s !== name));
|
|
323
|
+
setDisconnected((prev) => { const next = new Set(prev); next.delete(name); return next; });
|
|
324
|
+
|
|
325
|
+
// Use discard endpoint to kill PTY and clean up session metadata
|
|
326
|
+
authFetch(`/api/terminal/${encodeURIComponent(name)}/discard`, { method: "DELETE" }).catch(() => {});
|
|
327
|
+
onDestroySession?.(name);
|
|
328
|
+
}, [authFetch, onDestroySession]);
|
|
287
329
|
|
|
288
330
|
// Auto-spawn + show/hide when story changes
|
|
289
331
|
useEffect(() => {
|
|
@@ -365,11 +407,17 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory }: Te
|
|
|
365
407
|
<span className={`w-1.5 h-1.5 rounded-full ${
|
|
366
408
|
disconnected.has(name) ? "bg-amber-500" : name === storyName ? "bg-green-600" : "bg-muted/50"
|
|
367
409
|
}`} />
|
|
368
|
-
<span className=
|
|
410
|
+
<span className={`truncate max-w-[120px] ${name.startsWith("_new_") ? "italic" : ""}`}>
|
|
411
|
+
{name.startsWith("_new_") ? "Untitled" : name}
|
|
412
|
+
</span>
|
|
369
413
|
<button
|
|
370
414
|
onClick={(e) => {
|
|
371
415
|
e.stopPropagation();
|
|
372
|
-
|
|
416
|
+
if (name.startsWith("_new_")) {
|
|
417
|
+
setConfirmingDiscard(name);
|
|
418
|
+
} else {
|
|
419
|
+
destroySession(name);
|
|
420
|
+
}
|
|
373
421
|
}}
|
|
374
422
|
className="ml-0.5 text-muted hover:text-error text-[10px] leading-none"
|
|
375
423
|
title="Close terminal"
|
|
@@ -379,6 +427,22 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory }: Te
|
|
|
379
427
|
</div>
|
|
380
428
|
))
|
|
381
429
|
}
|
|
430
|
+
{/* Cancel button for untitled / Archive button for confirmed stories */}
|
|
431
|
+
{storyName?.startsWith("_new_") ? (
|
|
432
|
+
<button
|
|
433
|
+
onClick={() => setConfirmingDiscard(storyName)}
|
|
434
|
+
className="ml-auto px-2 py-0.5 text-xs text-error hover:bg-surface rounded flex items-center gap-1 flex-shrink-0"
|
|
435
|
+
>
|
|
436
|
+
Cancel ×
|
|
437
|
+
</button>
|
|
438
|
+
) : storyName && onArchiveStory && confirmedStories?.has(storyName) ? (
|
|
439
|
+
<button
|
|
440
|
+
onClick={() => setConfirmingArchive(storyName)}
|
|
441
|
+
className="ml-auto px-2 py-0.5 text-xs text-muted hover:text-foreground hover:bg-surface rounded flex items-center gap-1 flex-shrink-0"
|
|
442
|
+
>
|
|
443
|
+
Archive
|
|
444
|
+
</button>
|
|
445
|
+
) : null}
|
|
382
446
|
</div>
|
|
383
447
|
)}
|
|
384
448
|
|
|
@@ -396,6 +460,76 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory }: Te
|
|
|
396
460
|
</div>
|
|
397
461
|
)}
|
|
398
462
|
|
|
463
|
+
{/* Discard confirmation overlay */}
|
|
464
|
+
{confirmingDiscard && (
|
|
465
|
+
<div className="absolute inset-0 flex items-center justify-center z-10" style={{ background: "rgba(240, 235, 225, 0.9)" }}>
|
|
466
|
+
<div className="text-center space-y-3 p-6 bg-surface border border-border rounded-lg shadow-lg max-w-sm">
|
|
467
|
+
<p className="text-sm font-serif text-foreground font-medium">Discard this session?</p>
|
|
468
|
+
<p className="text-xs text-muted">
|
|
469
|
+
This session will be lost — your AI hasn't created a story structure yet.
|
|
470
|
+
</p>
|
|
471
|
+
<div className="flex items-center justify-center gap-2">
|
|
472
|
+
<button
|
|
473
|
+
onClick={() => setConfirmingDiscard(null)}
|
|
474
|
+
className="px-4 py-1.5 border border-border text-sm rounded hover:bg-surface"
|
|
475
|
+
>
|
|
476
|
+
Cancel
|
|
477
|
+
</button>
|
|
478
|
+
<button
|
|
479
|
+
onClick={() => {
|
|
480
|
+
const name = confirmingDiscard;
|
|
481
|
+
setConfirmingDiscard(null);
|
|
482
|
+
discardSession(name);
|
|
483
|
+
}}
|
|
484
|
+
className="px-4 py-1.5 bg-error text-white text-sm rounded hover:opacity-80"
|
|
485
|
+
>
|
|
486
|
+
Discard
|
|
487
|
+
</button>
|
|
488
|
+
</div>
|
|
489
|
+
</div>
|
|
490
|
+
</div>
|
|
491
|
+
)}
|
|
492
|
+
|
|
493
|
+
{/* Archive confirmation overlay */}
|
|
494
|
+
{confirmingArchive && (
|
|
495
|
+
<div className="absolute inset-0 flex items-center justify-center z-10" style={{ background: "rgba(240, 235, 225, 0.9)" }}>
|
|
496
|
+
<div className="text-center space-y-3 p-6 bg-surface border border-border rounded-lg shadow-lg max-w-sm">
|
|
497
|
+
<p className="text-sm font-serif text-foreground font-medium">Archive this story?</p>
|
|
498
|
+
<p className="text-xs text-muted">
|
|
499
|
+
You can restore it later from the Archives view.
|
|
500
|
+
</p>
|
|
501
|
+
<div className="flex items-center justify-center gap-2">
|
|
502
|
+
<button
|
|
503
|
+
onClick={() => setConfirmingArchive(null)}
|
|
504
|
+
className="px-4 py-1.5 border border-border text-sm rounded hover:bg-surface"
|
|
505
|
+
>
|
|
506
|
+
Cancel
|
|
507
|
+
</button>
|
|
508
|
+
<button
|
|
509
|
+
onClick={async () => {
|
|
510
|
+
const name = confirmingArchive;
|
|
511
|
+
setConfirmingArchive(null);
|
|
512
|
+
try {
|
|
513
|
+
const res = await authFetchRef.current("/api/stories/archive", {
|
|
514
|
+
method: "POST",
|
|
515
|
+
headers: { "Content-Type": "application/json" },
|
|
516
|
+
body: JSON.stringify({ name }),
|
|
517
|
+
});
|
|
518
|
+
if (res.ok) {
|
|
519
|
+
destroySession(name);
|
|
520
|
+
onArchiveStory?.(name);
|
|
521
|
+
}
|
|
522
|
+
} catch { /* ignore */ }
|
|
523
|
+
}}
|
|
524
|
+
className="px-4 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent-dim"
|
|
525
|
+
>
|
|
526
|
+
Archive
|
|
527
|
+
</button>
|
|
528
|
+
</div>
|
|
529
|
+
</div>
|
|
530
|
+
</div>
|
|
531
|
+
)}
|
|
532
|
+
|
|
399
533
|
{/* Reconnect overlay */}
|
|
400
534
|
{isDisconnected && storyName && (
|
|
401
535
|
<div className="absolute inset-0 flex items-center justify-center" style={{ background: "rgba(240, 235, 225, 0.9)" }}>
|