plotlink-ows 1.0.10 → 1.0.14
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 +53 -0
- package/app/routes/terminal.ts +26 -0
- package/app/web/components/Settings.tsx +10 -3
- package/app/web/components/StoriesPage.tsx +37 -1
- package/app/web/components/StoryBrowser.tsx +93 -12
- package/app/web/components/TerminalPanel.tsx +132 -2
- package/app/web/dist/assets/index-B5GQoSdd.js +130 -0
- package/app/web/dist/assets/index-DHjiVVCV.css +32 -0
- package/app/web/dist/index.html +2 -2
- package/package.json +1 -1
- package/app/web/dist/assets/index-W1yUR9t5.css +0 -32
- package/app/web/dist/assets/index-arJRA1vb.js +0 -129
package/app/routes/stories.ts
CHANGED
|
@@ -101,6 +101,59 @@ stories.get("/", (c) => {
|
|
|
101
101
|
return c.json({ stories: result });
|
|
102
102
|
});
|
|
103
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
|
+
|
|
104
157
|
/** GET /api/stories/:name — single story detail */
|
|
105
158
|
stories.get("/:name", (c) => {
|
|
106
159
|
const name = safeName(c.req.param("name"));
|
package/app/routes/terminal.ts
CHANGED
|
@@ -167,6 +167,32 @@ terminal.delete("/:storyName", (c) => {
|
|
|
167
167
|
return c.json({ ok: true, message: "not running" });
|
|
168
168
|
});
|
|
169
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
|
+
|
|
170
196
|
/** POST /api/terminal/stop — kill PTY (legacy, kills default) */
|
|
171
197
|
terminal.post("/stop", (c) => {
|
|
172
198
|
const session = ptySessions.get("default");
|
|
@@ -9,7 +9,7 @@ export function Settings({ token, onLogout }: { token: string; onLogout: () => v
|
|
|
9
9
|
const [savingPassphrase, setSavingPassphrase] = useState(false);
|
|
10
10
|
|
|
11
11
|
// Agent identity registration
|
|
12
|
-
const [linkStatus, setLinkStatus] = useState<{ linked: boolean; agentId?: number; owsWallet?: string; owner?: string } | null>(null);
|
|
12
|
+
const [linkStatus, setLinkStatus] = useState<{ linked: boolean; agentId?: number; owsWallet?: string; owner?: string; txHash?: string } | null>(null);
|
|
13
13
|
const [agentName, setAgentName] = useState("AI Writer");
|
|
14
14
|
const [agentDescription, setAgentDescription] = useState("");
|
|
15
15
|
const [agentGenre, setAgentGenre] = useState("");
|
|
@@ -52,7 +52,7 @@ export function Settings({ token, onLogout }: { token: string; onLogout: () => v
|
|
|
52
52
|
});
|
|
53
53
|
const data = await res.json();
|
|
54
54
|
if (!res.ok) throw new Error(data.error || "Registration failed");
|
|
55
|
-
setLinkStatus({ linked: true, agentId: data.agentId, owsWallet: data.owsWallet });
|
|
55
|
+
setLinkStatus({ linked: true, agentId: data.agentId, owsWallet: data.owsWallet, txHash: data.txHash });
|
|
56
56
|
} catch (err: unknown) {
|
|
57
57
|
setRegisterError(err instanceof Error ? err.message : "Registration failed");
|
|
58
58
|
}
|
|
@@ -141,8 +141,15 @@ export function Settings({ token, onLogout }: { token: string; onLogout: () => v
|
|
|
141
141
|
Owner: {linkStatus.owner.slice(0, 6)}...{linkStatus.owner.slice(-4)}
|
|
142
142
|
</p>
|
|
143
143
|
)}
|
|
144
|
+
{linkStatus.txHash && (
|
|
145
|
+
<p className="text-muted text-xs">
|
|
146
|
+
<a href={`https://basescan.org/tx/${linkStatus.txHash}`} target="_blank" rel="noopener noreferrer" className="text-accent underline">
|
|
147
|
+
View transaction on BaseScan
|
|
148
|
+
</a>
|
|
149
|
+
</p>
|
|
150
|
+
)}
|
|
144
151
|
<p className="text-muted text-xs">
|
|
145
|
-
<a href={`https://plotlink.xyz/
|
|
152
|
+
<a href={`https://plotlink.xyz/profile/${linkStatus.owsWallet}`} target="_blank" rel="noopener noreferrer" className="text-accent underline">
|
|
146
153
|
View agent profile on plotlink.xyz
|
|
147
154
|
</a>
|
|
148
155
|
</p>
|
|
@@ -278,6 +278,42 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
278
278
|
}
|
|
279
279
|
}, []);
|
|
280
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
|
+
|
|
281
317
|
return (
|
|
282
318
|
<div ref={containerRef} className="h-[calc(100vh-3.5rem)] flex">
|
|
283
319
|
{/* Story Browser Sidebar */}
|
|
@@ -294,7 +330,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
|
|
|
294
330
|
|
|
295
331
|
{/* Terminal — sized by ratio of available space */}
|
|
296
332
|
<div className="min-w-0 border-r border-border" style={{ flex: `${ratio} 0 0` }}>
|
|
297
|
-
<TerminalPanel token={token} storyName={selectedStory} authFetch={authFetch} onSelectStory={handleSelectStory} onDestroySession={handleDestroySession} />
|
|
333
|
+
<TerminalPanel token={token} storyName={selectedStory} authFetch={authFetch} onSelectStory={handleSelectStory} onDestroySession={handleDestroySession} onArchiveStory={handleArchiveStory} confirmedStories={confirmedStories} />
|
|
298
334
|
</div>
|
|
299
335
|
|
|
300
336
|
{/* Drag Handle */}
|
|
@@ -42,7 +42,9 @@ const STATUS_COLOR: Record<string, string> = {
|
|
|
42
42
|
|
|
43
43
|
export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectFile, onNewStory, untitledSessions = [] }: StoryBrowserProps) {
|
|
44
44
|
const [stories, setStories] = useState<StoryInfo[]>([]);
|
|
45
|
+
const [archivedStories, setArchivedStories] = useState<StoryInfo[]>([]);
|
|
45
46
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
|
47
|
+
const [showArchives, setShowArchives] = useState(false);
|
|
46
48
|
|
|
47
49
|
const loadStories = useCallback(async () => {
|
|
48
50
|
try {
|
|
@@ -54,6 +56,30 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
|
|
|
54
56
|
} catch { /* ignore */ }
|
|
55
57
|
}, [authFetch]);
|
|
56
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
|
+
|
|
57
83
|
useEffect(() => {
|
|
58
84
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial load + polling
|
|
59
85
|
loadStories();
|
|
@@ -61,6 +87,14 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
|
|
|
61
87
|
return () => clearInterval(interval);
|
|
62
88
|
}, [loadStories]);
|
|
63
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
|
+
|
|
64
98
|
// Auto-expand selected story
|
|
65
99
|
useEffect(() => {
|
|
66
100
|
if (selectedStory) {
|
|
@@ -111,12 +145,62 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
|
|
|
111
145
|
return [...files].sort((a, b) => order(a.file) - order(b.file));
|
|
112
146
|
};
|
|
113
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
|
+
|
|
114
187
|
return (
|
|
115
188
|
<div className="h-full flex flex-col">
|
|
116
189
|
<div className="px-3 py-1.5 border-b border-border flex items-center justify-between">
|
|
117
190
|
<span className="text-xs font-mono text-muted">Stories</span>
|
|
118
191
|
<span className="text-xs text-muted">{stories.length}</span>
|
|
119
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
|
+
)}
|
|
120
204
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
|
121
205
|
{/* Untitled new story sessions */}
|
|
122
206
|
{untitledSessions.map((id) => (
|
|
@@ -135,7 +219,7 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
|
|
|
135
219
|
{stories.length === 0 && untitledSessions.length === 0 ? (
|
|
136
220
|
<div className="p-3 text-sm text-muted">
|
|
137
221
|
<p>No stories yet.</p>
|
|
138
|
-
<p className="mt-1 text-xs">Click "+ New Story"
|
|
222
|
+
<p className="mt-1 text-xs">Click "+ New Story" above to start writing.</p>
|
|
139
223
|
</div>
|
|
140
224
|
) : (
|
|
141
225
|
stories.filter((s) => s.name !== "_example").map((story) => (
|
|
@@ -173,17 +257,14 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
|
|
|
173
257
|
))
|
|
174
258
|
)}
|
|
175
259
|
</div>
|
|
176
|
-
|
|
177
|
-
<
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
>
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
</button>
|
|
185
|
-
</div>
|
|
186
|
-
)}
|
|
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>
|
|
187
268
|
</div>
|
|
188
269
|
);
|
|
189
270
|
}
|
|
@@ -10,6 +10,8 @@ interface TerminalPanelProps {
|
|
|
10
10
|
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
|
|
11
11
|
onSelectStory?: (storyName: string) => void;
|
|
12
12
|
onDestroySession?: (storyName: string) => void;
|
|
13
|
+
onArchiveStory?: (storyName: string) => void;
|
|
14
|
+
confirmedStories?: Set<string>;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
interface TerminalSession {
|
|
@@ -90,14 +92,26 @@ async function loadScrollback(storyName: string): Promise<string | null> {
|
|
|
90
92
|
});
|
|
91
93
|
}
|
|
92
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
|
+
|
|
93
105
|
// Sessions live outside React state to avoid ref-in-effect lint issues
|
|
94
106
|
const sessions = new Map<string, TerminalSession>();
|
|
95
107
|
|
|
96
|
-
export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDestroySession }: TerminalPanelProps) {
|
|
108
|
+
export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDestroySession, onArchiveStory, confirmedStories }: TerminalPanelProps) {
|
|
97
109
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
98
110
|
const authFetchRef = useRef(authFetch);
|
|
99
111
|
const [sessionList, setSessionList] = useState<string[]>([]);
|
|
100
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);
|
|
101
115
|
|
|
102
116
|
const connectWsRef = useRef<(name: string, session: TerminalSession, resume: boolean) => void>(() => {});
|
|
103
117
|
|
|
@@ -287,6 +301,32 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
|
|
|
287
301
|
onDestroySession?.(name);
|
|
288
302
|
}, [authFetch, onDestroySession]);
|
|
289
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]);
|
|
329
|
+
|
|
290
330
|
// Auto-spawn + show/hide when story changes
|
|
291
331
|
useEffect(() => {
|
|
292
332
|
if (!storyName) return;
|
|
@@ -373,7 +413,11 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
|
|
|
373
413
|
<button
|
|
374
414
|
onClick={(e) => {
|
|
375
415
|
e.stopPropagation();
|
|
376
|
-
|
|
416
|
+
if (name.startsWith("_new_")) {
|
|
417
|
+
setConfirmingDiscard(name);
|
|
418
|
+
} else {
|
|
419
|
+
destroySession(name);
|
|
420
|
+
}
|
|
377
421
|
}}
|
|
378
422
|
className="ml-0.5 text-muted hover:text-error text-[10px] leading-none"
|
|
379
423
|
title="Close terminal"
|
|
@@ -383,6 +427,22 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
|
|
|
383
427
|
</div>
|
|
384
428
|
))
|
|
385
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}
|
|
386
446
|
</div>
|
|
387
447
|
)}
|
|
388
448
|
|
|
@@ -400,6 +460,76 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
|
|
|
400
460
|
</div>
|
|
401
461
|
)}
|
|
402
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
|
+
|
|
403
533
|
{/* Reconnect overlay */}
|
|
404
534
|
{isDisconnected && storyName && (
|
|
405
535
|
<div className="absolute inset-0 flex items-center justify-center" style={{ background: "rgba(240, 235, 225, 0.9)" }}>
|