feat: harden runtime readiness and session workflow

This commit is contained in:
User
2026-07-14 10:27:25 +02:00
parent 6d7738d538
commit 6dbf93fff9
52 changed files with 1464 additions and 169 deletions
+134 -24
View File
@@ -11,13 +11,14 @@ import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
import { StopConfirmDialog } from "./StopConfirmDialog";
import { SteerInput, ComposerFooter } from "./SteerInput";
import { WorkflowBar } from "./WorkflowBar";
import { Pencil, ArrowLeft, ArrowRight } from "lucide-react";
import { Pencil, ArrowLeft, ArrowRight, Trash2 } from "lucide-react";
import { Button } from "../components/ui/button";
import { Checkbox } from "../components/ui/checkbox";
import { Toaster } from "../components/ui/sonner";
import { toast } from "sonner";
import {
closeSession, listSessions, resumeSession, getSession,
renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession,
renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession, prewarmRuntime,
} from "../api/sessions";
import type { SessionSummary } from "../api/types";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -31,6 +32,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
*/
export function AppShell() {
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [creatingSession, setCreatingSession] = useState(false);
const { data: sessions = [] } = useQuery<SessionSummary[]>({
queryKey: ["sessions"], queryFn: listSessions, refetchInterval: 10_000,
});
@@ -42,7 +44,8 @@ export function AppShell() {
const [activeOpen, setActiveOpen] = useState(true);
const [archiveOpen, setArchiveOpen] = useState(false);
const [renameTarget, setRenameTarget] = useState<SessionSummary | null>(null);
const [deleteTarget, setDeleteTarget] = useState<SessionSummary | null>(null);
const [deleteTargets, setDeleteTargets] = useState<SessionSummary[]>([]);
const [selectedSessionIds, setSelectedSessionIds] = useState<Set<string>>(new Set());
const [stopConfirm, setStopConfirm] = useState(false);
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
const [renameGroupTarget, setRenameGroupTarget] = useState<string | null>(null);
@@ -57,6 +60,31 @@ export function AppShell() {
const refresh = () => queryClient.invalidateQueries({ queryKey: ["sessions"] });
const activeSession = sessions.find((s) => s.id === activeSessionId) ?? null;
const finalized = activeSession?.status === "finalized";
const selectedSessions = sessions.filter((session) => selectedSessionIds.has(session.id));
const allSessionsSelected = sessions.length > 0 && selectedSessions.length === sessions.length;
// A background refresh can remove a session (for example from another browser).
// Keep the local selection aligned with the authoritative list.
useEffect(() => {
const availableIds = new Set(sessions.map((session) => session.id));
setSelectedSessionIds((current) => {
const next = new Set([...current].filter((id) => availableIds.has(id)));
return next.size === current.size ? current : next;
});
}, [sessions]);
function setSessionSelected(id: string, selected: boolean) {
setSelectedSessionIds((current) => {
const next = new Set(current);
if (selected) next.add(id);
else next.delete(id);
return next;
});
}
function toggleAllSessions(selected: boolean) {
setSelectedSessionIds(selected ? new Set(sessions.map((session) => session.id)) : new Set());
}
function openPanel(id: string) {
const s = sessions.find((x) => x.id === id);
@@ -138,6 +166,24 @@ export function AppShell() {
}
}
async function deleteSessions(targets: SessionSummary[]) {
try {
const results = await Promise.allSettled(targets.map((session) => deleteSession(session.id)));
const deletedIds = new Set(
targets.filter((_, index) => results[index].status === "fulfilled").map((session) => session.id),
);
if (deletedIds.has(panelSession?.id ?? "")) setPanelSession(null);
if (deletedIds.has(activeSessionId ?? "")) { resetSession(); setActiveSessionId(null); }
setSelectedSessionIds((current) => new Set([...current].filter((id) => !deletedIds.has(id))));
refresh();
if (deletedIds.size !== targets.length) {
toast.error(`Deleted ${deletedIds.size} of ${targets.length} sessions.`);
}
} catch {
toast.error("Failed to delete selected sessions.");
}
}
function menuFor(s: SessionSummary) {
return (
<SessionMenu
@@ -149,7 +195,7 @@ export function AppShell() {
onMove={(g) => move(s, g)}
onNewGroup={() => newGroup(s)}
onArchiveToggle={() => toggleArchive(s)}
onDelete={() => setDeleteTarget(s)}
onDelete={() => { void deleteSessions([s]); }}
/>
);
}
@@ -164,7 +210,8 @@ export function AppShell() {
const setAgentActive = useSessionStore((s) => s.setAgentActive);
const lastSystemEvent = useSessionStore((s) => s.lastSystemEvent);
const agentActive = useSessionStore((s) => s.agentActive);
const working = Boolean(activeSessionId) && !pendingWidget && agentActive;
const sessionViewOpen = Boolean(activeSessionId) || creatingSession;
const working = sessionViewOpen && !pendingWidget && agentActive;
// Processing time counts only while the harness works, not while a finalized
// session sits idle or a gate awaits the reviewer (pendingWidget).
const running = working && !finalized;
@@ -179,7 +226,12 @@ export function AppShell() {
useEffect(() => {
if (lastSystemEvent?.type !== "system_event") return;
const ev = (lastSystemEvent as any).event;
if (ev === "session_exit") stopSession();
if (ev === "session_exit") {
// Never let a streamed event terminate the managed Pi child. Only the
// explicit “Stop & save” action is allowed to call /close.
resetSession();
setActiveSessionId(null);
}
// The final workflow turn ends with the session already finalized on disk:
// refetch now instead of waiting for the 10s poll, so the completed state
// (and the new-session invite below the transcript) appears immediately.
@@ -189,10 +241,32 @@ export function AppShell() {
function startNewSession() {
resetSession();
setCreatingSession(false);
setActiveSessionId(null);
// Best effort only: session creation keeps the authoritative readiness gate.
// Composer focus is deliberately independent of this network request.
void prewarmRuntime().catch(() => undefined);
requestAnimationFrame(() => composerRef.current?.focus());
}
function beginSessionCreation() {
setCreatingSession(true);
}
function finishSessionCreation(id: string) {
// React batches these updates, preserving the provisional view and timer
// while useSessionStream opens the durable session's SSE channel.
setActiveSessionId(id);
setCreatingSession(false);
refresh();
}
function failSessionCreation() {
setCreatingSession(false);
resetSession();
toast.error("Failed to create session. Your question is ready to retry.");
}
async function stopSession() {
if (!activeSessionId) return;
try {
@@ -241,10 +315,13 @@ export function AppShell() {
<main className="flex-1 overflow-y-auto px-6 py-8">
<div className="mx-auto flex max-w-4xl flex-col gap-6">
{activeSessionId ? (
{sessionViewOpen ? (
<>
<CentralStatus working={working} />
<WidgetHost sessionId={activeSessionId} />
<CentralStatus
working={working}
workingLabel={creatingSession ? "Creating session…" : "Analyzing question…"}
/>
{activeSessionId && <WidgetHost sessionId={activeSessionId} />}
{finalized && !agentActive && (
<div className="rounded-2xl border border-border/80 bg-card p-5 text-center shadow-md">
<p className="text-sm text-muted-foreground">
@@ -270,7 +347,9 @@ export function AppShell() {
<div className="px-2.5 py-2">
<SteerInput
sessionId={activeSessionId}
onSessionCreated={setActiveSessionId}
onSessionCreating={beginSessionCreation}
onSessionCreated={finishSessionCreation}
onSessionCreateFailed={failSessionCreation}
onStop={() => setStopConfirm(true)}
inputRef={composerRef}
/>
@@ -311,6 +390,32 @@ export function AppShell() {
Sessions
</span>
</div>
<div className="flex items-center justify-between px-4 pb-2">
<label className="flex cursor-pointer items-center gap-2 text-xs font-medium text-muted-foreground hover:text-foreground">
<Checkbox
checked={allSessionsSelected}
aria-label="Select all sessions"
disabled={sessions.length === 0}
onCheckedChange={(selected) => toggleAllSessions(selected === true)}
/>
<span>Select all</span>
</label>
{selectedSessions.length > 0 && (
<Button
variant="destructive"
size="xs"
aria-label={`Delete ${selectedSessions.length} selected sessions`}
onClick={() => {
if (allSessionsSelected) setDeleteTargets(selectedSessions);
else void deleteSessions(selectedSessions);
}}
>
<Trash2 />
Delete ({selectedSessions.length})
</Button>
)}
</div>
<div className="flex-1 overflow-y-auto px-2 pb-4">
{/* L2 — section toggle */}
<button
@@ -352,6 +457,8 @@ export function AppShell() {
activeSessionId={activeSessionId}
onOpenPanel={openPanel}
menuFor={menuFor}
selectedIds={selectedSessionIds}
onSelectionChange={setSessionSelected}
/>
)}
</div>
@@ -365,6 +472,8 @@ export function AppShell() {
activeSessionId={activeSessionId}
onOpenPanel={openPanel}
menuFor={menuFor}
selectedIds={selectedSessionIds}
onSelectionChange={setSessionSelected}
/>
)}
</div>
@@ -381,7 +490,14 @@ export function AppShell() {
<span className="tabular-nums">Archive ({archivedList.length})</span>
</button>
{archiveOpen && (
<NavSessions sessions={archivedList} activeSessionId={activeSessionId} onOpenPanel={openPanel} menuFor={menuFor} />
<NavSessions
sessions={archivedList}
activeSessionId={activeSessionId}
onOpenPanel={openPanel}
menuFor={menuFor}
selectedIds={selectedSessionIds}
onSelectionChange={setSessionSelected}
/>
)}
</div>
</aside>
@@ -419,21 +535,15 @@ export function AppShell() {
title="Rename group"
/>
)}
{deleteTarget && (
{deleteTargets.length > 0 && (
<DeleteConfirmDialog
open
label={deleteTarget.name || deleteTarget.question}
onOpenChange={(o) => { if (!o) setDeleteTarget(null); }}
onConfirm={async () => {
try {
await deleteSession(deleteTarget.id);
if (panelSession?.id === deleteTarget.id) setPanelSession(null);
if (activeSessionId === deleteTarget.id) { resetSession(); setActiveSessionId(null); }
setDeleteTarget(null);
refresh();
} catch {
toast.error("Failed to delete session.");
}
labels={deleteTargets.map((session) => session.name || session.question || session.id)}
onOpenChange={(o) => { if (!o) setDeleteTargets([]); }}
onConfirm={() => {
const targets = deleteTargets;
setDeleteTargets([]);
void deleteSessions(targets);
}}
/>
)}