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 */}
+
+
+ );
+}
+
+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..6e1cab8bc
--- /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,