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
39 changes: 35 additions & 4 deletions app/routes/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand Down
14 changes: 11 additions & 3 deletions app/web/components/StoriesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
const [ratio, setRatio] = useState(loadRatio);
const [untitledSessions, setUntitledSessions] = useState<string[]>([]);
const knownStoriesRef = useRef<Set<string>>(new Set());
const renameRef = useRef<((oldName: string, newName: string) => Promise<boolean>) | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const dragging = useRef(false);

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -330,7 +338,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {

{/* Terminal — sized by ratio of available space */}
<div className="min-w-0 border-r border-border" style={{ flex: `${ratio} 0 0` }}>
<TerminalPanel token={token} storyName={selectedStory} authFetch={authFetch} onSelectStory={handleSelectStory} onDestroySession={handleDestroySession} onArchiveStory={handleArchiveStory} confirmedStories={confirmedStories} />
<TerminalPanel token={token} storyName={selectedStory} authFetch={authFetch} onSelectStory={handleSelectStory} onDestroySession={handleDestroySession} onArchiveStory={handleArchiveStory} confirmedStories={confirmedStories} renameRef={renameRef} />
</div>

{/* Drag Handle */}
Expand Down
47 changes: 46 additions & 1 deletion app/web/components/TerminalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface TerminalPanelProps {
onDestroySession?: (storyName: string) => void;
onArchiveStory?: (storyName: string) => void;
confirmedStories?: Set<string>;
renameRef?: React.RefObject<((oldName: string, newName: string) => Promise<boolean>) | null>;
}

interface TerminalSession {
Expand Down Expand Up @@ -105,7 +106,7 @@ async function deleteScrollback(storyName: string): Promise<void> {
// Sessions live outside React state to avoid ref-in-effect lint issues
const sessions = new Map<string, TerminalSession>();

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<HTMLDivElement>(null);
const authFetchRef = useRef(authFetch);
const [sessionList, setSessionList] = useState<string[]>([]);
Expand Down Expand Up @@ -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<boolean> => {
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;
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.20",
"version": "1.0.21",
"bin": {
"plotlink-ows": "./bin/plotlink-ows.js"
},
Expand Down
Loading