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:
Codex
2026-09-13 14:26:39 +02:00
parent 2d1b714ebe
commit d8a29bfbdd
207 changed files with 8570 additions and 2164 deletions
+98 -97
View File
@@ -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>
);
}