diff --git a/studio/Taskfile.yml b/studio/Taskfile.yml index 24ffb17680..37a4fa37ac 100644 --- a/studio/Taskfile.yml +++ b/studio/Taskfile.yml @@ -21,7 +21,9 @@ tasks: generates: - node_modules/.package-lock.json cmds: - - npm install + # Pinned via npx: a system npm 11 rewrites the lockfile in a shape + # npm 10 (what CI's Node 22 ships) rejects, breaking npm ci. + - npx -y npm@10.9.4 install dev: desc: Start Studio + its mecated supervisor in the background (runs install if needed) diff --git a/studio/knip.ts b/studio/knip.ts index a6d0c5455e..6b5cbbbf9b 100644 --- a/studio/knip.ts +++ b/studio/knip.ts @@ -27,6 +27,7 @@ const config: KnipConfig = { "@radix-ui/react-checkbox", "@radix-ui/react-popover", "@radix-ui/react-progress", + "@radix-ui/react-separator", "@radix-ui/react-toggle", "@radix-ui/react-toggle-group", ], diff --git a/studio/src/app/workspace/_components/resize-handle.tsx b/studio/src/app/workspace/_components/resize-handle.tsx index 53abe49361..ca3e94dc01 100644 --- a/studio/src/app/workspace/_components/resize-handle.tsx +++ b/studio/src/app/workspace/_components/resize-handle.tsx @@ -16,7 +16,7 @@ export function ResizeHandle({ width, onWidthChange, min = 200, - max = 500, + max = 720, }: ResizeHandleProps) { const isDraggingRef = useRef(false); diff --git a/studio/src/app/workspace/chat/_components/approval-panel.tsx b/studio/src/app/workspace/chat/_components/approval-panel.tsx index 2a7a7347b8..3fbb52ed60 100644 --- a/studio/src/app/workspace/chat/_components/approval-panel.tsx +++ b/studio/src/app/workspace/chat/_components/approval-panel.tsx @@ -75,9 +75,11 @@ export function ApprovalPanel({

{approval.description}

{actions.length > 0 && (
- {actions.map((a) => ( + {/* Parsed actions can repeat (or parse without a verb), so keys + carry the position to stay unique. */} + {actions.map((a, index) => ( {/* Body: root message, replies, composer */} @@ -356,6 +358,7 @@ export function ChatView({ error, onRetry, sidebarOpen, + sidebarSide, onToggleSidebar, pendingApproval, onRespondApproval, @@ -379,6 +382,7 @@ export function ChatView({ error?: string | null; onRetry?: () => void; sidebarOpen: boolean; + sidebarSide: SessionListSide; onToggleSidebar: () => void; pendingApproval: ApprovalRequest | null; onRespondApproval: (choice: ApprovalChoice) => void; @@ -431,6 +435,37 @@ export function ChatView({ onSidePanelOpenChange?.(sidePanelOpen); }, [sidePanelOpen, onSidePanelOpenChange]); + // The sidebar toggle renders on the header edge nearest the panel it + // controls: leading when the session list docks left, trailing when right. + const sidebarToggle = !isMobile && ( + + + + + + {sidebarOpen ? "Hide sidebar" : "Show sidebar"} + + + ); + return (
-
- - - - - - {isMobile - ? "Back to chats" - : sidebarOpen - ? "Hide sidebar" - : "Show sidebar"} - - +
+ {isMobile && ( + + )} + {sidebarSide === "left" && sidebarToggle} + {isStreaming && ( + + )}

)} + {sidebarSide === "right" && sidebarToggle} {!isMobile && ( @@ -539,7 +561,7 @@ export function ChatView({ onAddToChat={(text) => setAppendText(text)} onAskInSideChat={(text) => setAppendText(text)} /> -
+
{messages.map((msg) => (
-
+
{error && (
diff --git a/studio/src/app/workspace/chat/_components/chat-workspace.tsx b/studio/src/app/workspace/chat/_components/chat-workspace.tsx index 0a776c4569..6837c3e895 100644 --- a/studio/src/app/workspace/chat/_components/chat-workspace.tsx +++ b/studio/src/app/workspace/chat/_components/chat-workspace.tsx @@ -1,6 +1,6 @@ "use client"; -import { Loader2, PanelLeft, SquarePen } from "lucide-react"; +import { Loader2, PanelLeft, PanelRight, SquarePen } from "lucide-react"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@/components/ui/button"; @@ -11,20 +11,28 @@ import { } from "@/components/ui/tooltip"; import { type AgentSession, + type RosterAgent, useAgentChat, + useAgentRoster, useAgentSessions, } from "@/features/agent"; import { useConfirm } from "@/hooks/use-confirm"; import { useIsCompact, useIsMobile } from "@/hooks/use-mobile"; import { useNavReopenSidebar } from "@/hooks/use-nav-reopen-sidebar"; +import { usePanelWidth } from "@/hooks/use-panel-width"; import { usePrompt } from "@/hooks/use-prompt"; -import { useSidebarWidth } from "@/hooks/use-sidebar-width"; -import { useAgentDisplayName } from "@/lib/profile-preferences"; +import { + type SessionListSide, + useAgentDisplayName, + useSessionListSide, +} from "@/lib/profile-preferences"; import { useShortcut } from "@/lib/shortcuts/use-shortcuts"; +import { cn } from "@/lib/utils"; import { ChatInput } from "../../_components/chat-input"; import { ResizeHandle } from "../../_components/resize-handle"; import { ChatView } from "./chat-view"; import { + AgentList, type SessionActions, SessionList, SidebarGroup, @@ -76,6 +84,7 @@ function SidebarContent({ isLoading, error, groups, + agents, selectedId, onSelect, actions, @@ -84,14 +93,17 @@ function SidebarContent({ isLoading: boolean; error: string | null; groups: { label: string; sessions: AgentSession[] }[]; + agents: RosterAgent[]; selectedId: string; onSelect: (id: string) => void; actions: SessionActions; }) { return ( <> -
-

Chats

+
+

+ Session List +

); @@ -151,6 +170,7 @@ function DraftView({ onPickSeed, error, showSidebarButton, + sidebarSide, onShowSidebar, }: { onSend: (content: string) => void; @@ -159,50 +179,66 @@ function DraftView({ onPickSeed: (text: string) => void; error: string | null; showSidebarButton: boolean; + sidebarSide: SessionListSide; onShowSidebar: () => void; }) { return ( -
- {showSidebarButton && ( - - )} -
-
-

What can I help you with?

-

- Start a new chat, or pick a starting point below. -

-
-
- -
-
- {STARTER_PROMPTS.map((p) => ( - - ))} +
+ {/* Same header bar as an open chat, so a draft doesn't lose the title + row and its controls. */} +
+

+ New chat +

+ {showSidebarButton && ( + + )} +
+
+
+
+

+ What can I help you with? +

+

+ Start a new chat, or pick a starting point below. +

+
+
+ +
+
+ {STARTER_PROMPTS.map((p) => ( + + ))} +
+ {error &&

{error}

}
- {error &&

{error}

}
); @@ -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.isStreaming ? ( - @@ -215,7 +219,7 @@ export function SessionList({ @@ -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) => ( + + ))} +
+ ); +} 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
. Without it those boxes + resolve to the document and grow the page itself. */} +
+ {children} +
- +
); } 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 ( + + ); +} + 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 ( - - ); - })} +
+
+

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 `