Add full shell, replaceable Omics adapter and bilingual interaction
Implement approved specification #32 and tickets #33-#37. Keep host authentication server-verified and pin session interaction language. Compile scoped base selectors for browser compatibility and retain full gutters during CSS pruning.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { useI18n } from "../i18n";
|
||||
import { useSessionStream } from "../stream/useSessionStream";
|
||||
import { useSessionStore } from "../store/sessionStore";
|
||||
import { WidgetHost } from "./WidgetHost";
|
||||
@@ -48,6 +49,10 @@ import { AdministrationHeader } from "./administration/AdministrationPage";
|
||||
import { WorkingContextProvider, useWorkingContext } from "../workspaces/WorkingContext";
|
||||
import { WorkingContextShelf } from "./WorkingContextShelf";
|
||||
import { useInteractionModelBusy } from "../models/useInteractionModelBusy";
|
||||
import { FullHeader } from "./host/FullHeader";
|
||||
import { useShell } from "./host/ShellProvider";
|
||||
import { SessionNotification } from "./host/SessionNotification";
|
||||
import { readRememberedSession, rememberSession } from "./host/rememberedSession";
|
||||
|
||||
interface AppShellProps {
|
||||
canLogout: boolean;
|
||||
@@ -91,6 +96,8 @@ export function resolveDatabaseManagementPresentation({
|
||||
}
|
||||
|
||||
export function AppShell({ canLogout }: AppShellProps) {
|
||||
const { t } = useI18n();
|
||||
const shell = useShell();
|
||||
const workingContext = useWorkingContext();
|
||||
const interactionBusy = useInteractionModelBusy();
|
||||
const mutationBusy = useIsMutating() > 0;
|
||||
@@ -186,7 +193,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
&& preprocessingQuery.data
|
||||
&& preprocessingQuery.data.state !== "ready",
|
||||
);
|
||||
const { data: sessions = [] } = useQuery<SessionSummary[]>({
|
||||
const { data: sessions = [], isSuccess: sessionsLoaded } = useQuery<SessionSummary[]>({
|
||||
queryKey: ["sessions", sessionScope],
|
||||
queryFn: async () => {
|
||||
const guard = captureAuthOperation({ disposalEpoch: operationEpochRef.current });
|
||||
@@ -198,6 +205,21 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||
const restoredSelection = useRef(false);
|
||||
useEffect(() => {
|
||||
if (restoredSelection.current || !authenticatedUser || !sessionsLoaded) return;
|
||||
const savedId = readRememberedSession(authenticatedUser);
|
||||
const saved = sessions.find(session => session.id === savedId);
|
||||
// A saved selection may belong to another user. The initial mine list is
|
||||
// not exhaustive for reviewers authorized to browse all sessions.
|
||||
if (savedId && !saved && sessionScope === "mine" && canReadAllSessions) {
|
||||
setSessionScope("all");
|
||||
return;
|
||||
}
|
||||
restoredSelection.current = true;
|
||||
if (saved) setPanelSession(saved);
|
||||
else if (savedId) rememberSession(authenticatedUser, null);
|
||||
}, [authenticatedUser, sessions, sessionsLoaded, sessionScope, canReadAllSessions]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const [showActivity, setShowActivity] = useState(false);
|
||||
@@ -264,6 +286,8 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
};
|
||||
|
||||
function selectActiveSession(id: string | null) {
|
||||
restoredSelection.current = true;
|
||||
if (authenticatedUser) rememberSession(authenticatedUser, id);
|
||||
// Keep async Resume completions synchronized before React commits the state update.
|
||||
if (activeSessionIdRef.current !== id) activeSessionEpochRef.current += 1;
|
||||
activeSessionIdRef.current = id;
|
||||
@@ -323,7 +347,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
: activeSurface === "memory-management" ? memoryDirty
|
||||
: activeSurface === "database-management" ? databaseNavigationRef.current.dirty : false;
|
||||
if (dirty) {
|
||||
toast.warning("Save your administration changes, or cancel the edit, before leaving this page.");
|
||||
toast.warning(t("Save your administration changes, or cancel the edit, before leaving this page."));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -334,6 +358,8 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
if (!canLeaveDatabaseManagement()) return;
|
||||
const s = sessions.find((x) => x.id === id);
|
||||
if (!s) return;
|
||||
restoredSelection.current = true;
|
||||
if (authenticatedUser) rememberSession(authenticatedUser, id);
|
||||
navigate({ surface: "core" }, true);
|
||||
// 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
|
||||
@@ -445,7 +471,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
latestResumeIntentRef.current?.token === operation.latestToken
|
||||
&& latestResumeIntentRef.current.id === id
|
||||
) {
|
||||
toast.error("Failed to resume session.");
|
||||
toast.error(t("Failed to resume session."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -457,11 +483,11 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
if (!isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) return;
|
||||
refresh(guard);
|
||||
} catch {
|
||||
if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) toast.error("Failed to move session.");
|
||||
if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) toast.error(t("Failed to move session."));
|
||||
}
|
||||
}
|
||||
async function newGroup(s: SessionSummary) {
|
||||
const name = window.prompt("New group:");
|
||||
const name = window.prompt(t("New group:"));
|
||||
if (name && name.trim()) {
|
||||
const guard = captureAuthOperation({ sessionId: s.id, disposalEpoch: operationEpochRef.current });
|
||||
if (!guard) return;
|
||||
@@ -470,7 +496,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
if (!isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) return;
|
||||
refresh(guard);
|
||||
} catch {
|
||||
if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) toast.error("Failed to update group.");
|
||||
if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) toast.error(t("Failed to update group."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -487,7 +513,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
refresh(guard);
|
||||
return true;
|
||||
} catch {
|
||||
if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) toast.error("Failed to rename group.");
|
||||
if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) toast.error(t("Failed to rename group."));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -501,7 +527,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
refresh(guard);
|
||||
} catch {
|
||||
if (isAuthOperationCurrent(guard, { sessionId: id, disposalEpoch: operationEpochRef.current })) {
|
||||
toast.error("Failed to rename session.");
|
||||
toast.error(t("Failed to rename session."));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -515,15 +541,15 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
refresh(guard);
|
||||
} catch {
|
||||
if (isAuthOperationCurrent(guard, { sessionId: s.id, disposalEpoch: operationEpochRef.current })) {
|
||||
toast.error(s.archived ? "Failed to restore session." : "Failed to archive session.");
|
||||
toast.error(s.archived ? t("Failed to restore session.") : t("Failed to archive session."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
const label = session.author ? t("{author}'s session", { author: session.author }) : t("this session");
|
||||
if (!window.confirm(t("Archive {label}?", { label }))) return;
|
||||
}
|
||||
void toggleArchive(session);
|
||||
}
|
||||
@@ -545,10 +571,10 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
setSelectedSessionIds((current) => new Set([...current].filter((id) => !deletedIds.has(id))));
|
||||
refresh(guard);
|
||||
if (deletedIds.size !== targets.length) {
|
||||
toast.error(`Deleted ${deletedIds.size} of ${targets.length} sessions.`);
|
||||
toast.error(t("Deleted {deleted} of {total} sessions.", { deleted: deletedIds.size, total: targets.length }));
|
||||
}
|
||||
} catch {
|
||||
if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) toast.error("Failed to delete selected sessions.");
|
||||
if (isAuthOperationCurrent(guard, { disposalEpoch: operationEpochRef.current })) toast.error(t("Failed to delete selected sessions."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -566,7 +592,13 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
session={s}
|
||||
groups={groups}
|
||||
onResume={() => doResume(s.id)}
|
||||
onView={() => { if (canLeaveDatabaseManagement()) { navigate({ surface: "core" }, true); setPanelSession(s); } }}
|
||||
onView={() => {
|
||||
if (!canLeaveDatabaseManagement()) return;
|
||||
restoredSelection.current = true;
|
||||
if (authenticatedUser) rememberSession(authenticatedUser, s.id);
|
||||
navigate({ surface: "core" }, true);
|
||||
setPanelSession(s);
|
||||
}}
|
||||
onRename={() => setRenameTarget(s)}
|
||||
onMove={(g) => move(s, g)}
|
||||
onNewGroup={() => newGroup(s)}
|
||||
@@ -596,10 +628,11 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
}
|
||||
|
||||
for (const notification of sessionToasts.slice(deliveredToastCountRef.current)) {
|
||||
if (notification.level === "error") toast.error(notification.text);
|
||||
else if (notification.level === "success") toast.success(notification.text);
|
||||
else if (notification.level === "warning") toast.warning(notification.text);
|
||||
else toast.info(notification.text);
|
||||
const message = <SessionNotification text={notification.text} />;
|
||||
if (notification.level === "error") toast.error(message);
|
||||
else if (notification.level === "success") toast.success(message);
|
||||
else if (notification.level === "warning") toast.warning(message);
|
||||
else toast.info(message);
|
||||
}
|
||||
|
||||
deliveredToastCountRef.current = sessionToasts.length;
|
||||
@@ -644,7 +677,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
function startNewSession() {
|
||||
if (!workingContext.ready || administrationBusy) return;
|
||||
if (preprocessingBlocksNewSession) {
|
||||
toast.warning(preprocessingQuery.data?.detail ?? "Workspace preprocessing is required.");
|
||||
toast.warning(preprocessingQuery.data?.detail ?? t("Workspace preprocessing is required."));
|
||||
return;
|
||||
}
|
||||
if (!canLeaveDatabaseManagement()) return;
|
||||
@@ -691,7 +724,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
if (!operation || operation.target !== activeSessionIdRef.current || operation.epoch !== activeSessionEpochRef.current) return;
|
||||
setCreatingSession(false);
|
||||
resetSession();
|
||||
toast.error(message ?? "Failed to create session. Your question is ready to retry.");
|
||||
toast.error(message ? <SessionNotification text={message} /> : t("Failed to create session. Your question is ready to retry."));
|
||||
}
|
||||
|
||||
async function stopSession() {
|
||||
@@ -743,6 +776,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
return (
|
||||
<WorkingContextProvider value={{ workspaceId: workingContext.workspaceId, ready: workingContext.ready, locked: contextLocked, administrationBusy: administrationBusy || (interactionBusy && !agentActive) }}>
|
||||
<AdministrationNavigationProvider navigate={navigate}>
|
||||
<FullHeader onLogout={canLogout ? signOut : undefined} />
|
||||
<div className="thot-context-layout">
|
||||
<WorkingContextShelf context={workingContext} locked={contextLocked} onWorkspace={changeContextWorkspace} onModel={changeContextModel} />
|
||||
<div
|
||||
@@ -760,7 +794,10 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
{panelSession && (
|
||||
<SessionDocumentsPanel
|
||||
session={panelSession}
|
||||
onClose={() => setPanelSession(null)}
|
||||
onClose={() => {
|
||||
setPanelSession(null);
|
||||
if (authenticatedUser) rememberSession(authenticatedUser, activeSessionId);
|
||||
}}
|
||||
onResume={doResume}
|
||||
desktopSplit={sessionDesktopSplit}
|
||||
hidden={activeSurface !== "core"}
|
||||
@@ -794,9 +831,9 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
|
||||
{/* Conversation column */}
|
||||
<WorkAreaPanelHost data-testid="conversation-column" className="flex min-w-0 flex-1 flex-col">
|
||||
{!workingContext.ready && <main className="thot-administration-page" aria-label="Working context required"><AdministrationHeader title="Choose your working context" description="Select an available workspace and AI model above to enable Core and Administration." /></main>}
|
||||
{!administrationPermitted && <main className="thot-administration-page" aria-label="Administration unavailable">
|
||||
<AdministrationHeader title="Administration unavailable" description="Your account does not have permission to open this page." />
|
||||
{!workingContext.ready && <main className="thot-administration-page" aria-label={t("Working context required")}><AdministrationHeader title={t("Choose your working context")} description={t("Select an available workspace and AI model above to enable Core and Administration.")} /></main>}
|
||||
{!administrationPermitted && <main className="thot-administration-page" aria-label={t("Administration unavailable")}>
|
||||
<AdministrationHeader title={t("Administration unavailable")} description={t("Your account does not have permission to open this page.")} />
|
||||
</main>}
|
||||
{workingContext.ready && isAdmin && canManageMemory && (activeSurface === "memory-management" || visited.has("memory-management")) && (
|
||||
<div style={{ display: activeSurface === "memory-management" ? "contents" : "none" }}><MemoryManagementPage key={selectedWorkspaceId} initialWorkspaceId={selectedWorkspaceId} canManage={canManageMemory} onDirtyChange={setMemoryDirty} onBusyChange={setMemoryBusy} /></div>
|
||||
@@ -824,8 +861,8 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
type="button"
|
||||
onClick={toggleActivity}
|
||||
aria-expanded={showActivity}
|
||||
aria-label={showActivity ? "Hide model activity" : "Show model activity"}
|
||||
title={showActivity ? "Hide model activity" : "Show model activity"}
|
||||
aria-label={showActivity ? t("Hide model activity") : t("Show model activity")}
|
||||
title={showActivity ? t("Hide model activity") : t("Show model activity")}
|
||||
className="absolute left-4 top-1/2 grid size-7 -translate-y-1/2 place-items-center rounded-md border border-border text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
{showActivity ? <ArrowLeft className="size-4" /> : <ArrowRight className="size-4" />}
|
||||
@@ -846,7 +883,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
<>
|
||||
{activeSession?.question && (
|
||||
<header className="sticky top-0 z-10 -mx-6 -mt-8 border-b border-border/60 bg-background/95 px-6 pb-3 pt-8 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<p className="thot-label mb-0.5 text-muted-foreground">Question</p>
|
||||
<p className="thot-label mb-0.5 text-muted-foreground">{t("Question")}</p>
|
||||
<h2 className="font-heading text-[0.95rem] font-semibold leading-snug text-foreground break-words">
|
||||
{activeSession.question}
|
||||
</h2>
|
||||
@@ -856,13 +893,8 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
{activeSessionId && <WidgetHost key={`widget:${activeSessionId}:${activeSessionEpochRef.current}`} 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">
|
||||
Session completed and finalized — the SQL and all phase
|
||||
documents are saved.
|
||||
</p>
|
||||
<Button className="mt-3" onClick={startNewSession}>
|
||||
Start a new question
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">{t("Session completed and finalized. The SQL and all phase documents are saved.")}</p>
|
||||
<Button className="mt-3" onClick={startNewSession}>{t("Start a new question")}</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -917,34 +949,23 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
{/* Right session rail */}
|
||||
{(
|
||||
<ArchiveNavigation surface={activeSurface} forceDrawer={activeSurface === "core" && showActivity}>
|
||||
<aside aria-label="Session navigation" className="flex w-64 shrink-0 flex-col bg-sidebar">
|
||||
<aside aria-label={t("Session navigation")} className="flex w-64 shrink-0 flex-col bg-sidebar">
|
||||
<div className="thot-session-navigation__header relative text-center">
|
||||
<h1 className="font-heading text-xl font-semibold leading-none tracking-tight text-foreground">
|
||||
Thoth<span className="text-primary">II</span>
|
||||
</h1>
|
||||
<p className="thot-label mt-1.5">
|
||||
Datamart Builder with
|
||||
<br />
|
||||
Human-in-the-loop review
|
||||
</p>
|
||||
{authenticatedUser && (
|
||||
<p className="thot-label mt-1.5">{t("Datamart Builder with")}<br />{t("Human-in-the-loop review")}</p>
|
||||
{authenticatedUser && shell.mode === "embedded" && (
|
||||
<div className="mt-4 flex items-center justify-between gap-2 border-t border-border/70 pt-3 text-left">
|
||||
<span className="min-w-0 truncate text-xs text-muted-foreground" title={authenticatedUser.displayName ?? authenticatedUser.subject}>
|
||||
{authenticatedUser.displayName ?? authenticatedUser.subject}
|
||||
</span>
|
||||
{canLogout && (
|
||||
<Button variant="ghost" size="xs" onClick={() => { void signOut().catch(() => undefined); }}>
|
||||
Log out
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="thot-session-navigation__primary-controls">
|
||||
{activeSurface !== "core" && <Button variant="outline" size="sm" className="w-full" onClick={() => navigate({ surface: "core" })}>
|
||||
Return to session
|
||||
</Button>}
|
||||
{activeSurface !== "core" && <Button variant="outline" size="sm" className="w-full" onClick={() => navigate({ surface: "core" })}>{t("Return to session")}</Button>}
|
||||
<Button
|
||||
variant={currentNavigation === "core" ? "navigationActive" : "outline"}
|
||||
size="sm"
|
||||
@@ -956,9 +977,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
disabled={preprocessingBlocksNewSession || !workingContext.ready || administrationBusy}
|
||||
title={preprocessingBlocksNewSession ? preprocessingQuery.data?.detail : undefined}
|
||||
onClick={startNewSession}
|
||||
>
|
||||
New session
|
||||
</Button>
|
||||
>{t("New session")}</Button>
|
||||
{isAdmin && (
|
||||
<Accordion.Root
|
||||
value={adminNavigationValue}
|
||||
@@ -976,7 +995,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
})}
|
||||
data-navigation-state={!adminNavigationOpen && managementNavigationCurrent ? "current" : "available"}
|
||||
>
|
||||
<span>Administration</span>
|
||||
<span>{t("Administration")}</span>
|
||||
<ChevronDown
|
||||
aria-hidden="true"
|
||||
data-icon="inline-end"
|
||||
@@ -992,33 +1011,31 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
aria-current={currentNavigation === "database" ? "page" : undefined}
|
||||
data-navigation-state={currentNavigation === "database" ? "current" : canManageDatabase ? "available" : "unavailable"}
|
||||
disabled={!canManageDatabase}
|
||||
title={canManageDatabase ? undefined : "Database management permission is required"}
|
||||
title={canManageDatabase ? undefined : t("Database management permission is required")}
|
||||
onClick={() => {
|
||||
if (!canLeaveDatabaseManagement()) return;
|
||||
navigate({ surface: "database-management" }, true);
|
||||
}}
|
||||
>
|
||||
Database
|
||||
</Button>
|
||||
>{t("Database")}</Button>
|
||||
<Button
|
||||
variant={currentNavigation === "memory" ? "navigationActive" : "outline"}
|
||||
size="sm"
|
||||
className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
|
||||
aria-current={currentNavigation === "memory" ? "page" : undefined}
|
||||
disabled={!canManageMemory}
|
||||
title={canManageMemory ? undefined : "Memory management permission is required"}
|
||||
title={canManageMemory ? undefined : t("Memory management permission is required")}
|
||||
data-navigation-state={currentNavigation === "memory" ? "current" : "available"}
|
||||
onClick={() => {
|
||||
if (!canLeaveDatabaseManagement()) return;
|
||||
navigate({ surface: "memory-management" }, true);
|
||||
}}
|
||||
>Memory</Button>
|
||||
>{t("Memory")}</Button>
|
||||
<Button variant={currentNavigation === "evidence" ? "navigationActive" : "outline"}
|
||||
size="sm" className="w-full min-w-0 max-w-full gap-[0.25rem] px-[0.75rem]"
|
||||
aria-current={currentNavigation === "evidence" ? "page" : undefined}
|
||||
disabled={!canManageEvidence} title={canManageEvidence ? undefined : "Evidence management permission is required"}
|
||||
disabled={!canManageEvidence} title={canManageEvidence ? undefined : t("Evidence management permission is required")}
|
||||
onClick={() => navigate({ surface: "evidence-management" })}
|
||||
>Evidence</Button>
|
||||
>{t("Evidence")}</Button>
|
||||
<div role="separator" aria-orientation="horizontal" className="mx-1 h-px bg-border/90" />
|
||||
<Button
|
||||
variant={currentNavigation === "workspace" ? "navigationActive" : "outline"}
|
||||
@@ -1030,9 +1047,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
if (!canLeaveDatabaseManagement()) return;
|
||||
navigate({ surface: "workspace-management" }, true);
|
||||
}}
|
||||
>
|
||||
Workspace
|
||||
</Button>
|
||||
>{t("Workspace")}</Button>
|
||||
<Button
|
||||
variant={currentNavigation === "pi" ? "navigationActive" : "outline"}
|
||||
size="sm"
|
||||
@@ -1040,16 +1055,13 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
aria-current={currentNavigation === "pi" ? "page" : undefined}
|
||||
data-navigation-state={currentNavigation === "pi" ? "current" : canManagePi ? "available" : "unavailable"}
|
||||
disabled={!canManagePi}
|
||||
title={canManagePi ? undefined : "Pi management permission is required"}
|
||||
title={canManagePi ? undefined : t("Pi management permission is required")}
|
||||
onClick={() => {
|
||||
if (!canLeaveDatabaseManagement()) return;
|
||||
navigate({ surface: "pi-management" }, true);
|
||||
}}
|
||||
>
|
||||
Pi configuration
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="w-full min-w-0 whitespace-normal" onClick={() => navigate({ surface: "workspace-management", workspace: selectedWorkspaceId })}>
|
||||
Workspace readiness: {preprocessingQuery.isError ? "unavailable" : preprocessingQuery.data?.state ?? "checking"}
|
||||
>{t("Pi configuration")}</Button>
|
||||
<Button variant="ghost" size="sm" className="w-full min-w-0 whitespace-normal" onClick={() => navigate({ surface: "workspace-management", workspace: selectedWorkspaceId })}>{t("Workspace readiness: {state}", { state: t(preprocessingQuery.isError ? "unavailable" : preprocessingQuery.data?.state ?? "checking") })}
|
||||
</Button>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
@@ -1061,7 +1073,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
<div className="px-4">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Session scope"
|
||||
aria-label={t("Session scope")}
|
||||
aria-orientation="horizontal"
|
||||
className="grid grid-cols-2 border-b border-border"
|
||||
>
|
||||
@@ -1077,9 +1089,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
className={sessionScopeTabClass(sessionScope === "mine")}
|
||||
onClick={() => setSessionScope("mine")}
|
||||
onKeyDown={(event) => handleSessionScopeTabKeyDown(event, "mine")}
|
||||
>
|
||||
My sessions
|
||||
</button>
|
||||
>{t("My sessions")}</button>
|
||||
<button
|
||||
ref={(node) => { sessionScopeTabRefs.current.all = node; }}
|
||||
id="session-scope-all-tab"
|
||||
@@ -1092,9 +1102,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
className={sessionScopeTabClass(showingAllSessions)}
|
||||
onClick={() => setSessionScope("all")}
|
||||
onKeyDown={(event) => handleSessionScopeTabKeyDown(event, "all")}
|
||||
>
|
||||
All sessions
|
||||
</button>
|
||||
>{t("All sessions")}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1106,39 +1114,35 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
{canReadAllSessions && showingAllSessions && (
|
||||
<p className="mx-4 mb-2 mt-2 text-xs font-medium text-amber-700 dark:text-amber-400">
|
||||
Administrator view: all sessions
|
||||
</p>
|
||||
<p className="mx-4 mb-2 mt-2 text-xs font-medium text-amber-700 dark:text-amber-400">{t("Administrator view: all sessions")}</p>
|
||||
)}
|
||||
|
||||
{/* L1 — rail title */}
|
||||
<div className="px-4 pb-1.5 pt-1">
|
||||
<span className="thot-label text-[0.8rem] font-bold tracking-[0.18em] text-primary">
|
||||
Sessions
|
||||
</span>
|
||||
<span className="thot-label text-[0.8rem] font-bold tracking-[0.18em] text-primary">{t("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"
|
||||
aria-label={t("Select all sessions")}
|
||||
disabled={sessions.length === 0}
|
||||
onCheckedChange={(selected) => toggleAllSessions(selected === true)}
|
||||
/>
|
||||
<span>Select all</span>
|
||||
<span>{t("Select all")}</span>
|
||||
</label>
|
||||
{selectedSessions.length > 0 && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="xs"
|
||||
aria-label={`Delete ${selectedSessions.length} selected sessions`}
|
||||
aria-label={t("Delete {count} selected sessions", { count: selectedSessions.length })}
|
||||
onClick={() => {
|
||||
requestDelete(selectedSessions);
|
||||
}}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete ({selectedSessions.length})
|
||||
{t("Delete ({count})", { count: selectedSessions.length })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -1151,7 +1155,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
className="thot-label flex w-full items-center gap-1 px-1 pb-1 pt-1 text-left text-foreground/65 hover:text-foreground"
|
||||
>
|
||||
<span className="select-none">{activeOpen ? "▾" : "▸"}</span>
|
||||
<span>Active sessions</span>
|
||||
<span>{t("Active sessions")}</span>
|
||||
</button>
|
||||
{activeOpen && (
|
||||
<div className="flex flex-col gap-3 pb-2">
|
||||
@@ -1170,7 +1174,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Rename group ${g}`}
|
||||
aria-label={t("Rename group {name}", { name: g })}
|
||||
onClick={() => setRenameGroupTarget(g)}
|
||||
className="rounded-md p-0.5 text-muted-foreground opacity-0 transition-opacity hover:bg-accent group-hover/gh:opacity-100"
|
||||
>
|
||||
@@ -1215,7 +1219,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
className="thot-label mt-2 flex w-full items-center gap-1 px-1 pb-1 pt-1 text-left text-foreground/65 hover:text-foreground"
|
||||
>
|
||||
<span className="select-none">{archiveOpen ? "▾" : "▸"}</span>
|
||||
<span className="tabular-nums">Archive ({archivedList.length})</span>
|
||||
<span className="tabular-nums">{t("Archive ({count})", { count: archivedList.length })}</span>
|
||||
</button>
|
||||
{archiveOpen && (
|
||||
<NavSessions
|
||||
@@ -1233,7 +1237,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
</aside>
|
||||
</ArchiveNavigation>
|
||||
)}
|
||||
<Toaster />
|
||||
<Toaster theme={shell.theme} />
|
||||
|
||||
<StopConfirmDialog
|
||||
open={stopConfirm}
|
||||
@@ -1261,7 +1265,7 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
setRenameGroupTarget(null);
|
||||
}
|
||||
}}
|
||||
title="Rename group"
|
||||
title={t("Rename group")}
|
||||
/>
|
||||
)}
|
||||
{deleteTargets.length > 0 && (
|
||||
@@ -1284,16 +1288,13 @@ export function AppShell({ canLogout }: AppShellProps) {
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="flex min-h-[72vh] flex-col items-center justify-center text-center">
|
||||
<span className="font-heading text-5xl font-semibold tracking-tight text-foreground">
|
||||
Thoth<span className="text-primary">II</span>
|
||||
</span>
|
||||
<p className="mt-4 max-w-md text-balance text-[0.95rem] leading-relaxed text-muted-foreground">
|
||||
Build datamarts from your database through a guided,
|
||||
human-in-the-loop workflow. Type your question in the box below to begin
|
||||
a session.
|
||||
</p>
|
||||
<p className="mt-4 max-w-md text-balance text-[0.95rem] leading-relaxed text-muted-foreground">{t("Build datamarts from your database through a guided, human-in-the-loop workflow. Type your question in the box below to begin a session.")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user