diff --git a/app/routes/terminal.ts b/app/routes/terminal.ts index f81fcdc..87c2956 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"); @@ -193,6 +198,32 @@ 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); + + 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); + + // 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..ec533a9 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,8 +86,15 @@ 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 - setUntitledSessions((prev) => prev.slice(1)); + // New story appeared — rename the oldest untitled session to the story name + const oldName = untitledSessions[0]; + let renamed = false; + if (renameRef.current) { + renamed = await renameRef.current(oldName, name).catch(() => false); + } + if (renamed) { + setUntitledSessions((prev) => prev.slice(1)); + } setSelectedStory(name); setSelectedFile(null); } @@ -330,7 +338,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..7a30418 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,50 @@ 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. + * 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 false; + + // 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 false; + + // 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; + }); + + return true; + }, []); + + // 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" },