feat(frontend): add database management surface
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { http, HttpResponse } 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 { clearAuthState, setAuthState } from "../auth/authState";
|
||||
import { AppShell } from "./AppShell";
|
||||
|
||||
function renderShell() {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<AppShell canLogout={false} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearAuthState();
|
||||
setAuthState({
|
||||
issuer: "test",
|
||||
subject: "test",
|
||||
roles: ["user"],
|
||||
permissions: ["session.use", "workspace.manage"],
|
||||
isAdmin: false,
|
||||
csrfToken: null,
|
||||
session: null,
|
||||
});
|
||||
localStorage.clear();
|
||||
FakeEventSource.instances = [];
|
||||
(globalThis as any).EventSource = FakeEventSource;
|
||||
useSessionStore.getState().resetSession();
|
||||
server.use(
|
||||
http.get("/api/me", () => HttpResponse.json({
|
||||
issuer: "test",
|
||||
subject: "test",
|
||||
displayName: "Test",
|
||||
isAdmin: false,
|
||||
})),
|
||||
http.get("/api/sessions", () => HttpResponse.json([])),
|
||||
http.get("/api/settings", () => HttpResponse.json({
|
||||
workspace: "default",
|
||||
provider: "test",
|
||||
model: "test",
|
||||
thinking: "low",
|
||||
})),
|
||||
http.get("/api/workspaces", () => HttpResponse.json([])),
|
||||
http.get("/api/models", () => HttpResponse.json({ models: [] })),
|
||||
http.post("/api/runtime/prewarm", () => new HttpResponse(null, { status: 202 })),
|
||||
);
|
||||
});
|
||||
|
||||
test("opens the blank database management surface and returns to the core", async () => {
|
||||
renderShell();
|
||||
|
||||
const composer = screen.getByRole("textbox", { name: /new question/i });
|
||||
await userEvent.type(composer, "Unsent draft");
|
||||
|
||||
const databaseManagement = screen.getByRole("button", { name: "Database management" });
|
||||
await userEvent.click(databaseManagement);
|
||||
|
||||
expect(screen.getByRole("main", { name: "Database management" })).toBeVisible();
|
||||
expect(databaseManagement).toHaveAttribute("aria-current", "page");
|
||||
expect(databaseManagement).toHaveClass("bg-sidebar-accent", "font-bold");
|
||||
expect(screen.queryByRole("textbox", { name: /new question/i })).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "New session" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("main", { name: "Database management" })).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole("textbox", { name: /new question/i })).toHaveValue("Unsent draft");
|
||||
});
|
||||
|
||||
test("keeps a live core session connected and returns when that session is opened", async () => {
|
||||
server.use(
|
||||
http.get("/api/sessions", () => HttpResponse.json([{
|
||||
id: "s1",
|
||||
status: "open",
|
||||
question: "Active question",
|
||||
summary: null,
|
||||
created_at: "2026-01-02T00:00:00Z",
|
||||
updated_at: null,
|
||||
author: "test",
|
||||
name: null,
|
||||
group: null,
|
||||
archived: false,
|
||||
active: true,
|
||||
}])),
|
||||
http.post("/api/sessions/:id/resume", ({ params }) => HttpResponse.json({
|
||||
id: params.id,
|
||||
alreadyActive: true,
|
||||
})),
|
||||
http.get("/api/sessions/:id", ({ params }) => HttpResponse.json({
|
||||
id: params.id,
|
||||
status: "open",
|
||||
phase: 1,
|
||||
})),
|
||||
);
|
||||
renderShell();
|
||||
|
||||
const session = await screen.findByTestId("session-item-s1");
|
||||
await userEvent.click(session);
|
||||
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
||||
const source = FakeEventSource.instances[0];
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Database management" }));
|
||||
|
||||
expect(screen.getByRole("main", { name: "Database management" })).toBeVisible();
|
||||
expect(source.closed).toBe(false);
|
||||
expect(FakeEventSource.instances).toHaveLength(1);
|
||||
|
||||
await userEvent.click(session);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("main", { name: "Database management" })).not.toBeInTheDocument();
|
||||
});
|
||||
expect(source.closed).toBe(false);
|
||||
expect(FakeEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("hides database management from users without workspace management permission", () => {
|
||||
clearAuthState();
|
||||
setAuthState({
|
||||
issuer: "test",
|
||||
subject: "test",
|
||||
roles: ["user"],
|
||||
permissions: ["session.use"],
|
||||
isAdmin: false,
|
||||
csrfToken: null,
|
||||
session: null,
|
||||
});
|
||||
|
||||
renderShell();
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Database management" })).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
|
||||
import { StopConfirmDialog } from "./StopConfirmDialog";
|
||||
import { SteerInput, ComposerFooter } from "./SteerInput";
|
||||
import { WorkflowBar } from "./WorkflowBar";
|
||||
import { DatabaseManagementPage } from "./DatabaseManagementPage";
|
||||
import { Pencil, ArrowLeft, ArrowRight, Trash2 } from "lucide-react";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Checkbox } from "../components/ui/checkbox";
|
||||
@@ -39,6 +40,8 @@ interface AppShellProps {
|
||||
canLogout: boolean;
|
||||
}
|
||||
|
||||
type ActiveSurface = "core" | "database-management";
|
||||
|
||||
export function AppShell({ canLogout }: AppShellProps) {
|
||||
const authenticatedUser = useAuthUser();
|
||||
const [panelSession, setPanelSession] = useState<SessionSummary | null>(null);
|
||||
@@ -118,6 +121,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [showActivity, setShowActivity] = useState(false);
|
||||
const [activeSurface, setActiveSurface] = useState<ActiveSurface>("core");
|
||||
const [workspaceManagerOpen, setWorkspaceManagerOpen] = useState(false);
|
||||
const [piManagementOpen, setPiManagementOpen] = useState(false);
|
||||
const [activeOpen, setActiveOpen] = useState(true);
|
||||
@@ -195,6 +199,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
function openPanel(id: string) {
|
||||
const s = sessions.find((x) => x.id === id);
|
||||
if (!s) return;
|
||||
setActiveSurface("core");
|
||||
// A session with a live Pi runtime opens straight into its live view: doResume
|
||||
// reconnects to the already-active runtime and replays its pending gate, so an
|
||||
// in-progress session never shows an empty screen that reads as "stopped". Cold or
|
||||
@@ -214,6 +219,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
});
|
||||
}
|
||||
async function doResume(id: string) {
|
||||
setActiveSurface("core");
|
||||
const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current });
|
||||
if (!guard) return;
|
||||
const token = ++resumeInvocationRef.current;
|
||||
@@ -495,6 +501,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
}, [lastSystemEvent]);
|
||||
|
||||
function startNewSession() {
|
||||
setActiveSurface("core");
|
||||
invalidateResumeIntent();
|
||||
newSessionOperationRef.current = null;
|
||||
resetSession();
|
||||
@@ -580,9 +587,10 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
onClose={() => setPanelSession(null)}
|
||||
onResume={doResume}
|
||||
desktopSplit={sessionDesktopSplit}
|
||||
hidden={activeSurface !== "core"}
|
||||
/>
|
||||
)}
|
||||
{panelSession && sessionDesktopSplit && (
|
||||
{activeSurface === "core" && panelSession && sessionDesktopSplit && (
|
||||
<div className="relative w-0 shrink-0">
|
||||
<div
|
||||
{...sessionSeparatorProps}
|
||||
@@ -594,8 +602,8 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showActivity && <ModelActivityPanel desktopSplit={desktopSplit} onClose={() => setShowActivity(false)} onOpenWorkspaceManager={() => setWorkspaceManagerOpen(true)} />}
|
||||
{showActivity && desktopSplit && (
|
||||
{showActivity && <ModelActivityPanel desktopSplit={desktopSplit} onClose={() => setShowActivity(false)} onOpenWorkspaceManager={() => setWorkspaceManagerOpen(true)} hidden={activeSurface !== "core"} />}
|
||||
{activeSurface === "core" && showActivity && desktopSplit && (
|
||||
<div className="relative w-0 shrink-0">
|
||||
<div
|
||||
{...activitySeparatorProps}
|
||||
@@ -610,6 +618,11 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
|
||||
{/* Conversation column */}
|
||||
<div data-testid="conversation-column" className="flex min-w-0 flex-1 flex-col">
|
||||
{activeSurface === "database-management" && <DatabaseManagementPage />}
|
||||
<div
|
||||
aria-hidden={activeSurface !== "core"}
|
||||
className={activeSurface === "core" ? "contents" : "hidden"}
|
||||
>
|
||||
{activeSessionId && (
|
||||
<div className="relative shrink-0 border-b border-border/70 bg-background/80 px-6 py-2.5 backdrop-blur">
|
||||
{/* Arrow toggle for the left Model-activity panel: → opens it, ← closes it. */}
|
||||
@@ -686,6 +699,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right session rail */}
|
||||
@@ -731,6 +745,22 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
>
|
||||
Workspace management
|
||||
</Button>
|
||||
{canManageWorkspace && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={[
|
||||
"w-full justify-start hover:bg-sidebar-accent",
|
||||
activeSurface === "database-management"
|
||||
? "bg-sidebar-accent font-bold text-sidebar-accent-foreground"
|
||||
: "",
|
||||
].join(" ")}
|
||||
aria-current={activeSurface === "database-management" ? "page" : undefined}
|
||||
onClick={() => setActiveSurface("database-management")}
|
||||
>
|
||||
Database management
|
||||
</Button>
|
||||
)}
|
||||
{canManagePi && (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export function DatabaseManagementPage() {
|
||||
return (
|
||||
<main
|
||||
aria-label="Database management"
|
||||
className="flex-1 overflow-y-auto bg-background"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -62,10 +62,12 @@ export function ModelActivityPanel({
|
||||
desktopSplit = false,
|
||||
onClose,
|
||||
onOpenWorkspaceManager,
|
||||
hidden = false,
|
||||
}: {
|
||||
desktopSplit?: boolean;
|
||||
onClose: () => void;
|
||||
onOpenWorkspaceManager?: () => void;
|
||||
hidden?: boolean;
|
||||
}) {
|
||||
const activityLog = useSessionStore((s) => s.activityLog);
|
||||
const visibleActivity = activityLog.filter(isVisibleModelActivity);
|
||||
@@ -80,7 +82,7 @@ export function ModelActivityPanel({
|
||||
}, [visibleCount, visibleTail]);
|
||||
|
||||
return (
|
||||
<aside className={desktopSplit
|
||||
<aside aria-hidden={hidden || undefined} hidden={hidden} className={desktopSplit
|
||||
? "static z-auto flex min-w-0 w-[var(--activity-panel-width)] shrink-0 flex-col border-r-0 bg-sidebar"
|
||||
: "fixed inset-y-0 left-0 z-30 flex min-w-0 w-[min(90vw,24rem)] shrink-0 flex-col border-r border-border bg-sidebar"
|
||||
}>
|
||||
|
||||
@@ -13,6 +13,7 @@ interface Props {
|
||||
onClose: () => void;
|
||||
onResume: (id: string) => void;
|
||||
desktopSplit?: boolean;
|
||||
hidden?: boolean;
|
||||
}
|
||||
|
||||
function statusLabel(s: SessionSummary): string {
|
||||
@@ -150,7 +151,7 @@ function decisionChipClass(type: string): string {
|
||||
return "bg-[oklch(var(--primary)/0.1)] text-primary";
|
||||
}
|
||||
|
||||
export function SessionDocumentsPanel({ session, onClose, onResume, desktopSplit = false }: Props) {
|
||||
export function SessionDocumentsPanel({ session, onClose, onResume, desktopSplit = false, hidden = false }: Props) {
|
||||
const { data: docs = [], isLoading } = useQuery<SessionDocument[]>({
|
||||
queryKey: ["session-documents", session.id],
|
||||
queryFn: () => getSessionDocuments(session.id),
|
||||
@@ -160,6 +161,8 @@ export function SessionDocumentsPanel({ session, onClose, onResume, desktopSplit
|
||||
return (
|
||||
<aside
|
||||
aria-label="Session summary"
|
||||
aria-hidden={hidden || undefined}
|
||||
hidden={hidden}
|
||||
className={[
|
||||
"flex shrink-0 flex-col border-r border-border bg-sidebar",
|
||||
desktopSplit
|
||||
|
||||
Reference in New Issue
Block a user