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
53 changes: 53 additions & 0 deletions app/routes/stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,59 @@ stories.get("/", (c) => {
return c.json({ stories: result });
});

const ARCHIVED_DIR = path.join(STORIES_DIR, ".archived");

/** GET /api/stories/archived — list archived stories */
stories.get("/archived", (c) => {
if (!fs.existsSync(ARCHIVED_DIR)) {
return c.json({ stories: [] });
}

const dirs = fs.readdirSync(ARCHIVED_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory() && !d.name.startsWith("."))
.map((d) => d.name)
.sort();

const result = dirs.map((name) => scanStory(path.join(ARCHIVED_DIR, name), name));
return c.json({ stories: result });
});

/** POST /api/stories/archive — move story to .archived/ */
stories.post("/archive", async (c) => {
const body = await c.req.json<{ name: string }>();
const name = safeName(body.name);
if (!name) return c.json({ error: "Invalid story name" }, 400);

const src = path.join(STORIES_DIR, name);
if (!fs.existsSync(src)) return c.json({ error: "Story not found" }, 404);
if (!fs.existsSync(path.join(src, "structure.md"))) {
return c.json({ error: "Only stories with structure.md can be archived" }, 400);
}

fs.mkdirSync(ARCHIVED_DIR, { recursive: true });
const dest = path.join(ARCHIVED_DIR, name);
if (fs.existsSync(dest)) return c.json({ error: "Already archived" }, 409);

fs.renameSync(src, dest);
return c.json({ ok: true });
});

/** POST /api/stories/restore — move story back from .archived/ */
stories.post("/restore", async (c) => {
const body = await c.req.json<{ name: string }>();
const name = safeName(body.name);
if (!name) return c.json({ error: "Invalid story name" }, 400);

const src = path.join(ARCHIVED_DIR, name);
if (!fs.existsSync(src)) return c.json({ error: "Archived story not found" }, 404);

const dest = path.join(STORIES_DIR, name);
if (fs.existsSync(dest)) return c.json({ error: "Story already exists" }, 409);

fs.renameSync(src, dest);
return c.json({ ok: true });
});

/** GET /api/stories/:name — single story detail */
stories.get("/:name", (c) => {
const name = safeName(c.req.param("name"));
Expand Down
38 changes: 37 additions & 1 deletion app/web/components/StoriesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,42 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
}
}, []);

// Track confirmed stories (those with structure.md) for Archive gating
const [confirmedStories, setConfirmedStories] = useState<Set<string>>(new Set());
useEffect(() => {
authFetch("/api/stories").then((res) => res.ok ? res.json() : null).then((data) => {
if (data?.stories) {
setConfirmedStories(new Set(
(data.stories as { name: string; hasStructure: boolean }[])
.filter((s) => s.hasStructure)
.map((s) => s.name)
));
}
}).catch(() => {});
const interval = setInterval(async () => {
try {
const res = await authFetch("/api/stories");
if (res.ok) {
const data = await res.json();
setConfirmedStories(new Set(
(data.stories as { name: string; hasStructure: boolean }[])
.filter((s) => s.hasStructure)
.map((s) => s.name)
));
}
} catch { /* ignore */ }
}, 5000);
return () => clearInterval(interval);
}, [authFetch]);

const handleArchiveStory = useCallback((name: string) => {
// Archive API already called by TerminalPanel — just clear selection
if (selectedStory === name) {
setSelectedStory(null);
setSelectedFile(null);
}
}, [selectedStory]);

return (
<div ref={containerRef} className="h-[calc(100vh-3.5rem)] flex">
{/* Story Browser Sidebar */}
Expand All @@ -294,7 +330,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} />
<TerminalPanel token={token} storyName={selectedStory} authFetch={authFetch} onSelectStory={handleSelectStory} onDestroySession={handleDestroySession} onArchiveStory={handleArchiveStory} confirmedStories={confirmedStories} />
</div>

