From f76713b9e4aed1c9fb149af7af78d9b2dc0e2c92 Mon Sep 17 00:00:00 2001 From: Akhil Singh Date: Fri, 12 Jun 2026 02:14:32 -0700 Subject: [PATCH 1/3] Add LiteLLM spend analytics and resume capture --- README.md | 98 ++- ROADMAP.md | 39 +- apps/server/src/db.ts | 31 +- apps/server/src/gitArtifacts.ts | 27 + apps/server/src/processManager.ts | 153 +++- apps/server/src/routes/dashboard.ts | 33 + apps/server/src/routes/sessions.ts | 16 +- apps/server/src/ws.ts | 57 +- apps/web/src/App.tsx | 594 +++++---------- apps/web/src/ClaudeSdkChat.tsx | 52 +- apps/web/src/Dashboard.tsx | 721 ++++++++++-------- apps/web/src/GitDiffViewer.tsx | 219 ++++++ apps/web/src/LiteLLMChat.tsx | 269 ++----- apps/web/src/SessionArtifacts.tsx | 148 +++- apps/web/src/TerminalPane.tsx | 11 +- apps/web/src/TerminalReplay.tsx | 62 +- apps/web/src/api.ts | 32 + packages/shared/src/index.ts | 20 +- packages/shared/src/modelPrices.ts | 8 + screenshots/Budget_Warning_Notification.png | Bin 0 -> 80049 bytes screenshots/Budget_Warning_Permission.png | Bin 0 -> 148037 bytes screenshots/Budget_Warning_Toast.png | Bin 0 -> 305628 bytes screenshots/LiteLLM_Spend_By_Model.png | Bin 0 -> 195954 bytes screenshots/LiteLLM_Spend_Daily.png | Bin 0 -> 175409 bytes screenshots/Session_Resume_Artifact.png | Bin 0 -> 340622 bytes screenshots/Session_Resume_Terminal.png | Bin 0 -> 471634 bytes screenshots/git_diff_viewer.png | Bin 0 -> 669604 bytes .../mvp2_screenshots/new_git_diff_long.png | Bin 0 -> 1072145 bytes .../new_git_diff_long_Diff.png | Bin 0 -> 934457 bytes 29 files changed, 1543 insertions(+), 1047 deletions(-) create mode 100644 apps/web/src/GitDiffViewer.tsx create mode 100644 screenshots/Budget_Warning_Notification.png create mode 100644 screenshots/Budget_Warning_Permission.png create mode 100644 screenshots/Budget_Warning_Toast.png create mode 100644 screenshots/LiteLLM_Spend_By_Model.png create mode 100644 screenshots/LiteLLM_Spend_Daily.png create mode 100644 screenshots/Session_Resume_Artifact.png create mode 100644 screenshots/Session_Resume_Terminal.png create mode 100644 screenshots/git_diff_viewer.png create mode 100644 screenshots/mvp2_screenshots/new_git_diff_long.png create mode 100644 screenshots/mvp2_screenshots/new_git_diff_long_Diff.png diff --git a/README.md b/README.md index 0c428cb..3931482 100644 --- a/README.md +++ b/README.md @@ -11,21 +11,31 @@ Local-first “mission control” for AI coding agent CLIs (and any shell comman ![AgentFleet: Stop Runaway AI Agents with Local Mission Control](screenshots/AgentFleet_Local_AI_Mission_Control.png) ## ✨ Recently Shipped -- **PR #5: Screenshot refresh + dashboard history updates** - - Updated the README demo images to match the latest screenshot set - - Added refreshed history and dashboard visuals for the current UI and spend analytics views - - Kept the product docs aligned with the current MVP surface -- **PR #4: Spend analytics dashboard + session lifecycle improvements** - - Added a spend dashboard with breakdowns by day, repo, command, and model - - Improved server-side session stopping and timeout handling for chat sessions - - Refreshed product docs/screenshots to match the dashboard UI -- **PR #3: LiteLLM chat + terminal replay improvements** - - Added LiteLLM-backed chat support with model selection and the same approve/reject tool flow used by Claude SDK - - Improved persisted PTY replay by stripping alternate-screen escape sequences more reliably - - Refreshed the README screenshots and demo assets to match the current MVP -- **PR #2: Real-time usage tracking** - - Parse Claude Code status lines for more accurate token/cost counting instead of estimates - - Budget enforcement that actually works +- **LiteLLM Spend Analytics tab** (latest) + - Real spend data pulled from your LiteLLM proxy (`/spend/logs`, `/user/daily/activity`) + - Matches Agents Fleet layout: header stats, This Week chart, Weekly Budget strip, By Model and Daily tabs + - Weekly budget resets Sunday; projects spend and flags over-budget +- **Budget 80% warning notifications** + - Native browser notification + in-app toast when a session reaches 80% of its USD or token budget + - Toast auto-dismisses after 8s; works even when browser notifications are blocked +- **One-click session resume** (latest) + - `claude --resume ` and `codex resume ` commands are captured automatically on session exit and shown in the Artifacts tab + - **Resume** button spawns a new shell session instantly — no copy-paste needed + - Backfilled across all historical sessions in the database +- **Graceful session exit for Claude and Codex** (latest) + - Stop button sends Ctrl+C → `/exit` instead of hard-killing, giving Claude/Codex time to save state and print the resume command before exiting +- **Interactive Git Diff Viewer** + - Side-by-side diff display with line-by-line numbering + - Paired removed/added lines render adjacent for easy comparison + - File-level grouping with syntax coloring +- **Spend Analytics Dashboard** + - View total spend by month, week, or day + - Drill down by repo, command, or model + - Real-time cost tracking with USD budgets +- **Budget Tracking in Session Header** + - Display token budgets (input + output combined) and USD budgets side-by-side with current usage + - Shows on all tabs: Shell, Claude (SDK), LiteLLM + - Example: `total 81,772 / 100,000 budget $1.23 / $5.00` This repository contains a **working MVP**: - pnpm workspace monorepo - React + Vite + TypeScript “Mission Control” web app @@ -43,7 +53,7 @@ This repository contains a **working MVP**: **Mission control overview** -![Mission control overview](screenshots/AI_Agent_Mission_Control_System.png) +![Mission control overview](screenshots/AI_Agent_Mission_Control_Overview.png) **Local-first architecture** @@ -53,7 +63,7 @@ This repository contains a **working MVP**: - Shell session -![New shell session](screenshots/New_Session_Shell.jpg) +![New shell session](screenshots/New_Session_Shell.png) - Claude (SDK) session @@ -81,7 +91,7 @@ This repository contains a **working MVP**: - Chat conversation view -![Claude SDK chat](screenshots/claude_sdk_Chat.jpg) +![Claude SDK chat](screenshots/claude_sdk_approval_gate.jpg.png) - Command approval gate @@ -101,15 +111,15 @@ This repository contains a **working MVP**: **Per-session artifacts (git diff snapshots)** -- Git diff snapshot +- Git diff viewer (side-by-side, file tabs) -![Git diff snapshot](screenshots/new_git_diff_long.png) +![Git diff viewer](screenshots/git_diff_viewer.png) **Spend dashboards / budget tracking** - Default spend dashboard -![Spend dashboard default view](screenshots/Spend_Dashboard_Default_View.png) +![Spend dashboard default view](screenshots/Spend_Dashboard_Month.png) - Spend dashboard today @@ -131,6 +141,40 @@ This repository contains a **working MVP**: ![Spend dashboard by model](screenshots/Spend_Dashboard_By_Model.png) +**Session resume** + +- Resume artifact in Artifacts tab with Copy + Resume button + +![Session resume artifact](screenshots/Session_Resume_Artifact.png) + +- Resumed session live terminal + +![Session resume terminal](screenshots/Session_Resume_Terminal.png) + +**LiteLLM Spend Analytics** + +- By Model tab — real spend breakdown from proxy + +![LiteLLM spend by model](screenshots/LiteLLM_Spend_By_Model.png) + +- Daily tab — per-day requests, tokens, and spend + +![LiteLLM spend daily](screenshots/LiteLLM_Spend_Daily.png) + +**Budget warnings** + +- In-app toast (shown when browser notifications are blocked or as a persistent overlay) + +![Budget warning toast](screenshots/Budget_Warning_Toast.png) + +- Native browser notification + +![Budget warning notification](screenshots/Budget_Warning_Notification.png) + +- Browser notification permission prompt + +![Budget warning permission](screenshots/Budget_Warning_Permission.png) + **SQLite persistence / debug views** - Sessions table @@ -143,7 +187,7 @@ This repository contains a **working MVP**: ### Videos -- `screenshots/AgentFleet_Mission_Control.mp4` +- `screenshots/AgentFleet__AI_Mission_Control.mp4` The MVP persists several tables in `data/agents_fleet.sqlite`: @@ -159,7 +203,7 @@ The MVP persists several tables in `data/agents_fleet.sqlite`: > Tip: GitHub renders MP4 previews nicely in README. `.mov` files are ignored by default in `.gitignore` to avoid bloating git history. -- `screenshots/Agents_Fleet__Mission_Control_for_Your_Local_AI_Workers.mp4` +- `screenshots/AgentFleet__AI_Mission_Control.mp4` ## Architecture See `ARCHITECTURE.md`. @@ -292,7 +336,7 @@ Notes: Screenshots: - Claude SDK session stopped by budget -![Claude SDK budget stop](screenshots/claude_sdk_Chat.jpg) +![Claude SDK budget stop](screenshots/claude_sdk_approval_gate.jpg.png) - Claude SDK tool call + output @@ -332,10 +376,13 @@ If you're running a local or enterprise LiteLLM proxy, Agents Fleet will route a ## Budgets (estimated) - Optional `Budget USD` and/or `Budget tokens` apply to the entire session lifetime. +- **Token budget:** counts **input + output tokens combined**. When `total_tokens >= budget_tokens`, the session stops. +- **USD budget:** when `estimated_cost >= budget_usd`, the session stops. - Token estimation: `ceil(text.length / 4)`. - Cost estimation: - - shell/PTY sessions use the default rates in `apps/server/src/budget.ts` + - shell/PTY sessions use the default rates in `apps/server/src/budget.ts` ($3.00 per 1M input, $15.00 per 1M output by default) - Claude SDK sessions use a model-based pricing table (`computeModelCostUsd`) and SDK-reported usage when available. + - LiteLLM sessions use model-specific pricing from your LiteLLM proxy configuration. - If a budget is exceeded, the session is stopped automatically and `stop_reason` becomes `budget_exceeded`. > Note: USD cost is still an estimate unless you configure model pricing to match your account/contract. @@ -375,3 +422,4 @@ COREPACK_HOME="$PWD/.corepack" pnpm -C apps/server test - PTY sessions do not preserve stdout/stderr separation. - Token/cost is an estimate unless the CLI provides actual usage. - Some TUIs (notably Claude) may clear/restore the alternate screen on exit. The persisted replay is a faithful stream replay, so end-of-session scrollback may differ from what you remember seeing just before exit. +- **No multi-line input in the terminal pane.** The xterm.js terminal forwards keystrokes directly to the PTY; the shell owns the line and executes on Enter. Shift+Enter, Ctrl+Enter, etc. all behave the same as plain Enter — there is no way to insert a newline without submitting at the terminal protocol level. Workarounds inside the shell: end a line with `\` for continuation, or use `$'line1\nline2'` quoting. Inside Claude Code's TUI specifically, `Option+Enter` inserts a newline in the prompt. For free-form multi-line composition, use the Claude (SDK) or LiteLLM chat tabs instead, where Shift+Enter works as expected. diff --git a/ROADMAP.md b/ROADMAP.md index 63e20f2..e859cbd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,36 +1,21 @@ # Agents Fleet Roadmap -## Now (MVP hardening) -- CI for build/lint/typecheck/test (done: GitHub Actions runs `pnpm lint`, `pnpm typecheck`, `pnpm -r test`, `pnpm build`). -- Improve terminal fit/focus reliability across reloads/session switches. -- Harden crash recovery: detect orphaned running sessions and mark them ended on server start. -- Better error surfaces in UI (spawn failures, invalid repo paths, budget stops). -- Budget accuracy hardening: strip ANSI escape sequences before token estimation (done). -- Capture git diff + changed files per session (optional on stop / on exit) and store as a per-session artifact. (done: stored in `session_artifacts`, viewable in UI) - -### Claude SDK chat (done) -- Chat-style UI backed by Anthropic SDK (done). -- Per-session transcript persisted as artifacts (done). -- WS streaming for assistant output (done). -- Tool-calling: `run_command` (any shell command) executed in repo, gated by Approve/Reject (done). -- Tool output capped (100KB) to protect context/budgets (done). -- Budget enforcement for Claude SDK sessions, including within tool loops (done; model-aware cost estimate). -- Display session id and token usage (input/output; thinking/cache when present via usage artifacts) (done). - -### LiteLLM chat (done) -- Chat-style UI backed by LiteLLM proxy support (done). -- Model selection in the UI for proxy-backed chat sessions (done). -- Tool-calling with the same Approve/Reject workflow as Claude SDK (done). -- Persisted usage/session artifacts for budget enforcement and replay (done). -- Support enterprise/custom proxy endpoints via `LITELLM_BASE_URL` + `LITELLM_API_KEY` (done). +## Done +- Budget 80% warning: native browser notification + in-app toast when a session hits 80% of its USD or token budget. +- One-click session resume: Resume button in Artifacts tab spawns a new shell session instantly. `claude --resume` / `codex resume` captured on graceful exit, backfilled across all historical sessions. +- Graceful exit for Claude and Codex: Stop button sends Ctrl+C → `/exit` before force-killing, so state is saved and the resume command is printed. +- Crash recovery: sessions stuck in `running` on server start are automatically marked `stopped` with `stop_reason: crash_recovery`. +- LiteLLM Spend Analytics: dedicated tab in Spend Analytics pulling real cost data from LiteLLM proxy — header stats, This Week chart, Weekly Budget strip (Sunday reset), By Model and Daily breakdown tabs. ## Next (Agent mission control) -- One-click rerun: restart a historical session (repoPath + command) in a fresh process. -- Per-session artifacts UX: view/export bundle (diff, changed files list, PTY replay export). (in progress: artifacts tab + JSON diff view) -- Make model pricing configurable (env/JSON) instead of hardcoded table. -- Improve budget accuracy: use model-specific pricing and SDK usage everywhere; add tests. +1. **Multiple sessions management** — Batch-stop, group by repo, launch parallel sessions from the UI. Core to the "fleet" value prop. +2. **Per-session artifacts UX** — View/export bundle (diff, changed files list, PTY replay export). +3. **Model pricing configurable** — env/JSON override instead of hardcoded table. +4. **Budget accuracy hardening** — Model-specific pricing and SDK-reported usage everywhere; add tests. ## Later +- Paste/attachments in Claude (SDK) and LiteLLM chat (images, files via Anthropic Files API). +- UI polish pass: budget progress animations, spinners, improved session status indicators. - Exact token/cost via a local tokenizer (e.g. WASM tiktoken) for closer budget enforcement. - Team/shared workspaces (still local-first, optional sync later). - Optional local proxy mode for exact budget enforcement and richer telemetry. diff --git a/apps/server/src/db.ts b/apps/server/src/db.ts index 2b5c550..88cc8ca 100644 --- a/apps/server/src/db.ts +++ b/apps/server/src/db.ts @@ -1,3 +1,4 @@ +import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import Database from "better-sqlite3"; @@ -125,5 +126,33 @@ export function getDb(): Db { } export async function bootstrapDb(): Promise { - getDb(); + const db = getDb(); + + // Recover orphaned sessions: any session still marked 'running' at startup + // was never cleaned up (server crash, forced kill, etc.). Mark them stopped. + const now = new Date().toISOString(); + const orphans = db + .prepare("SELECT id FROM sessions WHERE status = 'running'") + .all() as { id: string }[]; + + if (orphans.length > 0) { + const markStopped = db.prepare( + `UPDATE sessions SET status = 'stopped', ended_at = ?, stop_reason = 'crash_recovery' + WHERE id = ?`, + ); + const insertMarker = db.prepare( + `INSERT INTO session_markers (id, session_id, timestamp, kind) + VALUES (?, ?, ?, 'crash_recovery')`, + ); + const tx = db.transaction(() => { + for (const { id } of orphans) { + markStopped.run(now, id); + insertMarker.run(crypto.randomUUID(), id, now); + } + }); + tx(); + console.log( + `[crash-recovery] Marked ${orphans.length} orphaned session(s) as stopped.`, + ); + } } diff --git a/apps/server/src/gitArtifacts.ts b/apps/server/src/gitArtifacts.ts index 5bc5a32..faf97e0 100644 --- a/apps/server/src/gitArtifacts.ts +++ b/apps/server/src/gitArtifacts.ts @@ -114,6 +114,33 @@ export function storeSessionArtifact(args: { } as const; } +export function captureResumeArtifact(sessionId: string, command: string) { + const db = getDb(); + const rows = db + .prepare( + "SELECT data FROM pty_chunks WHERE session_id = ? ORDER BY timestamp ASC", + ) + .all(sessionId) as { data: string }[]; + + const fullText = rows.map((r) => r.data).join(""); + + // Strip ANSI escape sequences before matching. + const clean = fullText.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\][^\x07]*\x07/g, ""); + + const prefix = command === "codex" ? "codex" : "claude"; + const pattern = command === "codex" + ? `codex\\s+resume\\s+([a-f0-9-]{36})` + : `claude\\s+--resume\\s+([a-f0-9-]{36})`; + const m = clean.match(new RegExp(pattern, "i")); + if (!m) return null; + + const resumeCommand = command === "codex" + ? `codex resume ${m[1]}` + : `claude --resume ${m[1]}`; + storeSessionArtifact({ sessionId, kind: `${prefix}_resume`, content: resumeCommand }); + return resumeCommand; +} + export function buildGitArtifactContent(snapshot: GitSnapshot): string { const payload: GitArtifactV1 = { v: 1, diff --git a/apps/server/src/processManager.ts b/apps/server/src/processManager.ts index f36d8bd..9471f2c 100644 --- a/apps/server/src/processManager.ts +++ b/apps/server/src/processManager.ts @@ -9,6 +9,7 @@ import type { SessionWsHub } from "./ws"; import { buildGitArtifactContent, captureGitSnapshot, + captureResumeArtifact, storeSessionArtifact, } from "./gitArtifacts"; @@ -25,6 +26,12 @@ type RunningSession = { ptyBuffer: string; ptyFlushTimer: NodeJS.Timeout | null; + // Graceful-exit waiters: resolved when the pty onExit fires. + exitWaiters: Array<() => void>; + + // Track whether the 80% budget warning has already been sent. + budgetWarning80Sent?: boolean; + // Best-effort usage parsing for agent CLIs. // For Codex, usage lines report absolute totals; we overwrite session estimates from these. lastCodexUsage?: { input: number; output: number }; @@ -340,6 +347,7 @@ function captureGitArtifactBestEffort( } const PTY_FLUSH_MS = 50; +const IDLE_TIMEOUT_MS = 5 * 60 * 1000; const IDLE_POLL_MS = 15 * 1000; const CHAT_IDLE_TIMEOUT_MS = 10 * 60 * 1000; @@ -583,6 +591,7 @@ export class ProcessManager { lastOutputAt: Date.now(), ptyBuffer: "", ptyFlushTimer: null, + exitWaiters: [], }); void (async () => { @@ -768,6 +777,12 @@ export class ProcessManager { }); p.onExit(({ exitCode, signal }) => { + // Resolve any graceful-exit waiters before the async work below. + const r0 = this.running.get(args.sessionId); + if (r0) { + for (const resolve of r0.exitWaiters) resolve(); + r0.exitWaiters = []; + } void (async () => { const current = await getSession(args.sessionId); const wasStopped = current?.status === "stopped"; @@ -779,8 +794,8 @@ export class ProcessManager { const message = `process exit: code=${exitCode ?? "null"} signal=${signal ?? "null"}`; // Flush any buffered PTY data before we record exit. this.flushPty(args.sessionId); - insertMarker(args.sessionId, "process_exit"); insertPtyChunk(args.sessionId, `\r\n[system] ${message}\r\n`); + insertMarker(args.sessionId, "process_exit"); const endedAt = current?.ended_at ?? nowIso(); const updated = await updateSessionFields(args.sessionId, { status, @@ -798,6 +813,12 @@ export class ProcessManager { } catch { // ignore } + // Capture resume command from PTY output (best-effort). + try { + captureResumeArtifact(args.sessionId, args.command.trim()); + } catch { + // ignore + } if (updated) this.hub.broadcastSession(updated); this.running.delete(args.sessionId); })(); @@ -822,19 +843,21 @@ export class ProcessManager { // Capture git state when the user explicitly stops (best-effort). try { - captureGitArtifactBestEffort( - sessionId, - running.repoPath, - "git_on_stop", - ); + captureGitArtifactBestEffort(sessionId, running.repoPath, "git_on_stop"); } catch { // ignore } - try { - running.pty.kill(); - } catch { - // ignore + if (running.command.trim() === "claude") { + await this.gracefullyExitClaude(running, sessionId); + } else if (running.command.trim() === "codex") { + await this.gracefullyExitCodex(running, sessionId); + } else { + try { + running.pty.kill(); + } catch { + // ignore + } } return updated; @@ -855,6 +878,82 @@ export class ProcessManager { return updated; } + private async gracefullyExitClaude( + running: RunningSession, + sessionId: string, + ) { + const GRACEFUL_TIMEOUT_MS = 5000; + + const exitPromise = new Promise((resolve) => { + const timer = setTimeout(resolve, GRACEFUL_TIMEOUT_MS); + running.exitWaiters.push(() => { + clearTimeout(timer); + resolve(); + }); + }); + + // Interrupt any in-progress tool, then ask Claude to exit cleanly. + try { + running.pty.write("\x03"); + } catch { + // ignore + } + await new Promise((r) => setTimeout(r, 500)); + try { + running.pty.write("/exit\n"); + } catch { + // ignore + } + + await exitPromise; + + // If the process hasn't exited yet, force-kill it. + if (this.running.has(sessionId)) { + try { + running.pty.kill(); + } catch { + // ignore + } + } + } + + private async gracefullyExitCodex( + running: RunningSession, + sessionId: string, + ) { + const GRACEFUL_TIMEOUT_MS = 5000; + + const exitPromise = new Promise((resolve) => { + const timer = setTimeout(resolve, GRACEFUL_TIMEOUT_MS); + running.exitWaiters.push(() => { + clearTimeout(timer); + resolve(); + }); + }); + + try { + running.pty.write("\x03"); + } catch { + // ignore + } + await new Promise((r) => setTimeout(r, 500)); + try { + running.pty.write("/exit\n"); + } catch { + // ignore + } + + await exitPromise; + + if (this.running.has(sessionId)) { + try { + running.pty.kill(); + } catch { + // ignore + } + } + } + writeInput(sessionId: string, data: string) { const running = this.running.get(sessionId); if (!running) return false; @@ -914,6 +1013,40 @@ export class ProcessManager { if (session.status !== "running") return; const totalTokens = session.estimated_input_tokens + session.estimated_output_tokens; + + // 80% warning — fire once per session, before hard stop. + const running = this.running.get(sessionId); + if (running && !running.budgetWarning80Sent) { + const tokenPct = + typeof session.budget_tokens === "number" && session.budget_tokens > 0 + ? totalTokens / session.budget_tokens + : 0; + const usdPct = + typeof session.budget_usd === "number" && session.budget_usd > 0 + ? session.estimated_cost_usd / session.budget_usd + : 0; + + if (tokenPct >= 0.8 && tokenPct < 1) { + running.budgetWarning80Sent = true; + this.hub.broadcastBudgetWarning({ + sessionId, + pctUsed: Math.round(tokenPct * 100), + kind: "tokens", + current: totalTokens, + budget: session.budget_tokens as number, + }); + } else if (usdPct >= 0.8 && usdPct < 1) { + running.budgetWarning80Sent = true; + this.hub.broadcastBudgetWarning({ + sessionId, + pctUsed: Math.round(usdPct * 100), + kind: "usd", + current: session.estimated_cost_usd, + budget: session.budget_usd as number, + }); + } + } + const tokenExceeded = typeof session.budget_tokens === "number" && session.budget_tokens > 0 && diff --git a/apps/server/src/routes/dashboard.ts b/apps/server/src/routes/dashboard.ts index 9f94e06..fe95d6d 100644 --- a/apps/server/src/routes/dashboard.ts +++ b/apps/server/src/routes/dashboard.ts @@ -227,6 +227,7 @@ export function dashboardRouter(): Router { ORDER BY timestamp DESC LIMIT 1 ) WHERE s.created_at BETWEEN ? AND ? + AND JSON_VALID(sa.content) AND JSON_EXTRACT(sa.content, '$.model') IS NOT NULL GROUP BY JSON_EXTRACT(sa.content, '$.model'), s.command ORDER BY total_cost DESC`, @@ -333,5 +334,37 @@ export function dashboardRouter(): Router { }); }); + /** + * GET /api/dashboard/litellm/spend?from=YYYY-MM-DD&to=YYYY-MM-DD + * Proxy to LiteLLM spend endpoints. Returns { configured: false } if env vars missing. + */ + router.get("/dashboard/litellm/spend", async (req: Request, res: Response) => { + const baseUrl = process.env.LITELLM_BASE_URL?.replace(/\/$/, ""); + const apiKey = process.env.LITELLM_API_KEY; + + if (!baseUrl || !apiKey) { + return res.json({ configured: false }); + } + + const { from, to } = req.query; + const headers = { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }; + + try { + // /spend/logs returns array of daily objects: { startTime, spend, models: {model: cost} } + // /user/daily/activity returns { results: [{ date, metrics: { spend, prompt_tokens, ... } }] } + const [logsRes, activityRes] = await Promise.all([ + fetch(`${baseUrl}/spend/logs?start_date=${from ?? ""}&end_date=${to ?? ""}`, { headers }), + fetch(`${baseUrl}/user/daily/activity?start_date=${from ?? ""}&end_date=${to ?? ""}`, { headers }), + ]); + + const spendLogs = logsRes.ok ? await logsRes.json() : []; + const activityRaw = activityRes.ok ? await activityRes.json() : null; + + return res.json({ configured: true, spendLogs, activity: activityRaw }); + } catch (e) { + return res.status(502).json({ error: { message: `LiteLLM proxy error: ${String(e)}` } }); + } + }); + return router; } diff --git a/apps/server/src/routes/sessions.ts b/apps/server/src/routes/sessions.ts index 4150f9f..48149e8 100644 --- a/apps/server/src/routes/sessions.ts +++ b/apps/server/src/routes/sessions.ts @@ -1,12 +1,12 @@ -import crypto from "node:crypto"; -import fs from "node:fs/promises"; -import type { Request, Response, Router } from "express"; -import { Router as createRouter } from "express"; import type { - CreateSessionRequest, - Session, - SessionArtifact, + CreateSessionRequest, + Session, + SessionArtifact, } from "@agents_fleet/shared"; +import type { Request, Response, Router } from "express"; +import { Router as createRouter } from "express"; +import crypto from "node:crypto"; +import fs from "node:fs/promises"; import { getDb } from "../db"; import type { ProcessManager } from "../processManager"; @@ -260,6 +260,8 @@ export function sessionsRouter(processManager: ProcessManager): Router { res.json({ markers }); }); + + /** * GET /api/sessions/:id/artifacts?limit=...&offset=...&kind=...&latest=1 * Response: { artifacts: SessionArtifact[], limit: number, offset: number } diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0e0cad4..bdd517d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,30 +1,29 @@ -import type { IncomingMessage } from "node:http"; import type { - Session, - WsClientMessage, - WsServerMessage, + Session, + WsClientMessage, + WsServerMessage, } from "@agents_fleet/shared"; +import type { IncomingMessage } from "node:http"; +import { WebSocket, WebSocketServer } from "ws"; +import { computeLiteLlmModelCostUsdAsync, computeModelCostUsdAsync } from "./budget"; import { - assertClaudeSdkSession, - runClaudeSdkTurn, - storeClaudeSdkMessage, - storeClaudeSdkToolApproval, - storeClaudeSdkToolResult, - updateSessionEstimatesFromUsage, - loadClaudeSdkConfig, + assertClaudeSdkSession, + loadClaudeSdkConfig, + runClaudeSdkTurn, + storeClaudeSdkMessage, + storeClaudeSdkToolApproval, + storeClaudeSdkToolResult, + updateSessionEstimatesFromUsage, } from "./claudeSdk"; +import { runCommand } from "./commandRunner"; +import { getDb } from "./db"; import { - assertLiteLlmSession, - loadLiteLlmConfig, - runLiteLlmTurn, - storeLiteLlmMessage, - updateLiteLlmSessionEstimatesFromUsage, + assertLiteLlmSession, + loadLiteLlmConfig, + runLiteLlmTurn, + storeLiteLlmMessage, + updateLiteLlmSessionEstimatesFromUsage, } from "./litellm"; -import { computeModelCostUsdAsync } from "./budget"; -import { computeLiteLlmModelCostUsdAsync } from "./budget"; -import { getDb } from "./db"; -import { runCommand } from "./commandRunner"; -import { WebSocketServer, WebSocket } from "ws"; import type { ProcessManager } from "./processManager"; function safeSend(ws: WebSocket, message: WsServerMessage) { @@ -249,6 +248,8 @@ export class SessionWsHub { }); } + + private handleResize( ws: WebSocket, sessionId: string, @@ -791,6 +792,20 @@ export class SessionWsHub { safeSend(ws, { type: "session", session }); } } + + broadcastBudgetWarning(args: { + sessionId: string; + pctUsed: number; + kind: "usd" | "tokens"; + current: number; + budget: number; + }) { + const clients = this.sessionToClients.get(args.sessionId); + if (!clients) return; + for (const ws of clients) { + safeSend(ws, { type: "budget_warning", ...args }); + } + } } function cryptoRandomId() { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 38b525c..ab84d02 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,118 +1,83 @@ -import { useEffect, useMemo, useState } from "react"; import type { Session } from "@agents_fleet/shared"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { createSession, deleteSession, listSessions, stopSession } from "./api"; import ClaudeSdkChat from "./ClaudeSdkChat"; +import { DashboardContent } from "./Dashboard"; import LiteLLMChat from "./LiteLLMChat"; -import { openWs, type WsServerMessage } from "./ws"; +import SessionArtifacts from "./SessionArtifacts"; import TerminalPane from "./TerminalPane"; import TerminalReplay from "./TerminalReplay"; -import SessionArtifacts from "./SessionArtifacts"; -import Dashboard from "./Dashboard"; +import { openWs, type WsServerMessage } from "./ws"; +import AddIcon from "@mui/icons-material/Add"; +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; +import FiberManualRecordIcon from "@mui/icons-material/FiberManualRecord"; +import StopCircleOutlinedIcon from "@mui/icons-material/StopCircleOutlined"; import { - createTheme, - ThemeProvider, - CssBaseline, - Box, - Typography, - Button, - ButtonGroup, - Chip, - Alert, - Paper, - TextField, - Stack, - IconButton, - Tooltip, - Divider, + Alert, + Box, + Button, + ButtonGroup, + Chip, + createTheme, + CssBaseline, + Divider, + IconButton, + Paper, + Stack, + TextField, + ThemeProvider, + Tooltip, + Typography, + useTheme, } from "@mui/material"; -import BarChartIcon from "@mui/icons-material/BarChart"; -import StopCircleOutlinedIcon from "@mui/icons-material/StopCircleOutlined"; -import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; -import AddIcon from "@mui/icons-material/Add"; // ── Types ───────────────────────────────────────────────────────────────────── -type LeftTab = "shell" | "claude_sdk" | "litellm"; +type LeftTab = "shell" | "claude_sdk" | "litellm" | "dashboard"; type CenterTab = "terminal" | "logs" | "artifacts"; // ── Status badge ────────────────────────────────────────────────────────────── function StatusBadge({ status }: { status: string }) { - const styles: Record< - string, - { bgcolor: string; color: string; border?: string } - > = { - running: { bgcolor: "#dcfce7", color: "#15803d" }, - exited: { bgcolor: "#fef3c7", color: "#b45309" }, - stopped: { bgcolor: "#f1f5f9", color: "#475569" }, - error: { bgcolor: "#fee2e2", color: "#dc2626" }, + const styles: Record = { + running: { bgcolor: "#dcfce7", color: "#15803d" }, + exited: { bgcolor: "#fef3c7", color: "#b45309" }, + stopped: { bgcolor: "#f1f5f9", color: "#475569" }, + error: { bgcolor: "#fee2e2", color: "#dc2626" }, }; const s = styles[status] ?? styles.stopped; return ( ); } +// ── Status dot (used in info bar only) ─────────────────────────────────────── + +function StatusDot({ status }: { status: string }) { + const color = + status === "running" + ? "success.main" + : status === "error" + ? "error.main" + : "text.disabled"; + return ( + + ); +} + // ── Command type badge ──────────────────────────────────────────────────────── function CommandBadge({ command }: { command: string }) { if (command === "[claude-sdk]") - return ( - - ); + return ; if (command === "[litellm-chat]") - return ( - - ); - return ( - - ); + return ; + return ; } // ── Sessions sidebar ────────────────────────────────────────────────────────── @@ -132,6 +97,7 @@ function SessionsSidebar({ onSelect: (s: Session) => void; onDelete: (id: string) => void; }) { + const theme = useTheme(); const visible = sessions.filter((s) => showAll || s.status === "running"); const runningCount = sessions.filter((s) => s.status === "running").length; @@ -141,7 +107,7 @@ function SessionsSidebar({ square sx={{ display: "grid", - gridTemplateRows: "52px 1fr", + gridTemplateRows: "52px 1fr auto", minHeight: 0, overflow: "hidden", borderLeft: 1, @@ -180,7 +146,7 @@ function SessionsSidebar({ - {/* List */} + {/* Session list */} {visible.length === 0 ? ( @@ -216,27 +182,14 @@ function SessionsSidebar({ color: "text.primary", cursor: "pointer", display: "block", - "&:hover": { - bgcolor: isSelected ? "#e0f4fc" : "action.hover", - }, + "&:hover": { bgcolor: isSelected ? "#e0f4fc" : "action.hover" }, transition: "background 0.1s", }} > - + - + {new Date(s.created_at).toLocaleTimeString()} @@ -261,10 +214,7 @@ function SessionsSidebar({ top: 8, right: 6, color: "text.disabled", - "&:hover": { - color: "error.main", - bgcolor: "action.hover", - }, + "&:hover": { color: "error.main", bgcolor: "action.hover" }, }} > @@ -276,19 +226,29 @@ function SessionsSidebar({ }) )} + + {/* Credit */} + + + Built by Akhil Singh + + ); } // ── Main app ────────────────────────────────────────────────────────────────── -function MainApp({ onDashboard }: { onDashboard: () => void }) { +function MainApp() { + // ── Form state ────────────────────────────────────────────────────────────── const [repoPath, setRepoPath] = useState(""); const [command, setCommand] = useState(""); const [budgetUsd, setBudgetUsd] = useState(""); const [budgetTokens, setBudgetTokens] = useState(""); const [error, setError] = useState(null); + const [toasts, setToasts] = useState<{ id: number; message: string }[]>([]); + const toastCounter = useRef(0); // ── Tab state ─────────────────────────────────────────────────────────────── const [leftTab, setLeftTab] = useState("shell"); @@ -311,11 +271,7 @@ function MainApp({ onDashboard }: { onDashboard: () => void }) { async function refreshSessions(preserveSelected = true) { const next = await listSessions(); setSessions(next); - if ( - preserveSelected && - selectedId && - !next.some((s) => s.id === selectedId) - ) { + if (preserveSelected && selectedId && !next.some((s) => s.id === selectedId)) { setSelectedId(null); } } @@ -328,6 +284,12 @@ function MainApp({ onDashboard }: { onDashboard: () => void }) { return () => window.clearInterval(id); }, []); + useEffect(() => { + if ("Notification" in window && Notification.permission === "default") { + void Notification.requestPermission(); + } + }, []); + // ── WebSocket ──────────────────────────────────────────────────────────────── useEffect(() => { if (!selectedId) { @@ -342,10 +304,7 @@ function MainApp({ onDashboard }: { onDashboard: () => void }) { socket.send(JSON.stringify({ type: "subscribe", sessionId: selectedId })); socket.onmessage = (evt) => { const msg = JSON.parse(evt.data as string) as WsServerMessage; - if (msg.type === "error") { - setError(msg.message); - return; - } + if (msg.type === "error") { setError(msg.message); return; } if (msg.type === "pty" && msg.sessionId === selectedId) { window.dispatchEvent( new CustomEvent("agents_fleet:pty", { @@ -355,16 +314,24 @@ function MainApp({ onDashboard }: { onDashboard: () => void }) { return; } if (msg.type === "session") { - setSessions((prev) => - prev.map((s) => (s.id === msg.session.id ? msg.session : s)), - ); + setSessions((prev) => prev.map((s) => (s.id === msg.session.id ? msg.session : s))); + } + if (msg.type === "budget_warning") { + const label = msg.kind === "usd" + ? `$${msg.current.toFixed(4)} / $${msg.budget.toFixed(2)}` + : `${msg.current.toLocaleString()} / ${msg.budget.toLocaleString()} tokens`; + const body = `Session ${msg.sessionId.slice(0, 8)} is at ${msg.pctUsed}% of its ${msg.kind} budget (${label}).`; + if ("Notification" in window && Notification.permission === "granted") { + new Notification("⚠️ Budget warning — Agents Fleet", { body }); + } + // Always show in-app toast regardless of notification permission. + const id = ++toastCounter.current; + setToasts((prev) => [...prev, { id, message: body }]); + setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 8000); } }; socket.onerror = () => setError("WebSocket error"); - return () => { - socket.close(); - setWs(null); - }; + return () => { socket.close(); setWs(null); }; }, [selectedId]); // ── Handlers ───────────────────────────────────────────────────────────────── @@ -378,28 +345,19 @@ function MainApp({ onDashboard }: { onDashboard: () => void }) { budgetUsd: budgetUsd.trim() ? Number(budgetUsd) : undefined, budgetTokens: budgetTokens.trim() ? Number(budgetTokens) : undefined, }); - setRepoPath(""); - setCommand(""); - setBudgetUsd(""); - setBudgetTokens(""); + setRepoPath(""); setCommand(""); setBudgetUsd(""); setBudgetTokens(""); await refreshSessions(false); setSelectedId(session.id); setCenterTab("terminal"); - } catch (err) { - setError(String(err)); - } + } catch (err) { setError(String(err)); } } async function onStop() { if (!selected) return; try { const updated = await stopSession(selected.id); - setSessions((prev) => - prev.map((s) => (s.id === updated.id ? updated : s)), - ); - } catch (err) { - setError(String(err)); - } + setSessions((prev) => prev.map((s) => (s.id === updated.id ? updated : s))); + } catch (err) { setError(String(err)); } } function onSelectSession(s: Session) { @@ -423,6 +381,7 @@ function MainApp({ onDashboard }: { onDashboard: () => void }) { { key: "shell", label: "Shell" }, { key: "claude_sdk", label: "Claude (SDK)" }, { key: "litellm", label: "LiteLLM" }, + { key: "dashboard", label: "Spend Analytics" }, ]; // ── Center-tab config ──────────────────────────────────────────────────────── @@ -437,7 +396,7 @@ function MainApp({ onDashboard }: { onDashboard: () => void }) { sx={{ height: "100vh", display: "grid", - gridTemplateColumns: "1fr 300px", + gridTemplateColumns: leftTab === "dashboard" ? "1fr" : "1fr 300px", gridTemplateRows: "1fr", bgcolor: "background.default", overflow: "hidden", @@ -447,7 +406,7 @@ function MainApp({ onDashboard }: { onDashboard: () => void }) { void }) { fontWeight={800} fontSize={15} letterSpacing={1.5} - sx={{ - mr: 1, - whiteSpace: "nowrap", - color: "#ffffff", - fontFamily: "ui-monospace, monospace", - }} + sx={{ mr: 1, whiteSpace: "nowrap", color: "#ffffff", fontFamily: "ui-monospace, monospace" }} > AGENT FLEET - {/* Spend dashboard button */} - - {/* Left-tab switcher */} {leftTabs.map(({ key, label }) => ( @@ -528,8 +452,7 @@ function MainApp({ onDashboard }: { onDashboard: () => void }) { sx={{ textTransform: "none", fontWeight: leftTab === key ? 700 : 400, - bgcolor: - leftTab === key ? "rgba(255,255,255,0.22)" : "transparent", + bgcolor: leftTab === key ? "rgba(255,255,255,0.22)" : "transparent", color: "#fff", borderRadius: 1.5, px: 1.5, @@ -559,29 +482,19 @@ function MainApp({ onDashboard }: { onDashboard: () => void }) { ml: 0.5, color: "#fff", borderColor: "rgba(255,255,255,0.45)", - "&:hover": { - borderColor: "#fff", - bgcolor: "rgba(255,255,255,0.12)", - }, + "&:hover": { borderColor: "#fff", bgcolor: "rgba(255,255,255,0.12)" }, }} > New chat )} + {/* Content area */} {leftTab === "claude_sdk" ? ( - + {selected && selected.command === "[claude-sdk]" ? ( ) : ( @@ -598,15 +511,7 @@ function MainApp({ onDashboard }: { onDashboard: () => void }) { )} ) : leftTab === "litellm" ? ( - + {selected && selected.command === "[litellm-chat]" ? ( ) : ( @@ -622,15 +527,11 @@ function MainApp({ onDashboard }: { onDashboard: () => void }) { /> )} + ) : leftTab === "dashboard" ? ( + ) : ( /* Shell tab */ - + {/* Shell creation form */} void }) { onChange={(e) => setBudgetUsd(e.target.value)} size="small" sx={{ width: 110 }} - inputProps={{ - inputMode: "decimal", - style: { fontSize: 13 }, - }} + inputProps={{ inputMode: "decimal", style: { fontSize: 13 } }} InputLabelProps={{ style: { fontSize: 13 } }} /> void }) { onChange={(e) => setBudgetTokens(e.target.value)} size="small" sx={{ width: 120 }} - inputProps={{ - inputMode: "numeric", - style: { fontSize: 13 }, - }} + inputProps={{ inputMode: "numeric", style: { fontSize: 13 } }} InputLabelProps={{ style: { fontSize: 13 } }} /> @@ -711,13 +602,7 @@ function MainApp({ onDashboard }: { onDashboard: () => void }) { {/* Session header + terminal area */} - + {/* Selected session info bar */} {selected && ( void }) { fontSize={13} fontWeight={500} fontFamily="ui-monospace, monospace" - sx={{ - flex: 1, - minWidth: 0, - overflow: "hidden", - textOverflow: "ellipsis", - whiteSpace: "nowrap", - }} + sx={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={selected.repo_path} > {selected.repo_path} - - in{" "} - - {selected.estimated_input_tokens.toLocaleString()} - + + in {selected.estimated_input_tokens.toLocaleString()} - - out{" "} - - {selected.estimated_output_tokens.toLocaleString()} - + + out {selected.estimated_output_tokens.toLocaleString()} - - cost{" "} - - ${selected.estimated_cost_usd.toFixed(4)} - + + cost ${selected.estimated_cost_usd.toFixed(4)} + {(selected.budget_tokens || selected.budget_usd) && ( + <> + + {selected.budget_tokens && ( + + total {(selected.estimated_input_tokens + selected.estimated_output_tokens).toLocaleString()} / {selected.budget_tokens.toLocaleString()} + + )} + {selected.budget_usd && ( + + budget ${selected.estimated_cost_usd.toFixed(4)} / ${selected.budget_usd.toFixed(2)} + + )} + + )} {selected.stop_reason && ( <> - + {selected.stop_reason} @@ -808,14 +665,7 @@ function MainApp({ onDashboard }: { onDashboard: () => void }) { {selected.id.slice(0, 8)} @@ -823,19 +673,10 @@ function MainApp({ onDashboard }: { onDashboard: () => void }) { size="small" variant="outlined" color="error" - startIcon={ - - } + startIcon={} onClick={onStop} disabled={selected.status !== "running"} - sx={{ - textTransform: "none", - fontSize: 12, - py: 0.25, - flexShrink: 0, - }} + sx={{ textTransform: "none", fontSize: 12, py: 0.25, flexShrink: 0 }} > Stop @@ -854,40 +695,30 @@ function MainApp({ onDashboard }: { onDashboard: () => void }) { bgcolor: "background.default", }} > - {(["terminal", "logs", "artifacts"] as CenterTab[]).map( - (tab) => ( - - ), - )} + {(["terminal", "logs", "artifacts"] as CenterTab[]).map((tab) => ( + + ))} {/* Terminal / logs / artifacts */} @@ -899,19 +730,22 @@ function MainApp({ onDashboard }: { onDashboard: () => void }) { )} {selectedId && centerTab === "artifacts" && ( - - + + { + if (newSessionId === "__pre_launch__") { + setCenterTab("terminal"); + return; + } + void refreshSessions(false); + setSelectedId(newSessionId); + }} + /> )} {!selectedId && ( @@ -925,43 +759,44 @@ function MainApp({ onDashboard }: { onDashboard: () => void }) { )} - {/* ── Footer ── */} - - - AGENT FLEET - - - Built by{" "} - - Akhil Singh - - - - {/* ── Sessions sidebar ── */} - setShowAllSessions((v) => !v)} - onSelect={onSelectSession} - onDelete={onDeleteSession} - /> + {/* ── Sessions sidebar (only show for non-dashboard tabs) ── */} + {leftTab !== "dashboard" && ( + setShowAllSessions((v) => !v)} + onSelect={onSelectSession} + onDelete={onDeleteSession} + /> + )} + + {/* Budget warning toasts */} + {toasts.length > 0 && ( + + {toasts.map((t) => ( + + ⚠️ + {t.message} + + + ))} + + )} ); } @@ -1021,17 +856,10 @@ const lightTheme = createTheme({ // ── Root ────────────────────────────────────────────────────────────────────── export default function App() { - const [page, setPage] = useState<"main" | "dashboard">("main"); - - // Dashboard has its own ThemeProvider + toggle internally. - // MainApp is always light — its child components (ClaudeSdkChat, SessionArtifacts, etc.) - // have hardcoded light colors that can't be toggled without rewriting them. - return page === "dashboard" ? ( - setPage("main")} /> - ) : ( + return ( - setPage("dashboard")} /> + ); } diff --git a/apps/web/src/ClaudeSdkChat.tsx b/apps/web/src/ClaudeSdkChat.tsx index 7bcc24b..03c52ff 100644 --- a/apps/web/src/ClaudeSdkChat.tsx +++ b/apps/web/src/ClaudeSdkChat.tsx @@ -90,11 +90,13 @@ export default function ClaudeSdkChat(props: Props) { const wsRef = useRef(null); const subscribedSessionRef = useRef(null); + const startPromiseRef = useRef | null>(null); const activeStreamRef = useRef<{ sessionId: string; assistantItemId: string; } | null>(null); const messagesEndRef = useRef(null); + const inputRef = useRef(null); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); @@ -292,7 +294,11 @@ export default function ClaudeSdkChat(props: Props) { throw new Error("repoPath is required"); } - const created = await createClaudeSdkSession({ + // Return in-flight promise if creation already started — prevents duplicate + // sessions when onFocus and onSend race. + if (startPromiseRef.current) return startPromiseRef.current; + + startPromiseRef.current = createClaudeSdkSession({ repoPath: repoPath.trim(), permissionMode: permissionMode as | "acceptEdits" @@ -303,21 +309,16 @@ export default function ClaudeSdkChat(props: Props) { | "plan", model: model.trim().length > 0 ? model.trim() : undefined, budgetUsd: budgetUsd.trim().length > 0 ? Number(budgetUsd) : undefined, + }).then((created) => { + setSession(created); + setRepoPath(created.repo_path); + const ws = openClaudeWs(); + void subscribeClaudeWs(ws, created.id); + props.onCreated(created); + return created; }); - // Set immediately so the UI can show the session id / stats. - setSession(created); - - // Also update the repoPath field to match the created session. - // (This avoids confusing placeholder values like "/path/to/repo" after creation.) - setRepoPath(created.repo_path); - - // Ensure we're subscribed so streaming works immediately. - const ws = openClaudeWs(); - void subscribeClaudeWs(ws, created.id); - - props.onCreated(created); - return created; + return startPromiseRef.current; } async function ensureSessionReady() { @@ -393,6 +394,7 @@ export default function ClaudeSdkChat(props: Props) { const text = input; setInput(""); + if (inputRef.current) inputRef.current.style.height = "auto"; let sessionId: string; try { @@ -732,25 +734,33 @@ export default function ClaudeSdkChat(props: Props) {
-
- +