feat(frontend): AppShell session management — grouping, archive view, docs panel, mutations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 13:13:22 +02:00
co-authored by Claude Sonnet 4.6
parent 910928471a
commit f8be6613a5
2 changed files with 168 additions and 9 deletions
+123 -9
View File
@@ -3,14 +3,21 @@ import { useSessionStore } from "../store/sessionStore";
import { WidgetHost } from "./WidgetHost";
import { Transcript } from "./Transcript";
import { NavSessions } from "./NavSessions";
import { SessionMenu } from "./SessionMenu";
import { SessionDocumentsPanel } from "./SessionDocumentsPanel";
import { RenameDialog } from "./RenameDialog";
import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
import { SteerInput, ComposerFooter } from "./SteerInput";
import { WorkflowBar } from "./WorkflowBar";
import { Button } from "../components/ui/button";
import { Toaster } from "../components/ui/sonner";
import { closeSession, listSessions, resumeSession } from "../api/sessions";
import {
closeSession, listSessions, resumeSession,
renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession,
} from "../api/sessions";
import type { SessionSummary } from "../api/types";
import { useQuery } from "@tanstack/react-query";
import { useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useRef, useState } from "react";
/**
* The page is designed to live INSIDE the Omics Portal chrome (its left sidebar
@@ -25,6 +32,55 @@ export function AppShell() {
});
const composerRef = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient();
const [panelSession, setPanelSession] = useState<SessionSummary | null>(null);
const [showArchive, setShowArchive] = useState(false);
const [renameTarget, setRenameTarget] = useState<SessionSummary | null>(null);
const [deleteTarget, setDeleteTarget] = useState<SessionSummary | null>(null);
const groups = useMemo(
() => [...new Set(sessions.map((s) => s.group).filter((g): g is string => !!g))].sort(),
[sessions],
);
const activeList = sessions.filter((s) => !s.archived);
const archivedList = sessions.filter((s) => s.archived);
const refresh = () => queryClient.invalidateQueries({ queryKey: ["sessions"] });
function openPanel(id: string) {
const s = sessions.find((x) => x.id === id);
if (s) setPanelSession(s);
}
async function doResume(id: string) {
await resumeSession(id);
setPanelSession(null);
setActiveSessionId(id);
}
async function move(s: SessionSummary, group: string) { await setSessionGroup(s.id, group); refresh(); }
async function newGroup(s: SessionSummary) {
const name = window.prompt("New group:");
if (name && name.trim()) { await setSessionGroup(s.id, name.trim()); refresh(); }
}
async function toggleArchive(s: SessionSummary) {
await (s.archived ? unarchiveSession(s.id) : archiveSession(s.id));
if (panelSession?.id === s.id) setPanelSession(null);
refresh();
}
function menuFor(s: SessionSummary) {
return (
<SessionMenu
session={s}
groups={groups}
onView={() => setPanelSession(s)}
onRename={() => setRenameTarget(s)}
onMove={(g) => move(s, g)}
onNewGroup={() => newGroup(s)}
onArchiveToggle={() => toggleArchive(s)}
onDelete={() => setDeleteTarget(s)}
/>
);
}
// The harness "holds the ball" whenever a session is live and no widget is
// waiting on the human; once a widget appears, input is back in the user's court.
const pendingWidget = useSessionStore((s) => s.pendingWidget);
@@ -51,6 +107,14 @@ export function AppShell() {
return (
<div className="flex h-screen bg-background text-foreground">
{panelSession && (
<SessionDocumentsPanel
session={panelSession}
onClose={() => setPanelSession(null)}
onResume={doResume}
/>
)}
{/* Conversation column */}
<div className="flex min-w-0 flex-1 flex-col">
{activeSessionId && (
@@ -125,16 +189,66 @@ export function AppShell() {
</span>
</div>
<div className="flex-1 overflow-y-auto px-2 pb-4">
<NavSessions
sessions={sessions}
activeSessionId={activeSessionId}
onOpenPanel={async (id) => { await resumeSession(id); setActiveSessionId(id); }}
menuFor={() => null}
/>
{showArchive ? (
<NavSessions sessions={archivedList} activeSessionId={activeSessionId} onOpenPanel={openPanel} menuFor={menuFor} />
) : (
<div className="flex flex-col gap-3">
{groups.map((g) => (
<div key={g}>
<p className="px-1 pb-1 text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground">{g}</p>
<NavSessions
sessions={activeList.filter((s) => s.group === g)}
activeSessionId={activeSessionId}
onOpenPanel={openPanel}
menuFor={menuFor}
/>
</div>
))}
<div>
{groups.length > 0 && (
<p className="px-1 pb-1 text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground">No group</p>
)}
<NavSessions
sessions={activeList.filter((s) => !s.group)}
activeSessionId={activeSessionId}
onOpenPanel={openPanel}
menuFor={menuFor}
/>
</div>
</div>
)}
<button
onClick={() => setShowArchive((v) => !v)}
className="mt-3 w-full rounded-md px-2 py-1.5 text-left text-[0.7rem] font-semibold uppercase tracking-wide text-muted-foreground hover:bg-accent"
>
{showArchive ? "← Active sessions" : `Archive (${archivedList.length})`}
</button>
</div>
</aside>
<Toaster />
{renameTarget && (
<RenameDialog
open
initial={renameTarget.name ?? ""}
onOpenChange={(o) => { if (!o) setRenameTarget(null); }}
onSubmit={async (name) => { await renameSession(renameTarget.id, name); setRenameTarget(null); refresh(); }}
/>
)}
{deleteTarget && (
<DeleteConfirmDialog
open
label={deleteTarget.name || deleteTarget.question}
onOpenChange={(o) => { if (!o) setDeleteTarget(null); }}
onConfirm={async () => {
await deleteSession(deleteTarget.id);
if (panelSession?.id === deleteTarget.id) setPanelSession(null);
if (activeSessionId === deleteTarget.id) { resetSession(); setActiveSessionId(null); }
setDeleteTarget(null); refresh();
}}
/>
)}
</div>
);
}