{/* Drag Handle */}
Expand Down
81 changes: 81 additions & 0 deletions app/web/components/StoryBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ const STATUS_COLOR: Record<string, string> = {

export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectFile, onNewStory, untitledSessions = [] }: StoryBrowserProps) {
const [stories, setStories] = useState<StoryInfo[]>([]);
const [archivedStories, setArchivedStories] = useState<StoryInfo[]>([]);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [showArchives, setShowArchives] = useState(false);

const loadStories = useCallback(async () => {
try {
Expand All @@ -54,13 +56,45 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
} catch { /* ignore */ }
}, [authFetch]);

const loadArchivedStories = useCallback(async () => {
try {
const res = await authFetch("/api/stories/archived");
if (res.ok) {
const data = await res.json();
setArchivedStories(data.stories);
}
} catch { /* ignore */ }
}, [authFetch]);

const handleRestore = useCallback(async (name: string) => {
try {
const res = await authFetch("/api/stories/restore", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (res.ok) {
loadArchivedStories();
loadStories();
}
} catch { /* ignore */ }
}, [authFetch, loadArchivedStories, loadStories]);

useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial load + polling
loadStories();
const interval = setInterval(loadStories, 5000);
return () => clearInterval(interval);
}, [loadStories]);

// Load archived stories when archives view is shown
useEffect(() => {
if (showArchives) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- initial load for archives
loadArchivedStories();
}
}, [showArchives, loadArchivedStories]);

// Auto-expand selected story
useEffect(() => {
if (selectedStory) {
Expand Down Expand Up @@ -111,6 +145,45 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
return [...files].sort((a, b) => order(a.file) - order(b.file));
};

if (showArchives) {
return (
<div className="h-full flex flex-col">
<div className="px-3 py-1.5 border-b border-border flex items-center justify-between">
<span className="text-xs font-mono text-muted">Archives</span>
<span className="text-xs text-muted">{archivedStories.length}</span>
</div>
<div className="px-3 py-2 border-b border-border">
<button
onClick={() => setShowArchives(false)}
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"
>
<span>&larr;</span>
<span>Back</span>
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{archivedStories.length === 0 ? (
<div className="p-3 text-sm text-muted">
<p>No archived stories.</p>
</div>
) : (
archivedStories.map((story) => (
<div key={story.name} className="px-3 py-2 flex items-center justify-between hover:bg-surface">
<span className="text-sm font-medium truncate" title={story.name}>{story.title || story.name}</span>
<button
onClick={() => handleRestore(story.name)}
className="text-xs text-accent hover:text-accent-dim flex-shrink-0 ml-2"
>
Restore
</button>
</div>
))
)}
</div>
</div>
);
}

return (
<div className="h-full flex flex-col">
<div className="px-3 py-1.5 border-b border-border flex items-center justify-between">
Expand Down Expand Up @@ -184,6 +257,14 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
))
)}
</div>
<div className="px-3 py-2 border-t border-border">
<button
onClick={() => setShowArchives(true)}
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"
>
<span>Archives</span>
</button>
</div>
</div>
);
}
58 changes: 54 additions & 4 deletions app/web/components/TerminalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ interface TerminalPanelProps {
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
onSelectStory?: (storyName: string) => void;
onDestroySession?: (storyName: string) => void;
onArchiveStory?: (storyName: string) => void;
confirmedStories?: Set<string>;
}

interface TerminalSession {
Expand Down Expand Up @@ -103,12 +105,13 @@ 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 }: TerminalPanelProps) {
export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDestroySession, onArchiveStory, confirmedStories }: TerminalPanelProps) {
const wrapperRef = useRef<HTMLDivElement>(null);
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 [confirmingArchive, setConfirmingArchive] = useState<string | null>(null);

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

Expand Down Expand Up @@ -424,15 +427,22 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
</div>
))
}
{/* Cancel button for active untitled session */}
{storyName?.startsWith("_new_") && (
{/* Cancel button for untitled / Archive button for confirmed stories */}
{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>
)}
) : storyName && onArchiveStory && confirmedStories?.has(storyName) ? (
<button
onClick={() => setConfirmingArchive(storyName)}
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"
>
Archive
</button>
) : null}
</div>
)}

Expand Down Expand Up @@ -480,6 +490,46 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
</div>
)}

{/* Archive confirmation overlay */}
{confirmingArchive && (
<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">Archive this story?</p>
<p className="text-xs text-muted">
You can restore it later from the Archives view.
</p>
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setConfirmingArchive(null)}
className="px-4 py-1.5 border border-border text-sm rounded hover:bg-surface"
>
Cancel
</button>
<button
onClick={async () => {
const name = confirmingArchive;
setConfirmingArchive(null);
try {
const res = await authFetchRef.current("/api/stories/archive", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (res.ok) {
destroySession(name);
onArchiveStory?.(name);
}
} catch { /* ignore */ }
}}
className="px-4 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent-dim"
>
Archive
</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
Loading
Loading