Menu
popagent
publicLatest change fadf21d1cd584745f6f92eaa60509e0bef19d242 - fix task orchestration and compact overview cards by AkurAI Build
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { APP_VERSION } from "../version";
import {
ActionBarPrimitive,
AssistantRuntimeProvider,
type DataMessagePartProps,
ComposerPrimitive,
MessagePrimitive,
ThreadPrimitive,
useAuiState,
} from "@assistant-ui/react";
import { MarkdownTextPrimitive } from "@assistant-ui/react-markdown";
import { AssistantChatTransport, useChatRuntime } from "@assistant-ui/react-ai-sdk";
import type { UIMessage } from "ai";
import {
Archive,
ArchiveRestore,
ArrowUp,
Bot,
BookOpen,
Brain,
CheckCircle2,
Copy,
ChevronDown,
ChevronRight,
FolderGit2,
Hash,
Clock3,
Loader2,
Menu,
LayoutDashboard,
MessageSquare,
MessageSquarePlus,
MoreVertical,
Pencil,
Search,
RefreshCw,
Settings2,
ScrollText,
Sparkles,
Square,
Trash2,
ThumbsDown,
ThumbsUp,
Wrench,
X,
XCircle,
} from "lucide-react";
import remarkGfm from "remark-gfm";
import {
type AgentActivity,
type AgentRuntimeSettings,
type AgentTask,
type AgentWorkspace,
type HookAuditRun,
type HookRunListResponse,
type ModelCatalogResponse,
type ModelProfile,
type Session as ApiSession,
type SessionListResponse,
type TaskListResponse,
type WorkspaceListResponse,
type WorkspaceChatDeleteResponse,
type SessionSummary,
} from "../api-types";
import { isPlaintextSecretToolName } from "../chat-messages";
import { apiFetch } from "./api";
import { AgentSettingsPage } from "./AgentSettingsPage";
import { BrowserPanel } from "./BrowserPanel";
import { ChannelPage } from "./ChannelPage";
import { ChangelogPage } from "./ChangelogPage";
import { DocumentationWorkspace } from "./DocumentationPage";
import { MemoryDrawer } from "./MemoryDrawer";
import { ModelPicker } from "./ModelPicker";
import { filterByQuery, parseToolDisplayMode, TOOL_DISPLAY_KEY, type ToolDisplayMode } from "./preferences";
import { appRoutePath, parseAppRoute, type AppRoute } from "./routes";
import { singleFlight } from "./single-flight";
import { subscribeToasts, toast, type Toast } from "./toast";
import { WorkspaceChatDeleteDialog, WorkspaceDialog, WorkspaceOverview } from "./WorkspaceOverview";
import { useFocusTrap } from "./use-focus-trap";
type Session = ApiSession<UIMessage>;
type AppHistoryState = { settingsReturnTo?: string };
type RouteNavigationOptions = { replace?: boolean; state?: unknown };
const SUGGESTIONS = [
{ label: "What can you do?", prompt: "What can you help me with?" },
{ label: "What time is it?", prompt: "What time is it right now?" },
{ label: "Compare Bun and Node", prompt: "Summarize the pros and cons of Bun vs Node" },
];
type ToolPartProps = {
toolName?: string;
args?: unknown;
result?: unknown;
status?: { type?: string } | string;
};
function MarkdownText() {
return <MarkdownTextPrimitive remarkPlugins={[remarkGfm]} className="md-content" />;
}
function jsonPreview(value: unknown) {
if (value === undefined) return undefined;
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function ToolCall({ mode, ...props }: ToolPartProps & { mode: ToolDisplayMode }) {
const [open, setOpen] = useState(mode === "expanded");
const sensitive = isPlaintextSecretToolName(props.toolName);
const details = [
["Arguments", jsonPreview(sensitive ? "[redacted]" : props.args)],
["Result", jsonPreview(sensitive ? "[redacted]" : props.result)],
].filter((item): item is [string, string] => Boolean(item[1]));
const status = typeof props.status === "string" ? props.status : props.status?.type;
const expanded = mode === "expanded" || open;
return (
<div className={`my-2 rounded border bg-panel-2 text-xs ${mode === "timeline" ? "border-accent/35 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]" : "border-line"}`}>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-ink-dim"
aria-expanded={expanded}
>
<Wrench className="size-3.5 text-accent" />
<span className="min-w-0 flex-1 truncate"><code className="text-ink">{props.toolName ?? "tool"}</code></span>
<span className="capitalize">{status ?? "running"}</span>
{details.length ? expanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" /> : null}
</button>
{expanded && details.length ? (
<div className="space-y-2 border-t border-line px-3 py-2">
{details.map(([label, value]) => (
<div key={label}>
<div className="mb-1 font-medium text-ink-dim">{label}</div>
<pre className="max-h-52 overflow-auto whitespace-pre-wrap break-all rounded bg-bg p-2 text-[11px] text-ink">{value}</pre>
</div>
))}
</div>
) : null}
</div>
);
}
function AgentActivityPart({ data }: DataMessagePartProps) {
const activity = data as AgentActivity;
if (!activity || typeof activity !== "object" || typeof activity.agentName !== "string") return null;
const tools = Array.isArray(activity.tools) ? activity.tools.filter((tool): tool is string => typeof tool === "string") : [];
const text = typeof activity.text === "string" ? activity.text : "";
return (
<section className="my-3 rounded border border-accent/25 bg-accent-soft/40 p-3" aria-label={`${activity.agentName} activity`}>
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="flex items-center gap-1.5 font-medium text-ink"><Bot className="size-3.5 text-accent" />{activity.agentName}</span>
<span className="text-ink-dim">iteration {activity.iteration}{activity.maxIterations ? ` of ${activity.maxIterations}` : ""}</span>
<span className={`ml-auto rounded-sm px-2 py-0.5 ${activity.isFinal ? "bg-emerald-500/15 text-emerald-300" : "bg-accent/15 text-accent"}`}>{activity.isFinal ? "complete" : activity.finishReason}</span>
</div>
{tools.length ? <div className="mt-2 flex flex-wrap gap-1">{tools.map((tool) => <code key={tool} className="rounded bg-bg/70 px-1.5 py-0.5 text-[10px] text-ink-muted">{tool}</code>)}</div> : null}
{text ? <div className="mt-2 max-h-44 overflow-y-auto whitespace-pre-wrap text-xs leading-relaxed text-ink-muted">{text}</div> : null}
</section>
);
}
function UserMessage() {
return (
<MessagePrimitive.Root className="group flex flex-col items-end py-2.5">
<div className="user-bubble max-w-[85%] rounded rounded-br-sm border px-4 py-2.5 md:max-w-[75%]">
<MessagePrimitive.Parts components={{ Text: MarkdownText }} />
</div>
<ActionBarPrimitive.Root hideWhenRunning autohide="not-last" className="mt-1 flex gap-1 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
<ActionBarPrimitive.Copy aria-label="Copy message" className="liquid-control flex size-8 items-center justify-center rounded text-ink-muted hover:text-ink data-[copied]:text-accent">
<Copy className="size-3.5" />
</ActionBarPrimitive.Copy>
</ActionBarPrimitive.Root>
</MessagePrimitive.Root>
);
}
function traceIdFrom(metadata: unknown): string | undefined {
if (!metadata || typeof metadata !== "object" || !("custom" in metadata)) return undefined;
const custom = metadata.custom;
if (!custom || typeof custom !== "object" || !("traceId" in custom)) return undefined;
return typeof custom.traceId === "string" ? custom.traceId : undefined;
}
function makeAssistantMessage(toolMode: ToolDisplayMode) {
return function AssistantMessage() {
const traceId = useAuiState((state) => traceIdFrom(state.message.metadata));
const [feedback, setFeedback] = useState<1 | -1>();
const rate = async (value: 1 | -1) => {
if (!traceId || feedback) return;
const response = await apiFetch("/api/observability/feedback", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ traceId, value }),
});
if (response.ok) setFeedback(value);
else toast("Unable to record response feedback");
};
return (
<MessagePrimitive.Root className="flex gap-3.5 py-2.5">
<div className="assistant-mark mt-1 flex size-7 shrink-0 items-center justify-center rounded border">
<Bot className="size-4 text-accent" />
</div>
<div className="group min-w-0 max-w-[90%] pt-1 md:max-w-[85%]">
<MessagePrimitive.Parts
components={{
Text: MarkdownText,
data: { by_name: { agentActivity: AgentActivityPart } },
tools: { Fallback: (props) => <ToolCall {...props} mode={toolMode} /> },
}}
/>
<ActionBarPrimitive.Root hideWhenRunning autohide="not-last" className="mt-1 flex gap-1 opacity-0 transition-opacity focus-within:opacity-100 group-hover:opacity-100">
<ActionBarPrimitive.Copy aria-label="Copy response" className="liquid-control flex size-8 items-center justify-center rounded text-ink-muted hover:text-ink data-[copied]:text-accent">
<Copy className="size-3.5" />
</ActionBarPrimitive.Copy>
<ActionBarPrimitive.Reload aria-label="Regenerate response" className="liquid-control flex size-8 items-center justify-center rounded text-ink-muted hover:text-ink">
<RefreshCw className="size-3.5" />
</ActionBarPrimitive.Reload>
{traceId ? <><button type="button" aria-label="Rate response helpful" aria-pressed={feedback === 1} disabled={feedback !== undefined} onClick={() => void rate(1)} className="liquid-control flex size-8 items-center justify-center rounded text-ink-muted hover:text-ink aria-pressed:text-accent disabled:opacity-60"><ThumbsUp className="size-3.5" /></button><button type="button" aria-label="Rate response unhelpful" aria-pressed={feedback === -1} disabled={feedback !== undefined} onClick={() => void rate(-1)} className="liquid-control flex size-8 items-center justify-center rounded text-ink-muted hover:text-ink aria-pressed:text-red-400 disabled:opacity-60"><ThumbsDown className="size-3.5" /></button><button type="button" aria-label="Copy trace ID" onClick={() => void navigator.clipboard.writeText(traceId)} className="liquid-control rounded px-2 font-mono text-[10px] text-ink-dim hover:text-ink">trace {traceId.slice(0, 8)}</button></> : null}
</ActionBarPrimitive.Root>
</div>
</MessagePrimitive.Root>
);
};
}
function Composer() {
return (
<ComposerPrimitive.Root className="liquid-composer flex min-h-14 items-end gap-2 rounded border p-2 transition-colors focus-within:border-accent/40">
<ComposerPrimitive.Input
placeholder="Message popagent…"
rows={1}
className="max-h-40 min-h-11 flex-1 resize-none bg-transparent px-2.5 py-2.5 text-ink outline-none placeholder:text-ink-dim"
/>
<ThreadPrimitive.If running={false}>
<ComposerPrimitive.Send aria-label="Send message" className="brand-orb flex size-11 shrink-0 items-center justify-center rounded transition-transform hover:scale-[1.03] disabled:opacity-30">
<ArrowUp className="size-4" />
</ComposerPrimitive.Send>
</ThreadPrimitive.If>
<ThreadPrimitive.If running>
<ComposerPrimitive.Cancel aria-label="Stop response" className="liquid-control flex size-11 shrink-0 items-center justify-center rounded text-ink">
<Square className="size-3.5" />
</ComposerPrimitive.Cancel>
</ThreadPrimitive.If>
</ComposerPrimitive.Root>
);
}
function EmptyState() {
return (
<div className="flex h-full flex-col items-center justify-end gap-5 pb-6 text-center md:pb-12">
<div className="brand-orb flex size-14 items-center justify-center rounded"><Sparkles className="size-7" /></div>
<div><h1 className="text-2xl font-semibold tracking-[-0.035em]">What can I help with?</h1><p className="mt-1.5 text-sm text-ink-muted">Choose a model and start building.</p></div>
<div className="flex max-w-full gap-2 overflow-x-auto px-1 pb-1 md:flex-wrap md:justify-center">
{SUGGESTIONS.map(({ label, prompt }) => (
<ThreadPrimitive.Suggestion key={prompt} prompt={prompt} method="replace" autoSend className="liquid-control min-h-11 shrink-0 rounded px-3.5 py-2 text-sm text-ink-muted hover:text-ink">{label}</ThreadPrimitive.Suggestion>
))}
</div>
</div>
);
}
function RunningIndicator() {
return (
<>
<ThreadPrimitive.If running><div className="page-gutter flex items-center gap-2 py-1 text-xs text-ink-dim"><Loader2 className="size-3 animate-spin" />thinking…</div></ThreadPrimitive.If>
<div aria-live="polite" className="sr-only">
<ThreadPrimitive.If running>Assistant is responding</ThreadPrimitive.If>
<ThreadPrimitive.If running={false}>Response complete</ThreadPrimitive.If>
</div>
</>
);
}
function Toasts() {
const [items, setItems] = useState<Toast[]>([]);
useEffect(() => subscribeToasts(setItems), []);
if (!items.length) return null;
return (
<div role="status" aria-live="polite" className="fixed inset-x-0 bottom-4 z-50 flex flex-col items-center gap-2 px-4">
{items.map((item) => (
<div key={item.id} className="liquid-popover max-w-md rounded border border-red-400/40 px-4 py-2.5 text-sm text-ink shadow-2xl">{item.message}</div>
))}
</div>
);
}
function formatTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}K`;
return String(tokens);
}
function inputTokensFrom(value: unknown): number | undefined {
if (!value || typeof value !== "object" || !("inputTokens" in value)) return undefined;
return typeof value.inputTokens === "number" ? value.inputTokens : undefined;
}
function ContextUsage({ limit }: { limit: number }) {
const measured = useAuiState((state) => {
for (let index = state.thread.messages.length - 1; index >= 0; index--) {
const metadata = state.thread.messages[index]?.metadata;
const direct = metadata && typeof metadata === "object" && "usage" in metadata ? inputTokensFrom(metadata.usage) : undefined;
const custom = inputTokensFrom(metadata?.custom?.usage);
if (direct !== undefined || custom !== undefined) return direct ?? custom;
}
});
const textLength = useAuiState((state) => state.thread.messages.reduce((total, message) => total + message.content.reduce((partTotal, part) => partTotal + (part.type === "text" ? part.text.length : 0), 0), 0));
if (limit <= 0) return null;
const used = measured ?? Math.ceil(textLength / 4);
const percent = Math.min((used / limit) * 100, 100);
return (
<div className="mt-2" title={`${measured === undefined ? "Estimated " : ""}${used.toLocaleString()} of ${limit.toLocaleString()} context tokens used`}>
<div className="mb-1 flex justify-between text-[11px] text-ink-dim"><span>Context</span><span>{measured === undefined ? "≈" : ""}{formatTokens(used)} / {formatTokens(limit)}</span></div>
<div role="progressbar" aria-label="Context window usage" aria-valuemin={0} aria-valuemax={limit} aria-valuenow={Math.min(used, limit)} className="h-1.5 overflow-hidden rounded-sm bg-panel-2"><div className="h-full rounded-sm bg-accent transition-[width] duration-300" style={{ width: `${percent}%` }} /></div>
</div>
);
}
function HookTimeline({ runs }: { runs: HookAuditRun[] }) {
const groups = useMemo(() => {
const grouped = new Map<string, HookAuditRun[]>();
for (const run of runs) {
const key = run.turnId ?? "session";
grouped.set(key, [...(grouped.get(key) ?? []), run]);
}
return [...grouped.entries()];
}, [runs]);
const [open, setOpen] = useState(false);
if (!runs.length) return null;
return (
<div className="page-gutter border-t border-line bg-panel/70 py-2">
<div className="w-full">
<button type="button" onClick={() => setOpen((value) => !value)} className="flex items-center gap-2 text-xs text-ink-dim" aria-expanded={open}>
<Clock3 className="size-3.5 text-accent" />Hook activity · {groups.length} {groups.length === 1 ? "turn" : "turns"}{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
</button>
{open ? <div className="mt-2 max-h-40 space-y-2 overflow-y-auto border-l border-accent/40 pl-3">{groups.map(([turnId, entries], index) => <div key={turnId}><div className="mb-1 text-[10px] uppercase tracking-wide text-ink-dim">{turnId === "session" ? "Session" : `Turn ${index + 1}`}</div>{entries.map((run) => <div key={run.id} className="flex items-start gap-2 py-0.5 text-xs"><span className="mt-0.5">{run.status.startsWith("failed") || run.status === "denied" || run.error ? <XCircle className="size-3 text-red-400" /> : <CheckCircle2 className="size-3 text-emerald-400" />}</span><span className="text-ink">{run.eventName}</span><span className="text-ink-dim">{run.handlerId} · {Math.round(run.durationMs)}ms</span>{run.reason || run.error ? <span className="text-amber-300">{run.reason ?? run.error}</span> : null}</div>)}</div>)}</div> : null}
</div>
</div>
);
}
function ThreadWorkspace({
session,
models,
contextWindows,
profiles,
toolMode,
onSaved,
onOpenSessions,
onOpenMemory,
settingsOpen,
onToggleSettings,
onChooseToolMode,
}: {
session: Session;
models: string[];
contextWindows: Record<string, number>;
profiles: Record<string, ModelProfile>;
toolMode: ToolDisplayMode;
onSaved: (session: Session) => void;
onOpenSessions: () => void;
onOpenMemory: () => void;
settingsOpen: boolean;
onToggleSettings: () => void;
onChooseToolMode: (mode: ToolDisplayMode) => void;
}) {
const [model, setModel] = useState(session.model);
const [hookRuns, setHookRuns] = useState<HookAuditRun[]>([]);
const modelRef = useRef(model);
modelRef.current = model;
const loadHooks = useCallback(async () => {
const response = await apiFetch(`/api/sessions/${session.id}/hooks`);
if (response.ok) setHookRuns(((await response.json()) as HookRunListResponse).runs);
}, [session.id]);
useEffect(() => { void loadHooks(); }, [loadHooks]);
const save = useCallback(async (messages: UIMessage[]) => {
let response = await apiFetch(`/api/sessions/${session.id}`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: modelRef.current, messages, revision: session.revision }) });
if (response.status === 409) {
const conflict = (await response.json()) as { session?: Session };
if (conflict.session) response = await apiFetch(`/api/sessions/${session.id}`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ model: modelRef.current, messages, revision: conflict.session.revision }) });
}
if (response.ok) onSaved((await response.json()) as Session);
else toast("This conversation could not be saved");
await loadHooks();
}, [loadHooks, onSaved, session.id, session.revision]);
const runtime = useChatRuntime({ id: session.id, messages: session.messages, transport: new AssistantChatTransport({
api: "/api/chat",
body: () => ({ model: modelRef.current, workspaceId: session.workspaceId }),
prepareSendMessagesRequest: ({ id, messages }) => ({
body: { id, model: modelRef.current, workspaceId: session.workspaceId, messages: [messages.at(-1)] },
}),
}), onFinish: ({ messages }) => void save(messages) });
const AssistantMessage = useMemo(() => makeAssistantMessage(toolMode), [toolMode]);
return (
<AssistantRuntimeProvider runtime={runtime}>
<main className="flex min-w-0 flex-1 flex-col">
<header className="page-gutter liquid-header flex items-center gap-2">
<button type="button" aria-label="Open sessions" onClick={onOpenSessions} className="liquid-control flex size-10 shrink-0 items-center justify-center rounded lg:hidden"><Menu className="size-4" /></button>
<span className="min-w-0 flex-1 truncate px-1 text-sm font-medium tracking-[-0.01em] lg:hidden">{session.title}</span>
<div className="ml-auto flex items-center gap-1.5">
<ModelPicker models={models} profiles={profiles} value={model} onChange={setModel} />
<button type="button" aria-label="Open memory" onClick={onOpenMemory} className="liquid-control flex size-10 items-center justify-center rounded text-ink-muted hover:text-ink"><Brain className="size-4" /></button>
<div className="relative">
<button type="button" aria-label="Tool display settings" aria-expanded={settingsOpen} onClick={onToggleSettings} className="liquid-control flex size-10 items-center justify-center rounded text-ink-muted hover:text-ink"><Settings2 className="size-4" /></button>
{settingsOpen ? <div className="liquid-popover absolute right-0 top-12 z-20 w-52 border p-1.5">{(["compact", "expanded", "timeline"] as const).map((mode) => <button key={mode} type="button" onClick={() => onChooseToolMode(mode)} className={`min-h-11 w-full rounded px-3 text-left text-sm capitalize ${toolMode === mode ? "bg-accent-soft text-ink" : "text-ink-muted hover:bg-panel-2 hover:text-ink"}`}>{mode}</button>)}</div> : null}
</div>
</div>
</header>
<ThreadPrimitive.Root className="flex min-h-0 flex-1 flex-col">
<ThreadPrimitive.Viewport className="page-content flex-1 overflow-y-auto"><div className="h-full w-full"><ThreadPrimitive.Empty><EmptyState /></ThreadPrimitive.Empty><ThreadPrimitive.Messages components={{ UserMessage, AssistantMessage }} /></div></ThreadPrimitive.Viewport>
<RunningIndicator />
<HookTimeline runs={hookRuns} />
<div className="page-gutter pb-[max(1rem,env(safe-area-inset-bottom))]"><div className="w-full"><Composer /><ContextUsage limit={contextWindows[model] ?? 0} /></div></div>
</ThreadPrimitive.Root>
</main>
<BrowserPanel threadId={session.id} />
</AssistantRuntimeProvider>
);
}
function DeleteDialog({ session, onCancel, onDelete }: { session?: SessionSummary; onCancel: () => void; onDelete: (session: SessionSummary) => void }) {
const dialogRef = useRef<HTMLDivElement>(null);
useFocusTrap(dialogRef, Boolean(session));
useEffect(() => {
if (!session) return;
dialogRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
const close = (event: KeyboardEvent) => {
if (event.key === "Escape") onCancel();
};
document.addEventListener("keydown", close);
return () => document.removeEventListener("keydown", close);
}, [onCancel, session]);
if (!session) return null;
return <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/65 p-4"><div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="delete-title" className="w-full max-w-md rounded border border-line bg-panel p-5 shadow-2xl"><h2 id="delete-title" className="text-lg font-semibold">Delete “{session.title}”?</h2><p className="mt-2 text-sm leading-relaxed text-ink-dim">This permanently deletes the session transcript and its Mastra thread data. Independent long-term memories are retained.</p><div className="mt-5 flex justify-end gap-2"><button type="button" onClick={onCancel} className="min-h-11 rounded border border-line px-4 text-sm">Cancel</button><button type="button" onClick={() => onDelete(session)} className="min-h-11 rounded bg-red-500/90 px-4 text-sm font-medium text-white">Delete</button></div></div></div>;
}
export function PopagentBrand({ version }: { version: string }) {
return (
<div className="flex items-center gap-2.5">
<div className="brand-orb flex size-8 items-center justify-center rounded"><Bot className="size-4" /></div>
<div className="min-w-0 leading-none">
<span className="block font-semibold tracking-[-0.025em]">popagent</span>
<span className="mt-1 block text-[10px] tabular-nums text-ink-dim">v{version}</span>
</div>
</div>
);
}
function SessionSidebar({
mobile,
inert,
version,
active,
channelActive,
changelogActive,
sessions,
workspaces,
selectedWorkspaceId,
query,
archived,
menuSessionId,
onClose,
onOverview,
onOpenChannel,
onOpenChangelog,
onSelectWorkspace,
onCreate,
onOpenDocumentation,
onQuery,
onArchiveView,
onSelect,
onToggleMenu,
onRename,
onArchive,
onDelete,
onOpenSettings,
}: {
inert?: boolean;
mobile: boolean;
version: string;
active?: Session;
channelActive: boolean;
changelogActive: boolean;
sessions: SessionSummary[];
workspaces: AgentWorkspace[];
selectedWorkspaceId?: string;
query: string;
archived: boolean;
menuSessionId?: string;
onClose: () => void;
onOverview: () => void;
onOpenChannel: () => void;
onOpenChangelog: () => void;
onSelectWorkspace: (id: string) => void;
onCreate: () => void;
onOpenDocumentation: () => void;
onQuery: (query: string) => void;
onArchiveView: (archived: boolean) => void;
onSelect: (id: string) => void;
onToggleMenu: (id?: string) => void;
onRename: (session: SessionSummary) => void;
onArchive: (session: SessionSummary, archived: boolean) => void;
onDelete: (session: SessionSummary) => void;
onOpenSettings: () => void;
}) {
return (
<aside inert={inert || undefined} role={mobile ? "dialog" : undefined} aria-modal={mobile || undefined} aria-label="Session navigation" className={`${mobile ? "fixed inset-y-0 left-0 z-30 flex w-full max-w-80 lg:hidden" : "hidden w-64 shrink-0 lg:flex"} liquid-sidebar flex-col border-r shadow-2xl lg:shadow-none`}>
<div className="flex min-h-16 items-center px-4">
<PopagentBrand version={version} />
{mobile ? <button autoFocus type="button" aria-label="Close sessions" onClick={onClose} className="liquid-control ml-auto flex size-11 items-center justify-center rounded"><X className="size-4" /></button> : null}
</div>
<nav className="px-2 pb-3" aria-label="Workspaces">
<button type="button" onClick={onOverview} className={`flex min-h-11 w-full items-center gap-2 rounded px-2.5 text-left text-sm ${!selectedWorkspaceId && !channelActive && !changelogActive ? "active-session text-ink" : "text-ink-muted hover:bg-panel-2 hover:text-ink"}`}><LayoutDashboard className="size-4 shrink-0 text-accent" />Overview</button>
<button type="button" onClick={onOpenChannel} className={`mt-1 flex min-h-11 w-full items-center gap-2 rounded px-2.5 text-left text-sm ${channelActive ? "active-session text-ink" : "text-ink-muted hover:bg-panel-2 hover:text-ink"}`}><Hash className="size-4 shrink-0 text-accent" />#general<span className="ml-auto size-2 rounded-full bg-emerald-500" aria-label="Live" /></button>
<div className="mt-2 px-2 text-[10px] font-medium uppercase tracking-[0.12em] text-ink-dim">Workspaces</div>
<div className="mt-1 max-h-40 overflow-y-auto">
{workspaces.map((workspace) => <button key={workspace.id} type="button" onClick={() => onSelectWorkspace(workspace.id)} title={workspace.repositoryPath} className={`flex min-h-11 w-full items-center gap-2 rounded px-2.5 text-left text-sm ${selectedWorkspaceId === workspace.id ? "active-session text-ink" : "text-ink-muted hover:bg-panel-2 hover:text-ink"}`}><FolderGit2 className="size-3.5 shrink-0" /><span className="truncate">{workspace.name}</span></button>)}
</div>
</nav>
<div className="mx-3 grid grid-cols-2 gap-2">
<button type="button" aria-label="Create new chat" disabled={!selectedWorkspaceId} onClick={onCreate} className="liquid-control flex min-h-11 items-center gap-2 rounded px-3 text-sm text-ink disabled:cursor-not-allowed disabled:opacity-40"><MessageSquarePlus className="size-4 text-accent" />Chat</button>
<button type="button" disabled={!selectedWorkspaceId} onClick={onOpenDocumentation} className="liquid-control flex min-h-11 items-center gap-2 rounded px-3 text-sm text-ink disabled:cursor-not-allowed disabled:opacity-40"><BookOpen className="size-4 text-accent" />Docs</button>
</div>
<div className="px-3 pt-3">
<label className="liquid-control flex min-h-11 items-center gap-2 rounded px-3"><Search className="size-3.5 text-ink-muted" /><input aria-label="Search chats" value={query} onChange={(event) => onQuery(event.currentTarget.value)} placeholder="Search chats" className="min-w-0 flex-1 bg-transparent text-sm outline-none" /></label>
<div className="mt-2 grid grid-cols-2 rounded border border-line bg-black/10 p-1 text-xs">
<button type="button" onClick={() => onArchiveView(false)} className={`min-h-10 rounded transition-colors ${!archived ? "bg-panel-2 text-ink shadow-sm" : "text-ink-muted hover:text-ink"}`}>Active</button>
<button type="button" onClick={() => onArchiveView(true)} className={`min-h-10 rounded transition-colors ${archived ? "bg-panel-2 text-ink shadow-sm" : "text-ink-muted hover:text-ink"}`}>Archived</button>
</div>
</div>
<div className="mt-3 min-h-0 flex-1 overflow-y-auto px-2">
<div className="px-2 pb-2 text-[11px] font-medium uppercase tracking-[0.12em] text-ink-dim">{archived ? "Archived" : "Sessions"}</div>
{sessions.map((session) => (
<div key={session.id} className={`relative mb-1 flex items-center rounded border transition-colors ${active?.id === session.id ? "active-session border-accent/20" : "border-transparent hover:bg-panel-2"}`}>
<button aria-label={`Open chat: ${session.title}`} onClick={() => onSelect(session.id)} className={`flex min-h-11 min-w-0 flex-1 items-center gap-2 px-2.5 text-left text-sm ${active?.id === session.id ? "text-ink" : "text-ink-muted"}`}><MessageSquare className="size-3.5 shrink-0" /><span className="truncate">{session.title}</span></button>
<button type="button" aria-label={`Chat actions: ${session.title}`} aria-expanded={menuSessionId === session.id} onClick={() => onToggleMenu(menuSessionId === session.id ? undefined : session.id)} className="flex size-11 shrink-0 items-center justify-center rounded text-ink-muted hover:bg-panel-2 hover:text-ink"><MoreVertical className="size-4" /></button>
{menuSessionId === session.id ? (
<div className="liquid-popover absolute right-1 top-11 z-20 w-40 border p-1.5">
<button type="button" onClick={() => onRename(session)} className="flex min-h-11 w-full items-center gap-2 rounded px-3 text-sm text-ink-muted hover:bg-panel-2 hover:text-ink"><Pencil className="size-4" />Rename</button>
<button type="button" onClick={() => onArchive(session, !archived)} className="flex min-h-11 w-full items-center gap-2 rounded px-3 text-sm text-ink-muted hover:bg-panel-2 hover:text-ink">{archived ? <ArchiveRestore className="size-4" /> : <Archive className="size-4" />}{archived ? "Restore" : "Archive"}</button>
<button type="button" onClick={() => onDelete(session)} className="flex min-h-11 w-full items-center gap-2 rounded px-3 text-sm text-red-300 hover:bg-red-500/10"><Trash2 className="size-4" />Delete</button>
</div>
) : null}
</div>
))}
</div>
<button type="button" onClick={onOpenChangelog} className={`mx-3 mb-1 flex min-h-11 items-center gap-2 rounded px-3 text-sm transition-colors ${changelogActive ? "active-session text-ink" : "text-ink-muted hover:bg-panel-2 hover:text-ink"}`}><ScrollText className="size-4" />Changelog</button>
<button type="button" onClick={onOpenSettings} className="mx-3 mb-3 flex min-h-11 items-center gap-2 rounded px-3 text-sm text-ink-muted transition-colors hover:bg-panel-2 hover:text-ink"><Settings2 className="size-4" />Settings</button>
</aside>
);
}
export function Chat() {
const [pathname, setPathname] = useState(() => window.location.pathname);
const route = useMemo(() => parseAppRoute(pathname), [pathname]);
const navigateRoute = useCallback((next: AppRoute, options: RouteNavigationOptions = {}) => {
const nextPath = appRoutePath(next);
if (options.replace) window.history.replaceState(options.state ?? null, "", nextPath);
else window.history.pushState(options.state ?? null, "", nextPath);
setPathname(nextPath);
}, []);
useEffect(() => {
const syncPath = () => setPathname(window.location.pathname);
window.addEventListener("popstate", syncPath);
return () => window.removeEventListener("popstate", syncPath);
}, []);
useEffect(() => {
const canonicalPath = appRoutePath(route);
if (pathname === canonicalPath) return;
window.history.replaceState(window.history.state, "", canonicalPath);
setPathname(canonicalPath);
}, [pathname, route]);
const [models, setModels] = useState<string[]>([]);
const [contextWindows, setContextWindows] = useState<Record<string, number>>({});
const [modelProfiles, setModelProfiles] = useState<Record<string, ModelProfile>>({});
const [workspaces, setWorkspaces] = useState<AgentWorkspace[]>([]);
const [tasks, setTasks] = useState<AgentTask[]>([]);
const [taskStepLimit, setTaskStepLimit] = useState<number>();
const [sessions, setSessions] = useState<SessionSummary[]>([]);
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string>();
const [active, setActive] = useState<Session>();
const [query, setQuery] = useState("");
const [archived, setArchived] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [memoryOpen, setMemoryOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const agentSettingsOpen = route.page === "settings";
const [workspaceDialogOpen, setWorkspaceDialogOpen] = useState(false);
const [workspaceTarget, setWorkspaceTarget] = useState<AgentWorkspace>();
const [workspaceChatsDeleteTarget, setWorkspaceChatsDeleteTarget] = useState<AgentWorkspace>();
const [workspaceChatsDeleting, setWorkspaceChatsDeleting] = useState(false);
const [workspaceChatsDeleteError, setWorkspaceChatsDeleteError] = useState("");
const [menuSessionId, setMenuSessionId] = useState<string>();
const [deleteTarget, setDeleteTarget] = useState<SessionSummary>();
const sessionButtonRef = useRef<HTMLButtonElement>(null);
const memoryButtonRef = useRef<HTMLButtonElement>(null);
const agentSettingsButtonRef = useRef<HTMLElement>(null);
const restoreSettingsFocusRef = useRef(false);
const [toolMode, setToolMode] = useState<ToolDisplayMode>(() => typeof localStorage === "undefined" ? "compact" : parseToolDisplayMode(localStorage.getItem(TOOL_DISPLAY_KEY)));
const selectedWorkspace = workspaces.find((workspace) => workspace.id === selectedWorkspaceId);
const visibleSessions = useMemo(
() => filterByQuery(selectedWorkspaceId ? sessions.filter((session) => session.workspaceId === selectedWorkspaceId) : [], query),
[query, selectedWorkspaceId, sessions],
);
const loadSessions = useCallback(async (showArchived: boolean) => {
const response = await apiFetch(`/api/sessions?archived=${showArchived}`);
if (!response.ok) throw new Error(`Unable to load sessions: HTTP ${response.status}`);
setSessions(((await response.json()) as SessionListResponse).sessions);
}, []);
const loadWorkspaces = useCallback(async () => {
const response = await apiFetch("/api/workspaces");
if (!response.ok) throw new Error(`Unable to load workspaces: HTTP ${response.status}`);
setWorkspaces(((await response.json()) as WorkspaceListResponse).workspaces);
}, []);
const loadTasks = useCallback(async () => {
const response = await apiFetch("/api/tasks");
if (!response.ok) throw new Error(`Unable to load tasks: HTTP ${response.status}`);
setTasks(((await response.json()) as TaskListResponse).tasks);
}, []);
const createBackgroundTask = useCallback(async (workspaceId: string, prompt: string, model: string): Promise<boolean> => {
try {
const response = await apiFetch("/api/tasks", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ workspaceId, prompt, model }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
await loadTasks();
return true;
} catch {
toast("Unable to start task");
return false;
}
}, [loadTasks]);
const cancelBackgroundTask = useCallback(async (task: AgentTask) => {
try {
const response = await apiFetch(`/api/tasks/${task.id}/cancel`, { method: "POST" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
await loadTasks();
} catch {
toast("Unable to cancel task");
}
}, [loadTasks]);
const removeBackgroundTask = useCallback(async (task: AgentTask): Promise<boolean> => {
try {
const response = await apiFetch(`/api/tasks/${task.id}`, { method: "DELETE" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
await loadTasks();
toast("Task removed");
return true;
} catch {
toast("Unable to remove task");
return false;
}
}, [loadTasks]);
const closeSidebar = useCallback(() => {
setSidebarOpen(false);
requestAnimationFrame(() => sessionButtonRef.current?.focus());
}, []);
const closeMemory = useCallback(() => {
setMemoryOpen(false);
requestAnimationFrame(() => memoryButtonRef.current?.focus());
}, []);
const closeAgentSettings = useCallback(() => {
restoreSettingsFocusRef.current = true;
const returnTo = (window.history.state as AppHistoryState | null)?.settingsReturnTo;
if (returnTo) window.history.back();
else navigateRoute({ page: "overview" }, { replace: true });
}, [navigateRoute]);
useEffect(() => {
if (route.page === "settings" || !restoreSettingsFocusRef.current) return;
restoreSettingsFocusRef.current = false;
if (route.page !== "channel") setTimeout(() => {
if (agentSettingsButtonRef.current?.isConnected) agentSettingsButtonRef.current.focus();
else sessionButtonRef.current?.focus();
});
}, [route.page]);
const selectSession = useCallback((id: string) => {
navigateRoute({ page: "chat", sessionId: id });
setSidebarOpen(false);
setMenuSessionId(undefined);
}, [navigateRoute]);
const openWorkspace = useCallback((id: string) => {
navigateRoute({ page: "workspace", workspaceId: id });
setSidebarOpen(false);
setMenuSessionId(undefined);
}, [navigateRoute]);
const createSession = useCallback(singleFlight(async (model: string | undefined, workspaceId: string) => {
const response = await apiFetch("/api/sessions", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...(model ? { model } : {}), workspaceId }),
});
if (!response.ok) throw new Error(`Unable to create session: HTTP ${response.status}`);
const created = (await response.json()) as Session;
setArchived(false);
setSessions((current) => [created, ...current.filter((session) => session.id !== created.id)]);
setSelectedWorkspaceId(workspaceId);
setActive(created);
navigateRoute({ page: "chat", sessionId: created.id });
setSidebarOpen(false);
return created;
}), [navigateRoute]);
const [loadFailed, setLoadFailed] = useState(false);
const [needsKey, setNeedsKey] = useState(false);
const initialLoad = useCallback(() => {
setLoadFailed(false);
void apiFetch("/api/models")
.then(async (response) => {
if (!response.ok) throw new Error(`Unable to load models: HTTP ${response.status}`);
return response.json() as Promise<ModelCatalogResponse>;
})
.then((catalog) => {
setModels(catalog.models);
setContextWindows(catalog.contextWindows);
setModelProfiles(catalog.profiles);
})
.catch(() => toast("Unable to load the model catalog"));
void Promise.all([loadSessions(false), loadWorkspaces()])
.catch(() => setLoadFailed(true));
void loadTasks().catch(() => toast("Unable to load task activity"));
void apiFetch("/api/settings/agent-runtime")
.then(async (response) => {
if (!response.ok) throw new Error(`Unable to load task limits: HTTP ${response.status}`);
setTaskStepLimit(((await response.json()) as AgentRuntimeSettings).supervisorMaxSteps);
})
.catch(() => toast("Unable to load task progress limit"));
}, [loadSessions, loadTasks, loadWorkspaces]);
useEffect(() => initialLoad(), [initialLoad]);
useEffect(() => {
if (selectedWorkspaceId || route.page === "settings") return;
const timer = window.setInterval(() => void loadTasks().catch(() => undefined), 2_000);
return () => window.clearInterval(timer);
}, [loadTasks, route.page, selectedWorkspaceId]);
useEffect(() => {
const onUnauthorized = () => setNeedsKey(true);
window.addEventListener("popagent:unauthorized", onUnauthorized);
return () => window.removeEventListener("popagent:unauthorized", onUnauthorized);
}, []);
useEffect(() => {
if (route.page === "settings" || needsKey) return;
setSidebarOpen(false);
setMenuSessionId(undefined);
if (route.page === "overview") {
setSelectedWorkspaceId(undefined);
setActive(undefined);
if (archived) {
setArchived(false);
void loadSessions(false).catch(() => toast("Unable to load workspace chats"));
}
return;
}
if (route.page === "workspace" || route.page === "documentation") {
setSelectedWorkspaceId(route.workspaceId);
setActive(undefined);
return;
}
if (route.page === "channel") {
setSelectedWorkspaceId(undefined);
setActive(undefined);
return;
}
if (route.page === "changelog") {
setSelectedWorkspaceId(undefined);
setActive(undefined);
return;
}
if (active?.id === route.sessionId) return;
let cancelled = false;
void apiFetch(`/api/sessions/${route.sessionId}`)
.then(async (response) => {
if (cancelled) return;
if (response.ok) {
const session = (await response.json()) as Session;
setActive(session);
setSelectedWorkspaceId(session.workspaceId);
} else if (response.status === 404) {
toast("Chat not found");
navigateRoute({ page: "overview" }, { replace: true });
}
})
.catch(() => { if (!cancelled) toast("Unable to load chat"); });
return () => { cancelled = true; };
}, [active?.id, archived, loadSessions, navigateRoute, needsKey, route]);
useEffect(() => {
if (!sidebarOpen) return;
const close = (event: KeyboardEvent) => {
if (event.key === "Escape") closeSidebar();
};
document.addEventListener("keydown", close);
return () => document.removeEventListener("keydown", close);
}, [closeSidebar, sidebarOpen]);
const handleSaved = useCallback((saved: Session) => { setActive(saved); setSessions((current) => [saved, ...current.filter((session) => session.id !== saved.id)]); }, []);
const renameSession = async (session: SessionSummary) => {
setMenuSessionId(undefined);
const title = prompt("Rename chat", session.title)?.trim();
if (!title) return;
const response = await apiFetch(`/api/sessions/${session.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ title }) });
if (response.ok) {
const updated = (await response.json()) as Session;
setSessions((current) => current.map((item) => item.id === updated.id ? updated : item));
}
};
const setSessionArchived = async (session: SessionSummary, value: boolean) => {
const response = await apiFetch(`/api/sessions/${session.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ archived: value }) });
if (response.ok) {
setSessions((current) => current.filter((item) => item.id !== session.id));
if (active?.id === session.id) {
setActive(undefined);
navigateRoute({ page: "workspace", workspaceId: session.workspaceId });
}
}
};
const deleteSession = async (session: SessionSummary) => {
const response = await apiFetch(`/api/sessions/${session.id}`, { method: "DELETE" });
if (response.ok) {
setDeleteTarget(undefined);
setSessions((current) => current.filter((item) => item.id !== session.id));
if (active?.id === session.id) {
setActive(undefined);
navigateRoute({ page: "workspace", workspaceId: session.workspaceId });
}
}
};
const deleteWorkspaceChats = async () => {
const workspace = workspaceChatsDeleteTarget;
if (!workspace) return;
setWorkspaceChatsDeleting(true);
setWorkspaceChatsDeleteError("");
const response = await apiFetch(`/api/workspaces/${workspace.id}/sessions`, { method: "DELETE" });
if (response.ok) {
const { deleted } = await response.json() as WorkspaceChatDeleteResponse;
setSessions((current) => current.filter((session) => session.workspaceId !== workspace.id));
if (active?.workspaceId === workspace.id) setActive(undefined);
setWorkspaceChatsDeleteTarget(undefined);
toast(deleted ? `Deleted ${deleted} workspace chat${deleted === 1 ? "" : "s"}` : "This workspace has no chats");
} else {
const body = await response.json().catch(() => ({})) as { error?: string };
setWorkspaceChatsDeleteError(body.error ?? "Unable to delete workspace chats");
}
setWorkspaceChatsDeleting(false);
};
const chooseToolMode = (mode: ToolDisplayMode) => {
setToolMode(mode);
localStorage.setItem(TOOL_DISPLAY_KEY, mode);
setSettingsOpen(false);
};
const showOverview = () => {
navigateRoute({ page: "overview" });
setSelectedWorkspaceId(undefined);
setActive(undefined);
setArchived(false);
setSidebarOpen(false);
void loadSessions(false).catch(() => toast("Unable to load workspace chats"));
};
const openWorkspaceDialog = (workspace?: AgentWorkspace) => {
setWorkspaceTarget(workspace);
setWorkspaceDialogOpen(true);
};
const handleWorkspaceSaved = (saved?: AgentWorkspace) => {
const target = workspaceTarget;
setWorkspaceDialogOpen(false);
setWorkspaceTarget(undefined);
if (saved) {
setWorkspaces((current) => [saved, ...current.filter((workspace) => workspace.id !== saved.id)]);
if (!target) openWorkspace(saved.id);
} else if (target) {
setWorkspaces((current) => current.filter((workspace) => workspace.id !== target.id));
if (selectedWorkspaceId === target.id) showOverview();
}
void loadWorkspaces().catch(() => toast("Unable to refresh workspaces"));
};
const sidebarProps = {
version: APP_VERSION,
active,
channelActive: route.page === "channel",
changelogActive: route.page === "changelog",
sessions: visibleSessions,
workspaces,
selectedWorkspaceId,
query,
archived,
menuSessionId,
onClose: closeSidebar,
onOverview: showOverview,
onOpenChannel: () => { navigateRoute({ page: "channel", channelId: "general" }); setSidebarOpen(false); },
onOpenChangelog: () => { navigateRoute({ page: "changelog" }); setSidebarOpen(false); },
onSelectWorkspace: openWorkspace,
onCreate: () => {
if (selectedWorkspaceId) {
void createSession(active?.model, selectedWorkspaceId)
.catch(() => toast("Unable to create a chat"));
}
},
onOpenDocumentation: () => {
if (selectedWorkspaceId) navigateRoute({ page: "documentation", workspaceId: selectedWorkspaceId });
setSidebarOpen(false);
},
onQuery: setQuery,
onArchiveView: (value: boolean) => {
setArchived(value);
void loadSessions(value).catch(() => toast("Unable to load workspace chats"));
},
onSelect: selectSession,
onToggleMenu: setMenuSessionId,
onRename: (session: SessionSummary) => void renameSession(session),
onArchive: (session: SessionSummary, value: boolean) => void setSessionArchived(session, value),
onDelete: (session: SessionSummary) => { setMenuSessionId(undefined); setDeleteTarget(session); },
onOpenSettings: () => {
agentSettingsButtonRef.current = document.activeElement as HTMLElement;
setSidebarOpen(false);
navigateRoute(
{ page: "settings", section: "company" },
{ state: { settingsReturnTo: appRoutePath(route) } satisfies AppHistoryState },
);
},
};
if (needsKey) {
return (
<div className="app-shell flex h-full items-center justify-center p-6">
<form
className="liquid-popover w-full max-w-sm rounded border p-6"
onSubmit={(event) => {
event.preventDefault();
const key = new FormData(event.currentTarget).get("key");
if (typeof key === "string" && key.trim()) {
localStorage.setItem("popagent:api-key", key.trim());
setNeedsKey(false);
initialLoad();
}
}}
>
<h1 className="text-lg font-semibold">Access key required</h1>
<p className="mt-1 text-sm text-ink-muted">Enter the access key configured on this server.</p>
<input name="key" type="password" autoFocus aria-label="Access key" className="liquid-control mt-4 block min-h-11 w-full rounded px-3 text-ink outline-none" />
<button type="submit" className="brand-orb mt-4 min-h-11 w-full rounded text-sm font-medium">Unlock</button>
</form>
</div>
);
}
if (loadFailed) {
return (
<div className="app-shell flex h-full items-center justify-center p-6">
<div className="liquid-popover w-full max-w-sm rounded border p-6 text-center">
<h1 className="text-lg font-semibold">Unable to load chats</h1>
<p className="mt-1 text-sm text-ink-muted">The server did not respond. Check that popagent is running.</p>
<button type="button" onClick={initialLoad} className="brand-orb mt-4 min-h-11 w-full rounded text-sm font-medium">Retry</button>
</div>
</div>
);
}
return (
<div className="app-shell flex h-full overflow-hidden">
<Toasts />
<SessionSidebar inert={sidebarOpen || memoryOpen || agentSettingsOpen || workspaceDialogOpen || Boolean(workspaceChatsDeleteTarget)} mobile={false} {...sidebarProps} />
{sidebarOpen ? (
<>
<button type="button" tabIndex={-1} aria-hidden="true" aria-label="Close session navigation" onClick={closeSidebar} className="fixed inset-0 z-20 bg-black/70 lg:hidden" />
<SessionSidebar mobile {...sidebarProps} />
</>
) : null}
<div inert={sidebarOpen || memoryOpen || agentSettingsOpen || workspaceDialogOpen || Boolean(workspaceChatsDeleteTarget) || undefined} className="relative flex min-w-0 flex-1">
{route.page === "changelog" ? (
<ChangelogPage onOpenNavigation={() => { sessionButtonRef.current = document.activeElement as HTMLButtonElement; setSidebarOpen(true); }} />
) : route.page === "channel" ? (
<ChannelPage
channelId={route.channelId}
workspaces={workspaces}
focusSettingsOnMount={restoreSettingsFocusRef.current}
onOpenNavigation={() => { sessionButtonRef.current = document.activeElement as HTMLButtonElement; setSidebarOpen(true); }}
onOpenSettings={() => {
agentSettingsButtonRef.current = document.activeElement as HTMLElement;
navigateRoute({ page: "settings", section: "channels" }, { state: { settingsReturnTo: appRoutePath(route) } satisfies AppHistoryState });
}}
/>
) : !selectedWorkspaceId ? (
<WorkspaceOverview
workspaces={workspaces}
sessions={sessions}
tasks={tasks}
taskStepLimit={taskStepLimit}
models={models}
modelProfiles={modelProfiles}
onCreateTask={createBackgroundTask}
onCancelTask={(task) => void cancelBackgroundTask(task)}
onRemoveTask={removeBackgroundTask}
onOpen={openWorkspace}
onManage={openWorkspaceDialog}
onOpenNavigation={() => { sessionButtonRef.current = document.activeElement as HTMLButtonElement; setSidebarOpen(true); }}
/>
) : route.page === "documentation" && selectedWorkspace ? (
<DocumentationWorkspace
workspace={selectedWorkspace}
path={route.path}
onOpen={(path, workspaceId) => navigateRoute({ page: "documentation", workspaceId: workspaceId ?? selectedWorkspace.id, ...(path ? { path } : {}) })}
onOpenNavigation={() => { sessionButtonRef.current = document.activeElement as HTMLButtonElement; setSidebarOpen(true); }}
/>
) : active ? (
<ThreadWorkspace
key={active.id}
session={active}
models={models}
profiles={modelProfiles}
contextWindows={contextWindows}
toolMode={toolMode}
onSaved={handleSaved}
onOpenSessions={() => { sessionButtonRef.current = document.activeElement as HTMLButtonElement; setSidebarOpen(true); }}
onOpenMemory={() => { memoryButtonRef.current = document.activeElement as HTMLButtonElement; setMemoryOpen(true); }}
settingsOpen={settingsOpen}
onToggleSettings={() => setSettingsOpen((value) => !value)}
onChooseToolMode={chooseToolMode}
/>
) : (
<main className="flex min-w-0 flex-1 flex-col">
<header className="page-gutter liquid-header flex items-center gap-2">
<button type="button" aria-label="Open workspace navigation" onClick={() => { sessionButtonRef.current = document.activeElement as HTMLButtonElement; setSidebarOpen(true); }} className="liquid-control flex size-10 items-center justify-center rounded lg:hidden"><Menu className="size-4" /></button>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{selectedWorkspace?.name ?? "Workspace"}</span>
</header>
<div className="page-content flex flex-1 items-center justify-center text-center">
<div className="max-w-md">
<div className="brand-orb mx-auto flex size-14 items-center justify-center rounded"><FolderGit2 className="size-7" /></div>
<h1 className="mt-4 text-2xl font-semibold tracking-[-0.035em]">{selectedWorkspace?.name ?? "Workspace"}</h1>
<code className="mt-2 block truncate text-xs text-ink-dim">{selectedWorkspace?.repositoryPath}</code>
<p className="mt-3 text-sm text-ink-muted">Choose a conversation, edit the project documentation, or start a new chat in this repository.</p>
<div className="mt-5 flex flex-wrap justify-center gap-2"><button type="button" onClick={() => void createSession(undefined, selectedWorkspaceId).catch(() => toast("Unable to create a chat"))} className="brand-orb min-h-11 rounded px-5 text-sm font-medium"><MessageSquarePlus className="mr-2 inline size-4" />New chat</button><button type="button" onClick={() => navigateRoute({ page: "documentation", workspaceId: selectedWorkspaceId })} className="liquid-control min-h-11 rounded px-5 text-sm"><BookOpen className="mr-2 inline size-4" />Documentation</button><button type="button" onClick={() => { if (selectedWorkspace) { setWorkspaceChatsDeleteError(""); setWorkspaceChatsDeleteTarget(selectedWorkspace); } }} className="min-h-11 rounded border border-red-500/30 px-5 text-sm text-red-300 hover:bg-red-500/10"><Trash2 className="mr-2 inline size-4" />Delete all chats</button></div>
</div>
</div>
</main>
)}
</div>
{memoryOpen ? <button type="button" tabIndex={-1} aria-hidden="true" aria-label="Close memory overlay" onClick={closeMemory} className="fixed inset-0 z-30 bg-black/70" /> : null}
<MemoryDrawer open={memoryOpen} onClose={closeMemory} />
<DeleteDialog session={deleteTarget} onCancel={() => setDeleteTarget(undefined)} onDelete={(session) => void deleteSession(session)} />
<AgentSettingsPage
open={agentSettingsOpen}
section={route.page === "settings" ? route.section : "company"}
onSectionChange={(section) => navigateRoute(
{ page: "settings", section },
{ replace: true, state: window.history.state },
)}
onClose={closeAgentSettings}
workspaces={workspaces}
workspaceId={selectedWorkspaceId}
/>
<WorkspaceDialog open={workspaceDialogOpen} workspace={workspaceTarget} onClose={() => { setWorkspaceDialogOpen(false); setWorkspaceTarget(undefined); }} onSaved={handleWorkspaceSaved} />
<WorkspaceChatDeleteDialog workspace={workspaceChatsDeleteTarget} deleting={workspaceChatsDeleting} error={workspaceChatsDeleteError} onCancel={() => { setWorkspaceChatsDeleteTarget(undefined); setWorkspaceChatsDeleteError(""); }} onDelete={() => void deleteWorkspaceChats()} />
</div>
);
}