Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions app/routes/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,32 @@ terminal.delete("/:storyName", (c) => {
return c.json({ ok: true, message: "not running" });
});

/** DELETE /api/terminal/:storyName/discard — discard session, kill PTY, clean up metadata */
terminal.delete("/:storyName/discard", (c) => {
const storyName = safeName(c.req.param("storyName"));
if (!storyName) return c.json({ error: "Invalid story name" }, 400);

const session = ptySessions.get(storyName);
if (session?.term && session.state === "running") {
// Send exit gracefully, then kill
try { session.term.write("exit\n"); } catch { /* ignore */ }
setTimeout(() => {
try { session.term.kill(); } catch { /* ignore */ }
}, 500);
session.state = "stopped";
}
ptySessions.delete(storyName);

// Remove session metadata from terminal-sessions.json
const sessionMap = loadSessionMap();
if (sessionMap[storyName]) {
delete sessionMap[storyName];
saveSessionMap(sessionMap);
}

return c.json({ ok: true });
});

/** POST /api/terminal/stop — kill PTY (legacy, kills default) */
terminal.post("/stop", (c) => {
const session = ptySessions.get("default");
Expand Down
82 changes: 81 additions & 1 deletion app/web/components/TerminalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,16 @@ async function loadScrollback(storyName: string): Promise<string | null> {
});
}

async function deleteScrollback(storyName: string): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, "readwrite");
tx.objectStore(STORE_NAME).delete(storyName);
tx.oncomplete = () => { db.close(); resolve(); };
tx.onerror = () => { db.close(); reject(tx.error); };
});
}

// Sessions live outside React state to avoid ref-in-effect lint issues
const sessions = new Map<string, TerminalSession>();

Expand All @@ -98,6 +108,7 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
const authFetchRef = useRef(authFetch);
const [sessionList, setSessionList] = useState<string[]>([]);
const [disconnected, setDisconnected] = useState<Set<string>>(new Set());
const [confirmingDiscard, setConfirmingDiscard] = useState<string | null>(null);

const connectWsRef = useRef<(name: string, session: TerminalSession, resume: boolean) => void>(() => {});

Expand Down Expand Up @@ -287,6 +298,32 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
onDestroySession?.(name);
}, [authFetch, onDestroySession]);

/** Discard an untitled session: send exit, kill PTY, delete scrollback & session metadata */
const discardSession = useCallback((name: string) => {
const session = sessions.get(name);
if (!session) return;

// Send exit command gracefully before killing
if (session.ws?.readyState === WebSocket.OPEN) {
session.ws.send("exit\n");
}

// Delete scrollback instead of saving
deleteScrollback(name).catch(() => {});

session.observer.disconnect();
if (session.ws) session.ws.close();
session.term.dispose();
session.container.remove();
sessions.delete(name);
setSessionList((prev) => prev.filter((s) => s !== name));
setDisconnected((prev) => { const next = new Set(prev); next.delete(name); return next; });

// Use discard endpoint to kill PTY and clean up session metadata
authFetch(`/api/terminal/${encodeURIComponent(name)}/discard`, { method: "DELETE" }).catch(() => {});
onDestroySession?.(name);
}, [authFetch, onDestroySession]);

// Auto-spawn + show/hide when story changes
useEffect(() => {
if (!storyName) return;
Expand Down Expand Up @@ -373,7 +410,11 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
<button
onClick={(e) => {
e.stopPropagation();
destroySession(name);
if (name.startsWith("_new_")) {
setConfirmingDiscard(name);
} else {
destroySession(name);
}
}}
className="ml-0.5 text-muted hover:text-error text-[10px] leading-none"
title="Close terminal"
Expand All @@ -383,6 +424,15 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
</div>
))
}
{/* Cancel button for active untitled session */}
{storyName?.startsWith("_new_") && (
<button
onClick={() => setConfirmingDiscard(storyName)}
className="ml-auto px-2 py-0.5 text-xs text-error hover:bg-surface rounded flex items-center gap-1 flex-shrink-0"
>
Cancel ×
</button>
)}
</div>
)}

Expand All @@ -400,6 +450,36 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
</div>
)}

{/* Discard confirmation overlay */}
{confirmingDiscard && (
<div className="absolute inset-0 flex items-center justify-center z-10" style={{ background: "rgba(240, 235, 225, 0.9)" }}>
<div className="text-center space-y-3 p-6 bg-surface border border-border rounded-lg shadow-lg max-w-sm">
<p className="text-sm font-serif text-foreground font-medium">Discard this session?</p>
<p className="text-xs text-muted">
This session will be lost — your AI hasn&apos;t created a story structure yet.
</p>
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setConfirmingDiscard(null)}
className="px-4 py-1.5 border border-border text-sm rounded hover:bg-surface"
>
Cancel
</button>
<button
onClick={() => {
const name = confirmingDiscard;
setConfirmingDiscard(null);
discardSession(name);
}}
className="px-4 py-1.5 bg-error text-white text-sm rounded hover:opacity-80"
>
Discard
</button>
</div>
</div>
</div>
)}

{/* Reconnect overlay */}
{isDisconnected && storyName && (
<div className="absolute inset-0 flex items-center justify-center" style={{ background: "rgba(240, 235, 225, 0.9)" }}>
Expand Down
129 changes: 0 additions & 129 deletions app/web/dist/assets/index-BvR_f6_r.js

This file was deleted.

32 changes: 0 additions & 32 deletions app/web/dist/assets/index-CF78M7uF.css

This file was deleted.

32 changes: 32 additions & 0 deletions app/web/dist/assets/index-k9t26Frd.css

Large diffs are not rendered by default.

130 changes: 130 additions & 0 deletions app/web/dist/assets/index-utAhzv0p.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions app/web/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Lora:wght@400;600;700&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="/assets/index-BvR_f6_r.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CF78M7uF.css">
<script type="module" crossorigin src="/assets/index-utAhzv0p.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-k9t26Frd.css">
</head>
<body>
<div id="root"></div>
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "plotlink-ows",
"version": "1.0.11",
"version": "1.0.12",
"bin": {
"plotlink-ows": "./bin/plotlink-ows.js"
},
Expand Down
Loading