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.
@@ -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
- return { name, files, hasStructure, hasGenesis, plotCount, publishedCount };
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"));
@@ -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
- const storyDir = path.join(STORIES_DIR, storyName);
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>&larr;</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
- {stories.length === 0 ? (
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">Use the terminal to start writing with Claude.</p>
222
+ <p className="mt-1 text-xs">Click &quot;+ New Story&quot; 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
- }, [authFetch]);
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="truncate max-w-[120px]">{name}</span>
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
- destroySession(name);
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&apos;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)" }}>