From bc9552a24bc646d991abc6d32f4ea90fa6c49d89 Mon Sep 17 00:00:00 2001 From: soyboyscout Date: Mon, 2 Mar 2026 19:44:06 -0500 Subject: [PATCH 1/2] feat: add Regent orchestrator as persistent right sidebar Replace the full-page Regent route with a persistent right sidebar panel that includes vertical thread tabs and an AI chat interface powered by Tambo. Users can now interact with the Regent without leaving their current session. - Add RegentSidebar component with vertical tab bar for thread switching - Add Regent toggle button to TopBar (diamond icon, matches sidebar pattern) - Add store state for regentPanelOpen and rightPanelActiveTab - Split RegentPanel into RegentProvider + RegentChat exports - Add session-aware Tambo tools and custom components (SessionCard, TaskOverview) - Remove Regent from sidebar navigation and routing - Remove inline collapsed Context button (TopBar toggle handles it) - Add VITE_TAMBO_API_KEY to .env.example --- web/.env.example | 1 + web/package.json | 4 + web/src/App.test.tsx | 4 + web/src/App.tsx | 22 +- web/src/components/RegentPanel.tsx | 159 +++++++++++ web/src/components/RightPanel.test.tsx | 149 ++++++++++ web/src/components/RightPanel.tsx | 121 ++++++++ web/src/components/TopBar.test.tsx | 4 + web/src/components/TopBar.tsx | 19 ++ .../regent/components/SessionCard.test.tsx | 80 ++++++ web/src/regent/components/SessionCard.tsx | 149 ++++++++++ .../regent/components/TaskOverview.test.tsx | 89 ++++++ web/src/regent/components/TaskOverview.tsx | 125 +++++++++ web/src/regent/tools/session-tools.test.ts | 259 +++++++++++++++++ web/src/regent/tools/session-tools.ts | 261 ++++++++++++++++++ web/src/store.ts | 12 + 16 files changed, 1441 insertions(+), 17 deletions(-) create mode 100644 web/src/components/RegentPanel.tsx create mode 100644 web/src/components/RightPanel.test.tsx create mode 100644 web/src/components/RightPanel.tsx create mode 100644 web/src/regent/components/SessionCard.test.tsx create mode 100644 web/src/regent/components/SessionCard.tsx create mode 100644 web/src/regent/components/TaskOverview.test.tsx create mode 100644 web/src/regent/components/TaskOverview.tsx create mode 100644 web/src/regent/tools/session-tools.test.ts create mode 100644 web/src/regent/tools/session-tools.ts diff --git a/web/.env.example b/web/.env.example index ad4ba94ce..744f2abe4 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1,2 +1,3 @@ VITE_POSTHOG_KEY=phc_your_project_key VITE_POSTHOG_HOST=https://us.i.posthog.com +VITE_TAMBO_API_KEY=your_tambo_api_key diff --git a/web/package.json b/web/package.json index e34cbfe98..5b273770b 100644 --- a/web/package.json +++ b/web/package.json @@ -53,6 +53,10 @@ "@codemirror/lang-xml": "^6.1.0", "@codemirror/lang-yaml": "^6.1.2", "@codemirror/view": "^6.39.15", + "@tambo-ai/client": "^0.0.1", + "@tambo-ai/react": "^1.1.0", + "@tambo-ai/typescript-sdk": "^0.93.1", + "@tanstack/react-query": "^5.90.21", "@uiw/react-codemirror": "^4.25.4", "croner": "^10.0.1", "diff": "^8.0.3", diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index e7965be7a..348d1a24e 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -181,6 +181,10 @@ vi.mock("./components/TerminalPage.js", () => ({ TerminalPage: () =>
TerminalPage
, })); +vi.mock("./components/RightPanel.js", () => ({ + RegentSidebar: () => null, +})); + vi.mock("./components/ProcessPanel.js", () => ({ ProcessPanel: () =>
ProcessPanel
, })); diff --git a/web/src/App.tsx b/web/src/App.tsx index 5f935f536..81ceaf004 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -11,6 +11,7 @@ import { TopBar } from "./components/TopBar.js"; import { HomePage } from "./components/HomePage.js"; import { TaskPanel } from "./components/TaskPanel.js"; import { DiffPanel } from "./components/DiffPanel.js"; +import { RegentSidebar } from "./components/RightPanel.js"; import { UpdateBanner } from "./components/UpdateBanner.js"; import { SessionLaunchOverlay } from "./components/SessionLaunchOverlay.js"; import { SessionTerminalDock } from "./components/SessionTerminalDock.js"; @@ -30,7 +31,6 @@ const AgentsPage = lazy(() => import("./components/AgentsPage.js").then((m) => ( const TerminalPage = lazy(() => import("./components/TerminalPage.js").then((m) => ({ default: m.TerminalPage }))); const ProcessPanel = lazy(() => import("./components/ProcessPanel.js").then((m) => ({ default: m.ProcessPanel }))); - function LazyFallback() { return (
@@ -284,24 +284,9 @@ export default function App() {
- {/* Task panel — overlay on mobile, inline on desktop */} + {/* Task panel — overlay on mobile, inline on desktop (session view only) */} {currentSessionId && isSessionView && ( <> - {!taskPanelOpen && ( - - )} - - {/* Mobile overlay backdrop */} {taskPanelOpen && (
)} + + {/* Regent sidebar — persistent, always available */} +
); diff --git a/web/src/components/RegentPanel.tsx b/web/src/components/RegentPanel.tsx new file mode 100644 index 000000000..d2cfe6b2f --- /dev/null +++ b/web/src/components/RegentPanel.tsx @@ -0,0 +1,159 @@ +import { useRef, useEffect, type ReactNode } from "react"; +import { + TamboProvider, + useTambo, + useTamboThreadInput, + ComponentRenderer, +} from "@tambo-ai/react"; +import { sessionTools } from "../regent/tools/session-tools.js"; +import { sessionCardTamboComponent } from "../regent/components/SessionCard.js"; +import { taskOverviewTamboComponent } from "../regent/components/TaskOverview.js"; + +const TAMBO_API_KEY = (import.meta as unknown as Record>).env?.VITE_TAMBO_API_KEY; + +export function hasTamboApiKey(): boolean { + return !!TAMBO_API_KEY; +} + +export function RegentChat() { + const { messages, isStreaming, currentThreadId } = useTambo(); + const { value, setValue, submit, isPending } = useTamboThreadInput(); + const feedRef = useRef(null); + + useEffect(() => { + if (feedRef.current) { + feedRef.current.scrollTop = feedRef.current.scrollHeight; + } + }, [messages, isStreaming]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!value.trim() || isPending) return; + await submit(); + }; + + return ( +
+ {/* Message feed */} +
+ {messages.length === 0 && ( +
+
+ Ask your Regent about your agent sessions +
+
+
"What are all my sessions doing?"
+
"Which sessions need permission approvals?"
+
"Summarize the progress across all agents"
+
+
+ )} + + {messages.map((msg) => ( +
+ {msg.content.map((content, i) => { + if (content.type === "text") { + return ( +
+ {content.text} +
+ ); + } + if (content.type === "component") { + return ( +
+ +
+ ); + } + return null; + })} +
+ ))} + + {isStreaming && ( +
+ + Thinking... +
+ )} +
+ + {/* Composer */} +
+
+ setValue(e.target.value)} + placeholder="Ask the Regent..." + className="flex-1 bg-cc-bg border border-cc-border rounded-lg px-3 py-2 text-sm text-cc-fg placeholder:text-cc-muted/50 focus:outline-none focus:border-cc-accent" + disabled={isPending} + /> + +
+
+
+ ); +} + +interface RegentProviderProps { + children: ReactNode; +} + +export function RegentProvider({ children }: RegentProviderProps) { + if (!TAMBO_API_KEY) { + return ( +
+ + + +
Regent requires a Tambo API key
+
+ Set VITE_TAMBO_API_KEY in your environment to enable Regent. +
+
+ ); + } + + return ( + + {children} + + ); +} + +export function RegentPanel() { + return ( + + + + ); +} + +export default RegentPanel; diff --git a/web/src/components/RightPanel.test.tsx b/web/src/components/RightPanel.test.tsx new file mode 100644 index 000000000..c479f786e --- /dev/null +++ b/web/src/components/RightPanel.test.tsx @@ -0,0 +1,149 @@ +// @vitest-environment jsdom +import { render, screen, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockSwitchThread = vi.fn(); +const mockStartNewThread = vi.fn(); + +vi.mock("@tambo-ai/react", () => ({ + TamboProvider: ({ children }: { children: React.ReactNode }) =>
{children}
, + useTambo: () => ({ + messages: [], + isStreaming: false, + currentThreadId: "thread-1", + switchThread: mockSwitchThread, + startNewThread: mockStartNewThread, + client: {}, + thread: undefined, + streamingState: { status: "idle" }, + isWaiting: false, + isIdle: true, + registerComponent: vi.fn(), + registerTool: vi.fn(), + registerTools: vi.fn(), + componentList: new Map(), + toolRegistry: new Map(), + initThread: vi.fn(), + dispatch: vi.fn(), + cancelRun: vi.fn(), + authState: { status: "identified" }, + isIdentified: true, + updateThreadName: vi.fn(), + }), + useTamboThreadInput: () => ({ + value: "", + setValue: vi.fn(), + submit: vi.fn(), + isPending: false, + }), + useTamboThreadList: () => ({ + data: { + threads: [ + { id: "thread-1", name: "Thread One", runStatus: "idle", createdAt: "2025-01-01", updatedAt: "2025-01-01" }, + { id: "thread-2", name: "Thread Two", runStatus: "idle", createdAt: "2025-01-01", updatedAt: "2025-01-01" }, + ], + hasMore: false, + }, + isLoading: false, + isError: false, + }), + ComponentRenderer: () => null, +})); + +const workerStub = vi.hoisted(() => { + return vi.fn().mockImplementation(() => ({ + postMessage: vi.fn(), + terminate: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + onmessage: null, + onerror: null, + })); +}); + +vi.stubGlobal("Worker", workerStub); + +vi.mock("../regent/tools/session-tools.js", () => ({ + sessionTools: [], +})); + +vi.mock("../regent/components/SessionCard.js", () => ({ + sessionCardTamboComponent: { + name: "SessionCard", + description: "test", + component: () => null, + propsSchema: { type: "object" as const, properties: {}, required: [] }, + }, + SessionCard: () => null, +})); + +vi.mock("../regent/components/TaskOverview.js", () => ({ + taskOverviewTamboComponent: { + name: "TaskOverview", + description: "test", + component: () => null, + propsSchema: { type: "object" as const, properties: {}, required: [] }, + }, + TaskOverview: () => null, +})); + +import { RegentSidebar } from "./RightPanel.js"; +import { useStore } from "../store.js"; + +beforeEach(() => { + vi.clearAllMocks(); + useStore.setState({ + regentPanelOpen: true, + rightPanelActiveTab: "", + }); +}); + +describe("RegentSidebar", () => { + it("renders Regent thread tabs from Tambo thread list", () => { + render(); + expect(screen.getByTitle("Thread One")).toBeInTheDocument(); + expect(screen.getByTitle("Thread Two")).toBeInTheDocument(); + }); + + it("shows new thread button", () => { + render(); + expect(screen.getByTitle("New Regent thread")).toBeInTheDocument(); + }); + + it("renders RegentChat when panel is open", () => { + render(); + expect(screen.getByPlaceholderText("Ask the Regent...")).toBeInTheDocument(); + }); + + it("switching to a thread tab calls switchThread", () => { + render(); + fireEvent.click(screen.getByTitle("Thread Two")); + expect(mockSwitchThread).toHaveBeenCalledWith("thread-2"); + }); + + it("collapses panel when closed", () => { + useStore.setState({ regentPanelOpen: false }); + const { container } = render(); + const panelWrapper = container.querySelector(".translate-x-full"); + expect(panelWrapper).toBeInTheDocument(); + }); + + it("clicking new thread button calls startNewThread", () => { + render(); + fireEvent.click(screen.getByTitle("New Regent thread")); + expect(mockStartNewThread).toHaveBeenCalled(); + }); + + it("shows diamond header icon in vertical tab bar", () => { + render(); + const tabBar = screen.getByTitle("New Regent thread").parentElement; + expect(tabBar).toBeInTheDocument(); + }); + + it("shows numbered tabs for each thread", () => { + render(); + expect(screen.getByText("1")).toBeInTheDocument(); + expect(screen.getByText("2")).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/RightPanel.tsx b/web/src/components/RightPanel.tsx new file mode 100644 index 000000000..deb7e426c --- /dev/null +++ b/web/src/components/RightPanel.tsx @@ -0,0 +1,121 @@ +import { useEffect, useMemo } from "react"; +import { useTambo, useTamboThreadList } from "@tambo-ai/react"; +import { useStore } from "../store.js"; +import { RegentProvider, RegentChat, hasTamboApiKey } from "./RegentPanel.js"; + +function VerticalTabBar() { + const activeTab = useStore((s) => s.rightPanelActiveTab); + const setActiveTab = useStore((s) => s.setRightPanelActiveTab); + const regentPanelOpen = useStore((s) => s.regentPanelOpen); + const { startNewThread, switchThread, currentThreadId } = useTambo(); + const { data: threads } = useTamboThreadList(); + + const threadList = useMemo( + () => (threads?.threads ?? []).slice(0, 20), + [threads], + ); + + const handleTabClick = (tabId: string) => { + if (!regentPanelOpen) { + useStore.getState().setRegentPanelOpen(true); + } + setActiveTab(tabId); + switchThread(tabId); + }; + + const handleNewThread = async () => { + if (!regentPanelOpen) { + useStore.getState().setRegentPanelOpen(true); + } + await startNewThread(); + }; + + useEffect(() => { + if (currentThreadId && currentThreadId !== activeTab) { + setActiveTab(currentThreadId); + } + }, [currentThreadId]); + + return ( +
+ {/* Header icon */} +
+ + + +
+ + {/* Regent thread tabs */} +
+ {threadList.map((thread, i) => { + const isActive = activeTab === thread.id; + return ( + + ); + })} +
+ + {/* New thread button */} + +
+ ); +} + +export function RegentSidebar() { + const regentPanelOpen = useStore((s) => s.regentPanelOpen); + const hasApiKey = hasTamboApiKey(); + + if (!hasApiKey) return null; + + return ( + + {/* Mobile overlay backdrop */} + {regentPanelOpen && ( +
useStore.getState().setRegentPanelOpen(false)} + /> + )} + +
+
+ {/* Chat content area */} +
+ +
+ + {/* Vertical tab bar */} + +
+
+ + ); +} diff --git a/web/src/components/TopBar.test.tsx b/web/src/components/TopBar.test.tsx index b12f3c1c8..f16ea019b 100644 --- a/web/src/components/TopBar.test.tsx +++ b/web/src/components/TopBar.test.tsx @@ -12,6 +12,10 @@ vi.mock("../ws.js", () => ({ sendToSession: vi.fn(), })); +vi.mock("./RegentPanel.js", () => ({ + hasTamboApiKey: () => false, +})); + interface MockStoreState { currentSessionId: string | null; cliConnected: Map; diff --git a/web/src/components/TopBar.tsx b/web/src/components/TopBar.tsx index febbd139e..a7a5a0f38 100644 --- a/web/src/components/TopBar.tsx +++ b/web/src/components/TopBar.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; import { useStore } from "../store.js"; import { parseHash } from "../utils/routing.js"; import { AiValidationToggle } from "./AiValidationToggle.js"; +import { hasTamboApiKey } from "./RegentPanel.js"; type WorkspaceTab = "chat" | "diff" | "terminal" | "processes" | "editor"; @@ -24,6 +25,8 @@ export function TopBar() { const setSidebarOpen = useStore((s) => s.setSidebarOpen); const taskPanelOpen = useStore((s) => s.taskPanelOpen); const setTaskPanelOpen = useStore((s) => s.setTaskPanelOpen); + const regentPanelOpen = useStore((s) => s.regentPanelOpen); + const setRegentPanelOpen = useStore((s) => s.setRegentPanelOpen); const activeTab = useStore((s) => s.activeTab); const setActiveTab = useStore((s) => s.setActiveTab); const markChatTabReentry = useStore((s) => s.markChatTabReentry); @@ -254,6 +257,22 @@ export function TopBar() { )} + {hasTamboApiKey() && ( + + )}
diff --git a/web/src/regent/components/SessionCard.test.tsx b/web/src/regent/components/SessionCard.test.tsx new file mode 100644 index 000000000..e78418f66 --- /dev/null +++ b/web/src/regent/components/SessionCard.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +import { render, screen, fireEvent } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { describe, it, expect, vi } from "vitest"; +import { SessionCard } from "./SessionCard.js"; + +vi.mock("../../utils/routing.js", () => ({ + navigateToSession: vi.fn(), + parseHash: () => ({ page: "home" }), + navigateHome: vi.fn(), + sessionHash: (id: string) => `#/session/${id}`, +})); + +const defaultProps = { + sessionId: "test-session", + name: "Feature Work", + state: "running", + model: "claude-sonnet-4-20250514", + backendType: "claude", + sessionStatus: "idle", + isStreaming: false, + pendingPermissionCount: 0, + currentTask: null, + gitBranch: "main", +}; + +describe("SessionCard", () => { + it("renders session name and model", () => { + render(); + expect(screen.getByText("Feature Work")).toBeInTheDocument(); + expect(screen.getByText("claude-sonnet-4-20250514")).toBeInTheDocument(); + }); + + it("displays backend type", () => { + render(); + expect(screen.getByText("claude")).toBeInTheDocument(); + }); + + it("shows git branch when provided", () => { + render(); + expect(screen.getByText("feat/new-stuff")).toBeInTheDocument(); + }); + + it("shows current task when provided", () => { + render(); + expect(screen.getByText("Adding unit tests")).toBeInTheDocument(); + }); + + it("shows pending permissions warning", () => { + render(); + expect(screen.getByText("3 pending permissions")).toBeInTheDocument(); + }); + + it("shows singular permission text for count of 1", () => { + render(); + expect(screen.getByText("1 pending permission")).toBeInTheDocument(); + }); + + it("does not show permissions section when count is 0", () => { + render(); + expect(screen.queryByText(/pending permission/)).not.toBeInTheDocument(); + }); + + it("navigates to session on click", async () => { + const { navigateToSession } = await import("../../utils/routing.js"); + render(); + fireEvent.click(screen.getByRole("button")); + expect(navigateToSession).toHaveBeenCalledWith("test-session"); + }); + + it("shows streaming status indicator", () => { + render(); + expect(screen.getByText("Streaming")).toBeInTheDocument(); + }); + + it("shows compacting status when sessionStatus is compacting", () => { + render(); + expect(screen.getByText("Compacting")).toBeInTheDocument(); + }); +}); diff --git a/web/src/regent/components/SessionCard.tsx b/web/src/regent/components/SessionCard.tsx new file mode 100644 index 000000000..9bbea3a7d --- /dev/null +++ b/web/src/regent/components/SessionCard.tsx @@ -0,0 +1,149 @@ +import { navigateToSession } from "../../utils/routing.js"; + +interface SessionCardProps { + sessionId: string; + name: string; + state: string; + model: string; + backendType: string; + sessionStatus: string; + isStreaming: boolean; + pendingPermissionCount: number; + currentTask: string | null; + gitBranch: string | null; +} + +function statusDot(state: string, isStreaming: boolean): string { + if (isStreaming) return "bg-blue-400 animate-pulse"; + if (state === "running") return "bg-green-400"; + if (state === "connected") return "bg-green-400"; + if (state === "starting") return "bg-yellow-400 animate-pulse"; + return "bg-cc-muted"; +} + +function statusLabel( + state: string, + isStreaming: boolean, + sessionStatus: string, +): string { + if (isStreaming) return "Streaming"; + if (sessionStatus === "compacting") return "Compacting"; + if (state === "running") return "Running"; + if (state === "connected") return "Connected"; + if (state === "starting") return "Starting"; + if (state === "exited") return "Exited"; + return state; +} + +export function SessionCard({ + sessionId, + name, + state, + model, + backendType, + sessionStatus, + isStreaming, + pendingPermissionCount, + currentTask, + gitBranch, +}: SessionCardProps) { + return ( + + ); +} + +export const sessionCardTamboComponent = { + name: "SessionCard", + description: + "Displays a summary card for an agent session showing its status, model, current task, and pending permissions. Clickable to navigate to the session.", + component: SessionCard, + propsSchema: { + type: "object" as const, + properties: { + sessionId: { type: "string" as const, description: "Session ID" }, + name: { type: "string" as const, description: "Session display name" }, + state: { + type: "string" as const, + description: "Session state (starting, connected, running, exited)", + }, + model: { type: "string" as const, description: "AI model being used" }, + backendType: { + type: "string" as const, + description: "Backend type (claude or codex)", + }, + sessionStatus: { + type: "string" as const, + description: "Session status (idle, running, compacting, etc.)", + }, + isStreaming: { + type: "boolean" as const, + description: "Whether the session is currently streaming", + }, + pendingPermissionCount: { + type: "number" as const, + description: "Number of pending permission requests", + }, + currentTask: { + type: "string" as const, + description: "Currently active task description, or null", + }, + gitBranch: { + type: "string" as const, + description: "Git branch name, or null", + }, + }, + required: [ + "sessionId", + "name", + "state", + "model", + "backendType", + "sessionStatus", + "isStreaming", + "pendingPermissionCount", + ], + }, +}; diff --git a/web/src/regent/components/TaskOverview.test.tsx b/web/src/regent/components/TaskOverview.test.tsx new file mode 100644 index 000000000..fab97869f --- /dev/null +++ b/web/src/regent/components/TaskOverview.test.tsx @@ -0,0 +1,89 @@ +// @vitest-environment jsdom +import { render, screen } from "@testing-library/react"; +import "@testing-library/jest-dom"; +import { describe, it, expect } from "vitest"; +import { TaskOverview } from "./TaskOverview.js"; + +const mixedTasks = [ + { sessionName: "Session A", subject: "Build API", status: "in_progress" as const, activeForm: "Building API" }, + { sessionName: "Session A", subject: "Write tests", status: "pending" as const }, + { sessionName: "Session B", subject: "Fix bug", status: "completed" as const }, + { sessionName: "Session B", subject: "Deploy", status: "pending" as const }, +]; + +describe("TaskOverview", () => { + it("renders summary counts", () => { + render(); + expect(screen.getByText("4 total")).toBeInTheDocument(); + expect(screen.getByText("1 active")).toBeInTheDocument(); + expect(screen.getByText("2 pending")).toBeInTheDocument(); + expect(screen.getByText("1 done")).toBeInTheDocument(); + }); + + it("renders title when provided", () => { + render(); + expect(screen.getByText("Task Dashboard")).toBeInTheDocument(); + }); + + it("does not render title when not provided", () => { + render(); + expect(screen.queryByText("Task Dashboard")).not.toBeInTheDocument(); + }); + + it("shows activeForm when available, subject otherwise", () => { + render(); + expect(screen.getByText("Building API")).toBeInTheDocument(); + expect(screen.getByText("Write tests")).toBeInTheDocument(); + }); + + it("shows session name next to each displayed task", () => { + render(); + const sessionALabels = screen.getAllByText("(Session A)"); + expect(sessionALabels.length).toBe(2); + const sessionBLabels = screen.getAllByText("(Session B)"); + expect(sessionBLabels.length).toBe(2); + }); + + it("renders empty state with zero counts", () => { + render(); + expect(screen.getByText("0 total")).toBeInTheDocument(); + expect(screen.getByText("0 active")).toBeInTheDocument(); + expect(screen.getByText("0 pending")).toBeInTheDocument(); + expect(screen.getByText("0 done")).toBeInTheDocument(); + }); + + it("truncates pending tasks beyond 5 and shows overflow message", () => { + const manyPending = Array.from({ length: 8 }, (_, i) => ({ + sessionName: `S${i}`, + subject: `Task ${i}`, + status: "pending" as const, + })); + render(); + expect(screen.getByText("+3 more pending")).toBeInTheDocument(); + }); + + it("renders completed tasks", () => { + render(); + expect(screen.getByText("Fix bug")).toBeInTheDocument(); + }); + + it("truncates completed tasks beyond 3 and shows overflow message", () => { + const manyCompleted = Array.from({ length: 5 }, (_, i) => ({ + sessionName: `S${i}`, + subject: `Done ${i}`, + status: "completed" as const, + })); + render(); + expect(screen.getByText("+2 more completed")).toBeInTheDocument(); + }); + + it("does not show overflow message when pending count is 5 or fewer", () => { + const fewPending = Array.from({ length: 5 }, (_, i) => ({ + sessionName: `S${i}`, + subject: `Task ${i}`, + status: "pending" as const, + })); + render(); + expect(screen.queryByText(/more pending/)).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/regent/components/TaskOverview.tsx b/web/src/regent/components/TaskOverview.tsx new file mode 100644 index 000000000..8c88f434c --- /dev/null +++ b/web/src/regent/components/TaskOverview.tsx @@ -0,0 +1,125 @@ +interface TaskEntry { + sessionName: string; + subject: string; + status: "pending" | "in_progress" | "completed"; + activeForm?: string; +} + +interface TaskOverviewProps { + tasks: TaskEntry[]; + title?: string; +} + +const STATUS_STYLES: Record = { + in_progress: { dot: "bg-blue-400 animate-pulse", text: "text-blue-400" }, + pending: { dot: "bg-yellow-400", text: "text-yellow-400" }, + completed: { dot: "bg-green-400", text: "text-green-400" }, +}; + +export function TaskOverview({ tasks, title }: TaskOverviewProps) { + const inProgress = tasks.filter((t) => t.status === "in_progress"); + const pending = tasks.filter((t) => t.status === "pending"); + const completed = tasks.filter((t) => t.status === "completed"); + + return ( +
+ {title && ( +
{title}
+ )} + +
+ {tasks.length} total + {inProgress.length} active + {pending.length} pending + {completed.length} done +
+ + {inProgress.length > 0 && ( +
+ {inProgress.map((t, i) => ( + + ))} +
+ )} + + {pending.length > 0 && inProgress.length > 0 && ( +
+ )} + + {pending.length > 0 && ( +
+ {pending.slice(0, 5).map((t, i) => ( + + ))} + {pending.length > 5 && ( +
+ +{pending.length - 5} more pending +
+ )} +
+ )} + + {completed.length > 0 && (inProgress.length > 0 || pending.length > 0) && ( +
+ )} + + {completed.length > 0 && ( +
+ {completed.slice(0, 3).map((t, i) => ( + + ))} + {completed.length > 3 && ( +
+ +{completed.length - 3} more completed +
+ )} +
+ )} +
+ ); +} + +function TaskRow({ task }: { task: TaskEntry }) { + const style = STATUS_STYLES[task.status] ?? STATUS_STYLES.pending; + return ( +
+ +
+ + {task.activeForm ?? task.subject} + + ({task.sessionName}) +
+
+ ); +} + +export const taskOverviewTamboComponent = { + name: "TaskOverview", + description: + "Displays an aggregated view of tasks across multiple sessions with color-coded status indicators. Groups by in-progress, pending, and completed.", + component: TaskOverview, + propsSchema: { + type: "object" as const, + properties: { + tasks: { + type: "array" as const, + description: "Array of task entries from across sessions", + items: { + type: "object" as const, + properties: { + sessionName: { type: "string" as const }, + subject: { type: "string" as const }, + status: { type: "string" as const }, + activeForm: { type: "string" as const }, + }, + }, + }, + title: { + type: "string" as const, + description: "Optional title for the overview", + }, + }, + required: ["tasks"], + }, +}; diff --git a/web/src/regent/tools/session-tools.test.ts b/web/src/regent/tools/session-tools.test.ts new file mode 100644 index 000000000..56a4e8942 --- /dev/null +++ b/web/src/regent/tools/session-tools.test.ts @@ -0,0 +1,259 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.hoisted(() => { + if (typeof globalThis.Worker === "undefined") { + globalThis.Worker = class Worker { + constructor() {} + postMessage() {} + terminate() {} + addEventListener() {} + removeEventListener() {} + onmessage = null; + onerror = null; + } as unknown as typeof globalThis.Worker; + } +}); + +vi.mock("../../api.js", () => ({ + api: { + getChangedFiles: vi.fn().mockResolvedValue({ + files: [ + { path: "/project/src/index.ts", status: "modified" }, + { path: "/project/src/utils.ts", status: "added" }, + ], + }), + }, +})); + +const mockStoreState = { + sdkSessions: [ + { + sessionId: "s1", + name: "Feature Work", + state: "running" as const, + model: "claude-sonnet-4-20250514", + cwd: "/project", + backendType: "claude" as const, + gitBranch: "feat/new-feature", + createdAt: 1700000000000, + archived: false, + }, + { + sessionId: "s2", + name: "Bug Fix", + state: "connected" as const, + model: "gpt-4", + cwd: "/other-project", + backendType: "codex" as const, + gitBranch: "fix/bug-123", + createdAt: 1700001000000, + archived: false, + }, + { + sessionId: "s3", + name: "Archived Session", + state: "exited" as const, + model: "claude-sonnet-4-20250514", + cwd: "/old", + backendType: "claude" as const, + createdAt: 1699000000000, + archived: true, + }, + ], + messages: new Map([ + [ + "s1", + [ + { id: "m1", role: "user", content: "Hello, build a feature", timestamp: 1700000001000 }, + { id: "m2", role: "assistant", content: "Working on it now...", timestamp: 1700000002000 }, + { id: "m3", role: "user", content: "Add tests too", timestamp: 1700000003000 }, + ], + ], + ]), + sessionTasks: new Map([ + [ + "s1", + [ + { id: "1", subject: "Implement feature", status: "completed", activeForm: "Implementing feature" }, + { id: "2", subject: "Add unit tests", status: "in_progress", activeForm: "Adding unit tests" }, + { id: "3", subject: "Run CI", status: "pending", activeForm: "Running CI" }, + ], + ], + ]), + connectionStatus: new Map([ + ["s1", "connected"], + ["s2", "connected"], + ]), + cliConnected: new Map([ + ["s1", true], + ["s2", true], + ]), + streaming: new Map([["s1", "partial text"]]), + sessionStatus: new Map([["s1", "running"]]), + pendingPermissions: new Map([ + [ + "s1", + new Map([ + ["perm1", { request_id: "perm1", tool_name: "Bash", description: "Run build" }], + ]), + ], + ]), + sessions: new Map([ + ["s1", { model: "claude-sonnet-4-20250514", permissionMode: "auto" }], + ]), +}; + +vi.mock("../../store.js", () => ({ + useStore: Object.assign(() => mockStoreState, { + getState: () => mockStoreState, + subscribe: () => () => {}, + setState: () => {}, + destroy: () => {}, + }), +})); + +import { sessionTools } from "./session-tools.js"; + +function findTool(name: string) { + const tool = sessionTools.find((t) => t.name === name); + if (!tool) throw new Error(`Tool ${name} not found`); + return tool; +} + +describe("session-tools", () => { + describe("listSessions", () => { + it("returns only non-archived sessions", async () => { + const tool = findTool("listSessions"); + const result = JSON.parse(await tool.tool({} as never)); + expect(result).toHaveLength(2); + expect(result[0].sessionId).toBe("s1"); + expect(result[1].sessionId).toBe("s2"); + }); + + it("includes session metadata", async () => { + const tool = findTool("listSessions"); + const result = JSON.parse(await tool.tool({} as never)); + expect(result[0].name).toBe("Feature Work"); + expect(result[0].model).toBe("claude-sonnet-4-20250514"); + expect(result[0].backendType).toBe("claude"); + expect(result[0].gitBranch).toBe("feat/new-feature"); + }); + }); + + describe("getSessionMessages", () => { + it("returns messages for a valid session", async () => { + const tool = findTool("getSessionMessages"); + const result = JSON.parse(await tool.tool({ sessionId: "s1" })); + expect(result).toHaveLength(3); + expect(result[0].role).toBe("user"); + expect(result[0].content).toContain("Hello"); + }); + + it("returns empty array for session with no messages", async () => { + const tool = findTool("getSessionMessages"); + const result = JSON.parse(await tool.tool({ sessionId: "s2" })); + expect(result).toHaveLength(0); + }); + + it("respects the limit parameter", async () => { + const tool = findTool("getSessionMessages"); + const result = JSON.parse(await tool.tool({ sessionId: "s1", limit: 1 })); + expect(result).toHaveLength(1); + expect(result[0].content).toContain("Add tests"); + }); + + it("clamps limit to maximum of 50", async () => { + const tool = findTool("getSessionMessages"); + const result = JSON.parse(await tool.tool({ sessionId: "s1", limit: 999 })); + expect(result).toHaveLength(3); + }); + + it("clamps limit to minimum of 1", async () => { + const tool = findTool("getSessionMessages"); + const result = JSON.parse(await tool.tool({ sessionId: "s1", limit: 0 })); + expect(result).toHaveLength(1); + }); + }); + + describe("getSessionTasks", () => { + it("returns tasks for a valid session", async () => { + const tool = findTool("getSessionTasks"); + const result = JSON.parse(await tool.tool({ sessionId: "s1" })); + expect(result).toHaveLength(3); + expect(result[0].status).toBe("completed"); + expect(result[1].status).toBe("in_progress"); + expect(result[2].status).toBe("pending"); + }); + + it("returns empty array for session with no tasks", async () => { + const tool = findTool("getSessionTasks"); + const result = JSON.parse(await tool.tool({ sessionId: "s2" })); + expect(result).toHaveLength(0); + }); + }); + + describe("getSessionStatus", () => { + it("returns status details for a session", async () => { + const tool = findTool("getSessionStatus"); + const result = JSON.parse(await tool.tool({ sessionId: "s1" })); + expect(result.connectionStatus).toBe("connected"); + expect(result.cliConnected).toBe(true); + expect(result.isStreaming).toBe(true); + expect(result.sessionStatus).toBe("running"); + expect(result.pendingPermissionCount).toBe(1); + }); + + it("returns defaults for unknown session", async () => { + const tool = findTool("getSessionStatus"); + const result = JSON.parse(await tool.tool({ sessionId: "unknown" })); + expect(result.connectionStatus).toBe("disconnected"); + expect(result.cliConnected).toBe(false); + expect(result.isStreaming).toBe(false); + expect(result.pendingPermissionCount).toBe(0); + }); + }); + + describe("getSessionDiff", () => { + it("returns diff info for a valid session", async () => { + const tool = findTool("getSessionDiff"); + const result = JSON.parse(await tool.tool({ sessionId: "s1" })); + expect(result.changedFileCount).toBe(2); + expect(result.files).toHaveLength(2); + expect(result.files[0].status).toBe("modified"); + }); + + it("returns error for nonexistent session", async () => { + const tool = findTool("getSessionDiff"); + const result = JSON.parse(await tool.tool({ sessionId: "nonexistent" })); + expect(result.error).toBe("Session not found"); + }); + }); + + describe("getAllSessionsSummary", () => { + it("returns aggregated summary", async () => { + const tool = findTool("getAllSessionsSummary"); + const result = JSON.parse(await tool.tool({} as never)); + expect(result.totalSessions).toBe(2); + expect(result.sessions).toHaveLength(2); + }); + + it("includes task summary per session", async () => { + const tool = findTool("getAllSessionsSummary"); + const result = JSON.parse(await tool.tool({} as never)); + const s1 = result.sessions.find((s: { sessionId: string }) => s.sessionId === "s1"); + expect(s1.taskSummary.total).toBe(3); + expect(s1.taskSummary.inProgress).toBe(1); + expect(s1.taskSummary.completed).toBe(1); + expect(s1.taskSummary.currentTask).toBe("Adding unit tests"); + }); + + it("includes streaming and permission status", async () => { + const tool = findTool("getAllSessionsSummary"); + const result = JSON.parse(await tool.tool({} as never)); + const s1 = result.sessions.find((s: { sessionId: string }) => s.sessionId === "s1"); + expect(s1.isStreaming).toBe(true); + expect(s1.pendingPermissionCount).toBe(1); + }); + }); +}); diff --git a/web/src/regent/tools/session-tools.ts b/web/src/regent/tools/session-tools.ts new file mode 100644 index 000000000..1a9fa716e --- /dev/null +++ b/web/src/regent/tools/session-tools.ts @@ -0,0 +1,261 @@ +import { defineTool } from "@tambo-ai/react"; +import { useStore } from "../../store.js"; +import { api } from "../../api.js"; +import type { TamboTool } from "@tambo-ai/react"; + +function getStore() { + return useStore.getState(); +} + +const listSessionsTool = defineTool({ + name: "listSessions", + description: + "List all active agent sessions with their name, status, model, working directory, and backend type. Use this to get an overview of what agents are currently running.", + tool: async () => { + const store = getStore(); + const sessions = store.sdkSessions.filter((s) => !s.archived); + return JSON.stringify( + sessions.map((s) => ({ + sessionId: s.sessionId, + name: s.name ?? s.sessionId, + state: s.state, + model: s.model ?? "unknown", + cwd: s.cwd, + backendType: s.backendType ?? "claude", + gitBranch: s.gitBranch, + createdAt: new Date(s.createdAt).toISOString(), + })), + ); + }, + inputSchema: { + type: "object" as const, + properties: {}, + required: [] as string[], + }, + outputSchema: { + type: "object" as const, + properties: {}, + }, +}); + +const getSessionMessagesTool = defineTool({ + name: "getSessionMessages", + description: + "Get recent messages from a specific agent session. Returns the conversation history including user messages and assistant responses.", + tool: async (args: { sessionId: string; limit?: number }) => { + const store = getStore(); + const messages = store.messages.get(args.sessionId) ?? []; + const limit = Math.max(1, Math.min(args.limit ?? 20, 50)); + const recent = messages.slice(-limit); + return JSON.stringify( + recent.map((m) => ({ + id: m.id, + role: m.role, + content: m.content.slice(0, 500), + timestamp: new Date(m.timestamp).toISOString(), + })), + ); + }, + inputSchema: { + type: "object" as const, + properties: { + sessionId: { + type: "string" as const, + description: "The session ID to get messages from", + }, + limit: { + type: "number" as const, + description: "Maximum number of recent messages to return (default: 20)", + }, + }, + required: ["sessionId"], + }, + outputSchema: { + type: "object" as const, + properties: {}, + }, +}); + +const getSessionTasksTool = defineTool({ + name: "getSessionTasks", + description: + "Get the current task list (todo items) for a specific agent session. Shows what the agent is working on and its progress.", + tool: async (args: { sessionId: string }) => { + const store = getStore(); + const tasks = store.sessionTasks.get(args.sessionId) ?? []; + return JSON.stringify( + tasks.map((t) => ({ + id: t.id, + subject: t.subject, + status: t.status, + activeForm: t.activeForm, + })), + ); + }, + inputSchema: { + type: "object" as const, + properties: { + sessionId: { + type: "string" as const, + description: "The session ID to get tasks from", + }, + }, + required: ["sessionId"], + }, + outputSchema: { + type: "object" as const, + properties: {}, + }, +}); + +const getSessionStatusTool = defineTool({ + name: "getSessionStatus", + description: + "Get detailed status of a specific agent session including connection state, streaming status, and pending permission requests.", + tool: async (args: { sessionId: string }) => { + const store = getStore(); + const connectionStatus = + store.connectionStatus.get(args.sessionId) ?? "disconnected"; + const cliConnected = store.cliConnected.get(args.sessionId) ?? false; + const isStreaming = store.streaming.has(args.sessionId); + const sessionStatus = store.sessionStatus.get(args.sessionId) ?? "idle"; + const pendingPerms = store.pendingPermissions.get(args.sessionId); + const pendingCount = pendingPerms ? pendingPerms.size : 0; + const session = store.sessions.get(args.sessionId); + + return JSON.stringify({ + sessionId: args.sessionId, + connectionStatus, + cliConnected, + isStreaming, + sessionStatus, + pendingPermissionCount: pendingCount, + model: session?.model, + permissionMode: session?.permissionMode, + }); + }, + inputSchema: { + type: "object" as const, + properties: { + sessionId: { + type: "string" as const, + description: "The session ID to get status for", + }, + }, + required: ["sessionId"], + }, + outputSchema: { + type: "object" as const, + properties: {}, + }, +}); + +const getSessionDiffTool = defineTool({ + name: "getSessionDiff", + description: + "Get a summary of git changes (diff) for the working directory of a specific session. Shows files added, modified, and deleted.", + tool: async (args: { sessionId: string }) => { + const store = getStore(); + const sdkSession = store.sdkSessions.find( + (s) => s.sessionId === args.sessionId, + ); + if (!sdkSession) { + return JSON.stringify({ error: "Session not found" }); + } + try { + const { files } = await api.getChangedFiles(sdkSession.cwd, "last-commit"); + return JSON.stringify({ + sessionId: args.sessionId, + cwd: sdkSession.cwd, + changedFileCount: files.length, + files: files.slice(0, 30).map((f) => ({ + path: f.path, + status: f.status, + })), + }); + } catch { + return JSON.stringify({ + sessionId: args.sessionId, + error: "Could not fetch diff", + }); + } + }, + inputSchema: { + type: "object" as const, + properties: { + sessionId: { + type: "string" as const, + description: "The session ID to get git diff for", + }, + }, + required: ["sessionId"], + }, + outputSchema: { + type: "object" as const, + properties: {}, + }, +}); + +const getAllSessionsSummaryTool = defineTool({ + name: "getAllSessionsSummary", + description: + "Get an aggregated overview of all active sessions. Shows which sessions are active, idle, streaming, or waiting for permissions. Use this for a quick status check across all agents.", + tool: async () => { + const store = getStore(); + const sessions = store.sdkSessions.filter((s) => !s.archived); + + const summary = sessions.map((s) => { + const connStatus = + store.connectionStatus.get(s.sessionId) ?? "disconnected"; + const isStreaming = store.streaming.has(s.sessionId); + const pendingPerms = store.pendingPermissions.get(s.sessionId); + const pendingCount = pendingPerms ? pendingPerms.size : 0; + const tasks = store.sessionTasks.get(s.sessionId) ?? []; + const inProgress = tasks.filter((t) => t.status === "in_progress"); + const completed = tasks.filter((t) => t.status === "completed"); + const sessionStatus = store.sessionStatus.get(s.sessionId) ?? "idle"; + + return { + sessionId: s.sessionId, + name: s.name ?? s.sessionId, + state: s.state, + model: s.model ?? "unknown", + backendType: s.backendType ?? "claude", + connectionStatus: connStatus, + isStreaming, + sessionStatus, + pendingPermissionCount: pendingCount, + taskSummary: { + total: tasks.length, + inProgress: inProgress.length, + completed: completed.length, + currentTask: inProgress[0]?.activeForm ?? null, + }, + gitBranch: s.gitBranch, + }; + }); + + return JSON.stringify({ + totalSessions: sessions.length, + sessions: summary, + }); + }, + inputSchema: { + type: "object" as const, + properties: {}, + required: [] as string[], + }, + outputSchema: { + type: "object" as const, + properties: {}, + }, +}); + +export const sessionTools: TamboTool[] = [ + listSessionsTool, + getSessionMessagesTool, + getSessionTasksTool, + getSessionStatusTool, + getSessionDiffTool, + getAllSessionsSummaryTool, +]; diff --git a/web/src/store.ts b/web/src/store.ts index 3d8526319..81c3b0871 100644 --- a/web/src/store.ts +++ b/web/src/store.ts @@ -131,6 +131,8 @@ interface AppState { homeResetKey: number; editorTabEnabled: boolean; activeTab: "chat" | "diff" | "terminal" | "processes" | "editor"; + regentPanelOpen: boolean; + rightPanelActiveTab: string; chatTabReentryTickBySession: Map; diffPanelSelectedFile: Map; @@ -223,6 +225,10 @@ interface AppState { setUpdateOverlayActive: (active: boolean) => void; setEditorTabEnabled: (enabled: boolean) => void; + // Regent panel actions + setRegentPanelOpen: (open: boolean) => void; + setRightPanelActiveTab: (tab: string) => void; + // Diff panel actions setActiveTab: (tab: "chat" | "diff" | "terminal" | "processes" | "editor") => void; markChatTabReentry: (sessionId: string) => void; @@ -376,6 +382,8 @@ export const useStore = create((set) => ({ homeResetKey: 0, editorTabEnabled: false, activeTab: "chat", + regentPanelOpen: typeof window !== "undefined" ? window.innerWidth >= 1280 : false, + rightPanelActiveTab: "", chatTabReentryTickBySession: new Map(), diffPanelSelectedFile: new Map(), quickTerminalOpen: false, @@ -842,6 +850,8 @@ export const useStore = create((set) => ({ setUpdateOverlayActive: (active) => set({ updateOverlayActive: active }), setEditorTabEnabled: (enabled) => set({ editorTabEnabled: enabled }), + setRegentPanelOpen: (open) => set({ regentPanelOpen: open }), + setRightPanelActiveTab: (tab) => set({ rightPanelActiveTab: tab }), setActiveTab: (tab) => set({ activeTab: tab }), markChatTabReentry: (sessionId) => set((s) => { @@ -959,6 +969,8 @@ export const useStore = create((set) => ({ taskPanelConfigMode: false, editorTabEnabled: false, activeTab: "chat" as const, + regentPanelOpen: false, + rightPanelActiveTab: "", chatTabReentryTickBySession: new Map(), diffPanelSelectedFile: new Map(), quickTerminalOpen: false, From 775a6eb36488ff582e3610364eea35f55256c5c2 Mon Sep 17 00:00:00 2001 From: soyboyscout Date: Tue, 3 Mar 2026 10:04:21 -0500 Subject: [PATCH 2/2] fix: opaque background and mobile TopBar occlusion for Regent panel Add bg-cc-bg for solid background and use top-11 positioning on mobile so the panel renders below the TopBar instead of covering it. --- web/src/components/RightPanel.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/components/RightPanel.tsx b/web/src/components/RightPanel.tsx index deb7e426c..6e1cab8bc 100644 --- a/web/src/components/RightPanel.tsx +++ b/web/src/components/RightPanel.tsx @@ -93,15 +93,15 @@ export function RegentSidebar() { {/* Mobile overlay backdrop */} {regentPanelOpen && (
useStore.getState().setRegentPanelOpen(false)} /> )}