);
@@ -224,8 +260,10 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
renameSession,
refreshSessions,
} = useAgentSessions();
+ const { agents } = useAgentRoster();
const router = useRouter();
const { name: agentName } = useAgentDisplayName();
+ const { side: sidebarSide } = useSessionListSide();
const isMobile = useIsMobile();
const isCompact = useIsCompact();
const { confirm, ConfirmDialog } = useConfirm();
@@ -286,16 +324,22 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
}
wasCompactRef.current = isCompact;
}, [isCompact]);
- const [sidebarWidth, setSidebarWidth] = useSidebarWidth();
+ const [sidebarWidth, setSidebarWidth] = usePanelWidth();
const handleSessionCreated = useCallback(
(id: string) => {
draftMintedIdRef.current = id;
setSelectedIdState(id);
- router.replace(chatHref(id));
+ // Native replaceState, deliberately not router.replace: moving the
+ // optional catch-all from zero segments to one changes the route
+ // shape, which remounts this page — and a remount replaces the chat
+ // hook instance, so the in-flight stream would render into dead
+ // state and the pane would sit empty until a reload. The App Router
+ // syncs its state from native history updates without remounting.
+ window.history.replaceState(null, "", chatHref(id));
void refreshSessions();
},
- [router, refreshSessions],
+ [refreshSessions],
);
// The draft keeps a null hook id even after its session is minted and the
@@ -448,6 +492,7 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
isLoading: sessionsLoading,
error: sessionsError,
groups,
+ agents,
selectedId,
onSelect: handleSelectSession,
actions: sessionActions,
@@ -476,6 +521,7 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
onSend={sendMessage}
botName={agentName}
sidebarOpen={open}
+ sidebarSide={sidebarSide}
onToggleSidebar={onToggle}
pendingApproval={pendingApproval}
onRespondApproval={respondToApproval}
@@ -513,6 +559,7 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
onPickSeed={setDraftSeed}
error={turnError}
showSidebarButton
+ sidebarSide={sidebarSide}
onShowSidebar={() => setSidebarOpen(true)}
/>
)}
@@ -523,6 +570,23 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
return (
{dialogs}
+
+ {selectedSession ? (
+ chatView(sidebarOpen, () => setSidebarOpen((o) => !o))
+ ) : (
+ setSidebarOpen(true)}
+ />
+ )}
+
+
{sidebarOpen &&
(isCompact ? (
<>
@@ -534,10 +598,15 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
onClick={() => setSidebarOpen(false)}
/>
+ {/* Handle on the panel's inner edge: dragging inward widens */}
@@ -546,32 +615,21 @@ export function ChatWorkspace({ sessionId }: { sessionId?: string }) {
>
) : (
+ {/* Handle on the panel's inner edge: dragging inward widens */}
))}
-
-
- {selectedSession ? (
- chatView(sidebarOpen, () => setSidebarOpen((o) => !o))
- ) : (
- setSidebarOpen(true)}
- />
- )}
-
);
}
diff --git a/studio/src/app/workspace/chat/_components/session-sidebar.tsx b/studio/src/app/workspace/chat/_components/session-sidebar.tsx
index 92dbfb12e0..faa607509a 100644
--- a/studio/src/app/workspace/chat/_components/session-sidebar.tsx
+++ b/studio/src/app/workspace/chat/_components/session-sidebar.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Ellipsis, Loader2, Pencil, Trash2 } from "lucide-react";
+import { Bot, Ellipsis, Pencil, Trash2 } from "lucide-react";
import { useState } from "react";
import {
DropdownMenu,
@@ -9,7 +9,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
-import type { AgentSession } from "@/features/agent";
+import type { AgentSession, RosterAgent } from "@/features/agent";
import { formatRelativeTime } from "@/lib/formatters";
import { cn } from "@/lib/utils";
@@ -109,12 +109,15 @@ function SessionRow({
actions: SessionActions;
}) {
const [menuOpen, setMenuOpen] = useState(false);
+ const isRunning = session.isStreaming || session.state === "running";
return (
{session.title || "Untitled"}
- {session.isStreaming ? (
-
@@ -215,7 +219,7 @@ export function SessionList({
setShowAll((v) => !v)}
- className="pl-6 pr-3 py-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors text-left"
+ className="pl-[15px] pr-3 py-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors text-left"
>
{showAll ? "Show less" : `Show ${sessions.length - 8} more`}
@@ -223,3 +227,37 @@ export function SessionList({
);
}
+
+/**
+ * The daemon's real agent roster, listed below the chat groups. Agents are
+ * not chat containers — selecting one simply starts a new chat draft.
+ */
+export function AgentList({
+ agents,
+ onStartChat,
+}: {
+ agents: RosterAgent[];
+ onStartChat: () => void;
+}) {
+ return (
+
+ {agents.map((agent) => (
+
+
+
+
+
+ {agent.name}
+
+
+ ))}
+
+ );
+}
diff --git a/studio/src/app/workspace/chat/_components/side-panel.tsx b/studio/src/app/workspace/chat/_components/side-panel.tsx
index eafa32438c..e2bdae4a97 100644
--- a/studio/src/app/workspace/chat/_components/side-panel.tsx
+++ b/studio/src/app/workspace/chat/_components/side-panel.tsx
@@ -1,13 +1,14 @@
"use client";
import { Fullscreen, Minimize2, X } from "lucide-react";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
+import { usePanelWidth } from "@/hooks/use-panel-width";
import { cn } from "@/lib/utils";
/** Maximize/restore + close controls, spaced apart so they read as two. */
@@ -73,7 +74,6 @@ export function SidePanel({
onClose,
headerExtra,
toolbar,
- initialWidth = 520,
minWidth = 320,
children,
}: {
@@ -87,11 +87,12 @@ export function SidePanel({
headerExtra?: React.ReactNode;
/** Optional row rendered under the header (e.g. a formatting toolbar). */
toolbar?: React.ReactNode;
- initialWidth?: number;
minWidth?: number;
children: React.ReactNode;
}) {
- const [width, setWidth] = useState(initialWidth);
+ // The persisted width shared with the session list, so one resize setting
+ // carries across every chat panel and across reloads.
+ const [width, setWidth] = usePanelWidth();
const isDragging = useRef(false);
const panelRef = useRef
(null);
@@ -102,6 +103,8 @@ export function SidePanel({
panelRef.current.parentElement?.getBoundingClientRect();
if (!parentRect) return;
const newWidth = parentRect.right - e.clientX;
+ // The store clamps to its own global bounds; the parent-relative cap
+ // keeps the conversation readable on narrow windows.
setWidth(Math.max(minWidth, Math.min(newWidth, parentRect.width * 0.75)));
};
const handleMouseUp = () => {
diff --git a/studio/src/app/workspace/layout.tsx b/studio/src/app/workspace/layout.tsx
index 30f6493ec8..ddd4e768cb 100644
--- a/studio/src/app/workspace/layout.tsx
+++ b/studio/src/app/workspace/layout.tsx
@@ -1,7 +1,17 @@
-import { ConsoleShell } from "@/components/shell/console-shell";
-import { SidebarProvider } from "@/components/ui/sidebar";
+import { TopNav } from "@/components/shell/top-nav";
import { RuntimeStatusProvider } from "@/features/agent/runtime-status";
+import { ShortcutsProvider } from "@/lib/shortcuts/use-shortcuts";
+/**
+ * The workspace shell: a fixed dark-green radial gradient carrying the top
+ * navigation bar, with all five surfaces rendered inside one rounded card
+ * that follows the theme. The gradient itself is a fixed brand colour —
+ * identical in light and dark themes — so only the card interior themes.
+ *
+ * `RuntimeStatusProvider` stays outermost: its offline banner renders above
+ * the top nav at full width. Workspace sections manage their own scrolling
+ * and padding inside the card (`h-full overflow-y-auto …`).
+ */
export default function WorkspaceLayout({
children,
}: Readonly<{
@@ -9,14 +19,21 @@ export default function WorkspaceLayout({
}>) {
return (
-
- {/* Not SidebarInset: the shell renders the page's one , and
- SidebarInset is itself a , which would nest them. This div
- carries the flex sizing the shell layout needs. */}
-
-
{children}
+
+ {/* The design's green radial gradient; dark mode deepens each stop so
+ the shell recedes behind the dark card instead of outglowing it. */}
+
+
+ {/* relative makes the card the containing block for absolutely-
+ positioned descendants with no positioned ancestor of their own
+ — notably the hidden form-integration checkbox Radix renders
+ beside each Switch inside a
-
+
);
}
diff --git a/studio/src/app/workspace/settings/appearance/page.tsx b/studio/src/app/workspace/settings/appearance/page.tsx
index f188dadd68..3c6d928270 100644
--- a/studio/src/app/workspace/settings/appearance/page.tsx
+++ b/studio/src/app/workspace/settings/appearance/page.tsx
@@ -1,8 +1,9 @@
"use client";
-import { Monitor, Moon, Sun } from "lucide-react";
+import { Monitor, Moon, PanelLeft, PanelRight, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
+import { useSessionListSide } from "@/lib/profile-preferences";
import { cn } from "@/lib/utils";
import { ProfileSection } from "../_components/profile-section";
import { SettingsCard } from "../_components/settings-card";
@@ -13,8 +14,50 @@ const THEMES = [
{ value: "system", label: "System", icon: Monitor },
] as const;
+const SESSION_LIST_SIDES = [
+ { value: "left", label: "Left", icon: PanelLeft },
+ { value: "right", label: "Right", icon: PanelRight },
+] as const;
+
+function PillGroup({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function Pill({
+ isActive,
+ onClick,
+ icon: Icon,
+ label,
+}: {
+ isActive: boolean;
+ onClick: () => void;
+ icon: React.ComponentType<{ className?: string }>;
+ label: string;
+}) {
+ return (
+
+
+ {label}
+
+ );
+}
+
export default function AppearanceSettingsPage() {
const { theme: activeTheme, setTheme } = useTheme();
+ const { side, setSide } = useSessionListSide();
// next-themes resolves only on the client; gate the active-pill highlight on
// mount so the selected theme shows instead of nothing on first paint.
@@ -25,26 +68,40 @@ export default function AppearanceSettingsPage() {
<>
-
- {THEMES.map(({ value, label, icon: Icon }) => {
- const isActive = mounted && activeTheme === value;
- return (
-
setTheme(value)}
- className={cn(
- "flex items-center gap-1.5 rounded-full px-3.5 py-1.5 text-sm font-medium transition-colors",
- isActive
- ? "bg-background text-foreground shadow-sm"
- : "text-muted-foreground hover:text-foreground",
- )}
- >
-
- {label}
-
- );
- })}
+
+
+
Theme
+
+ {THEMES.map(({ value, label, icon }) => (
+ setTheme(value)}
+ icon={icon}
+ label={label}
+ />
+ ))}
+
+
+
+
+
Session list position
+
+ {SESSION_LIST_SIDES.map(({ value, label, icon }) => (
+ setSide(value)}
+ icon={icon}
+ label={label}
+ />
+ ))}
+
+
+ Which side of the chat the session list docks on. Threads and
+ document panels stay on the right.
+
+
>
diff --git a/studio/src/components/app/nav-items.ts b/studio/src/components/app/nav-items.ts
index 626f91bc5a..59876fed8c 100644
--- a/studio/src/components/app/nav-items.ts
+++ b/studio/src/components/app/nav-items.ts
@@ -1,9 +1,9 @@
/**
- * The Atrium workspace console's sidebar configuration.
- *
- * The app is Atrium-only: a single rail of workspace destinations — the in-app
- * chat plus the state that outlives a turn (Skills, Memory, Scheduled). The
- * wordmark and home both point at Chats.
+ * The Atrium workspace navigation configuration — the single canonical
+ * nav-items module. The top nav is generic over the `ShellNav` built here:
+ * a single row of workspace destinations — the in-app chat plus the state
+ * that outlives a turn (Skills, Memory, Scheduled). The wordmark and home
+ * both point at Chats.
*/
import {
@@ -13,9 +13,36 @@ import {
MessageCircle,
Settings,
} from "lucide-react";
-import type { ShellNav } from "@/components/shell/nav-items";
+import type { ComponentType } from "react";
import { ATRIUM_WORKSPACE_HOME } from "@/lib/feature-flags";
+interface NavItem {
+ /** Stable key for React keys and test selectors. */
+ readonly key: string;
+ /** Nav label (pill text when active, tooltip when inactive). */
+ readonly label: string;
+ /** Absolute route this destination points at. */
+ readonly href: string;
+ /** Nav icon. */
+ readonly icon: ComponentType<{ className?: string }>;
+}
+
+/**
+ * The console's navigation configuration. One value drives the whole shell:
+ * the top-nav destinations and the wordmark's home link.
+ */
+export interface ShellNav {
+ /** The console's root route (used for the "home" active-state rule). */
+ readonly homeHref: string;
+ /** Where the wordmark links, if different from `homeHref` (e.g. Atrium
+ sends the logo to the workspace chats rather than the gateway home). */
+ readonly logoHref?: string;
+ /** `aria-label` for the `
` landmark. */
+ readonly navLabel: string;
+ /** Ordered nav destinations. */
+ readonly items: readonly NavItem[];
+}
+
/** Builds the Atrium workspace `ShellNav`. */
export function buildUserNav(): ShellNav {
return {
diff --git a/studio/src/components/shell/console-shell.tsx b/studio/src/components/shell/console-shell.tsx
deleted file mode 100644
index 9a9fc5ffa5..0000000000
--- a/studio/src/components/shell/console-shell.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-"use client";
-
-import { usePathname } from "next/navigation";
-import type { ReactNode } from "react";
-import { buildUserNav } from "@/components/app/nav-items";
-import { Shell } from "@/components/shell/shell";
-import { ShortcutsProvider } from "@/lib/shortcuts/use-shortcuts";
-
-/**
- * Renders the Atrium workspace shell. The `ShellNav` is built client-side
- * because nav items carry icon component references, which cannot cross a
- * Server → Client prop boundary, so the server layout renders this wrapper and
- * the nav config stays inside the client module graph.
- *
- * Workspace sections (`/workspace/*`) manage their own scrolling, inner rails,
- * and padding, so they get a full-bleed `h-full` wrapper; anything else gets a
- * comfortable padded column.
- */
-
-const PADDED_COLUMN = "w-full px-4 pt-6 pb-14 min-[500px]:px-8";
-const FULL_BLEED = "h-full";
-
-/** Route prefixes whose section layouts own their scrolling and padding. */
-const FULL_BLEED_PREFIXES = ["/workspace"] as const;
-
-export function ConsoleShell({
- userMenu,
- children,
-}: {
- /** Right-aligned topbar profile-menu slot (the existing `UserMenu`). */
- userMenu?: ReactNode;
- children: ReactNode;
-}) {
- const pathname = usePathname() ?? "";
- const nav = buildUserNav();
-
- const isFullBleed = FULL_BLEED_PREFIXES.some(
- (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
- );
-
- return (
-
-
- {children}
-
-
- );
-}
diff --git a/studio/src/components/shell/nav-drawer.tsx b/studio/src/components/shell/nav-drawer.tsx
deleted file mode 100644
index aa7f1dbecb..0000000000
--- a/studio/src/components/shell/nav-drawer.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-"use client";
-
-import { Menu } from "lucide-react";
-import { usePathname } from "next/navigation";
-import { useEffect, useRef, useState } from "react";
-import type { ShellNav } from "@/components/shell/nav-items";
-import { Sidebar } from "@/components/shell/sidebar";
-import {
- Sheet,
- SheetContent,
- SheetTitle,
- SheetTrigger,
-} from "@/components/ui/sheet";
-
-/**
- * The small-screen navigation drawer.
- *
- * Below the `md` breakpoint the fixed sidebar is hidden and this menu button
- * takes its place in the topbar. Opening it renders the same `Sidebar` inside
- * a left-side Sheet, which traps focus while open and closes on Escape. The
- * drawer also closes on a route change: when `pathname` changes it drops back
- * to closed, and each destination link calls `onNavigate` to close immediately
- * on select. The trigger and drawer are `md:hidden`, so on wide screens the
- * persistent sidebar is the only nav. Generic over the `ShellNav` it renders,
- * so both consoles share it. Interactive — carries `"use client"`.
- */
-export function NavDrawer({ nav }: { nav: ShellNav }) {
- const [open, setOpen] = useState(false);
- const pathname = usePathname();
- const lastPath = useRef(pathname);
-
- // Close on route change so a chosen destination never leaves the drawer up.
- // Comparing against the previous path keeps `pathname` a genuine dependency
- // (not a stale-closure trick) and only closes when the route actually moved.
- useEffect(() => {
- if (pathname !== lastPath.current) {
- lastPath.current = pathname;
- setOpen(false);
- }
- }, [pathname]);
-
- return (
-
-
-
-
-
- Navigation
- setOpen(false)}
- />
-
-
- );
-}
diff --git a/studio/src/components/shell/nav-items.ts b/studio/src/components/shell/nav-items.ts
deleted file mode 100644
index 76cb5aed4c..0000000000
--- a/studio/src/components/shell/nav-items.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-/**
- * The shared console-shell vocabulary. The `shell/` components (sidebar,
- * topbar, nav-drawer) are generic over these types, so the admin console and
- * the user console render the *same* chrome and differ only in the `ShellNav`
- * each one passes in. Each console owns its own destination list.
- */
-
-import type { ComponentType } from "react";
-
-export interface NavItem {
- /** Stable key for React keys and test selectors. */
- readonly key: string;
- /** Sidebar label. */
- readonly label: string;
- /** Absolute route this destination points at. */
- readonly href: string;
- /** Sidebar icon. */
- readonly icon: ComponentType<{ className?: string }>;
- /**
- * Optional group heading rendered as a small uppercase muted label above
- * the first item of each contiguous run sharing the same value (e.g. the
- * user console's "Workspace" and "Tools" groups). Ungrouped consoles omit
- * it entirely.
- */
- readonly group?: string;
-}
-
-/**
- * The counterpart console the profile menu offers to switch to, if any.
- */
-interface ConsoleSwitch {
- /** Profile-menu item label. */
- readonly label: string;
- /** Absolute route the switcher navigates to. */
- readonly href: string;
-}
-
-/**
- * A console's navigation configuration. One value per console drives the whole
- * shell: the sidebar destinations, the wordmark's home link, and the optional
- * footer-pinned destination.
- */
-export interface ShellNav {
- /** The console's root route (used for the "home" active-state rule). */
- readonly homeHref: string;
- /** Where the wordmark links, if different from `homeHref` (e.g. Atrium
- sends the logo to the workspace chats rather than the gateway home). */
- readonly logoHref?: string;
- /** `aria-label` for the sidebar `` landmark. */
- readonly navLabel: string;
- /** Ordered sidebar destinations. */
- readonly items: readonly NavItem[];
- /**
- * Key of the destination pinned to the sidebar footer (e.g. admin's Org
- * Settings), if any. Consoles with no footer destination omit it.
- */
- readonly footerKey?: string;
- /** Role line shown under the display name in the profile menu, if used. */
- readonly roleLabel?: string;
- /**
- * The counterpart console a profile-menu switcher navigates to, if any.
- */
- readonly switchTo?: ConsoleSwitch;
-}
diff --git a/studio/src/components/shell/navbar.tsx b/studio/src/components/shell/navbar.tsx
deleted file mode 100644
index ddf5db27e3..0000000000
--- a/studio/src/components/shell/navbar.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import type { ReactNode } from "react";
-import { GlobalSearch } from "@/components/shell/global-search";
-import { NavDrawer } from "@/components/shell/nav-drawer";
-import type { ShellNav } from "@/components/shell/nav-items";
-
-/**
- * The light topbar. On the left: the small-screen menu button that opens the
- * navigation drawer (hidden at `md` and up, where the persistent sidebar
- * carries the brand). On the right: the `userMenu` slot — the caller drops
- * the existing profile menu (Experience switch, theme, presentation modes,
- * sign out) in here, restyled for a light surface.
- *
- * It is generic over the `ShellNav` it is given, so the admin console and the
- * user console render the same topbar and differ only in their destinations.
- * The header sits on the light `sidebar` surface with a standard border and
- * references none of the dark nav-band tokens. A Server Component; the
- * interactive pieces it composes carry their own `"use client"`.
- */
-export function Navbar({
- nav,
- userMenu,
-}: {
- nav: ShellNav;
- /** Right-aligned profile menu slot (e.g. the existing `UserMenu`). */
- userMenu?: ReactNode;
-}) {
- return (
-
- );
-}
diff --git a/studio/src/components/shell/shell.tsx b/studio/src/components/shell/shell.tsx
deleted file mode 100644
index 1824baf03f..0000000000
--- a/studio/src/components/shell/shell.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-"use client";
-
-import type { ReactNode } from "react";
-import type { ShellNav } from "@/components/shell/nav-items";
-import { Navbar } from "@/components/shell/navbar";
-import { Sidebar } from "@/components/shell/sidebar";
-
-/**
- * The console chrome shared by the admin console and the user console: the
- * persistent light sidebar beside the topbar and the centred content column.
- * It is generic over the `ShellNav` it is given.
- *
- * This is a Client Component on purpose. A `ShellNav`'s `items` carry `icon`
- * component references, which are functions and so cannot cross a Server →
- * Client Component prop boundary. Each console therefore supplies its `nav`
- * from a Client Component wrapper that imports the config directly, so the
- * icons stay inside the client module graph and never get passed as props
- * from a Server Component. The server layout renders that wrapper with only
- * serializable props plus the page as `children`.
- *
- * Below the `md` breakpoint the persistent sidebar hides and the topbar's
- * menu button opens it as an overlay drawer instead.
- */
-export function Shell({
- nav,
- userMenu,
- railStorageKey,
- railDefaultWidth,
- railCollapsible = false,
- contentClassName = "w-full px-4 pb-7 pt-6 sm:px-6",
- children,
-}: {
- nav: ShellNav;
- /** Right-aligned topbar profile-menu slot (e.g. the existing `UserMenu`). */
- userMenu?: ReactNode;
- /**
- * Persisted-width key + starting width for the resizable rail. Chat passes a
- * collapsed default so the rail starts icon-only there while staying
- * resizable; other pages default to the expanded width.
- */
- railStorageKey?: string;
- railDefaultWidth?: number;
- /** Whether the rail may collapse to icons (Chat) or keeps a labelled min. */
- railCollapsible?: boolean;
- /** The centred content column's wrapper classes; consoles vary the max-width. */
- contentClassName?: string;
- children: ReactNode;
-}) {
- return (
-
-
-
-
- {/* scrollbar-gutter keeps the centred column at the same x whether or
- not the page is tall enough to scroll — without it, short pages
- render a few px right of scrolling ones.
- relative makes main the containing block for absolutely-positioned
- descendants with no positioned ancestor of their own — notably the
- hidden form-integration checkbox Radix renders beside each Switch
- inside a
-
- );
-}
diff --git a/studio/src/components/shell/sidebar.tsx b/studio/src/components/shell/sidebar.tsx
deleted file mode 100644
index f1f87a8aab..0000000000
--- a/studio/src/components/shell/sidebar.tsx
+++ /dev/null
@@ -1,420 +0,0 @@
-"use client";
-
-import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
-import Link from "next/link";
-import { usePathname, useSearchParams } from "next/navigation";
-import { useEffect, useRef } from "react";
-import type { NavItem, ShellNav } from "@/components/shell/nav-items";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import { useNavCollapsed } from "@/hooks/use-nav-collapsed";
-import {
- RAIL_COLLAPSE_AT,
- RAIL_LABELED_MIN,
- RAIL_MAX_WIDTH,
- RAIL_MIN_WIDTH,
- useRailWidth,
-} from "@/hooks/use-rail-width";
-import { cn } from "@/lib/utils";
-
-/**
- * True when the destination at `href` is the active one for the current
- * `pathname` (and, for query-bearing hrefs, `search`).
- *
- * A console-root destination is active only on an exact match — otherwise
- * every deeper page would light it up. Every other destination is active on
- * its exact route or any path segment under it. The under-route check compares
- * against `${href}/` (not a raw string prefix) so a sibling like
- * `/admin/identity-providers` never counts as "under" `/admin/identity`.
- *
- * When an href carries a query (e.g. two "Setup" destinations that deep-link
- * the same page to different tabs via `?mode=`), the path must match *and*
- * every query param on the href must be present with the same value in the
- * current `search`, so only the matching tab lights up.
- *
- * Pure and exported so tests can pin the rule without rendering. `homeHref`
- * names the console root so the exact-match rule is not hard-wired to one
- * console.
- */
-function isNavItemActive(
- href: string,
- pathname: string,
- homeHref: string,
- search = "",
-): boolean {
- const [hrefPath, hrefQuery] = href.split("?");
- if (hrefPath === homeHref) {
- return pathname === homeHref;
- }
- const pathActive =
- pathname === hrefPath || pathname.startsWith(`${hrefPath}/`);
- if (!pathActive || !hrefQuery) {
- return pathActive;
- }
- const current = new URLSearchParams(search);
- const wanted = new URLSearchParams(hrefQuery);
- for (const [key, value] of wanted) {
- if (current.get(key) !== value) {
- return false;
- }
- }
- return true;
-}
-
-/**
- * Among a console's destinations, the key of the single active one for
- * `pathname`/`search` — the most specific match (longest `href`) so a parent
- * like `/admin/tools` does not stay lit while on a nested destination such as
- * `/admin/tools/[serverId]`. Returns null when nothing matches.
- */
-function activeNavItemKey(
- items: readonly NavItem[],
- pathname: string,
- homeHref: string,
- search = "",
-): string | null {
- let bestKey: string | null = null;
- let bestLen = -1;
- for (const item of items) {
- if (
- isNavItemActive(item.href, pathname, homeHref, search) &&
- item.href.length > bestLen
- ) {
- bestKey = item.key;
- bestLen = item.href.length;
- }
- }
- return bestKey;
-}
-
-/**
- * The light left sidebar with a console's destinations. Each destination is a
- * full-width bar; the active route carries a brand-coloured left accent and a
- * muted fill. The Stacklok wordmark sits at the top, tinted with the `--logo`
- * token, and links to the console root.
- *
- * The component is generic over the `ShellNav` it is given, so the admin
- * console and the user console render the same sidebar and differ only in
- * their `items`, `homeHref`, `navLabel`, and optional `footerKey`. Contiguous
- * runs of items sharing a `group` get a small uppercase muted heading above
- * the run. The nav keeps `role="navigation"` (via the `` element) and
- * marks the active destination with `aria-current="page"`. `onNavigate` lets
- * the responsive drawer close itself when a destination is chosen.
- */
-export function Sidebar({
- nav,
- className,
- resizable = false,
- collapsible = false,
- collapseToggle = false,
- storageKey,
- defaultWidth,
- onNavigate,
-}: {
- nav: ShellNav;
- className?: string;
- /**
- * Let the user drag the rail's right edge to resize it, persisting the width.
- * Dragging below `RAIL_COLLAPSE_AT` snaps it to the icon-only strip (the
- * wordmark becomes the logo mark, group headings drop to spacing, and labels
- * move into hover tooltips). Only the persistent rail is resizable — the
- * mobile drawer is not.
- */
- resizable?: boolean;
- /**
- * Allow the rail to collapse all the way to the icon strip (Chat). When
- * false, the rail can still be resized but never below the labelled minimum —
- * it never becomes icons-only.
- */
- collapsible?: boolean;
- /**
- * Render the persistent rail's expand/collapse toggle and honour the user's
- * persisted `useNavCollapsed` choice: when collapsed, force the icons-only
- * strip regardless of the remembered width. Only the persistent rail passes
- * this; the mobile drawer leaves it off so it always shows full labels.
- */
- collapseToggle?: boolean;
- /** Persisted-width key + starting width; lets Chat default to collapsed. */
- storageKey?: string;
- defaultWidth?: number;
- onNavigate?: () => void;
-}) {
- const pathname = usePathname();
- const searchParams = useSearchParams();
- const [railWidth, setRailWidth] = useRailWidth(storageKey, defaultWidth);
- const [navCollapsed, setNavCollapsed] = useNavCollapsed();
- const navRef = useRef(null);
- const draggingRef = useRef(false);
-
- // The explicit toggle is authoritative: when the user collapses the rail it
- // is icons-only whatever its remembered width. Only applies where the toggle
- // is shown (the persistent rail), never the mobile drawer.
- const collapsedByToggle = collapseToggle && navCollapsed;
-
- // A non-collapsible rail can't be dragged below the width that keeps its
- // labels readable; a collapsible one (Chat) can go all the way to the icon
- // strip. `iconOnly` engages for collapsible rails dragged narrow, or whenever
- // the user has explicitly collapsed the rail via the toggle.
- const minWidth = collapsible ? RAIL_MIN_WIDTH : RAIL_LABELED_MIN;
- const iconOnly =
- collapsedByToggle ||
- (resizable && collapsible && railWidth < RAIL_COLLAPSE_AT);
- const width = Math.max(minWidth, Math.min(RAIL_MAX_WIDTH, railWidth));
- // A collapsed rail is a fixed icon strip, so hide the drag handle there.
- const showHandle = resizable && !collapsedByToggle;
-
- useEffect(() => {
- if (!showHandle) return;
- const onMove = (e: MouseEvent) => {
- if (!draggingRef.current) return;
- const left = navRef.current?.getBoundingClientRect().left ?? 0;
- setRailWidth(
- Math.max(minWidth, Math.min(RAIL_MAX_WIDTH, e.clientX - left)),
- );
- };
- const onUp = () => {
- if (!draggingRef.current) return;
- draggingRef.current = false;
- document.body.style.cursor = "";
- document.body.style.userSelect = "";
- };
- window.addEventListener("mousemove", onMove);
- window.addEventListener("mouseup", onUp);
- return () => {
- window.removeEventListener("mousemove", onMove);
- window.removeEventListener("mouseup", onUp);
- };
- }, [showHandle, setRailWidth, minWidth]);
- // Resolve a single active destination (most specific match) so a parent like
- // Connectors doesn't stay lit on a nested connector, and so query-bearing
- // destinations (e.g. Setup's `?mode=` tabs) light up per current tab.
- const activeKey = activeNavItemKey(
- nav.items,
- pathname,
- nav.homeHref,
- searchParams.toString(),
- );
-
- // The footer destination (e.g. admin's Org Settings) stays in `items` so the
- // shell reads one canonical list, but the sidebar pins it to the footer
- // rather than the scrollable nav. Split it out by key so the destination set
- // never drifts. Consoles with no footer destination (`footerKey` unset)
- // render only the scrollable nav.
- const footerItem = nav.footerKey
- ? nav.items.find((item) => item.key === nav.footerKey)
- : undefined;
- const mainItems = nav.items.filter((item) => item.key !== nav.footerKey);
-
- // Renders a destination as a full-width square-edged bar with a 3px left
- // accent. Shared by the scrollable nav and the pinned footer so their
- // styling and semantics can never diverge. In `iconOnly` mode it collapses to
- // a centred icon whose label is disclosed by a hover tooltip.
- const renderNavLink = (item: NavItem) => {
- const isActive = item.key === activeKey;
- const Icon = item.icon;
- const link = (
- {
- // Parity with the old top nav: re-choosing the active destination
- // asks the page to reopen its own inner sidebar (chat/teams listen
- // for this via use-nav-reopen-sidebar).
- if (isActive) {
- window.dispatchEvent(new CustomEvent("nav-reopen-sidebar"));
- }
- onNavigate?.();
- }}
- aria-current={isActive ? "page" : undefined}
- className={cn(
- "flex items-center border-l-[3px] border-transparent font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground",
- iconOnly
- ? "justify-center py-2.5"
- : "gap-[0.65rem] px-5 py-2 text-[0.85rem]",
- isActive &&
- "border-brand-ink bg-accent text-brand-ink hover:text-brand-ink",
- )}
- >
-
- {iconOnly ? {item.label} : item.label}
-
- );
- if (!iconOnly) {
- return link;
- }
- return (
-
- {link}
-
- {item.label}
-
-
- );
- };
-
- return (
-
- {showHandle && (
- // biome-ignore lint/a11y/noStaticElementInteractions: drag-to-resize handle
- {
- draggingRef.current = true;
- document.body.style.cursor = "col-resize";
- document.body.style.userSelect = "none";
- }}
- />
- )}
-
- {/* The logo is rendered as a mask so it recolours with the theme
- (--logo) instead of baking in a fixed fill. Icon-only mode reuses the
- SAME wordmark image at the SAME 21px height, but clips the container
- to the arrow mark's width so only the glyph shows — this keeps the
- mark pixel-identical in size and ratio to the mark in the full logo,
- rather than restretching a separate square asset. */}
-
-
Stacklok
-
- {/* Full-width square-edged bars with a 3px left accent: no horizontal
- container padding, no radius. A group heading precedes the first item
- of each contiguous run sharing the same `group`. In icon-only mode the
- heading text is dropped and the group boundary becomes vertical space. */}
-
- {mainItems.map((item, index) => {
- const previous = mainItems[index - 1];
- const showGroupHeading =
- item.group !== undefined && item.group !== previous?.group;
- // A trailing ungrouped item after a group (e.g. Org Settings) gets
- // the same top gap a group heading would, so it reads as separated.
- const standaloneAfterGroup =
- item.group === undefined && previous?.group !== undefined;
- // Icon-only: no heading text, but a small top gap at each group
- // boundary so the runs still read as groups.
- if (iconOnly) {
- const needsGap =
- index > 0 && (showGroupHeading || standaloneAfterGroup);
- return (
-
- {renderNavLink(item)}
-
- );
- }
- if (showGroupHeading) {
- return (
-
-
0 ? "pt-7" : "pt-3",
- )}
- >
- {item.group}
-
- {renderNavLink(item)}
-
- );
- }
- return standaloneAfterGroup ? (
-
- {renderNavLink(item)}
-
- ) : (
- renderNavLink(item)
- );
- })}
-
- {/* A footer destination (e.g. admin's Org Settings) is pinned to the
- bottom, in a `py-3 border-t` block after the scrollable nav. It stays
- inside the `
` so every destination lives in one landmark. */}
- {footerItem && (
-
- {renderNavLink(footerItem)}
-
- )}
- {/* The expand/collapse toggle, pinned below any footer destination. Only
- the persistent rail renders it (the drawer leaves `collapseToggle`
- off). The chevron points the way the rail will move; the label is a
- hover tooltip when collapsed and inline when expanded. */}
- {collapseToggle && (
-
- {collapsedByToggle ? (
-
-
- setNavCollapsed(false)}
- aria-label="Expand sidebar"
- className="flex w-full items-center justify-center py-3.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
- >
-
-
-
-
- Expand sidebar
-
-
- ) : (
-
setNavCollapsed(true)}
- aria-label="Collapse sidebar"
- className="flex w-full items-center gap-[0.65rem] px-5 py-3.5 text-[0.85rem] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
- >
-
- Collapse
-
- )}
-
- )}
-
- );
-}
diff --git a/studio/src/components/shell/top-nav.tsx b/studio/src/components/shell/top-nav.tsx
new file mode 100644
index 0000000000..a264dd123d
--- /dev/null
+++ b/studio/src/components/shell/top-nav.tsx
@@ -0,0 +1,99 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { buildUserNav } from "@/components/app/nav-items";
+import { GlobalSearch } from "@/components/shell/global-search";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+
+/**
+ * The workspace top navigation bar. It sits directly on the fixed dark-green
+ * gradient (see `workspace/layout.tsx`), so every colour here is a fixed
+ * brand colour, identical in light and dark themes — only the card below it
+ * follows the theme.
+ *
+ * Left: the Stacklok logo mark, painted white via a CSS mask over the brand
+ * SVG (the asset itself is never recoloured). Centre-right: the pill nav from
+ * `buildUserNav()` — the active route renders as a light pill with icon +
+ * label, inactive routes are icon-only round buttons with a tooltip.
+ * Far right: the global search trigger (the existing ⌘K `GlobalSearch`
+ * palette), restyled from the outside for the dark band.
+ *
+ * A Client Component: it needs `usePathname()` for the active state, and the
+ * nav config carries icon component references that must stay inside the
+ * client module graph.
+ */
+export function TopNav() {
+ const pathname = usePathname() ?? "";
+ const nav = buildUserNav();
+
+ return (
+
+ );
+}
diff --git a/studio/src/features/agent/hooks/use-agent-roster.ts b/studio/src/features/agent/hooks/use-agent-roster.ts
new file mode 100644
index 0000000000..0fa3cea553
--- /dev/null
+++ b/studio/src/features/agent/hooks/use-agent-roster.ts
@@ -0,0 +1,40 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { listHarnessAgents } from "@/lib/harness/client";
+import { useRuntimeStatus } from "../runtime-status";
+
+/** One row of the daemon's resolved agent inventory (`GET /v1/agents`). */
+export interface RosterAgent {
+ name: string;
+ description: string;
+}
+
+/**
+ * The daemon's real agent roster, read-only. Gated on the runtime being
+ * connected; an unreachable daemon yields an empty roster, never demo data.
+ */
+export function useAgentRoster() {
+ const { connected } = useRuntimeStatus();
+ const [agents, setAgents] = useState
([]);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ if (!connected) return;
+ const controller = new AbortController();
+ setIsLoading(true);
+ listHarnessAgents(controller.signal)
+ .then((roster) => {
+ if (!controller.signal.aborted) setAgents(roster);
+ })
+ .catch(() => {
+ if (!controller.signal.aborted) setAgents([]);
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) setIsLoading(false);
+ });
+ return () => controller.abort();
+ }, [connected]);
+
+ return { agents, isLoading: isLoading && connected };
+}
diff --git a/studio/src/features/agent/index.ts b/studio/src/features/agent/index.ts
index e903e01641..c1c6843afa 100644
--- a/studio/src/features/agent/index.ts
+++ b/studio/src/features/agent/index.ts
@@ -2,6 +2,7 @@
export { useAgentChat } from "./hooks/use-agent-chat";
export { useAgentCron } from "./hooks/use-agent-cron";
export { useAgentMemory } from "./hooks/use-agent-memory";
+export { type RosterAgent, useAgentRoster } from "./hooks/use-agent-roster";
export { useAgentSessions } from "./hooks/use-agent-sessions";
// Types
diff --git a/studio/src/hooks/use-nav-collapsed.ts b/studio/src/hooks/use-nav-collapsed.ts
deleted file mode 100644
index 4897db7a6b..0000000000
--- a/studio/src/hooks/use-nav-collapsed.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-"use client";
-
-import { useCallback, useSyncExternalStore } from "react";
-
-/**
- * Whether the console's main left nav rail is collapsed to its icons-only
- * state. This is an explicit user choice — a toggle in the rail footer — kept
- * separate from the drag-to-resize width (`use-rail-width`): the toggle is
- * authoritative, so a collapsed rail stays icon-only regardless of its
- * remembered width. Backed by a localStorage external store so the choice
- * persists across reloads and stays in sync between every mounted rail.
- * Defaults to expanded (false).
- */
-const STORAGE_KEY = "console-rail-collapsed";
-
-const listeners = new Set<() => void>();
-
-function getSnapshot(): boolean {
- return localStorage.getItem(STORAGE_KEY) === "true";
-}
-
-function getServerSnapshot(): boolean {
- return false;
-}
-
-function subscribe(callback: () => void): () => void {
- listeners.add(callback);
- return () => listeners.delete(callback);
-}
-
-export function useNavCollapsed() {
- const collapsed = useSyncExternalStore(
- subscribe,
- getSnapshot,
- getServerSnapshot,
- );
-
- const setCollapsed = useCallback((next: boolean) => {
- localStorage.setItem(STORAGE_KEY, String(next));
- for (const fn of listeners) fn();
- }, []);
-
- return [collapsed, setCollapsed] as const;
-}
diff --git a/studio/src/hooks/use-sidebar-width.ts b/studio/src/hooks/use-panel-width.ts
similarity index 72%
rename from studio/src/hooks/use-sidebar-width.ts
rename to studio/src/hooks/use-panel-width.ts
index 40b7cca975..8c2718fc98 100644
--- a/studio/src/hooks/use-sidebar-width.ts
+++ b/studio/src/hooks/use-panel-width.ts
@@ -2,10 +2,15 @@
import { useCallback, useSyncExternalStore } from "react";
-const STORAGE_KEY = "workspace-sidebar-width";
-const DEFAULT_WIDTH = 340;
+/**
+ * One persisted width, shared by every resizable chat panel — the session
+ * list and the threaded/file side panels read and write the same setting, so
+ * a resize in one carries to the others and survives reloads.
+ */
+const STORAGE_KEY = "workspace-panel-width";
+const DEFAULT_WIDTH = 400;
const MIN_WIDTH = 200;
-const MAX_WIDTH = 500;
+const MAX_WIDTH = 720;
const listeners = new Set<() => void>();
@@ -27,7 +32,7 @@ function subscribe(callback: () => void): () => void {
return () => listeners.delete(callback);
}
-export function useSidebarWidth() {
+export function usePanelWidth() {
const width = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const setWidth = useCallback((next: number) => {
diff --git a/studio/src/hooks/use-rail-width.ts b/studio/src/hooks/use-rail-width.ts
deleted file mode 100644
index 437974e23e..0000000000
--- a/studio/src/hooks/use-rail-width.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-"use client";
-
-import { useCallback, useSyncExternalStore } from "react";
-
-/** Default storage key + expanded width, and the drag bounds. */
-const RAIL_STORAGE_KEY = "console-rail-width";
-/** Floor for a non-collapsible rail — fits the longest nav label, never icons. */
-export const RAIL_LABELED_MIN = 188;
-/** Starting width: a little more than the labelled minimum. */
-const RAIL_DEFAULT_WIDTH = 204;
-/** Floor for a collapsible rail (icon strip); kept for the opt-in collapse path. */
-export const RAIL_MIN_WIDTH = 64;
-export const RAIL_MAX_WIDTH = 400;
-/** Below this dragged width a collapsible rail snaps to the icon-only strip. */
-export const RAIL_COLLAPSE_AT = 176;
-
-const listeners = new Set<() => void>();
-
-function readWidth(storageKey: string, defaultWidth: number): number {
- const stored = localStorage.getItem(storageKey);
- if (!stored) return defaultWidth;
- const n = Number(stored);
- return Number.isFinite(n)
- ? Math.max(RAIL_MIN_WIDTH, Math.min(RAIL_MAX_WIDTH, n))
- : defaultWidth;
-}
-
-function subscribe(callback: () => void): () => void {
- listeners.add(callback);
- return () => listeners.delete(callback);
-}
-
-/**
- * Persisted width of the console's left rail, keyed so different contexts (e.g.
- * Chat vs the rest of the console) can remember their own width. Backed by a
- * localStorage external store; dragging below `RAIL_COLLAPSE_AT` collapses the
- * rail to the icon-only strip.
- */
-export function useRailWidth(
- storageKey: string = RAIL_STORAGE_KEY,
- defaultWidth: number = RAIL_DEFAULT_WIDTH,
-) {
- const width = useSyncExternalStore(
- subscribe,
- () => readWidth(storageKey, defaultWidth),
- () => defaultWidth,
- );
-
- const setWidth = useCallback(
- (next: number) => {
- const clamped = Math.max(RAIL_MIN_WIDTH, Math.min(RAIL_MAX_WIDTH, next));
- localStorage.setItem(storageKey, String(clamped));
- for (const fn of listeners) fn();
- },
- [storageKey],
- );
-
- return [width, setWidth] as const;
-}
diff --git a/studio/src/lib/profile-preferences.ts b/studio/src/lib/profile-preferences.ts
index 03fdf574ae..b797970681 100644
--- a/studio/src/lib/profile-preferences.ts
+++ b/studio/src/lib/profile-preferences.ts
@@ -10,6 +10,7 @@ import { useCallback, useEffect, useState } from "react";
*/
const AGENT_NAME_KEY = "mecatl-studio.agent-name";
const AVATAR_KEY = "mecatl-studio.user-avatar";
+const SESSION_LIST_SIDE_KEY = "mecatl-studio.session-list-side";
const DEFAULT_AGENT_NAME = "Mecatl";
function readLocalStorage(key: string): string | null {
@@ -63,3 +64,25 @@ export function useUserAvatar() {
return { avatarUrl, setAvatarUrl };
}
+
+export type SessionListSide = "left" | "right";
+
+/**
+ * Which side of the chat the session list docks on. The thread and document
+ * panels stay on the right regardless — only the list moves.
+ */
+export function useSessionListSide() {
+ const [side, setSideState] = useState("right");
+ useEffect(() => {
+ if (readLocalStorage(SESSION_LIST_SIDE_KEY) === "left") {
+ setSideState("left");
+ }
+ }, []);
+
+ const setSide = useCallback((next: SessionListSide) => {
+ setSideState(next);
+ writeLocalStorage(SESSION_LIST_SIDE_KEY, next === "left" ? "left" : null);
+ }, []);
+
+ return { side, setSide };
+}