feat(frontend): expose owned session scopes
This commit is contained in:
@@ -0,0 +1,51 @@
|
|||||||
|
# Task 6 — Frontend identity and administrator UX report
|
||||||
|
|
||||||
|
## RED
|
||||||
|
|
||||||
|
- Added API tests for the `/me` principal call and `mine`/`all` session-list scopes.
|
||||||
|
- Added component tests for regular-user scope, admin scope switching, owner labels,
|
||||||
|
administrator banner, foreign-owner delete confirmation, and foreign-owner archive
|
||||||
|
confirmation.
|
||||||
|
- Initial focused run: 7 expected failures (missing `getMe`, missing scope query,
|
||||||
|
missing owner label/admin controls, and missing foreign-action confirmation).
|
||||||
|
- The archive-confirmation regression was also run separately before its implementation
|
||||||
|
and failed because `window.confirm` was not called.
|
||||||
|
|
||||||
|
## GREEN
|
||||||
|
|
||||||
|
- `npx vitest run src/api/sessions.test.ts src/shell/NavSessions.test.tsx src/shell/AppShell.session-mgmt.test.tsx`
|
||||||
|
— passed (47 tests before the archive follow-up; the focused archive regression then passed).
|
||||||
|
- `npm test` — passed: 44 files / 305 tests.
|
||||||
|
- `npx tsc -b` — passed.
|
||||||
|
- `npm run build` — passed.
|
||||||
|
- `git diff --check` — passed.
|
||||||
|
- `npm run e2e` reached Playwright but could not run: the environment has no Chromium
|
||||||
|
executable at Playwright's configured cache path. No application test failure was reported.
|
||||||
|
|
||||||
|
## Files changed
|
||||||
|
|
||||||
|
- `frontend/src/api/types.ts`: typed principal and session scope contracts.
|
||||||
|
- `frontend/src/api/sessions.ts`: typed `/me` API call; scoped listing defaults to `mine`.
|
||||||
|
- `frontend/src/shell/AppShell.tsx`: identity query, admin-only session scope selector and
|
||||||
|
banner, owner-aware destructive action confirmations.
|
||||||
|
- `frontend/src/shell/NavSessions.tsx`: owner labels in the all-sessions view.
|
||||||
|
- `frontend/src/api/sessions.test.ts`, `frontend/src/shell/NavSessions.test.tsx`, and
|
||||||
|
`frontend/src/shell/AppShell.session-mgmt.test.tsx`: contract and UX coverage.
|
||||||
|
|
||||||
|
## Self-review
|
||||||
|
|
||||||
|
- Regular users remain fail-closed on `mine`; no administrator control renders without
|
||||||
|
`principal.isAdmin`.
|
||||||
|
- The all-sessions view includes owner labels (including `Unknown` for legacy records).
|
||||||
|
- Delete confirmation preserves the pre-existing select-all behavior and adds confirmation
|
||||||
|
for foreign/unknown owners. Foreign archive now also requires an explicit browser
|
||||||
|
confirmation; existing Stop & save already has its confirmation dialog.
|
||||||
|
- A read-only review found no critical, important, or minor issues. The archive guard was
|
||||||
|
added after that review in response to the requirement to cover every destructive rail
|
||||||
|
action, and has its own RED/GREEN regression plus the final full verification above.
|
||||||
|
|
||||||
|
## Concerns
|
||||||
|
|
||||||
|
- E2E remains environment-blocked until the Playwright Chromium browser is installed.
|
||||||
|
- Existing Vitest runs emit pre-existing MSW unmatched-request and dialog-ref warnings; all
|
||||||
|
assertions pass and this task does not modify those shared test/UI primitives.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { http, HttpResponse } from "msw";
|
import { http, HttpResponse } from "msw";
|
||||||
import { server } from "../test/msw";
|
import { server } from "../test/msw";
|
||||||
import { createSession, listSessions, prewarmRuntime, resumeSession } from "./sessions";
|
import { createSession, getMe, listSessions, prewarmRuntime, resumeSession } from "./sessions";
|
||||||
import {
|
import {
|
||||||
renameSession, setSessionGroup, archiveSession, unarchiveSession,
|
renameSession, setSessionGroup, archiveSession, unarchiveSession,
|
||||||
deleteSession, getSessionDocuments,
|
deleteSession, getSessionDocuments,
|
||||||
@@ -30,10 +30,34 @@ test.each([202, 204])("prewarmRuntime accepts a body-less %s response", async (s
|
|||||||
expect(called).toBe(true);
|
expect(called).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("listSessions GETs the array", async () => {
|
test("listSessions defaults to the current user's scope", async () => {
|
||||||
server.use(http.get("http://localhost:8787/sessions", () => HttpResponse.json([{ id: "s1", status: "open", question: "q", summary: null, created_at: "t", updated_at: null, author: null }])));
|
let scope: string | null = null;
|
||||||
|
server.use(http.get("http://localhost:8787/sessions", ({ request }) => {
|
||||||
|
scope = new URL(request.url).searchParams.get("scope");
|
||||||
|
return HttpResponse.json([{ id: "s1", status: "open", question: "q", summary: null, created_at: "t", updated_at: null, author: null }]);
|
||||||
|
}));
|
||||||
const rows = await listSessions();
|
const rows = await listSessions();
|
||||||
expect(rows[0].id).toBe("s1");
|
expect(rows[0].id).toBe("s1");
|
||||||
|
expect(scope).toBe("mine");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("listSessions requests the selected administrator scope", async () => {
|
||||||
|
let scope: string | null = null;
|
||||||
|
server.use(http.get("http://localhost:8787/sessions", ({ request }) => {
|
||||||
|
scope = new URL(request.url).searchParams.get("scope");
|
||||||
|
return HttpResponse.json([]);
|
||||||
|
}));
|
||||||
|
await listSessions("all");
|
||||||
|
expect(scope).toBe("all");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("getMe fetches the typed authenticated principal", async () => {
|
||||||
|
server.use(http.get("http://localhost:8787/me", () =>
|
||||||
|
HttpResponse.json({ issuer: "portal", subject: "42", displayName: "Alice", isAdmin: true }),
|
||||||
|
));
|
||||||
|
await expect(getMe()).resolves.toEqual({
|
||||||
|
issuer: "portal", subject: "42", displayName: "Alice", isAdmin: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resumeSession returns the typed runtime disposition", async () => {
|
test("resumeSession returns the typed runtime disposition", async () => {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { apiFetch } from "./client";
|
import { apiFetch } from "./client";
|
||||||
import type { ResumeSessionResult, SessionSummary, SessionDocument, UiResponse } from "./types";
|
import type {
|
||||||
|
Principal, ResumeSessionResult, SessionScope, SessionSummary, SessionDocument, UiResponse,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
export const createSession = (i: { question: string; name?: string }) =>
|
export const createSession = (i: { question: string; name?: string }) =>
|
||||||
apiFetch<{ id: string }>("/sessions", { method: "POST", body: JSON.stringify(i) });
|
apiFetch<{ id: string }>("/sessions", { method: "POST", body: JSON.stringify(i) });
|
||||||
@@ -8,7 +10,10 @@ export const createSession = (i: { question: string; name?: string }) =>
|
|||||||
export const prewarmRuntime = () =>
|
export const prewarmRuntime = () =>
|
||||||
apiFetch<void>("/runtime/prewarm", { method: "POST" });
|
apiFetch<void>("/runtime/prewarm", { method: "POST" });
|
||||||
|
|
||||||
export const listSessions = () => apiFetch<SessionSummary[]>("/sessions");
|
export const getMe = () => apiFetch<Principal>("/me");
|
||||||
|
|
||||||
|
export const listSessions = (scope: SessionScope = "mine") =>
|
||||||
|
apiFetch<SessionSummary[]>(`/sessions?scope=${scope}`);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export const getSession = (id: string) => apiFetch<any>(`/sessions/${id}`);
|
export const getSession = (id: string) => apiFetch<any>(`/sessions/${id}`);
|
||||||
|
|||||||
@@ -104,6 +104,15 @@ export interface SessionSummary {
|
|||||||
archived: boolean;
|
archived: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SessionScope = "mine" | "all";
|
||||||
|
|
||||||
|
export interface Principal {
|
||||||
|
issuer: string;
|
||||||
|
subject: string;
|
||||||
|
displayName?: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ResumeSessionResult {
|
export interface ResumeSessionResult {
|
||||||
id: string;
|
id: string;
|
||||||
alreadyActive: boolean;
|
alreadyActive: boolean;
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ beforeEach(() => {
|
|||||||
window.matchMedia = vi.fn().mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() });
|
window.matchMedia = vi.fn().mockReturnValue({ matches: true, addEventListener: vi.fn(), removeEventListener: vi.fn() });
|
||||||
useSessionStore.getState().resetSession();
|
useSessionStore.getState().resetSession();
|
||||||
server.use(
|
server.use(
|
||||||
|
http.get("http://localhost:8787/me", () =>
|
||||||
|
HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: false }),
|
||||||
|
),
|
||||||
http.get("http://localhost:8787/sessions", () => HttpResponse.json(LIST)),
|
http.get("http://localhost:8787/sessions", () => HttpResponse.json(LIST)),
|
||||||
http.get("http://localhost:8787/sessions/:id/documents", () => HttpResponse.json([
|
http.get("http://localhost:8787/sessions/:id/documents", () => HttpResponse.json([
|
||||||
{ phase: "—", key: "question", title: "Domanda originale", format: "text", content: "Attiva uno" },
|
{ phase: "—", key: "question", title: "Domanda originale", format: "text", content: "Attiva uno" },
|
||||||
@@ -62,6 +65,91 @@ beforeEach(() => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("regular users load only their sessions and never see administrator controls", async () => {
|
||||||
|
let scope: string | null = null;
|
||||||
|
server.use(http.get("http://localhost:8787/sessions", ({ request }) => {
|
||||||
|
scope = new URL(request.url).searchParams.get("scope");
|
||||||
|
return HttpResponse.json(LIST);
|
||||||
|
}));
|
||||||
|
wrap();
|
||||||
|
await screen.findByText("Attiva uno");
|
||||||
|
expect(scope).toBe("mine");
|
||||||
|
expect(screen.queryByRole("button", { name: "All sessions" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/administrator view/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("administrators can explicitly switch to all sessions and see owners", async () => {
|
||||||
|
let scope = "";
|
||||||
|
server.use(
|
||||||
|
http.get("http://localhost:8787/me", () =>
|
||||||
|
HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: true }),
|
||||||
|
),
|
||||||
|
http.get("http://localhost:8787/sessions", ({ request }) => {
|
||||||
|
scope = new URL(request.url).searchParams.get("scope") ?? "";
|
||||||
|
return HttpResponse.json([
|
||||||
|
{ ...LIST[0], author: "Alice" },
|
||||||
|
{ ...LIST[1], id: "s3", question: "Another owner's session", archived: false, author: "Bob" },
|
||||||
|
]);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
wrap();
|
||||||
|
await screen.findByRole("button", { name: "All sessions" });
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "All sessions" }));
|
||||||
|
await waitFor(() => expect(scope).toBe("all"));
|
||||||
|
expect(await screen.findByText("Administrator view: all sessions")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Owner: Bob")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("administrator confirms before deleting another owner's session", async () => {
|
||||||
|
let deletes = 0;
|
||||||
|
server.use(
|
||||||
|
http.get("http://localhost:8787/me", () =>
|
||||||
|
HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: true }),
|
||||||
|
),
|
||||||
|
http.get("http://localhost:8787/sessions", () => HttpResponse.json([
|
||||||
|
{ ...LIST[0], author: "Bob" },
|
||||||
|
])),
|
||||||
|
http.delete("http://localhost:8787/sessions/:id", () => {
|
||||||
|
deletes += 1;
|
||||||
|
return new HttpResponse(null, { status: 204 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
wrap();
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "All sessions" }));
|
||||||
|
await screen.findByText("Owner: Bob");
|
||||||
|
await userEvent.click(screen.getByRole("checkbox", { name: "Select Attiva uno" }));
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Delete 1 selected sessions" }));
|
||||||
|
expect(deletes).toBe(0);
|
||||||
|
expect(await screen.findByRole("heading", { name: "Delete permanently" })).toBeInTheDocument();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
|
await waitFor(() => expect(deletes).toBe(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("administrator confirms before archiving another owner's session", async () => {
|
||||||
|
let archives = 0;
|
||||||
|
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||||
|
server.use(
|
||||||
|
http.get("http://localhost:8787/me", () =>
|
||||||
|
HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: true }),
|
||||||
|
),
|
||||||
|
http.get("http://localhost:8787/sessions", () => HttpResponse.json([
|
||||||
|
{ ...LIST[0], author: "Bob" },
|
||||||
|
])),
|
||||||
|
http.post("http://localhost:8787/sessions/:id/archive", () => {
|
||||||
|
archives += 1;
|
||||||
|
return new HttpResponse(null, { status: 204 });
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
wrap();
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "All sessions" }));
|
||||||
|
await screen.findByText("Owner: Bob");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Session actions" }));
|
||||||
|
await userEvent.click(await screen.findByText("Archive"));
|
||||||
|
expect(confirm).toHaveBeenCalledWith("Archive Bob's session?");
|
||||||
|
expect(archives).toBe(0);
|
||||||
|
confirm.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
test("active list shows group header and hides archived sessions", async () => {
|
test("active list shows group header and hides archived sessions", async () => {
|
||||||
wrap();
|
wrap();
|
||||||
expect(await screen.findByText("Attiva uno")).toBeInTheDocument();
|
expect(await screen.findByText("Attiva uno")).toBeInTheDocument();
|
||||||
|
|||||||
@@ -18,10 +18,10 @@ import { Checkbox } from "../components/ui/checkbox";
|
|||||||
import { Toaster } from "../components/ui/sonner";
|
import { Toaster } from "../components/ui/sonner";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
closeSession, listSessions, resumeSession, getSession,
|
closeSession, getMe, listSessions, resumeSession, getSession,
|
||||||
renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession, prewarmRuntime,
|
renameSession, setSessionGroup, archiveSession, unarchiveSession, deleteSession, prewarmRuntime,
|
||||||
} from "../api/sessions";
|
} from "../api/sessions";
|
||||||
import type { SessionSummary } from "../api/types";
|
import type { Principal, SessionScope, SessionSummary } from "../api/types";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
@@ -48,8 +48,10 @@ export function AppShell() {
|
|||||||
const [streamCursorResetEpoch, setStreamCursorResetEpoch] = useState(0);
|
const [streamCursorResetEpoch, setStreamCursorResetEpoch] = useState(0);
|
||||||
const [creatingSession, setCreatingSession] = useState(false);
|
const [creatingSession, setCreatingSession] = useState(false);
|
||||||
const [awaitingQuestion, setAwaitingQuestion] = useState(false);
|
const [awaitingQuestion, setAwaitingQuestion] = useState(false);
|
||||||
|
const [sessionScope, setSessionScope] = useState<SessionScope>("mine");
|
||||||
|
const { data: principal } = useQuery<Principal>({ queryKey: ["me"], queryFn: getMe, staleTime: Infinity });
|
||||||
const { data: sessions = [] } = useQuery<SessionSummary[]>({
|
const { data: sessions = [] } = useQuery<SessionSummary[]>({
|
||||||
queryKey: ["sessions"], queryFn: listSessions, refetchInterval: 10_000,
|
queryKey: ["sessions", sessionScope], queryFn: () => listSessions(sessionScope), refetchInterval: 10_000,
|
||||||
});
|
});
|
||||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
@@ -77,6 +79,12 @@ export function AppShell() {
|
|||||||
const finalized = activeSession?.status === "finalized";
|
const finalized = activeSession?.status === "finalized";
|
||||||
const selectedSessions = sessions.filter((session) => selectedSessionIds.has(session.id));
|
const selectedSessions = sessions.filter((session) => selectedSessionIds.has(session.id));
|
||||||
const allSessionsSelected = sessions.length > 0 && selectedSessions.length === sessions.length;
|
const allSessionsSelected = sessions.length > 0 && selectedSessions.length === sessions.length;
|
||||||
|
const showingAllSessions = sessionScope === "all";
|
||||||
|
const isForeignSession = (session: SessionSummary) => {
|
||||||
|
if (!showingAllSessions || !principal) return false;
|
||||||
|
if (!session.author) return true;
|
||||||
|
return session.author !== principal.subject && session.author !== principal.displayName;
|
||||||
|
};
|
||||||
|
|
||||||
function selectActiveSession(id: string | null) {
|
function selectActiveSession(id: string | null) {
|
||||||
// Keep async Resume completions synchronized before React commits the state update.
|
// Keep async Resume completions synchronized before React commits the state update.
|
||||||
@@ -245,6 +253,14 @@ export function AppShell() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestArchiveToggle(session: SessionSummary) {
|
||||||
|
if (!session.archived && isForeignSession(session)) {
|
||||||
|
const label = session.author ? `${session.author}'s session` : "this session";
|
||||||
|
if (!window.confirm(`Archive ${label}?`)) return;
|
||||||
|
}
|
||||||
|
void toggleArchive(session);
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteSessions(targets: SessionSummary[]) {
|
async function deleteSessions(targets: SessionSummary[]) {
|
||||||
try {
|
try {
|
||||||
const results = await Promise.allSettled(targets.map((session) => deleteSession(session.id)));
|
const results = await Promise.allSettled(targets.map((session) => deleteSession(session.id)));
|
||||||
@@ -266,6 +282,14 @@ export function AppShell() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestDelete(targets: SessionSummary[]) {
|
||||||
|
if (targets.some(isForeignSession) || allSessionsSelected) {
|
||||||
|
setDeleteTargets(targets);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void deleteSessions(targets);
|
||||||
|
}
|
||||||
|
|
||||||
function menuFor(s: SessionSummary) {
|
function menuFor(s: SessionSummary) {
|
||||||
return (
|
return (
|
||||||
<SessionMenu
|
<SessionMenu
|
||||||
@@ -276,8 +300,8 @@ export function AppShell() {
|
|||||||
onRename={() => setRenameTarget(s)}
|
onRename={() => setRenameTarget(s)}
|
||||||
onMove={(g) => move(s, g)}
|
onMove={(g) => move(s, g)}
|
||||||
onNewGroup={() => newGroup(s)}
|
onNewGroup={() => newGroup(s)}
|
||||||
onArchiveToggle={() => toggleArchive(s)}
|
onArchiveToggle={() => requestArchiveToggle(s)}
|
||||||
onDelete={() => { void deleteSessions([s]); }}
|
onDelete={() => requestDelete([s])}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -496,6 +520,32 @@ export function AppShell() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{principal?.isAdmin && (
|
||||||
|
<div className="px-4 pb-3">
|
||||||
|
<div className="grid grid-cols-2 gap-1 rounded-lg bg-muted p-1" aria-label="Session scope">
|
||||||
|
<Button
|
||||||
|
variant={sessionScope === "mine" ? "secondary" : "ghost"}
|
||||||
|
size="xs"
|
||||||
|
onClick={() => setSessionScope("mine")}
|
||||||
|
>
|
||||||
|
My sessions
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={showingAllSessions ? "secondary" : "ghost"}
|
||||||
|
size="xs"
|
||||||
|
onClick={() => setSessionScope("all")}
|
||||||
|
>
|
||||||
|
All sessions
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{showingAllSessions && (
|
||||||
|
<p className="mt-2 text-xs font-medium text-amber-700 dark:text-amber-400">
|
||||||
|
Administrator view: all sessions
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* L1 — rail title */}
|
{/* L1 — rail title */}
|
||||||
<div className="px-4 pb-1.5 pt-1">
|
<div className="px-4 pb-1.5 pt-1">
|
||||||
<span className="thot-label text-[0.8rem] font-bold tracking-[0.18em] text-primary">
|
<span className="thot-label text-[0.8rem] font-bold tracking-[0.18em] text-primary">
|
||||||
@@ -519,8 +569,7 @@ export function AppShell() {
|
|||||||
size="xs"
|
size="xs"
|
||||||
aria-label={`Delete ${selectedSessions.length} selected sessions`}
|
aria-label={`Delete ${selectedSessions.length} selected sessions`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (allSessionsSelected) setDeleteTargets(selectedSessions);
|
requestDelete(selectedSessions);
|
||||||
else void deleteSessions(selectedSessions);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 />
|
<Trash2 />
|
||||||
@@ -571,6 +620,7 @@ export function AppShell() {
|
|||||||
menuFor={menuFor}
|
menuFor={menuFor}
|
||||||
selectedIds={selectedSessionIds}
|
selectedIds={selectedSessionIds}
|
||||||
onSelectionChange={setSessionSelected}
|
onSelectionChange={setSessionSelected}
|
||||||
|
showOwner={showingAllSessions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -586,6 +636,7 @@ export function AppShell() {
|
|||||||
menuFor={menuFor}
|
menuFor={menuFor}
|
||||||
selectedIds={selectedSessionIds}
|
selectedIds={selectedSessionIds}
|
||||||
onSelectionChange={setSessionSelected}
|
onSelectionChange={setSessionSelected}
|
||||||
|
showOwner={showingAllSessions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -609,6 +660,7 @@ export function AppShell() {
|
|||||||
menuFor={menuFor}
|
menuFor={menuFor}
|
||||||
selectedIds={selectedSessionIds}
|
selectedIds={selectedSessionIds}
|
||||||
onSelectionChange={setSessionSelected}
|
onSelectionChange={setSessionSelected}
|
||||||
|
showOwner={showingAllSessions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ test("active session is highlighted", () => {
|
|||||||
expect(screen.getByTestId("session-item-s2")).toHaveAttribute("data-active", "true");
|
expect(screen.getByTestId("session-item-s2")).toHaveAttribute("data-active", "true");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("administrator session lists show the recorded owner", () => {
|
||||||
|
render(
|
||||||
|
<NavSessions
|
||||||
|
sessions={[{ ...SESSIONS[0], author: "Alice" }]}
|
||||||
|
activeSessionId={null}
|
||||||
|
onOpenPanel={vi.fn()}
|
||||||
|
menuFor={() => null}
|
||||||
|
showOwner
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("Owner: Alice")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
test("pressing Enter on a focused row opens the panel", async () => {
|
test("pressing Enter on a focused row opens the panel", async () => {
|
||||||
const onOpenPanel = vi.fn();
|
const onOpenPanel = vi.fn();
|
||||||
render(<NavSessions sessions={SESSIONS} activeSessionId={null} onOpenPanel={onOpenPanel} menuFor={() => null} />);
|
render(<NavSessions sessions={SESSIONS} activeSessionId={null} onOpenPanel={onOpenPanel} menuFor={() => null} />);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ interface Props {
|
|||||||
menuFor: (session: SessionSummary) => ReactNode;
|
menuFor: (session: SessionSummary) => ReactNode;
|
||||||
selectedIds?: ReadonlySet<string>;
|
selectedIds?: ReadonlySet<string>;
|
||||||
onSelectionChange?: (id: string, selected: boolean) => void;
|
onSelectionChange?: (id: string, selected: boolean) => void;
|
||||||
|
showOwner?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusIndicator(status: string) {
|
function statusIndicator(status: string) {
|
||||||
@@ -21,7 +22,7 @@ function statusIndicator(status: string) {
|
|||||||
|
|
||||||
export function NavSessions({
|
export function NavSessions({
|
||||||
sessions, activeSessionId, onOpenPanel, menuFor,
|
sessions, activeSessionId, onOpenPanel, menuFor,
|
||||||
selectedIds = new Set<string>(), onSelectionChange = () => undefined,
|
selectedIds = new Set<string>(), onSelectionChange = () => undefined, showOwner = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
if (sessions.length === 0) {
|
if (sessions.length === 0) {
|
||||||
return (
|
return (
|
||||||
@@ -70,13 +71,20 @@ export function NavSessions({
|
|||||||
title={indicator.label}
|
title={indicator.label}
|
||||||
className={["size-1.5 shrink-0 rounded-full", indicator.color].join(" ")}
|
className={["size-1.5 shrink-0 rounded-full", indicator.color].join(" ")}
|
||||||
/>
|
/>
|
||||||
<span
|
<span className="min-w-0 flex-1">
|
||||||
className={[
|
<span
|
||||||
"min-w-0 flex-1 truncate text-[0.8rem] leading-snug",
|
className={[
|
||||||
active ? "font-semibold text-foreground" : "font-medium text-foreground/90",
|
"block truncate text-[0.8rem] leading-snug",
|
||||||
].join(" ")}
|
active ? "font-semibold text-foreground" : "font-medium text-foreground/90",
|
||||||
>
|
].join(" ")}
|
||||||
{label}
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{showOwner && (
|
||||||
|
<span className="block truncate text-[0.65rem] text-muted-foreground">
|
||||||
|
Owner: {s.author ?? "Unknown"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
{menuFor(s)}
|
{menuFor(s)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user