feat: harden runtime readiness and session workflow
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { http, HttpResponse, delay } from "msw";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { server } from "../test/msw";
|
||||
import { FakeEventSource } from "../test/fakeEventSource";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
import { AppShell } from "./AppShell";
|
||||
|
||||
function renderShell() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(<QueryClientProvider client={client}><AppShell /></QueryClientProvider>);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
FakeEventSource.instances = [];
|
||||
(globalThis as any).EventSource = FakeEventSource;
|
||||
useSessionStore.getState().resetSession();
|
||||
server.use(
|
||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json([])),
|
||||
http.get("http://localhost:8787/settings", () =>
|
||||
HttpResponse.json({ workspace: "default", provider: "test", model: "test", thinking: "low" })),
|
||||
http.get("http://localhost:8787/workspaces", () => HttpResponse.json([])),
|
||||
http.get("http://localhost:8787/models", () => HttpResponse.json({ models: [] })),
|
||||
);
|
||||
});
|
||||
|
||||
test("New session starts prewarm without delaying composer focus", async () => {
|
||||
let prewarmStarted = false;
|
||||
server.use(
|
||||
http.post("http://localhost:8787/runtime/prewarm", async () => {
|
||||
prewarmStarted = true;
|
||||
await delay(100);
|
||||
return new HttpResponse(null, { status: 202 });
|
||||
}),
|
||||
);
|
||||
renderShell();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /^new session$/i }));
|
||||
|
||||
const composer = screen.getByRole("textbox", { name: /new question/i });
|
||||
await waitFor(() => expect(prewarmStarted).toBe(true));
|
||||
await waitFor(() => expect(composer).toHaveFocus());
|
||||
});
|
||||
|
||||
test("shows the question and creation timer before POST /sessions resolves, then opens SSE", async () => {
|
||||
let releaseCreate!: () => void;
|
||||
const createMayFinish = new Promise<void>((resolve) => { releaseCreate = resolve; });
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions", async () => {
|
||||
await createMayFinish;
|
||||
return HttpResponse.json({ id: "s-new" });
|
||||
}),
|
||||
);
|
||||
renderShell();
|
||||
|
||||
const composer = screen.getByRole("textbox", { name: /new question/i });
|
||||
await userEvent.type(composer, "How many patients?");
|
||||
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
|
||||
expect(await screen.findByText("How many patients?", { selector: "p" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Creating session…")).toBeInTheDocument();
|
||||
expect(screen.getByText(/\d+s/)).toBeInTheDocument();
|
||||
expect(FakeEventSource.instances).toHaveLength(0);
|
||||
|
||||
releaseCreate();
|
||||
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
||||
expect(screen.getByText("How many patients?", { selector: "p" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Analyzing question…")).toBeInTheDocument();
|
||||
expect(useSessionStore.getState().currentPhase).toBe("F1");
|
||||
});
|
||||
|
||||
test("a failed create restores the landing view and preserves the question for retry", async () => {
|
||||
server.use(
|
||||
http.post("http://localhost:8787/sessions", () =>
|
||||
new HttpResponse("unavailable", { status: 503 })),
|
||||
);
|
||||
renderShell();
|
||||
|
||||
const composer = screen.getByRole("textbox", { name: /new question/i });
|
||||
await userEvent.type(composer, "Keep this question");
|
||||
await userEvent.click(screen.getByRole("button", { name: /send/i }));
|
||||
|
||||
expect(await screen.findByText(/failed to create session/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/type your question/i)).toBeInTheDocument();
|
||||
expect(composer).toHaveValue("Keep this question");
|
||||
expect(useSessionStore.getState().lastUserEntry).toBeNull();
|
||||
expect(FakeEventSource.instances).toHaveLength(0);
|
||||
});
|
||||
+134
-24
@@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,7 @@ function tailLines(s: string, n = 5): string[] {
|
||||
* (elapsed time + a short tail of the model stream) while the model is busy, and the
|
||||
* gate's curated messages for the current step. The full verbose stream lives in the
|
||||
* left Model-activity panel. */
|
||||
export function CentralStatus({ working }: { working: boolean }) {
|
||||
export function CentralStatus({ working, workingLabel }: { working: boolean; workingLabel?: string }) {
|
||||
const lastUserEntry = useSessionStore((s) => s.lastUserEntry);
|
||||
const stepMessages = useSessionStore((s) => s.stepMessages);
|
||||
const transcript = useSessionStore((s) => s.transcript);
|
||||
@@ -62,7 +62,7 @@ export function CentralStatus({ working }: { working: boolean }) {
|
||||
<WorkingSpinner spinning className="size-3.5" />
|
||||
</span>
|
||||
<span className="shrink-0 font-mono tabular-nums">{elapsed}s</span>
|
||||
<span className="sr-only">ThothII is working</span>
|
||||
<span>{workingLabel ?? "ThothII is working"}</span>
|
||||
</div>
|
||||
{tailRows.length > 0 && (
|
||||
<ol className="ml-7 flex flex-col gap-0.5 overflow-hidden rounded-lg border border-border/60 bg-muted/40 px-3 py-2">
|
||||
|
||||
@@ -3,18 +3,24 @@ import { Button } from "../components/ui/button";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
label: string;
|
||||
labels?: string[];
|
||||
/** Backward-compatible single-session label. */
|
||||
label?: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function DeleteConfirmDialog({ open, label, onOpenChange, onConfirm }: Props) {
|
||||
export function DeleteConfirmDialog({ open, labels, label, onOpenChange, onConfirm }: Props) {
|
||||
const targets = labels ?? (label ? [label] : []);
|
||||
const description = targets.length > 1
|
||||
? `${targets.length} sessions will be permanently deleted, including all of their documents. This action cannot be undone.`
|
||||
: `"${targets[0]}" will be permanently deleted, including all of its documents. This action cannot be undone.`;
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>Delete permanently</DialogTitle></DialogHeader>
|
||||
<DialogDescription>
|
||||
"{label}" will be permanently deleted, including all of its documents. This action cannot be undone.
|
||||
{description}
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||
|
||||
@@ -7,6 +7,16 @@ import { Button } from "../components/ui/button";
|
||||
|
||||
const TAIL_PARAGRAPHS = 5;
|
||||
|
||||
export function formatModelActivity(text: string): string {
|
||||
return text
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.replace(/^[\t ]*[•‣–]\s+/gm, "- ")
|
||||
.replace(/^(\d+)\)\s+/gm, (_, n) => `${n}. `)
|
||||
.replace(/\n(?=\S)(?![-*+] |\d+\. |>|`|\|)/g, "\n\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Left drawer showing "what's happening": the tail of the model's streamed markdown
|
||||
* output, rendered as markdown (not raw text) so headings/emphasis/lists come through
|
||||
* and paragraphs stay visually separated. Collapsed it shows the last few paragraphs
|
||||
@@ -27,7 +37,7 @@ export function ModelActivityPanel({ onClose }: { onClose: () => void }) {
|
||||
.filter((p) => p !== "");
|
||||
const shown = expanded ? paragraphs : paragraphs.slice(-TAIL_PARAGRAPHS);
|
||||
const canExpand = paragraphs.length > TAIL_PARAGRAPHS;
|
||||
const source = shown.join("\n\n");
|
||||
const source = formatModelActivity(shown.join("\n\n"));
|
||||
|
||||
return (
|
||||
<aside className="flex w-[30vw] max-w-[30vw] shrink-0 flex-col border-r border-border bg-sidebar">
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
// frontend/src/shell/NavSessions.tsx
|
||||
import type { ReactNode } from "react";
|
||||
import type { SessionSummary } from "../api/types";
|
||||
import { Checkbox } from "../components/ui/checkbox";
|
||||
|
||||
interface Props {
|
||||
sessions: SessionSummary[];
|
||||
activeSessionId: string | null;
|
||||
onOpenPanel: (id: string) => void;
|
||||
menuFor: (session: SessionSummary) => ReactNode;
|
||||
selectedIds?: ReadonlySet<string>;
|
||||
onSelectionChange?: (id: string, selected: boolean) => void;
|
||||
}
|
||||
|
||||
export function NavSessions({ sessions, activeSessionId, onOpenPanel, menuFor }: Props) {
|
||||
function statusIndicator(status: string) {
|
||||
if (status === "finalized") return { color: "bg-[oklch(var(--success))]", label: "Completed" };
|
||||
if (status === "closed") return { color: "bg-[oklch(var(--warning))]", label: "Stopped by user" };
|
||||
if (status === "failed") return { color: "bg-destructive", label: "Stopped by system error" };
|
||||
return { color: "bg-muted-foreground/50", label: "In progress" };
|
||||
}
|
||||
|
||||
export function NavSessions({
|
||||
sessions, activeSessionId, onOpenPanel, menuFor,
|
||||
selectedIds = new Set<string>(), onSelectionChange = () => undefined,
|
||||
}: Props) {
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<p className="px-1 py-6 text-center text-xs text-muted-foreground">
|
||||
@@ -22,6 +35,7 @@ export function NavSessions({ sessions, activeSessionId, onOpenPanel, menuFor }:
|
||||
{sessions.map((s) => {
|
||||
const active = s.id === activeSessionId;
|
||||
const label = s.name || s.question || s.id;
|
||||
const indicator = statusIndicator(s.status);
|
||||
return (
|
||||
<li key={s.id}>
|
||||
<div
|
||||
@@ -42,22 +56,19 @@ export function NavSessions({ sessions, activeSessionId, onOpenPanel, menuFor }:
|
||||
"group flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1 text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/40",
|
||||
// The in-progress (active) session is marked in yellow — both its dot
|
||||
// and its row background — so it stands out from the rest of the rail.
|
||||
active ? "bg-[oklch(var(--warning)/0.22)]" : "hover:bg-accent",
|
||||
active ? "bg-accent" : "hover:bg-accent",
|
||||
].join(" ")}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(s.id)}
|
||||
aria-label={`Select ${label}`}
|
||||
onCheckedChange={(selected) => onSelectionChange(s.id, selected === true)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
<span
|
||||
aria-hidden
|
||||
title={s.status}
|
||||
className={[
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
active
|
||||
? "bg-[oklch(var(--warning))]"
|
||||
: s.status === "open"
|
||||
? "bg-[oklch(var(--success))]"
|
||||
: s.status === "finalized"
|
||||
? "bg-primary"
|
||||
: "bg-muted-foreground/50",
|
||||
].join(" ")}
|
||||
title={indicator.label}
|
||||
className={["size-1.5 shrink-0 rounded-full", indicator.color].join(" ")}
|
||||
/>
|
||||
<span
|
||||
className={[
|
||||
|
||||
@@ -15,13 +15,24 @@ interface Props {
|
||||
sessionId: string | null;
|
||||
/** Called with the new session id when the box starts a fresh session. */
|
||||
onSessionCreated?: (id: string) => void;
|
||||
/** Paint the provisional session view before the create request completes. */
|
||||
onSessionCreating?: (question: string) => void;
|
||||
/** Revert the provisional view when session creation fails. */
|
||||
onSessionCreateFailed?: () => void;
|
||||
/** Interrupt the running session, persisting its state. */
|
||||
onStop?: () => void;
|
||||
/** Lets the parent focus the composer (e.g. on "New session"). */
|
||||
inputRef?: React.Ref<HTMLTextAreaElement>;
|
||||
}
|
||||
|
||||
export function SteerInput({ sessionId, onSessionCreated, onStop, inputRef }: Props) {
|
||||
export function SteerInput({
|
||||
sessionId,
|
||||
onSessionCreated,
|
||||
onSessionCreating,
|
||||
onSessionCreateFailed,
|
||||
onStop,
|
||||
inputRef,
|
||||
}: Props) {
|
||||
const [text, setText] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const setLastUserEntry = useSessionStore((s) => s.setLastUserEntry);
|
||||
@@ -48,16 +59,29 @@ export function SteerInput({ sessionId, onSessionCreated, onStop, inputRef }: Pr
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (sessionId) {
|
||||
if (sessionId) {
|
||||
try {
|
||||
await postSteer(sessionId, trimmed);
|
||||
} else {
|
||||
const { id } = await createSession({ question: trimmed });
|
||||
setPhase("F1"); // optimistic: paint F1 yellow during the cold start, before the first gate
|
||||
onSessionCreated?.(id);
|
||||
setLastUserEntry({ kind: "input", text: trimmed });
|
||||
setText("");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
setLastUserEntry({ kind: "input", text: trimmed });
|
||||
return;
|
||||
}
|
||||
|
||||
// Session creation can include cold-start work. Hand the UI state to the
|
||||
// provisional session view before awaiting it so feedback is immediate.
|
||||
setPhase("F1");
|
||||
setLastUserEntry({ kind: "input", text: trimmed });
|
||||
onSessionCreating?.(trimmed);
|
||||
try {
|
||||
const { id } = await createSession({ question: trimmed });
|
||||
onSessionCreated?.(id);
|
||||
setText("");
|
||||
} catch {
|
||||
// Keep the question in the composer so retrying does not require retyping.
|
||||
onSessionCreateFailed?.();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -92,7 +116,8 @@ export function SteerInput({ sessionId, onSessionCreated, onStop, inputRef }: Pr
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={sessionId ? "Free steering (!)…" : "Ask a question to start a new session…"}
|
||||
aria-label={sessionId ? "Steering" : "New question"}
|
||||
className="max-h-40 flex-1 resize-none bg-transparent px-1 py-1 text-sm leading-relaxed outline-none placeholder:text-muted-foreground"
|
||||
data-awaiting-input={sessionId ? undefined : "true"}
|
||||
className={["max-h-40 flex-1 resize-none rounded-lg px-1 py-1 text-sm leading-relaxed outline-none placeholder:text-muted-foreground", sessionId ? "bg-transparent" : "thot-awaiting-input"].join(" ")}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user