From 58a7d9bc40a861c7ef0a3b9ab1e953b0edb4d2e2 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 24 Apr 2026 15:29:40 +0900 Subject: [PATCH 1/3] [#162] Reuse untitled session when new story is created Add POST /api/terminal/rename endpoint that moves a PTY session entry to a new key without killing the process. Frontend polling now calls rename instead of letting a new session auto-create, preserving the Claude conversation context. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/routes/terminal.ts | 24 +++++++++++++++ app/web/components/StoriesPage.tsx | 9 ++++-- app/web/components/TerminalPanel.tsx | 44 +++++++++++++++++++++++++++- package.json | 2 +- 4 files changed, 75 insertions(+), 4 deletions(-) diff --git a/app/routes/terminal.ts b/app/routes/terminal.ts index f81fcdc..4637a20 100644 --- a/app/routes/terminal.ts +++ b/app/routes/terminal.ts @@ -193,6 +193,30 @@ terminal.delete("/:storyName/discard", (c) => { return c.json({ ok: true }); }); +/** POST /api/terminal/rename — rename a session key without killing the process */ +terminal.post("/rename", async (c) => { + const body = await c.req.json<{ oldName?: string; newName?: string }>().catch(() => ({})); + const oldName = body.oldName && safeName(body.oldName); + const newName = body.newName && safeName(body.newName); + if (!oldName || !newName) return c.json({ error: "Invalid names" }, 400); + if (oldName === newName) return c.json({ ok: true }); + + const session = ptySessions.get(oldName); + if (!session) return c.json({ error: "Session not found" }, 404); + + // Move in-memory PTY entry + ptySessions.delete(oldName); + ptySessions.set(newName, session); + + // Update persisted session map: remove old key, store under new key + const sessionMap = loadSessionMap(); + delete sessionMap[oldName]; + sessionMap[newName] = session.sessionId; + saveSessionMap(sessionMap); + + return c.json({ ok: true, sessionId: session.sessionId }); +}); + /** POST /api/terminal/stop — kill PTY (legacy, kills default) */ terminal.post("/stop", (c) => { const session = ptySessions.get("default"); diff --git a/app/web/components/StoriesPage.tsx b/app/web/components/StoriesPage.tsx index c6e5887..e7b62b9 100644 --- a/app/web/components/StoriesPage.tsx +++ b/app/web/components/StoriesPage.tsx @@ -41,6 +41,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) { const [ratio, setRatio] = useState(loadRatio); const [untitledSessions, setUntitledSessions] = useState([]); const knownStoriesRef = useRef>(new Set()); + const renameRef = useRef<((oldName: string, newName: string) => Promise) | null>(null); const containerRef = useRef(null); const dragging = useRef(false); @@ -85,7 +86,11 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) { // Detect newly appeared stories for (const name of currentNames) { if (!knownStoriesRef.current.has(name) && untitledSessions.length > 0) { - // New story appeared — transition the oldest untitled session + // New story appeared — rename the oldest untitled session to the story name + const oldName = untitledSessions[0]; + if (renameRef.current) { + renameRef.current(oldName, name); + } setUntitledSessions((prev) => prev.slice(1)); setSelectedStory(name); setSelectedFile(null); @@ -330,7 +335,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) { {/* Terminal — sized by ratio of available space */}
- +
{/* Drag Handle */} diff --git a/app/web/components/TerminalPanel.tsx b/app/web/components/TerminalPanel.tsx index 01d3a36..9da1f43 100644 --- a/app/web/components/TerminalPanel.tsx +++ b/app/web/components/TerminalPanel.tsx @@ -12,6 +12,7 @@ interface TerminalPanelProps { onDestroySession?: (storyName: string) => void; onArchiveStory?: (storyName: string) => void; confirmedStories?: Set; + renameRef?: React.RefObject<((oldName: string, newName: string) => Promise) | null>; } interface TerminalSession { @@ -105,7 +106,7 @@ async function deleteScrollback(storyName: string): Promise { // Sessions live outside React state to avoid ref-in-effect lint issues const sessions = new Map(); -export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDestroySession, onArchiveStory, confirmedStories }: TerminalPanelProps) { +export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDestroySession, onArchiveStory, confirmedStories, renameRef }: TerminalPanelProps) { const wrapperRef = useRef(null); const authFetchRef = useRef(authFetch); const [sessionList, setSessionList] = useState([]); @@ -327,6 +328,47 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe onDestroySession?.(name); }, [authFetch, onDestroySession]); + /** Rename a session key (e.g. _new_123 → paper-chair) without killing the PTY */ + const renameSession = useCallback(async (oldName: string, newName: string) => { + const session = sessions.get(oldName); + if (!session || sessions.has(newName)) return; + + // Rename on the server first + const res = await authFetchRef.current("/api/terminal/rename", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ oldName, newName }), + }); + if (!res.ok) return; + + // Move in client-side sessions map + sessions.delete(oldName); + sessions.set(newName, session); + + // Migrate scrollback under the new key + try { + const data = session.serialize.serialize(); + await deleteScrollback(oldName); + await saveScrollback(newName, data); + } catch { /* ignore */ } + + // Update React state + setSessionList((prev) => prev.map((s) => (s === oldName ? newName : s))); + setDisconnected((prev) => { + if (!prev.has(oldName)) return prev; + const next = new Set(prev); + next.delete(oldName); + next.add(newName); + return next; + }); + }, []); + + // Expose renameSession to parent via ref + useEffect(() => { + if (renameRef) renameRef.current = renameSession; + return () => { if (renameRef) renameRef.current = null; }; + }, [renameRef, renameSession]); + // Auto-spawn + show/hide when story changes useEffect(() => { if (!storyName) return; diff --git a/package.json b/package.json index f9b36cb..16f68bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink-ows", - "version": "1.0.20", + "version": "1.0.21", "bin": { "plotlink-ows": "./bin/plotlink-ows.js" }, From 3cea7d596477723a91456878a2a452a082373e91 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 24 Apr 2026 15:33:03 +0900 Subject: [PATCH 2/3] [#162] Fix onExit lookup after rename, await rename before consuming session - onExit handler now finds session by term reference instead of captured storyName, so cleanup works after rename - StoriesPage awaits rename result and only consumes the untitled session on success, preventing orphaned PTYs on failure Co-Authored-By: Claude Opus 4.6 (1M context) --- app/routes/terminal.ts | 13 +++++++++---- app/web/components/StoriesPage.tsx | 9 ++++++--- app/web/components/TerminalPanel.tsx | 13 ++++++++----- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/app/routes/terminal.ts b/app/routes/terminal.ts index 4637a20..76ab375 100644 --- a/app/routes/terminal.ts +++ b/app/routes/terminal.ts @@ -84,14 +84,19 @@ function spawnPty(storyName: string, opts?: { sessionId?: string; resume?: boole ptySessions.set(storyName, session); term.onExit(({ exitCode }) => { - const s = ptySessions.get(storyName); - if (s?.term !== term) return; + // Find this session by term reference — key may have changed via rename + let currentName: string | undefined; + let s: typeof session | undefined; + for (const [key, entry] of ptySessions) { + if (entry.term === term) { currentName = key; s = entry; break; } + } + if (!currentName || !s) return; // If a resumed session exits quickly (< 5s), signal client to auto-reconnect fresh const elapsed = Date.now() - spawnTime; if (isResume && elapsed < 5000 && exitCode !== 0) { - console.log(`Resume for "${storyName}" failed (exit ${exitCode} in ${elapsed}ms), signaling fresh fallback`); - ptySessions.delete(storyName); + console.log(`Resume for "${currentName}" failed (exit ${exitCode} in ${elapsed}ms), signaling fresh fallback`); + ptySessions.delete(currentName); if (s.ws && s.ws.readyState <= 1) { // Close code 4000 = resume-failed, client should auto-reconnect fresh s.ws.close(4000, "resume-failed"); diff --git a/app/web/components/StoriesPage.tsx b/app/web/components/StoriesPage.tsx index e7b62b9..ec533a9 100644 --- a/app/web/components/StoriesPage.tsx +++ b/app/web/components/StoriesPage.tsx @@ -41,7 +41,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) { const [ratio, setRatio] = useState(loadRatio); const [untitledSessions, setUntitledSessions] = useState([]); const knownStoriesRef = useRef>(new Set()); - const renameRef = useRef<((oldName: string, newName: string) => Promise) | null>(null); + const renameRef = useRef<((oldName: string, newName: string) => Promise) | null>(null); const containerRef = useRef(null); const dragging = useRef(false); @@ -88,10 +88,13 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) { if (!knownStoriesRef.current.has(name) && untitledSessions.length > 0) { // New story appeared — rename the oldest untitled session to the story name const oldName = untitledSessions[0]; + let renamed = false; if (renameRef.current) { - renameRef.current(oldName, name); + renamed = await renameRef.current(oldName, name).catch(() => false); + } + if (renamed) { + setUntitledSessions((prev) => prev.slice(1)); } - setUntitledSessions((prev) => prev.slice(1)); setSelectedStory(name); setSelectedFile(null); } diff --git a/app/web/components/TerminalPanel.tsx b/app/web/components/TerminalPanel.tsx index 9da1f43..7a30418 100644 --- a/app/web/components/TerminalPanel.tsx +++ b/app/web/components/TerminalPanel.tsx @@ -12,7 +12,7 @@ interface TerminalPanelProps { onDestroySession?: (storyName: string) => void; onArchiveStory?: (storyName: string) => void; confirmedStories?: Set; - renameRef?: React.RefObject<((oldName: string, newName: string) => Promise) | null>; + renameRef?: React.RefObject<((oldName: string, newName: string) => Promise) | null>; } interface TerminalSession { @@ -328,10 +328,11 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe onDestroySession?.(name); }, [authFetch, onDestroySession]); - /** Rename a session key (e.g. _new_123 → paper-chair) without killing the PTY */ - const renameSession = useCallback(async (oldName: string, newName: string) => { + /** Rename a session key (e.g. _new_123 → paper-chair) without killing the PTY. + * Returns true on success, false on failure. */ + const renameSession = useCallback(async (oldName: string, newName: string): Promise => { const session = sessions.get(oldName); - if (!session || sessions.has(newName)) return; + if (!session || sessions.has(newName)) return false; // Rename on the server first const res = await authFetchRef.current("/api/terminal/rename", { @@ -339,7 +340,7 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe headers: { "Content-Type": "application/json" }, body: JSON.stringify({ oldName, newName }), }); - if (!res.ok) return; + if (!res.ok) return false; // Move in client-side sessions map sessions.delete(oldName); @@ -361,6 +362,8 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe next.add(newName); return next; }); + + return true; }, []); // Expose renameSession to parent via ref From 3810bc94246e97c005ecce0feb9d56cbaeef71f9 Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Fri, 24 Apr 2026 15:34:32 +0900 Subject: [PATCH 3/3] [#162] Add 409 collision guard to rename endpoint Reject rename when target session already exists in ptySessions to prevent overwriting a live session and orphaning its PTY. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/routes/terminal.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/routes/terminal.ts b/app/routes/terminal.ts index 76ab375..87c2956 100644 --- a/app/routes/terminal.ts +++ b/app/routes/terminal.ts @@ -209,6 +209,8 @@ terminal.post("/rename", async (c) => { const session = ptySessions.get(oldName); if (!session) return c.json({ error: "Session not found" }, 404); + if (ptySessions.has(newName)) return c.json({ error: "Target session already exists" }, 409); + // Move in-memory PTY entry ptySessions.delete(oldName); ptySessions.set(newName, session);