From 61bcdc55c69ab6dde39d7247146c79acb2201339 Mon Sep 17 00:00:00 2001 From: Pedro Nauck Date: Sun, 26 Apr 2026 19:26:09 -0300 Subject: [PATCH 1/4] feat: add agent session route --- web/src/components/app-sidebar.test.tsx | 216 ++++----------- web/src/components/app-sidebar.tsx | 253 +++--------------- .../stories/app-sidebar.stories.tsx | 2 - web/src/hooks/routes/use-agent-detail-page.ts | 80 ++++++ web/src/hooks/routes/use-app-layout.test.tsx | 8 +- web/src/hooks/routes/use-app-layout.ts | 1 - web/src/hooks/use-sessions-by-agent.ts | 33 --- web/src/routeTree.gen.ts | 52 ++++ web/src/routes/-_app.test.tsx | 1 + web/src/routes/_app.tsx | 17 +- ...sx => -agents.$name.sessions.$id.test.tsx} | 6 +- .../routes/_app/agents.$name.sessions.$id.tsx | 140 ++++++++++ web/src/routes/_app/agents.$name.tsx | 91 +++++++ web/src/routes/_app/session.$id.tsx | 145 +++------- ...=> -agents.$name.sessions.$id.stories.tsx} | 20 +- .../_app/stories/-agents.$name.stories.tsx | 183 +++++++++++++ .../agent/components/agent-info-panel.tsx | 69 +++++ .../agent/components/agent-page-header.tsx | 105 ++++++++ .../agent/components/agent-sessions-list.tsx | 177 ++++++++++++ .../agent/components/agent-stats-grid.tsx | 121 +++++++++ .../stories/agent-info-panel.stories.tsx | 68 +++++ .../stories/agent-page-header.stories.tsx | 104 +++++++ .../stories/agent-sessions-list.stories.tsx | 118 ++++++++ .../stories/agent-stats-grid.stories.tsx | 99 +++++++ .../systems/agent/hooks/use-agent-sessions.ts | 51 ++++ web/src/systems/agent/index.ts | 12 + web/src/systems/agent/lib/session-status.ts | 45 ++++ .../contexts/session-create-context.tsx | 20 ++ .../hooks/use-session-create-dialog.test.tsx | 4 +- .../hooks/use-session-create-dialog.ts | 5 +- .../session/hooks/use-session-create.ts | 14 + web/src/systems/session/index.ts | 5 + .../components/task-run-detail-header.tsx | 6 +- .../components/task-run-detail-panels.tsx | 6 +- 34 files changed, 1722 insertions(+), 555 deletions(-) create mode 100644 web/src/hooks/routes/use-agent-detail-page.ts delete mode 100644 web/src/hooks/use-sessions-by-agent.ts rename web/src/routes/_app/{-session.$id.test.tsx => -agents.$name.sessions.$id.test.tsx} (96%) create mode 100644 web/src/routes/_app/agents.$name.sessions.$id.tsx create mode 100644 web/src/routes/_app/agents.$name.tsx rename web/src/routes/_app/stories/{-session.stories.tsx => -agents.$name.sessions.$id.stories.tsx} (67%) create mode 100644 web/src/routes/_app/stories/-agents.$name.stories.tsx create mode 100644 web/src/systems/agent/components/agent-info-panel.tsx create mode 100644 web/src/systems/agent/components/agent-page-header.tsx create mode 100644 web/src/systems/agent/components/agent-sessions-list.tsx create mode 100644 web/src/systems/agent/components/agent-stats-grid.tsx create mode 100644 web/src/systems/agent/components/stories/agent-info-panel.stories.tsx create mode 100644 web/src/systems/agent/components/stories/agent-page-header.stories.tsx create mode 100644 web/src/systems/agent/components/stories/agent-sessions-list.stories.tsx create mode 100644 web/src/systems/agent/components/stories/agent-stats-grid.stories.tsx create mode 100644 web/src/systems/agent/hooks/use-agent-sessions.ts create mode 100644 web/src/systems/agent/lib/session-status.ts create mode 100644 web/src/systems/session/contexts/session-create-context.tsx create mode 100644 web/src/systems/session/hooks/use-session-create.ts diff --git a/web/src/components/app-sidebar.test.tsx b/web/src/components/app-sidebar.test.tsx index 2162b03e1..5a201c492 100644 --- a/web/src/components/app-sidebar.test.tsx +++ b/web/src/components/app-sidebar.test.tsx @@ -8,7 +8,6 @@ import { AppSidebar, type AppSidebarProps } from "@/components/app-sidebar"; const onSelectWorkspace = vi.fn(); const onCollapseChange = vi.fn(); -const onNewSession = vi.fn(); const onAddWorkspace = vi.fn(); let matchedRoute: Record = {}; let matchedRouteFuzzy: Record = {}; @@ -84,10 +83,6 @@ function makeProps(overrides: Partial = {}): AppSidebarProps { agentsLoading: false, agentsError: false, sessions: [], - onNewSession, - isCreatingSession: false, - pendingSessionAgentName: null, - pendingSessionWorkspaceId: null, ...overrides, }; } @@ -98,7 +93,6 @@ describe("AppSidebar", () => { matchedRouteFuzzy = {}; onSelectWorkspace.mockReset(); onCollapseChange.mockReset(); - onNewSession.mockReset(); onAddWorkspace.mockReset(); }); @@ -187,13 +181,26 @@ describe("AppSidebar", () => { }); describe("Agent List", () => { - it("renders agents with session counts", () => { + it("renders each agent as a flat link to /agents/$name", () => { renderSidebar( makeProps({ agents: [ { name: "coder", provider: "claude", prompt: "code" }, { name: "writer", provider: "openai", prompt: "write" }, ], + }) + ); + + const coderRow = screen.getByTestId("agent-row-coder"); + const writerRow = screen.getByTestId("agent-row-writer"); + expect(coderRow).toHaveAttribute("href", "/agents/coder"); + expect(writerRow).toHaveAttribute("href", "/agents/writer"); + }); + + it("does not render session counts, expand toggles, or new-session buttons in the sidebar", () => { + renderSidebar( + makeProps({ + agents: [{ name: "coder", provider: "claude", prompt: "code" }], sessions: [ { id: "s1", @@ -206,13 +213,41 @@ describe("AppSidebar", () => { updated_at: "2026-04-06T10:00:00Z", created_at: "2026-04-06T10:00:00Z", }, + ], + }) + ); + + expect(screen.queryByTestId("new-session-coder")).not.toBeInTheDocument(); + expect(screen.queryByTestId("agent-trigger-coder")).not.toBeInTheDocument(); + expect(screen.queryByTestId("session-row-s1")).not.toBeInTheDocument(); + }); + + it("shows a status dot only on agents that have at least one active session", () => { + renderSidebar( + makeProps({ + agents: [ + { name: "coder", provider: "claude", prompt: "code" }, + { name: "writer", provider: "openai", prompt: "write" }, + ], + sessions: [ { - id: "s2", - name: "Session 2", + id: "s_active", + name: "Live", agent_name: "coder", provider: "claude", workspace_id: "ws_alpha", workspace_path: "/workspace/alpha", + state: "active", + updated_at: "2026-04-06T10:00:00Z", + created_at: "2026-04-06T10:00:00Z", + }, + { + id: "s_done", + name: "Done", + agent_name: "writer", + provider: "openai", + workspace_id: "ws_alpha", + workspace_path: "/workspace/alpha", state: "stopped", updated_at: "2026-04-06T09:00:00Z", created_at: "2026-04-06T09:00:00Z", @@ -221,166 +256,29 @@ describe("AppSidebar", () => { }) ); - expect(screen.getByText("coder")).toBeInTheDocument(); - expect(screen.getByText("writer")).toBeInTheDocument(); - expect(screen.getByText("2")).toBeInTheDocument(); - expect(screen.getByText("0")).toBeInTheDocument(); - }); - - it("shows bootstrap hint when no agents are loaded", () => { - renderSidebar(makeProps()); - expect(screen.getByText("Run `agh install` to bootstrap AGH")).toBeInTheDocument(); - }); - - it("shows the loading state when agents are loading", () => { - renderSidebar(makeProps({ agentsLoading: true, agents: undefined })); - expect(screen.getByText("Loading agents...")).toBeInTheDocument(); - }); - - it("creates sessions via the agent's + button", () => { - renderSidebar( - makeProps({ - agents: [{ name: "claude-agent", provider: "anthropic", prompt: "You are helpful." }], - }) - ); - - fireEvent.click(screen.getByTestId("new-session-claude-agent")); - expect(onNewSession).toHaveBeenCalledWith("claude-agent"); + expect(screen.getByTestId("agent-status-dot-coder")).toBeInTheDocument(); + expect(screen.queryByTestId("agent-status-dot-writer")).not.toBeInTheDocument(); }); - it("disables the new-session button when no workspace is active", () => { + it("highlights the agent row whose route is active (fuzzy: covers nested session route)", () => { + matchedRouteFuzzy["/agents/$name"] = true; renderSidebar( makeProps({ - activeWorkspace: undefined, - activeWorkspaceId: null, - agents: [{ name: "claude-agent", provider: "anthropic", prompt: "help" }], + agents: [{ name: "coder", provider: "claude", prompt: "code" }], }) ); - - expect(screen.getByTestId("new-session-claude-agent")).toBeDisabled(); + expect(screen.getByTestId("agent-row-coder")).toHaveAttribute("data-active", "true"); + expect(screen.getByTestId("agent-active-coder")).toBeInTheDocument(); }); - it("shows a spinner and temporary starting row for the pending agent", () => { - renderSidebar( - makeProps({ - agents: [ - { name: "claude-agent", provider: "anthropic", prompt: "help" }, - { name: "general", provider: "openai", prompt: "general" }, - ], - isCreatingSession: true, - pendingSessionAgentName: "claude-agent", - pendingSessionWorkspaceId: "ws_alpha", - }) - ); - - expect(screen.getByTestId("new-session-claude-agent")).toBeDisabled(); - expect(screen.getByTestId("new-session-general")).toBeDisabled(); - expect(screen.getByTestId("new-session-spinner-claude-agent")).toBeInTheDocument(); - expect(screen.queryByTestId("new-session-spinner-general")).not.toBeInTheDocument(); - expect(screen.getByTestId("pending-session-row-claude-agent")).toHaveTextContent( - "starting..." - ); + it("shows bootstrap hint when no agents are loaded", () => { + renderSidebar(makeProps()); + expect(screen.getByText("Run `agh install` to bootstrap AGH")).toBeInTheDocument(); }); - it("does not render the temporary row when the pending session belongs to another workspace", () => { - renderSidebar( - makeProps({ - agents: [{ name: "claude-agent", provider: "anthropic", prompt: "help" }], - isCreatingSession: true, - pendingSessionAgentName: "claude-agent", - pendingSessionWorkspaceId: "ws_beta", - }) - ); - - expect(screen.queryByTestId("pending-session-row-claude-agent")).not.toBeInTheDocument(); - }); - - it("opens an agent group when sessions arrive after the initial render without Base UI warnings", () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); - try { - const agent = { name: "coder", provider: "claude", prompt: "code" }; - const initialProps = makeProps({ - agents: [agent], - sessions: [], - }); - const { rerender } = render( - - - - ); - - rerender( - - - - ); - - expect(screen.getByRole("link", { name: "New session" })).toBeInTheDocument(); - expect(warnSpy).not.toHaveBeenCalled(); - expect(errorSpy).not.toHaveBeenCalled(); - } finally { - warnSpy.mockRestore(); - errorSpy.mockRestore(); - } - }); - - it("returns to the derived closed state when agent sessions disappear", () => { - const agent = { name: "coder", provider: "claude", prompt: "code" }; - const { rerender } = render( - - - - ); - - expect(screen.getByRole("link", { name: "Live session" })).toBeInTheDocument(); - - rerender( - - - - ); - - expect(screen.queryByText("No sessions")).not.toBeInTheDocument(); + it("shows the loading state when agents are loading", () => { + renderSidebar(makeProps({ agentsLoading: true, agents: undefined })); + expect(screen.getByText("Loading agents...")).toBeInTheDocument(); }); }); diff --git a/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx index c36c09dc7..5eee3c451 100644 --- a/web/src/components/app-sidebar.tsx +++ b/web/src/components/app-sidebar.tsx @@ -1,10 +1,7 @@ -import { useState } from "react"; - import { Link, useMatchRoute } from "@tanstack/react-router"; import { Book, Bot, - ChevronRight, Clock3, ListChecks, Loader2, @@ -19,21 +16,16 @@ import { import { cn, - Collapsible, - CollapsibleContent, - CollapsibleTrigger, ConnectionIndicator, Logo, type ConnectionStatus, Sidebar, SidebarSectionLabel, StatusDot, - type StatusDotTone, } from "@agh/ui"; -import { useSessionsByAgent } from "@/hooks/use-sessions-by-agent"; import { AgentIcon, type AgentPayload } from "@/systems/agent"; -import type { SessionPayload, SessionState } from "@/systems/session"; +import type { SessionPayload } from "@/systems/session"; import type { WorkspacePayload } from "@/systems/workspace"; interface RailSlotProps { @@ -110,13 +102,6 @@ function HeaderSlot({ activeWorkspace }: HeaderSlotProps) { ); } -const SESSION_STATE_TONE: Record = { - active: { tone: "success", pulse: false }, - starting: { tone: "warning", pulse: true }, - stopping: { tone: "neutral", pulse: true }, - stopped: { tone: "neutral", pulse: false }, -}; - const NAV_ROW_CLASS = "relative flex items-center gap-2 rounded-[6px] px-2 py-1.5 text-[13px] text-[color:var(--color-text-secondary)] transition-colors hover:bg-[color:var(--color-hover)] hover:text-[color:var(--color-text-primary)]"; const ACTIVE_NAV_ROW_CLASS = @@ -156,187 +141,57 @@ function NavItem({ to, icon: Icon, label, fuzzy }: NavItemProps) { ); } -interface SidebarSessionItemProps { - session: SessionPayload; +interface AgentItemProps { + agent: AgentPayload; + hasActiveSession: boolean; } -function SidebarSessionItem({ session }: SidebarSessionItemProps) { +function AgentItem({ agent, hasActiveSession }: AgentItemProps) { const matchRoute = useMatchRoute(); - const isActive = Boolean(matchRoute({ to: "/session/$id", params: { id: session.id } })); - const displayTitle = session.name || session.id.slice(0, 8); - const { tone, pulse } = SESSION_STATE_TONE[session.state]; + const isActive = Boolean( + matchRoute({ to: "/agents/$name", params: { name: agent.name }, fuzzy: true }) + ); return ( {isActive && (