feat(shell): 30/55/15 layout, formatted activity log, instant resume, stop confirmation
Left panels 30vw / central 55% / right rail 15vw, minimal padding. Model-activity log and documents render via .thot-prose; decisions become hairline rows with semantic type chips. Resume switches the view optimistically (instant feedback). The Stop button now asks for confirmation before interrupting. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import { SessionMenu } from "./SessionMenu";
|
|||||||
import { SessionDocumentsPanel } from "./SessionDocumentsPanel";
|
import { SessionDocumentsPanel } from "./SessionDocumentsPanel";
|
||||||
import { RenameDialog } from "./RenameDialog";
|
import { RenameDialog } from "./RenameDialog";
|
||||||
import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
|
import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
|
||||||
|
import { StopConfirmDialog } from "./StopConfirmDialog";
|
||||||
import { SteerInput, ComposerFooter } from "./SteerInput";
|
import { SteerInput, ComposerFooter } from "./SteerInput";
|
||||||
import { WorkflowBar } from "./WorkflowBar";
|
import { WorkflowBar } from "./WorkflowBar";
|
||||||
import { Pencil } from "lucide-react";
|
import { Pencil } from "lucide-react";
|
||||||
@@ -42,6 +43,7 @@ export function AppShell() {
|
|||||||
const [archiveOpen, setArchiveOpen] = useState(false);
|
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||||
const [renameTarget, setRenameTarget] = useState<SessionSummary | null>(null);
|
const [renameTarget, setRenameTarget] = useState<SessionSummary | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<SessionSummary | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<SessionSummary | null>(null);
|
||||||
|
const [stopConfirm, setStopConfirm] = useState(false);
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||||
const [renameGroupTarget, setRenameGroupTarget] = useState<string | null>(null);
|
const [renameGroupTarget, setRenameGroupTarget] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -68,12 +70,16 @@ export function AppShell() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function doResume(id: string) {
|
async function doResume(id: string) {
|
||||||
|
const s = sessions.find((x) => x.id === id) ?? null;
|
||||||
|
// Optimistic switch: change to the session view IMMEDIATELY so the click feels
|
||||||
|
// instant (the resume POST spawns a Pi process and can take seconds). The
|
||||||
|
// working spinner shows straight away; the backend calls run after.
|
||||||
|
setPanelSession(null);
|
||||||
|
resetSession();
|
||||||
|
setActiveSessionId(id);
|
||||||
try {
|
try {
|
||||||
await resumeSession(id);
|
await resumeSession(id);
|
||||||
setPanelSession(null);
|
// Optimistic phase paint: colour the re-entry phase before the first gate.
|
||||||
setActiveSessionId(id);
|
|
||||||
// Optimistic phase paint on resume: colour the re-entry phase immediately,
|
|
||||||
// before the first gate arrives (mirrors the F1 paint for new questions).
|
|
||||||
// The manifest's `phase` is the 1-based current phase (1..8).
|
// The manifest's `phase` is the 1-based current phase (1..8).
|
||||||
try {
|
try {
|
||||||
const m = (await getSession(id)) as { phase?: number };
|
const m = (await getSession(id)) as { phase?: number };
|
||||||
@@ -84,6 +90,10 @@ export function AppShell() {
|
|||||||
/* non-fatal: the first gate will set the phase */
|
/* non-fatal: the first gate will set the phase */
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
// Revert the optimistic switch and restore the panel.
|
||||||
|
resetSession();
|
||||||
|
setActiveSessionId(null);
|
||||||
|
if (s) setPanelSession(s);
|
||||||
toast.error("Failed to resume session.");
|
toast.error("Failed to resume session.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,7 +226,7 @@ export function AppShell() {
|
|||||||
<SteerInput
|
<SteerInput
|
||||||
sessionId={activeSessionId}
|
sessionId={activeSessionId}
|
||||||
onSessionCreated={setActiveSessionId}
|
onSessionCreated={setActiveSessionId}
|
||||||
onStop={stopSession}
|
onStop={() => setStopConfirm(true)}
|
||||||
inputRef={composerRef}
|
inputRef={composerRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -227,7 +237,7 @@ export function AppShell() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right session rail — symmetric to the portal's left sidebar */}
|
{/* Right session rail — symmetric to the portal's left sidebar */}
|
||||||
<aside className="flex w-[280px] shrink-0 flex-col border-l border-border bg-sidebar">
|
<aside className="flex w-[15vw] shrink-0 flex-col border-l border-border bg-sidebar">
|
||||||
<div className="relative px-4 pb-3 pt-5 text-center">
|
<div className="relative px-4 pb-3 pt-5 text-center">
|
||||||
<h1 className="font-heading text-xl font-semibold leading-none tracking-tight text-foreground">
|
<h1 className="font-heading text-xl font-semibold leading-none tracking-tight text-foreground">
|
||||||
Thoth<span className="text-primary">II</span>
|
Thoth<span className="text-primary">II</span>
|
||||||
@@ -333,6 +343,12 @@ export function AppShell() {
|
|||||||
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|
||||||
|
<StopConfirmDialog
|
||||||
|
open={stopConfirm}
|
||||||
|
onOpenChange={setStopConfirm}
|
||||||
|
onConfirm={stopSession}
|
||||||
|
/>
|
||||||
|
|
||||||
{renameTarget && (
|
{renameTarget && (
|
||||||
<RenameDialog
|
<RenameDialog
|
||||||
open
|
open
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export function ModelActivityPanel({ onClose }: { onClose: () => void }) {
|
|||||||
const source = shown.join("\n\n");
|
const source = shown.join("\n\n");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex w-[380px] shrink-0 flex-col border-r border-border bg-sidebar">
|
<aside className="flex w-[30vw] max-w-[30vw] shrink-0 flex-col border-r border-border bg-sidebar">
|
||||||
<div className="flex items-center justify-between border-b border-border/70 px-4 py-3">
|
<div className="flex items-center justify-between border-b border-border/70 px-3 py-3">
|
||||||
<h2 className="font-heading text-sm font-semibold text-foreground">Model activity</h2>
|
<h2 className="font-heading text-sm font-semibold text-foreground">Model activity</h2>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{canExpand && (
|
{canExpand && (
|
||||||
@@ -50,11 +50,11 @@ export function ModelActivityPanel({ onClose }: { onClose: () => void }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
<div className="flex-1 overflow-y-auto px-3 py-5">
|
||||||
{shown.length === 0 ? (
|
{shown.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No activity yet.</p>
|
<p className="text-sm text-muted-foreground">No activity yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none text-foreground/90">
|
<div className="thot-prose max-w-[74ch]">
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{source}</ReactMarkdown>
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{source}</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -33,8 +33,9 @@ beforeEach(() => {
|
|||||||
|
|
||||||
test("renders the document titles from the API", async () => {
|
test("renders the document titles from the API", async () => {
|
||||||
wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
|
wrap(<SessionDocumentsPanel session={base} onClose={vi.fn()} onResume={vi.fn()} />);
|
||||||
expect(await screen.findByText("Original question")).toBeInTheDocument();
|
expect(await screen.findByRole("heading", { name: "Original question" })).toBeInTheDocument();
|
||||||
expect(screen.getByText("Final SQL")).toBeInTheDocument();
|
// "Final SQL" is both the section heading and the SqlViewer block label — target the heading.
|
||||||
|
expect(screen.getByRole("heading", { name: "Final SQL" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shows Resume for resumable, hides it for finalized", async () => {
|
test("shows Resume for resumable, hides it for finalized", async () => {
|
||||||
|
|||||||
@@ -31,21 +31,41 @@ function DocBody({ doc }: { doc: SessionDocument }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (doc.format === "decisions") {
|
if (doc.format === "decisions") {
|
||||||
const lines = doc.content.split("\n").filter(Boolean);
|
const items = doc.content
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => {
|
||||||
|
try { return JSON.parse(line) as { type?: string; subject?: string; detail?: string }; }
|
||||||
|
catch { return null; }
|
||||||
|
})
|
||||||
|
.filter((d): d is { type?: string; subject?: string; detail?: string } => d !== null);
|
||||||
|
if (items.length === 0) return <p className="text-sm text-muted-foreground">No decisions recorded.</p>;
|
||||||
return (
|
return (
|
||||||
<details>
|
<ul className="flex flex-col divide-y divide-border/50">
|
||||||
<summary className="cursor-pointer text-sm text-muted-foreground">{`${lines.length} decisions`}</summary>
|
{items.map((d, i) => (
|
||||||
<ul className="mt-2 flex flex-col gap-1 text-xs">
|
<li key={i} className="py-3 first:pt-0">
|
||||||
{lines.map((line, i) => {
|
<span className={`inline-block rounded-full px-2 py-0.5 text-[0.62rem] font-semibold uppercase tracking-wide ${decisionChipClass(d.type ?? "")}`}>
|
||||||
let d: { type?: string; subject?: string; detail?: string };
|
{(d.type ?? "decision").replace(/_/g, " ")}
|
||||||
try { d = JSON.parse(line); } catch { d = {}; }
|
</span>
|
||||||
return <li key={i} className="font-mono">[{d.type}] {d.subject}{d.detail ? ` — ${d.detail}` : ""}</li>;
|
{d.subject && <p className="mt-1.5 text-sm font-medium text-foreground">{d.subject}</p>}
|
||||||
})}
|
{d.detail && <p className="mt-0.5 text-sm leading-relaxed text-muted-foreground">{d.detail}</p>}
|
||||||
</ul>
|
</li>
|
||||||
</details>
|
))}
|
||||||
|
</ul>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return <p className="whitespace-pre-wrap text-sm">{doc.content}</p>;
|
return <p className="whitespace-pre-wrap text-sm leading-relaxed text-foreground/90">{doc.content}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Semantic tint for a decision-type chip: approvals/promotions read as progress
|
||||||
|
* (green), exclusions as neutral, everything else carries the brand accent. */
|
||||||
|
function decisionChipClass(type: string): string {
|
||||||
|
const t = type.toLowerCase();
|
||||||
|
if (t.includes("approved") || t.includes("promoted"))
|
||||||
|
return "bg-[oklch(var(--success)/0.15)] text-[oklch(0.47_0.12_165)]";
|
||||||
|
if (t.includes("excluded") || t.includes("rejected") || t.includes("reopened"))
|
||||||
|
return "bg-muted text-muted-foreground";
|
||||||
|
return "bg-[oklch(var(--primary)/0.1)] text-primary";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SessionDocumentsPanel({ session, onClose, onResume }: Props) {
|
export function SessionDocumentsPanel({ session, onClose, onResume }: Props) {
|
||||||
@@ -56,8 +76,8 @@ export function SessionDocumentsPanel({ session, onClose, onResume }: Props) {
|
|||||||
const resumable = session.status !== "finalized" && !session.archived;
|
const resumable = session.status !== "finalized" && !session.archived;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="flex w-[380px] shrink-0 flex-col border-r border-border bg-sidebar">
|
<aside className="flex w-[30vw] max-w-[30vw] shrink-0 flex-col border-r border-border bg-sidebar">
|
||||||
<div className="flex items-start justify-between gap-2 border-b border-border/70 px-4 py-3">
|
<div className="flex items-start justify-between gap-2 border-b border-border/70 px-3 py-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="truncate font-heading text-sm font-semibold text-foreground">
|
<h2 className="truncate font-heading text-sm font-semibold text-foreground">
|
||||||
{session.name || session.question}
|
{session.name || session.question}
|
||||||
@@ -73,16 +93,23 @@ export function SessionDocumentsPanel({ session, onClose, onResume }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
<div className="flex-1 overflow-y-auto px-3 py-5">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<p className="text-xs text-muted-foreground">Loading…</p>
|
<p className="text-xs text-muted-foreground">Loading…</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex max-w-[76ch] flex-col gap-7">
|
||||||
{docs.map((doc) => (
|
{docs.map((doc, idx) => (
|
||||||
<section key={doc.key}>
|
<section key={doc.key} className={idx > 0 ? "border-t border-border/50 pt-7" : ""}>
|
||||||
<h3 className="mb-2 text-[0.65rem] font-bold uppercase tracking-wide text-primary">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
{doc.title}{doc.phase !== "—" ? ` · ${doc.phase}` : ""}
|
<h3 className="text-[0.7rem] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
</h3>
|
{doc.title}
|
||||||
|
</h3>
|
||||||
|
{doc.phase !== "—" && (
|
||||||
|
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[0.6rem] font-medium text-muted-foreground">
|
||||||
|
{doc.phase}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<DocBody doc={doc} />
|
<DocBody doc={doc} />
|
||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "../components/ui/dialog";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StopConfirmDialog({ open, onOpenChange, onConfirm }: Props) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>Stop the session?</DialogTitle></DialogHeader>
|
||||||
|
<DialogDescription>
|
||||||
|
The running process will be interrupted. Progress so far is saved, and you can resume the session later.
|
||||||
|
</DialogDescription>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => onOpenChange(false)}>Cancel</Button>
|
||||||
|
<Button type="button" variant="destructive" size="sm" onClick={() => { onConfirm(); onOpenChange(false); }}>Stop & save</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user