diff --git a/.env.example b/.env.example index 464107ea1a4..d8962d0ba1a 100644 --- a/.env.example +++ b/.env.example @@ -106,3 +106,10 @@ QSTASH_NEXT_SIGNING_KEY= # MCP API Key for Claude Code SUPERSET_MCP_API_KEY= + +# ----------------------------------------------------------------------------- +# Ngrok (for local GitHub OAuth testing) +# ----------------------------------------------------------------------------- +# Your ngrok subdomain (paid) or static domain (free) +# Run `bun run dev:cloud` to start ngrok + dev servers +NGROK_SUBDOMAIN=superset-dev diff --git a/.gitignore b/.gitignore index da7b0a9ec27..b522f163817 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,6 @@ next-env.d.ts # Reference material downloaded for agents examples + +# Temporary exploration directories +temp_modal_vibe diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f765e1d3c8..8b5e637eb7c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,4 +25,4 @@ Please note we have a [code of conduct](./CODE_OF_CONDUCT.md), please follow it We try to follow guidelines from [Clean Code](https://gist.github.com/wojteklu/73c6914cc446146b8b533c0988cf8d29) and the boy scoute rule: -"Leave the code cleaner, not messier, than how you found it". \ No newline at end of file +"Leave the code cleaner, not messier, than how you found it". \ No newline at end of file diff --git a/apps/api/next.config.ts b/apps/api/next.config.ts index 6846a0131c2..6c90c82aeda 100644 --- a/apps/api/next.config.ts +++ b/apps/api/next.config.ts @@ -11,6 +11,9 @@ const config: NextConfig = { reactCompiler: true, typescript: { ignoreBuildErrors: true }, + // Allow ngrok domains for local dev with GitHub OAuth + allowedDevOrigins: ["*.ngrok.io", "*.ngrok-free.app"], + images: { remotePatterns: [ { diff --git a/apps/api/src/app/api/github/callback/route.ts b/apps/api/src/app/api/github/callback/route.ts index 94021159083..eeb5ce81f35 100644 --- a/apps/api/src/app/api/github/callback/route.ts +++ b/apps/api/src/app/api/github/callback/route.ts @@ -121,15 +121,29 @@ export async function GET(request: Request) { } // Queue initial sync job + const syncUrl = `${env.NEXT_PUBLIC_API_URL}/api/github/jobs/initial-sync`; + const syncBody = { + installationDbId: savedInstallation.id, + organizationId, + }; + try { - await qstash.publishJSON({ - url: `${env.NEXT_PUBLIC_API_URL}/api/github/jobs/initial-sync`, - body: { - installationDbId: savedInstallation.id, - organizationId, - }, - retries: 3, - }); + // In development, call the sync endpoint directly (QStash can't reach localhost) + if (env.NODE_ENV === "development") { + fetch(syncUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(syncBody), + }).catch((error) => { + console.error("[github/callback] Dev sync failed:", error); + }); + } else { + await qstash.publishJSON({ + url: syncUrl, + body: syncBody, + retries: 3, + }); + } } catch (error) { console.error( "[github/callback] Failed to queue initial sync job:", diff --git a/apps/api/src/app/api/github/install/route.ts b/apps/api/src/app/api/github/install/route.ts index 7dd5d45ee54..1f3183001ae 100644 --- a/apps/api/src/app/api/github/install/route.ts +++ b/apps/api/src/app/api/github/install/route.ts @@ -49,14 +49,13 @@ export async function GET(request: Request) { userId: session.user.id, }); + const apiUrl = env.NEXT_PUBLIC_NGROK_URL ?? env.NEXT_PUBLIC_API_URL; + const installUrl = new URL( "https://github.com/apps/superset-app/installations/new", ); installUrl.searchParams.set("state", state); - installUrl.searchParams.set( - "redirect_url", - `${env.NEXT_PUBLIC_API_URL}/api/github/callback`, - ); + installUrl.searchParams.set("redirect_url", `${apiUrl}/api/github/callback`); return Response.redirect(installUrl.toString()); } diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index a5e2bbc5b0f..da5538d506e 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -45,6 +45,7 @@ export const env = createEnv({ NEXT_PUBLIC_API_URL: z.string().url(), NEXT_PUBLIC_WEB_URL: z.string().url(), NEXT_PUBLIC_ADMIN_URL: z.string().url(), + NEXT_PUBLIC_NGROK_URL: z.string().url().optional(), NEXT_PUBLIC_SENTRY_DSN_API: z.string().optional(), NEXT_PUBLIC_SENTRY_ENVIRONMENT: z .enum(["development", "preview", "production"]) @@ -55,6 +56,7 @@ export const env = createEnv({ NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WEB_URL: process.env.NEXT_PUBLIC_WEB_URL, NEXT_PUBLIC_ADMIN_URL: process.env.NEXT_PUBLIC_ADMIN_URL, + NEXT_PUBLIC_NGROK_URL: process.env.NEXT_PUBLIC_NGROK_URL, NEXT_PUBLIC_SENTRY_DSN_API: process.env.NEXT_PUBLIC_SENTRY_DSN_API, NEXT_PUBLIC_SENTRY_ENVIRONMENT: process.env.NEXT_PUBLIC_SENTRY_ENVIRONMENT, }, diff --git a/apps/desktop/docs/CLOUD_ARCHITECTURE.md b/apps/desktop/docs/CLOUD_ARCHITECTURE.md new file mode 100644 index 00000000000..276415938eb --- /dev/null +++ b/apps/desktop/docs/CLOUD_ARCHITECTURE.md @@ -0,0 +1,679 @@ +# Superset Cloud Architecture + +Based on [Ramp's Inspect article](https://engineering.ramp.com/inspect) and the modal-vibe reference implementation. + +## Overview + +A cloud-based coding agent platform that: +- Runs isolated dev environments in Modal sandboxes +- Manages state via Cloudflare Durable Objects +- Bridges seamlessly to the desktop app for local continuation +- Integrates with Linear, GitHub, and Slack + +--- + +## Reference Architectures + +### Ramp's Inspect Stack + +| Layer | Technology | +|-------|------------| +| Sandbox VMs | Modal - instant spin-up, filesystem snapshots | +| API/State | Cloudflare Durable Objects - per-session SQLite | +| Real-time | Cloudflare Agents SDK + WebSocket Hibernation | +| Agent | OpenCode - server-first, typed SDK, plugin system | +| Integrations | Sentry, Datadog, LaunchDarkly, GitHub, Slack, Buildkite | + +### Modal Vibe (Starting Point) + +| Layer | Technology | +|-------|------------| +| Sandbox VMs | Modal Sandboxes with Node.js + Python | +| State | Modal Dict (distributed KV) | +| Tunnels | HTTPS tunnels per sandbox (API + frontend) | +| LLM | Claude generates/modifies React components | + +--- + +## Proposed Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ CLIENTS │ +├──────────┬──────────┬──────────┬──────────┬────────────────┤ +│ Desktop │ Web │ Slack │ Linear │ Chrome Ext │ +│ (Electron)│ │ Bot │ Webhook │ │ +└────┬─────┴────┬─────┴────┬─────┴────┬─────┴───────┬────────┘ + │ │ │ │ │ + └──────────┴──────────┼──────────┴─────────────┘ + │ + ┌────────────▼────────────┐ + │ Cloudflare Workers │ + │ (API + Auth + Router) │ + └────────────┬────────────┘ + │ + ┌─────────────────┼─────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌─────────────┐ ┌─────────────────┐ ┌───────────┐ +│ Durable │ │ Modal Sandbox │ │ GitHub │ +│ Objects │ │ (per session) │ │ + Linear │ +│ (state) │ │ │ │ │ +│ │ │ ┌───────────┐ │ └───────────┘ +│ - SQLite │ │ │ Terminal │ │ +│ - Messages │ │ │ (node-pty)│ │ +│ - Files │ │ ├───────────┤ │ +│ - Events │ │ │ Agent │ │ +│ │ │ │ (Claude) │ │ +└─────────────┘ │ ├───────────┤ │ + │ │ Dev Server│ │ + │ │ (Vite) │ │ + │ ├───────────┤ │ + │ │ Git + FS │ │ + │ └───────────┘ │ + └─────────────────┘ +``` + +--- + +## Sandbox Architecture + +### Pre-installed Agent Environment + +Each sandbox VM comes with coding agents pre-installed and configured: + +```dockerfile +# Base image built every 30 minutes +FROM node:22-slim + +# System dependencies +RUN apt-get update && apt-get install -y \ + git curl python3 python3-pip + +# Install Claude Code globally +RUN npm install -g @anthropic-ai/claude-code + +# Install OpenCode (alternative agent) +RUN curl -fsSL https://opencode.ai/install.sh | bash + +# Install user's preferred shell + tools +RUN apt-get install -y zsh fzf ripgrep + +# Pre-configure agent settings directory +RUN mkdir -p /root/.config/claude-code +``` + +### User Environment Sync + +User's local config synced to sandbox on session start: + +```typescript +interface UserEnvConfig { + // Shell configuration + shell: "bash" | "zsh" | "fish"; + shellRc: string; // .zshrc, .bashrc contents + + // Agent configuration + claudeConfig: { + settings: ClaudeSettings; + mcpServers: MCPServerConfig[]; + permissions: PermissionConfig; + }; + + // Git identity + git: { + name: string; + email: string; + }; + + // Editor preferences + vscode: { + settings: Record; + extensions: string[]; + }; +} + +async function syncUserEnv(sandboxId: string, config: UserEnvConfig) { + // Write shell config + await sandbox.writeFile("~/.zshrc", config.shellRc); + + // Write Claude Code config + await sandbox.writeFile( + "~/.config/claude-code/settings.json", + JSON.stringify(config.claudeConfig.settings) + ); + + // Configure MCP servers + await sandbox.writeFile( + "~/.config/claude-code/mcp.json", + JSON.stringify(config.claudeConfig.mcpServers) + ); + + // Set git identity + await sandbox.exec(`git config --global user.name "${config.git.name}"`); + await sandbox.exec(`git config --global user.email "${config.git.email}"`); +} +``` + +### Agent Execution Modes + +```typescript +// 1. Headless mode - agent runs autonomously +async function runAgentHeadless(sessionId: string, prompt: string) { + const sandbox = await getSandbox(sessionId); + + // Run Claude Code in non-interactive mode + const result = await sandbox.exec( + `claude-code --print "${prompt}"`, + { env: { ANTHROPIC_API_KEY: await getApiKey(sessionId) } } + ); + + return result; +} + +// 2. Interactive mode - user can chat with agent +async function runAgentInteractive(sessionId: string) { + const sandbox = await getSandbox(sessionId); + + // Start Claude Code server mode + const process = await sandbox.spawn("claude-code", ["--server"], { + env: { ANTHROPIC_API_KEY: await getApiKey(sessionId) } + }); + + // Connect WebSocket for streaming + return connectAgentStream(process); +} + +// 3. Background mode - agent works while user does other things +async function runAgentBackground(sessionId: string, prompt: string) { + const sandbox = await getSandbox(sessionId); + + // Start agent in background, notify on completion + await sandbox.exec(` + nohup claude-code --print "${prompt}" > /tmp/agent.log 2>&1 & + echo $! > /tmp/agent.pid + `); + + // Poll for completion and notify via webhook + watchAgentCompletion(sessionId, "/tmp/agent.pid", "/tmp/agent.log"); +} +``` + +--- + +## State Management + +### Durable Object Per Session + +```typescript +export class SessionDO extends DurableObject { + private sql: SqlStorage; + private sessions: Map = new Map(); + + constructor(state: DurableObjectState) { + super(state); + this.sql = state.storage.sql; + this.initSchema(); + } + + private initSchema() { + this.sql.exec(` + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY, + author_id TEXT NOT NULL, + author_name TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER DEFAULT (unixepoch()) + ); + + CREATE TABLE IF NOT EXISTS files ( + path TEXT PRIMARY KEY, + content TEXT NOT NULL, + updated_at INTEGER DEFAULT (unixepoch()) + ); + + CREATE TABLE IF NOT EXISTS terminal_state ( + pane_id TEXT PRIMARY KEY, + scrollback TEXT, + cursor_x INTEGER, + cursor_y INTEGER + ); + `); + } + + // Multiplayer: broadcast to all connected clients + broadcast(event: SessionEvent, exclude?: WebSocket) { + for (const [ws, _] of this.sessions) { + if (ws !== exclude && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(event)); + } + } + } + + // Handle incoming message from any client + async handleMessage(ws: WebSocket, data: ClientMessage) { + const client = this.sessions.get(ws); + + switch (data.type) { + case "prompt": + // Store message with author attribution + this.sql.exec( + `INSERT INTO messages (author_id, author_name, content) VALUES (?, ?, ?)`, + [client.userId, client.userName, data.content] + ); + + // Broadcast to other clients + this.broadcast({ type: "new_message", ...data, author: client }, ws); + + // Forward to sandbox agent + await this.forwardToAgent(data.content); + break; + + case "file_change": + // Sync file state + this.sql.exec( + `INSERT OR REPLACE INTO files (path, content) VALUES (?, ?)`, + [data.path, data.content] + ); + this.broadcast({ type: "file_change", ...data }, ws); + break; + } + } +} +``` + +--- + +## API Layer + +### tRPC Router (Mirrors Desktop Interface) + +```typescript +export const cloudRouter = router({ + session: router({ + create: protectedProcedure + .input(z.object({ + repoUrl: z.string(), + branch: z.string().optional(), + prompt: z.string().optional(), + linearIssueId: z.string().optional(), + })) + .mutation(async ({ input, ctx }) => { + // 1. Spin up Modal sandbox + const sandbox = await createSandbox(input.repoUrl); + + // 2. Create Durable Object for state + const sessionId = crypto.randomUUID(); + const stub = ctx.env.SESSION_DO.get( + ctx.env.SESSION_DO.idFromName(sessionId) + ); + + // 3. Sync user's env config + await syncUserEnv(sandbox.id, ctx.user.envConfig); + + // 4. Link Linear issue if provided + if (input.linearIssueId) { + await linkLinearIssue(sessionId, input.linearIssueId); + } + + // 5. Start agent with initial prompt + if (input.prompt) { + await stub.fetch("/agent/prompt", { + method: "POST", + body: JSON.stringify({ prompt: input.prompt }), + }); + } + + return { sessionId, tunnelUrl: sandbox.tunnelUrl }; + }), + + // Get session for desktop handoff + getState: protectedProcedure + .input(z.object({ sessionId: z.string() })) + .query(async ({ input, ctx }) => { + const stub = ctx.env.SESSION_DO.get( + ctx.env.SESSION_DO.idFromName(input.sessionId) + ); + return stub.fetch("/state").then(r => r.json()); + }), + }), + + terminal: router({ + // Same interface as desktop + createOrAttach: protectedProcedure + .input(z.object({ + sessionId: z.string(), + paneId: z.string(), + })) + .subscription(async function* ({ input, ctx }) { + const sandbox = await getSandbox(input.sessionId); + const ws = await sandbox.connectTerminal(input.paneId); + + for await (const data of ws) { + yield { type: "data", data }; + } + }), + + write: protectedProcedure + .input(z.object({ + sessionId: z.string(), + paneId: z.string(), + data: z.string(), + })) + .mutation(async ({ input }) => { + const sandbox = await getSandbox(input.sessionId); + await sandbox.writeTerminal(input.paneId, input.data); + }), + }), + + agent: router({ + prompt: protectedProcedure + .input(z.object({ + sessionId: z.string(), + message: z.string(), + })) + .subscription(async function* ({ input, ctx }) { + const sandbox = await getSandbox(input.sessionId); + + // Stream agent response + const stream = await sandbox.agentPrompt(input.message); + for await (const chunk of stream) { + yield chunk; + } + }), + + stop: protectedProcedure + .input(z.object({ sessionId: z.string() })) + .mutation(async ({ input }) => { + const sandbox = await getSandbox(input.sessionId); + await sandbox.exec("kill $(cat /tmp/agent.pid)"); + }), + }), + + git: router({ + createPR: protectedProcedure + .input(z.object({ + sessionId: z.string(), + title: z.string(), + body: z.string(), + })) + .mutation(async ({ input, ctx }) => { + const sandbox = await getSandbox(input.sessionId); + + // Push changes + await sandbox.exec("git push -u origin HEAD"); + + // Create PR as user (not bot) + const pr = await ctx.github.createPR({ + title: input.title, + body: input.body, + head: await sandbox.exec("git branch --show-current"), + }); + + // Update Linear issue if linked + const session = await getSession(input.sessionId); + if (session.linearIssueId) { + await updateLinearIssue(session.linearIssueId, { + state: "in-review", + attachments: [{ url: pr.url, title: pr.title }], + }); + } + + return pr; + }), + }), + + linear: router({ + linkIssue: protectedProcedure + .input(z.object({ + sessionId: z.string(), + issueId: z.string(), + })) + .mutation(async ({ input }) => { + await linkLinearIssue(input.sessionId, input.issueId); + + // Update issue status + await updateLinearIssue(input.issueId, { + state: "in-progress", + }); + }), + }), +}); +``` + +--- + +## Desktop Integration + +### CloudWorkspaceRuntime + +```typescript +// Implements same interface as LocalWorkspaceRuntime +export class CloudWorkspaceRuntime implements WorkspaceRuntime { + readonly id: WorkspaceRuntimeId; + readonly terminal: CloudTerminalRuntime; + readonly capabilities = { + terminal: { persistent: true, coldRestore: true } + }; + + constructor(private sessionId: string) { + this.id = `cloud:${sessionId}`; + this.terminal = new CloudTerminalRuntime(sessionId); + } +} + +export class CloudTerminalRuntime implements TerminalRuntime { + private ws: WebSocket | null = null; + private emitter = new EventEmitter(); + + constructor(private sessionId: string) {} + + async createOrAttach(params: CreateOrAttachParams): Promise { + // Connect to cloud terminal via WebSocket + this.ws = new WebSocket( + `wss://api.superset.sh/session/${this.sessionId}/terminal/${params.paneId}` + ); + + this.ws.onmessage = (event) => { + this.emitter.emit("data", { + paneId: params.paneId, + data: event.data, + }); + }; + + return { paneId: params.paneId, backendSessionId: params.paneId }; + } + + write({ paneId, data }: WriteParams): void { + this.ws?.send(JSON.stringify({ type: "write", paneId, data })); + } + + resize({ paneId, cols, rows }: ResizeParams): void { + this.ws?.send(JSON.stringify({ type: "resize", paneId, cols, rows })); + } + + on(event: string, listener: (...args: any[]) => void): this { + this.emitter.on(event, listener); + return this; + } +} +``` + +### Runtime Registry Extension + +```typescript +// registry.ts +export class WorkspaceRuntimeRegistry { + getForWorkspaceId(workspaceId: string): WorkspaceRuntime { + const workspace = getWorkspace(workspaceId); + + // Cloud workspace - connect to remote sandbox + if (workspace.cloudSessionId) { + return new CloudWorkspaceRuntime(workspace.cloudSessionId); + } + + // Local workspace (existing behavior) + return new LocalWorkspaceRuntime(workspaceId); + } +} +``` + +### Cloud → Local Handoff + +```typescript +async function claimCloudSession(cloudSessionId: string): Promise { + // 1. Get session state from cloud + const session = await cloudApi.session.getState({ sessionId: cloudSessionId }); + + // 2. Create local worktree + const worktree = await createWorktree({ + repo: session.repoPath, + branch: session.branch, + }); + + // 3. Fetch and checkout cloud changes + await exec(`git -C ${worktree.path} fetch origin ${session.branch}`); + await exec(`git -C ${worktree.path} checkout ${session.branch}`); + + // 4. Restore terminal scrollback + for (const terminal of session.terminals) { + await restoreTerminalScrollback(worktree.id, terminal.paneId, terminal.scrollback); + } + + // 5. Keep reference for sync-back (optional) + await updateWorktree(worktree.id, { cloudSessionId }); + + return worktree; +} + +// Sync local changes back to cloud +async function syncToCloud(worktreeId: string) { + const worktree = getWorktree(worktreeId); + if (!worktree.cloudSessionId) return; + + // Push local commits + await exec(`git -C ${worktree.path} push origin ${worktree.branch}`); + + // Notify cloud session to pull + await cloudApi.git.pull({ sessionId: worktree.cloudSessionId }); +} +``` + +--- + +## Linear Integration + +### Webhook Handler + +```typescript +// Listen for Linear issue updates +app.post("/webhooks/linear", async (req, res) => { + const event = req.body; + + switch (event.type) { + case "Issue": + if (event.action === "update" && event.data.stateId === STARTED_STATE_ID) { + // Issue moved to "Started" - offer to create session + await notifySlack(event.data.team.id, { + text: `Issue ${event.data.identifier} started. Create a coding session?`, + actions: [ + { type: "button", text: "Start Session", value: event.data.id } + ] + }); + } + break; + + case "Comment": + // Check for @superset mentions + if (event.data.body.includes("@superset")) { + const prompt = event.data.body.replace("@superset", "").trim(); + const session = await findSessionForIssue(event.data.issueId); + + if (session) { + // Send prompt to existing session + await cloudApi.agent.prompt({ + sessionId: session.id, + message: prompt, + }); + } + } + break; + } +}); +``` + +### Auto-link Issues + +```typescript +// When creating a session from Linear +async function createSessionFromLinear(issueId: string, userId: string) { + const issue = await linear.issue(issueId); + + // Parse repo from issue labels or project + const repo = await inferRepoFromIssue(issue); + + // Create branch name from issue + const branch = `${issue.identifier.toLowerCase()}-${slugify(issue.title)}`; + + // Create session + const session = await cloudApi.session.create({ + repoUrl: repo.url, + branch, + prompt: `Implement: ${issue.title}\n\n${issue.description}`, + linearIssueId: issueId, + }); + + // Update issue with session link + await linear.updateIssue(issueId, { + description: issue.description + `\n\n---\n[Superset Session](${session.url})`, + }); + + return session; +} +``` + +--- + +## Implementation Phases + +### Phase 1: Sandbox Infrastructure +- [ ] Extend modal-vibe sandbox with terminal WebSocket server +- [ ] Add file system API endpoints +- [ ] Pre-built images per repo (30-min rebuild cycle) +- [ ] Git credential injection (GitHub App token) +- [ ] Pre-install Claude Code + OpenCode + +### Phase 2: State + API +- [ ] Durable Object per session (SQLite state) +- [ ] tRPC API matching desktop interface +- [ ] Real-time subscriptions via WebSocket +- [ ] User env config sync + +### Phase 3: Desktop Integration +- [ ] `CloudWorkspaceRuntime` implementing existing interface +- [ ] Session claim/handoff flow +- [ ] Bidirectional sync option +- [ ] Terminal scrollback restoration + +### Phase 4: Agent + Integrations +- [ ] Claude Code running in sandbox +- [ ] Linear integration (link issues, update status, webhooks) +- [ ] GitHub PR creation as user +- [ ] Slack bot client + +### Phase 5: Clients +- [ ] Web UI (session editor, live preview) +- [ ] Chrome extension (visual React editing) +- [ ] Mobile-friendly web +- [ ] Voice input + +--- + +## Key Principles (from Ramp) + +1. **Pre-warm everything** - Build images every 30 min, warm pools for high-volume repos +2. **Let agent read before sync completes** - Only block writes +3. **Multiplayer is critical** - Attribute prompts to authors, sync across clients +4. **GitHub auth as user** - PRs opened by the person, not a bot +5. **Virality through public spaces** - Slack bot makes usage visible +6. **Speed is everything** - Session speed limited only by model TTFT diff --git a/apps/desktop/plans/20260130-cloud-workspaces-integration.md b/apps/desktop/plans/20260130-cloud-workspaces-integration.md new file mode 100644 index 00000000000..229e059c654 --- /dev/null +++ b/apps/desktop/plans/20260130-cloud-workspaces-integration.md @@ -0,0 +1,245 @@ +# Cloud Workspaces Integration Plan + +> **⚠️ SUPERSEDED**: This plan has been consolidated into `20260131-cloud-parity-plan.md`. See that file for the current roadmap. + +## Status: Sprint 4 Complete - Consolidated into Parity Plan + +## Completed + +### Infrastructure +- [x] Control Plane (Cloudflare Workers) - Deployed to `https://superset-control-plane.avi-6ac.workers.dev` + - Session Durable Objects with SQLite storage + - WebSocket support for real-time events + - REST API for session management + - HMAC token auth for Modal + - **Chat history persistence** - sends last 100 messages + 500 events on client subscribe +- [x] Modal Sandbox (Python) - Deployed + - Sandbox execution environment + - Git clone and branch management + - Claude Code CLI execution + - Event streaming to control plane +- [x] Database schema (`packages/db/src/schema/cloud-workspaces.ts`) +- [x] tRPC router (`packages/trpc/src/router/cloud-workspace/`) + +### Desktop App +- [x] Desktop sidebar - Cloud workspaces section +- [x] Desktop CloudWorkspaceView - WebView embedding web app + +### Web App - Phase 1-4 Complete +- [x] **Phase 1: Chat History Persistence** + - Control plane sends historical messages/events on subscribe + - Web hook handles `history` message type + - Events prepopulated on reconnect + +- [x] **Phase 2: Home Page & Session List** + - `/cloud` landing page with welcome message + - Session sidebar with search/filter + - Active/Inactive session grouping (7-day threshold) + - Relative time display + - New Session button + +- [x] **Phase 3: New Session Flow** + - `/cloud/new` page with form + - Repository selection dropdown + - Title input (optional) + - Model selection (Sonnet 4, Opus 4, Haiku 3.5) + - Base branch input + - Form validation and error handling + - tRPC mutation integration + +- [x] **Phase 4: User Messages Display** + - User messages shown in conversation + - Different styling for user vs assistant messages + - User messages added to event stream when sent + +### PR +- [x] PR created: https://github.com/superset-sh/superset/pull/1082 + +## Architecture: Bridge Pattern + +Based on [ColeMurray/background-agents](https://github.com/ColeMurray/background-agents) and [Ramp's blog post](https://builders.ramp.com/post/why-we-built-our-background-agent): + +### Data Flow +``` +User → Web App → Control Plane (WebSocket) → Sandbox (WebSocket) → Claude + ↑ ↓ + └────── Events ─────────────┘ +``` + +### Key Files +- `packages/control-plane/src/session/durable-object.ts` - Session DO with SQLite, history, events +- `packages/control-plane/src/types.ts` - Type definitions including HistoricalMessage +- `packages/sandbox/app.py` - Modal sandbox with Claude CLI execution +- `apps/web/src/app/cloud/page.tsx` - Cloud home page +- `apps/web/src/app/cloud/new/page.tsx` - New session page +- `apps/web/src/app/cloud/[sessionId]/page.tsx` - Session detail page +- `apps/web/src/app/cloud/[sessionId]/hooks/useCloudSession.ts` - WebSocket hook with history +- `apps/web/src/app/cloud/[sessionId]/components/CloudWorkspaceContent/` - Session UI + +## Completed - Sprint 2 (Chat Polish) + +Reference: `temp_modal_vibe/background-agents` - ColeMurray's Open-Inspect + +### Phase 5: Tool Call Display ✅ +- [x] `lib/tool-formatters.ts` - Format tool calls with summary + icon +- [x] `ToolCallItem/` - Collapsible item with chevron + icon + summary + time +- [x] `ToolCallGroup/` - Groups consecutive same-type tool calls +- [x] `ToolIcon/` - SVG icons for each tool type + +### Phase 6-8: Processing & Connection States ✅ +- [x] `isProcessing` state - tracks when prompt being executed +- [x] `isSandboxReady` computed from sandboxStatus +- [x] Auto-spawn sandbox when status is "stopped" +- [x] Disable input when sandbox not ready (syncing/spawning) +- [x] Dynamic placeholder text based on state +- [x] Processing indicator ("Claude is working...") +- [x] Sandbox status badge with spawning state + +### Phase 7: Markdown Rendering ✅ +- [x] `react-markdown` with `remark-gfm` +- [x] Custom code block styling +- [x] Inline code styling + +### Phase 9: WebSocket Hook Improvements ✅ +- [x] `isReconnecting` state +- [x] `reconnectAttempt` counter (shown as "Reconnecting (2/5)...") +- [x] Error message when max reconnects exceeded + +## Completed - Sprint 3 (GitHub Integration) + +### Phase 10: GitHub Repo Connection ✅ +- [x] Updated `/cloud/new` page to fetch GitHub installation status +- [x] Updated `/cloud/new` page to fetch GitHub repositories via `integration.github.listRepositories` +- [x] Show "Connect GitHub" CTA when GitHub not connected or suspended +- [x] Repository selector shows GitHub repos with private indicator (lock icon) +- [x] Auto-set base branch to repo's default branch on selection + +### Phase 11: Quick Repo Selector on Home Page ✅ +- [x] Repository dropdown above the prompt input on `/cloud` home page +- [x] Flow: select repo → type prompt → create session → redirect +- [x] Recent repos as quick-select chips +- [x] GitHub connect CTA when no installation + +## Completed - Sprint 4 (Layout & Polish) + +### Phase 15: Session Lifecycle ✅ +- [x] Inline session title editing (click to edit, Enter to save, Escape to cancel) +- [x] Session archiving via dropdown menu +- [x] tRPC mutations for update/archive already existed + +### Phase 16: Keyboard Shortcuts ✅ +- [x] `⌘+Enter` to send prompt (global) +- [x] `Escape` to stop execution +- [x] `⌘+K` to focus input +- [x] `⌘+\` to toggle sidebar + +## Pending + +### Phase 17: Sandbox Warm-up Optimization (Priority: High) +Reference: Ramp's background-agents approach - warm sandbox while user types + +**Warm-up During Typing:** +- [ ] Add `typing` message type to WebSocket protocol +- [ ] Send typing indicator when user starts typing in prompt input (debounced) +- [ ] Control plane spawns sandbox on first typing event if not already running +- [ ] Broadcast `sandbox_warming` status to show UI feedback + +**Implementation in CloudWorkspaceContent.tsx:** +```typescript +// Debounced typing handler +const handleInputChange = (value: string) => { + setPromptInput(value); + if (value.length > 0 && !isSandboxReady && !isSpawning) { + sendTypingIndicator(); // Triggers early spawn + } +}; +``` + +**Control Plane Changes (durable-object.ts):** +- [ ] Add `handleTyping()` method to spawn sandbox proactively +- [ ] Avoid duplicate spawns with `isSpawningSandbox` flag + +### Phase 18: Snapshot/Restore for Fast Startup (Priority: Medium) +Reference: Ramp rebuilds repo images every 30 minutes + +- [ ] Modal scheduler to create periodic repo snapshots +- [ ] Store snapshot IDs in database per repository +- [ ] Restore from snapshot instead of full git clone +- [ ] Only sync changes since snapshot (faster startup) + +### Phase 19: Home Page Quick Start (Priority: Medium) +Improve the flow from home page to active session: + +- [ ] Pre-warm sandbox when repo is selected (before prompt submission) +- [ ] Show sandbox status indicator on home page +- [ ] Optimistic redirect - navigate immediately, show syncing state in session view +- [ ] Initial prompt auto-sent after sandbox ready + +### Phase 12: Branch Management (Priority: Low) +- [ ] Fetch branches via GitHub API +- [ ] Branch selector in new session form +- [ ] Show repo's default branch + +### Phase 13: Right Sidebar (Session Details) +- [ ] Session metadata: model, created time, duration +- [ ] Sandbox status with real-time updates +- [ ] Repository info with GitHub link +- [ ] PR link when created (from artifacts) +- [ ] Files changed (aggregate from tool calls) + +### Phase 14: Artifacts System (Priority: Low) +Reference: background-agents stores PRs as artifacts + +- [ ] Artifact type: PR with state (open/merged/closed/draft) +- [ ] Display PR badge in sidebar +- [ ] Link to GitHub PR +- [ ] Screenshot artifacts (future) + +## Test Results +- [x] Control plane health check: Working +- [x] Session creation: Working +- [x] Session state retrieval: Working +- [x] Event storage and retrieval: Working +- [x] Modal sandbox health: Working +- [x] Sandbox spawning: Working +- [x] Git clone in sandbox: Working +- [x] Branch checkout: Working +- [x] Events streaming to control plane: Working +- [x] Bridge connection: Working +- [x] Prompt execution with Claude: Working +- [x] Chat history on reconnect: Working + +## Environment Variables +``` +NEXT_PUBLIC_CONTROL_PLANE_URL=https://superset-control-plane.avi-6ac.workers.dev +``` + +## Pending - Developer Experience + +### Local Development with GitHub OAuth +GitHub OAuth callbacks require a public URL. Use ngrok with a static domain for local development. + +- [ ] Document ngrok setup in README/CONTRIBUTING +- [ ] Reserve static ngrok domain (e.g., `superset-dev.ngrok-free.app`) +- [ ] Add `.env.example` entry for ngrok URL + +**Setup steps:** +1. Sign up at ngrok.com, claim free static domain in Dashboard → Domains +2. Run: `ngrok http 3001 --domain=your-domain.ngrok-free.app` +3. Set in `.env`: `NEXT_PUBLIC_API_URL=https://your-domain.ngrok-free.app` +4. Click "Connect GitHub" from localhost:3000 - callbacks will work + +## Commands +```bash +# Deploy control plane +cd packages/control-plane && wrangler deploy + +# Deploy sandbox +modal deploy packages/sandbox/app.py + +# Run web app +bun dev --filter=web + +# Spawn sandbox for testing +curl -X POST "https://superset-control-plane.avi-6ac.workers.dev/api/sessions/{sessionId}/spawn-sandbox" +``` diff --git a/apps/desktop/plans/20260131-cloud-parity-plan.md b/apps/desktop/plans/20260131-cloud-parity-plan.md new file mode 100644 index 00000000000..955084a97fe --- /dev/null +++ b/apps/desktop/plans/20260131-cloud-parity-plan.md @@ -0,0 +1,256 @@ +# Cloud Background Agent Parity Plan (Consolidated) + +## Goal +Reach feature parity and UX quality with Ramp Inspect / Open-Inspect for cloud sessions: +- Fast sandbox startup (snapshots + warm-on-typing) +- Rich session UI (participants, artifacts, tasks, files changed) +- Durable session continuity (full history) +- Multi-client readiness (desktop + web, future Slack) + +## Scope +- Web app: `apps/web/src/app/cloud/*` +- Control plane: `packages/control-plane/*` +- Modal sandbox: Modal endpoints + infra +- Desktop: webview wrapper + session list + +## References +- [Ramp Blog Post](https://builders.ramp.com/post/why-we-built-our-background-agent) +- [ColeMurray/background-agents](https://github.com/ColeMurray/background-agents) (Open-Inspect) +- Previous plan: `20260130-cloud-workspaces-integration.md` (superseded by this plan) + +--- + +## Completed (from previous sprints) + +### Infrastructure ✅ +- [x] Control Plane (Cloudflare Workers) - Deployed + - Session Durable Objects with SQLite storage + - WebSocket support for real-time events + - REST API for session management + - HMAC token auth for Modal + - Chat history persistence (last 100 messages + 500 events on subscribe) +- [x] Modal Sandbox (Python) - Deployed + - Git clone and branch management + - Claude Code CLI execution + - Event streaming to control plane +- [x] Database schema (`packages/db/src/schema/cloud-workspaces.ts`) +- [x] tRPC router (`packages/trpc/src/router/cloud-workspace/`) + +### Web App ✅ +- [x] Home page with session list, search, active/inactive grouping +- [x] New session flow with GitHub repo selection +- [x] Quick repo selector on home page with recent repos +- [x] Chat UI with user/assistant messages, markdown rendering +- [x] Tool call display with collapsible groups +- [x] Processing & connection state indicators +- [x] WebSocket reconnection with attempt counter +- [x] Keyboard shortcuts (⌘+Enter, Escape, ⌘+K, ⌘+\) +- [x] Session lifecycle (inline title edit, archive) + +### Desktop ✅ +- [x] Cloud workspaces section in sidebar +- [x] WebView embedding web app + +--- + +## MVP Phases + +### Phase 1: Developer Experience (Parallel Track) ✅ +*Can run in parallel with Phase 2* + +#### 1.1 Ngrok Setup for Local GitHub OAuth ✅ +- [x] Document ngrok setup: See [`ngrok-setup.md`](./ngrok-setup.md) +- [x] Reserved static ngrok domain approach documented +- [x] Full setup guide with troubleshooting + +#### 1.2 Error Recovery & Reliability ✅ +- [x] Sandbox spawn failure handling + auto-retry (max 3 attempts) +- [x] Stale sandbox detection (60s heartbeat timeout) and auto-respawn +- [x] WebSocket disconnect recovery with pending prompt queue +- [x] Graceful degradation when control plane unavailable +- [x] `isControlPlaneAvailable` state, `clearError()` function, retry UI + +### Phase 2: Sandbox Speed (Core) + +#### 2.1 Warm-on-Typing (High Priority) ✅ +Reference: Ramp spawns sandbox when user starts typing + +**Web App Changes:** +- [x] Add `typing` message type to WebSocket protocol +- [x] Send typing indicator on first keystroke (debounced 500ms) +- [x] Show "Warming..." status in UI with spinner + +**Control Plane Changes:** +- [x] Add `handleTyping()` method in durable-object.ts +- [x] Spawn sandbox on first typing event if not running +- [x] Add `isSpawningSandbox` flag to prevent duplicate spawns +- [x] Broadcast `sandbox_warming` status to clients + +#### 2.2 Warm-on-Repo-Select (Home Page) ✅ +- [x] Optimistic navigation - redirect immediately on submit with URL param +- [x] Auto-send initial prompt when sandbox becomes ready +- [ ] Pre-warm sandbox on repo select (deferred - requires orphan sandbox support) +- [ ] Show sandbox status indicator next to repo dropdown (deferred) + +#### 2.3 Snapshot Registry +*Deferred - requires Modal infrastructure changes* +- [ ] Define `snapshot_registry` table: repo, base_sha, snapshot_id, status, created_at, expires_at +- [ ] Add control plane endpoint: `GET /snapshots/:repoOwner/:repoName/latest` +- [ ] Store latest snapshot ID per repository + +#### 2.4 Snapshot Scheduler +*Deferred - requires Modal infrastructure changes* +- [ ] Background job to rebuild repo images every 30 minutes +- [ ] Create Modal snapshot after image build +- [ ] Update snapshot registry with new snapshot ID +- [ ] Expire old snapshots after 24 hours + +#### 2.5 Restore + Delta Sync +*Deferred - requires Modal infrastructure changes* +- [ ] Update sandbox spawn to restore from snapshot when available +- [ ] Delta sync: only fetch commits since snapshot base_sha +- [ ] Allow file reads during sync; block writes until complete + +### Phase 3: Control Plane Enhancements + +#### 3.1 Session History Completeness +Current state: Control plane sends last 100 messages + 500 events, but assistant message *content* may be incomplete. + +- [ ] Audit: Verify assistant messages include full text content +- [ ] Persist tool call results in history (not just tool_use events) +- [ ] Ensure idempotent replay in UI (dedupe by event ID) + +#### 3.2 Artifacts System +- [ ] Add `artifacts` table: session_id, type, url, metadata, created_at +- [ ] Artifact types: `pr`, `preview`, `screenshot` +- [ ] Detect PR creation from tool events, auto-create artifact +- [ ] Expose artifacts via WebSocket `artifacts_update` event +- [ ] REST endpoint: `GET /sessions/:id/artifacts` + +#### 3.3 Files Changed Rollup +- [ ] Aggregate file paths from Write/Edit tool events +- [ ] Store in session state: `files_changed: string[]` +- [ ] Broadcast on WebSocket when files change +- [ ] Dedupe and sort by most recently modified + +#### 3.4 Presence & Multiplayer +- [ ] Track participant presence in DO (userId, status, lastSeen) +- [ ] Emit `presence_sync` on subscribe (all current participants) +- [ ] Emit `presence_update` on join/leave/idle +- [ ] Attribute prompts to participant userId + +### Phase 4: Web UI Parity + +#### 4.1 Right Sidebar +- [ ] Collapsible sidebar component (Inspect-style) +- [ ] **Metadata section**: model, repo, branch, created/updated timestamps +- [ ] **Participants section**: avatars with online/idle/offline indicators +- [ ] **Files changed section**: list of modified files with icons +- [ ] **Artifacts section**: PR link, preview link with status badges + +#### 4.2 Action Bar +- [ ] "View PR" button (visible when PR artifact exists) +- [ ] "View Preview" button (visible when preview artifact exists) +- [ ] "Copy Link" button (copies session URL) +- [ ] Archive/Unarchive toggle + +#### 4.3 Session Continuity UX +- [ ] Render full assistant history on page load/reconnect +- [ ] Show pending prompt indicator when sandbox warming +- [ ] Sandbox timeline: warming → syncing → ready → running +- [ ] "Sandbox disconnected, reconnecting..." banner + +### Phase 5: Desktop Polish +- [ ] Surface sandbox status badges in desktop session list +- [ ] Deep links to PR/preview from desktop UI +- [ ] Verify webview auth persistence across app restarts +- [ ] Handle webview navigation (prevent external links breaking session) + +### Phase 6: Metrics & Observability +- [ ] Track sandbox spawn time (p50, p95, p99) +- [ ] Track time-to-first-token after prompt submit +- [ ] Track warm-on-typing hit rate (% of prompts with pre-warmed sandbox) +- [ ] Dashboard in admin panel or external tool (Grafana/Datadog) + +--- + +## Post-MVP Phases + +### Phase 7: Warm Pool (Deferred) +*Adds complexity; defer until MVP validated* + +- [ ] Maintain pool of pre-warmed sandboxes for top N repos +- [ ] Pool size configurable per org (cost management) +- [ ] Expire pool entries when new snapshot created +- [ ] Claim from pool on session create, spawn new if empty + +### Phase 8: Embedded Tools (Deferred) +*High complexity; consider alternatives first* + +- [ ] Hosted VS Code Server in sandbox with iframe embed +- [ ] Live preview stream via port proxy +- [ ] Screenshot gallery for visual artifacts +- [ ] **Alternative**: Link to GitHub Codespaces / external editor + +### Phase 9: Multi-Client Intake (Future) +- [ ] Slack MVP: create session from Slack, route to repo, send status updates +- [ ] Chrome extension: capture DOM context + prompt + +### Phase 10: Desktop → Cloud Handoff (Exploration) +- [ ] Investigate taking local desktop workspace state +- [ ] Spawn cloud sandbox with local `.env` + uncommitted changes +- [ ] Snapshot restore flow from desktop context + +--- + +## Milestones + +| Milestone | Description | Phases | +|-----------|-------------|--------| +| M1 | Fast Startup | 2.1-2.5 (warm-on-typing + snapshots) | +| M2 | Rich Session Data | 3.1-3.4 (history + artifacts + presence) | +| M3 | UI Parity | 4.1-4.3 (sidebar + action bar + continuity) | +| M4 | Desktop Polish | 5 (status badges + deep links) | +| M5 | Observability | 6 (metrics dashboard) | + +## Success Criteria + +- [ ] First token in <2s on warm sandbox +- [ ] First token in <10s on cold start with snapshot restore +- [ ] Sessions re-open with full conversation context +- [ ] Users can view PR/preview without leaving session +- [ ] Sandbox spawn failures auto-recover without user intervention + +## Risks & Open Questions + +| Risk | Mitigation | +|------|------------| +| Snapshot build cost | Start with top 5 repos, expand based on usage | +| Warm-on-typing false positives | Debounce aggressively (500ms+) | +| Stale snapshots (>30 min old) | Delta sync handles recent commits | +| Modal cold start latency | Snapshots + restore should help | +| Security for preview embedding | Sandbox network isolation, CSP headers | + +## Key Files + +- `packages/control-plane/src/session/durable-object.ts` - Session DO +- `packages/control-plane/src/types.ts` - Type definitions +- `packages/sandbox/app.py` - Modal sandbox +- `apps/web/src/app/cloud/[sessionId]/hooks/useCloudSession.ts` - WebSocket hook +- `apps/web/src/app/cloud/[sessionId]/components/CloudWorkspaceContent/` - Session UI + +## Commands + +```bash +# Deploy control plane +cd packages/control-plane && wrangler deploy + +# Deploy sandbox +modal deploy packages/sandbox/app.py + +# Run web app +bun dev --filter=web + +# Local dev with ngrok +ngrok http 3001 --domain=your-domain.ngrok-free.app +``` diff --git a/apps/desktop/plans/ngrok-setup.md b/apps/desktop/plans/ngrok-setup.md new file mode 100644 index 00000000000..f40b9bce362 --- /dev/null +++ b/apps/desktop/plans/ngrok-setup.md @@ -0,0 +1,212 @@ +# Ngrok Setup for Local Development + +This guide explains how to set up ngrok for testing GitHub OAuth locally with cloud workspaces. + +## Why Ngrok? + +GitHub OAuth requires a publicly accessible callback URL. When developing locally: +- Your web app runs on `localhost:3000` +- Your API runs on `localhost:3001` +- GitHub cannot redirect back to `localhost` after OAuth authorization + +Ngrok creates a secure tunnel from a public URL to your local machine, allowing GitHub OAuth callbacks to work. + +## Prerequisites + +- [ngrok account](https://ngrok.com) (free tier works, paid is easier) +- ngrok CLI installed + +## Free vs Paid Account + +| Feature | Free | Paid | +|---------|------|------| +| Static domain | 1 (must claim) | Unlimited custom subdomains | +| Session timeout | ~2 hours idle | No timeout | +| Custom domain | No | Yes (e.g., `dev.superset.sh`) | +| Setup complexity | Claim domain first | Just use `--subdomain` | + +**With a paid account**, skip step 3 and just run: +```bash +ngrok http 3001 --subdomain=superset-dev +# Gives you: https://superset-dev.ngrok.io +``` + +## Setup Steps + +### 1. Install ngrok + +```bash +# macOS +brew install ngrok + +# Or download from https://ngrok.com/download +``` + +### 2. Authenticate ngrok + +Get your auth token from the [ngrok dashboard](https://dashboard.ngrok.com/get-started/your-authtoken): + +```bash +ngrok config add-authtoken YOUR_AUTH_TOKEN +``` + +### 3. Reserve a Static Domain (Recommended) + +Free ngrok accounts can claim one static domain. This is better than random URLs because: +- The domain persists across sessions +- You don't need to update GitHub App settings each time + +1. Go to [ngrok Dashboard → Domains](https://dashboard.ngrok.com/cloud-edge/domains) +2. Click "New Domain" and claim a free static domain +3. Example: `your-name-superset.ngrok-free.app` + +### 4. Start the Tunnel + +The API server runs on port 3001: + +**Free account (with static domain):** +```bash +ngrok http 3001 --domain=your-name-superset.ngrok-free.app +``` + +**Paid account (custom subdomain):** +```bash +# Use any subdomain you want on ngrok.io +ngrok http 3001 --url=superset-dev.ngrok.io + +# This gives you: https://superset-dev.ngrok.io +``` + +**Paid account (custom domain):** +```bash +# Use your own domain (requires DNS setup) +ngrok http 3001 --url=dev.superset.sh +``` + +You'll see output like: +``` +Session Status online +Account your-email@example.com +Forwarding https://your-name-superset.ngrok-free.app -> http://localhost:3001 +``` + +### 5. Update Environment Variables + +In your `.env` file, add `NEXT_PUBLIC_NGROK_URL` for GitHub OAuth while keeping the regular API URL on localhost: + +```bash +# Regular API URL stays on localhost for Google OAuth and other features +NEXT_PUBLIC_API_URL=http://localhost:3001 + +# Ngrok URL is only used for GitHub OAuth (which requires public callback URL) +NEXT_PUBLIC_NGROK_URL=https://your-name-superset.ngrok-free.app +``` + +This approach keeps Google OAuth working (which requires the registered `localhost` redirect URI) while enabling GitHub OAuth through ngrok. + +### 6. Update GitHub App Callback URL + +The GitHub App needs its callback URL updated to point to your ngrok domain: + +1. Go to [GitHub App Settings](https://github.com/settings/apps) (or your organization's apps) +2. Click on the **superset-app** (or your app name) +3. Find **"Callback URL"** under "Identifying and authorizing users" +4. Change it to your ngrok URL: + ``` + https://your-name-superset.ngrok-free.app/api/github/callback + ``` +5. Save changes + +**Important:** Remember to change this back to the production URL (`https://api.superset.sh/api/github/callback`) when you're done testing locally, or it will break production GitHub OAuth. + +### 7. Start Development Servers + +In a separate terminal: + +```bash +bun dev +``` + +The web app still runs on `localhost:3000` - only the API traffic goes through ngrok. + +## Testing the Flow + +1. Navigate to `http://localhost:3000/cloud` +2. Click "Connect GitHub" +3. You should be redirected to GitHub for authorization +4. After authorizing, GitHub redirects to your ngrok URL +5. The API handles the callback and redirects back to localhost + +## Troubleshooting + +### "Connect GitHub" goes to 404 + +Make sure your `.env` has the correct `NEXT_PUBLIC_API_URL` pointing to your ngrok domain, then restart the dev server. + +### GitHub shows "Callback URL mismatch" + +Your GitHub App's callback URL must match your ngrok domain. See [Step 6](#6-update-github-app-callback-url) to update it in GitHub App Settings. + +### ngrok tunnel expires + +Free ngrok tunnels expire after ~2 hours of inactivity. Solutions: +- Restart `ngrok http 3001 --domain=...` +- Upgrade to paid (no timeouts) + +### Different ngrok URL each time + +Without a static domain, ngrok generates a random URL each session. Solutions: +- Claim a free static domain (step 3) +- Paid account: use `--subdomain=superset-dev` for consistent URL + +## Architecture Notes + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Browser │ │ ngrok │ │ Local API │ +│ localhost:3000│────▶│ tunnel │────▶│ localhost:3001│ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ + │ │ + ▼ ▼ +┌─────────────────┐ ┌─────────────────┐ +│ GitHub OAuth │◀──────────────────────────│ Callback │ +│ Authorization │ │ Handler │ +└─────────────────┘ └─────────────────┘ +``` + +1. User clicks "Connect GitHub" on localhost:3000 +2. Browser redirects to GitHub OAuth +3. GitHub redirects to ngrok URL after authorization +4. ngrok forwards to localhost:3001 +5. API handles callback, creates session, redirects back to localhost:3000 + +## Quick Start (Recommended) + +Use the `dev:cloud` script to run ngrok + dev servers together: + +```bash +# 1. Add to your .env: +NGROK_SUBDOMAIN=superset-dev +NEXT_PUBLIC_NGROK_URL=https://superset-dev.ngrok.io + +# 2. Run everything: +bun run dev:cloud +``` + +This starts ngrok in the background and runs the dev servers. When you stop the servers (Ctrl+C), ngrok also shuts down. + +Note: `NEXT_PUBLIC_API_URL` stays on `http://localhost:3001` for Google OAuth and general API calls. Only GitHub OAuth uses the ngrok URL. + +## Manual Commands + +```bash +# Start tunnel manually (replace with your domain) +ngrok http 3001 --url=superset-dev.ngrok.io + +# Check tunnel status +curl https://superset-dev.ngrok.io/health + +# View ngrok web inspector (shows all requests) +open http://localhost:4040 +``` diff --git a/apps/desktop/src/main/lib/workspace-runtime/cloud.ts b/apps/desktop/src/main/lib/workspace-runtime/cloud.ts new file mode 100644 index 00000000000..ff0f0a14543 --- /dev/null +++ b/apps/desktop/src/main/lib/workspace-runtime/cloud.ts @@ -0,0 +1,431 @@ +/** + * Cloud Workspace Runtime + * + * This is the cloud implementation of WorkspaceRuntime that connects + * to the Superset control plane via WebSocket for remote execution. + * + * Cloud workspaces run Claude Code in Modal sandboxes and stream + * events back through the control plane. + */ + +import { EventEmitter } from "node:events"; +import type { CreateSessionParams, SessionResult } from "../terminal/types"; +import type { + TerminalCapabilities, + TerminalManagement, + TerminalRuntime, + WorkspaceRuntime, + WorkspaceRuntimeId, +} from "./types"; + +// ============================================================================= +// Cloud Event Types +// ============================================================================= + +export interface CloudSessionConfig { + sessionId: string; + controlPlaneUrl: string; + authToken: string; +} + +export interface CloudEvent { + id: string; + type: + | "tool_call" + | "tool_result" + | "token" + | "error" + | "git_sync" + | "execution_complete" + | "heartbeat"; + timestamp: number; + data: unknown; + messageId?: string; +} + +export interface CloudSessionState { + sessionId: string; + status: string; + sandboxStatus: string; + repoOwner: string; + repoName: string; + branch: string; + baseBranch: string; + model: string; + participants: Array<{ + id: string; + userId: string; + userName: string; + avatarUrl?: string; + source: string; + isOnline: boolean; + }>; + messageCount: number; + eventCount: number; +} + +// ============================================================================= +// Cloud WebSocket Connection +// ============================================================================= + +class CloudWebSocketConnection extends EventEmitter { + private ws: WebSocket | null = null; + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + private reconnectDelay = 1000; + private pingInterval: NodeJS.Timeout | null = null; + + constructor(private config: CloudSessionConfig) { + super(); + } + + async connect(): Promise { + const wsUrl = this.config.controlPlaneUrl + .replace("https://", "wss://") + .replace("http://", "ws://"); + + const url = `${wsUrl}/api/sessions/${this.config.sessionId}/ws`; + + return new Promise((resolve, reject) => { + try { + this.ws = new WebSocket(url); + + this.ws.onopen = () => { + this.reconnectAttempts = 0; + // Send subscribe message with auth token + this.send({ type: "subscribe", token: this.config.authToken }); + this.startPingInterval(); + resolve(); + }; + + this.ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data as string); + this.handleMessage(message); + } catch (e) { + console.error("[cloud-ws] Failed to parse message:", e); + } + }; + + this.ws.onclose = () => { + this.stopPingInterval(); + this.emit("disconnect"); + this.attemptReconnect(); + }; + + this.ws.onerror = (error) => { + console.error("[cloud-ws] WebSocket error:", error); + this.emit("error", error); + reject(error); + }; + } catch (error) { + reject(error); + } + }); + } + + private handleMessage(message: { + type: string; + sessionId?: string; + state?: CloudSessionState; + event?: CloudEvent; + message?: string; + }): void { + switch (message.type) { + case "subscribed": + this.emit("subscribed", { + sessionId: message.sessionId, + state: message.state, + }); + break; + + case "event": + this.emit("event", message.event); + break; + + case "state_update": + this.emit("state_update", message.state); + break; + + case "error": + this.emit("server_error", message.message); + break; + + case "pong": + // Heartbeat response received + break; + } + } + + send(message: { type: string; [key: string]: unknown }): void { + if (this.ws?.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(message)); + } + } + + sendPrompt(content: string, authorId: string): void { + this.send({ type: "prompt", content, authorId }); + } + + sendStop(): void { + this.send({ type: "stop" }); + } + + private startPingInterval(): void { + this.pingInterval = setInterval(() => { + this.send({ type: "ping" }); + }, 30000); + } + + private stopPingInterval(): void { + if (this.pingInterval) { + clearInterval(this.pingInterval); + this.pingInterval = null; + } + } + + private attemptReconnect(): void { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + this.emit("reconnect_failed"); + return; + } + + this.reconnectAttempts++; + const delay = this.reconnectDelay * 2 ** (this.reconnectAttempts - 1); + + setTimeout(() => { + this.connect().catch((error) => { + console.error("[cloud-ws] Reconnect failed:", error); + }); + }, delay); + } + + disconnect(): void { + this.stopPingInterval(); + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } +} + +// ============================================================================= +// Cloud Terminal Runtime (Stub Implementation) +// ============================================================================= + +/** + * Cloud terminal runtime - provides terminal-like interface for cloud sessions. + * + * Note: Cloud workspaces don't have local terminals in the traditional sense. + * This implements the TerminalRuntime interface but delegates to the + * control plane for actual execution. + */ +class CloudTerminalRuntime extends EventEmitter implements TerminalRuntime { + readonly management: TerminalManagement | null = null; + readonly capabilities: TerminalCapabilities = { + persistent: false, // Cloud sessions are ephemeral from the desktop's perspective + coldRestore: false, + }; + + private connection: CloudWebSocketConnection | null = null; + private sessionState: CloudSessionState | null = null; + + constructor(private config: CloudSessionConfig) { + super(); + } + + async initialize(): Promise { + this.connection = new CloudWebSocketConnection(this.config); + + // Forward connection events + this.connection.on( + "subscribed", + (data: { sessionId: string; state: CloudSessionState }) => { + this.sessionState = data.state; + this.emit("subscribed", data); + }, + ); + + this.connection.on("event", (event: CloudEvent) => { + // Emit events in a format similar to terminal data + // so existing UI can consume them + this.emit(`event:${this.config.sessionId}`, event); + + // Also emit as data for terminal-like rendering + if (event.type === "token") { + const token = (event.data as { token: string }).token; + this.emit(`data:${this.config.sessionId}`, token); + } + }); + + this.connection.on("state_update", (state: Partial) => { + if (this.sessionState) { + Object.assign(this.sessionState, state); + } + this.emit("state_update", state); + }); + + this.connection.on("disconnect", () => { + this.emit(`disconnect:${this.config.sessionId}`); + }); + + this.connection.on("error", (error: Error) => { + this.emit(`error:${this.config.sessionId}`, error); + }); + + await this.connection.connect(); + } + + // =========================================================================== + // Session Operations (adapted for cloud) + // =========================================================================== + + async createOrAttach(_params: CreateSessionParams): Promise { + // For cloud workspaces, "creating" a session means connecting to an existing + // cloud session. The session is created on the control plane side. + return { + isNew: false, // Cloud sessions are always "existing" from desktop's perspective + scrollback: "", // No local scrollback for cloud sessions + wasRecovered: false, // Cloud sessions don't use local recovery + isColdRestore: false, + }; + } + + write(params: { paneId: string; data: string }): void { + // In cloud mode, "writing" means sending a prompt + // The control plane will execute it + if (this.connection && params.paneId === this.config.sessionId) { + this.connection.sendPrompt(params.data, "desktop-user"); + } + } + + resize(_params: { paneId: string; cols: number; rows: number }): void { + // Cloud terminals don't need resize - they're not PTY-based + } + + signal(params: { paneId: string; signal?: string }): void { + // Signal handling - stop the current execution + if (params.signal === "SIGINT" || params.signal === "SIGTERM") { + this.connection?.sendStop(); + } + } + + async kill(_params: { paneId: string }): Promise { + // Kill the cloud session connection + this.connection?.disconnect(); + } + + detach(_params: { paneId: string }): void { + // Detach just closes the WebSocket but keeps the cloud session alive + this.connection?.disconnect(); + } + + clearScrollback(_params: { paneId: string }): void { + // No scrollback in cloud mode - events are streamed + } + + ackColdRestore(_paneId: string): void { + // No cold restore in cloud mode + } + + getSession( + paneId: string, + ): { isAlive: boolean; cwd: string; lastActive: number } | null { + if (paneId !== this.config.sessionId) { + return null; + } + + return { + isAlive: this.sessionState !== null, + cwd: `${this.sessionState?.repoOwner}/${this.sessionState?.repoName}`, + lastActive: Date.now(), + }; + } + + getSessionState(): CloudSessionState | null { + return this.sessionState; + } + + // =========================================================================== + // Workspace Operations + // =========================================================================== + + async killByWorkspaceId( + _workspaceId: string, + ): Promise<{ killed: number; failed: number }> { + // Cloud workspaces have only one session per workspace + this.connection?.disconnect(); + return { killed: 1, failed: 0 }; + } + + async getSessionCountByWorkspaceId(_workspaceId: string): Promise { + return this.connection ? 1 : 0; + } + + refreshPromptsForWorkspace(_workspaceId: string): void { + // No prompt refresh needed in cloud mode + } + + // =========================================================================== + // Event Source + // =========================================================================== + + detachAllListeners(): void { + this.removeAllListeners(); + } + + // =========================================================================== + // Cleanup + // =========================================================================== + + async cleanup(): Promise { + this.connection?.disconnect(); + this.connection = null; + } +} + +// ============================================================================= +// Cloud Workspace Runtime +// ============================================================================= + +/** + * Cloud workspace runtime implementation. + * + * This provides the WorkspaceRuntime interface for cloud workspaces, + * connecting to the control plane for remote execution. + */ +export class CloudWorkspaceRuntime implements WorkspaceRuntime { + readonly id: WorkspaceRuntimeId; + readonly terminal: TerminalRuntime; + readonly capabilities: WorkspaceRuntime["capabilities"]; + + private terminalRuntime: CloudTerminalRuntime; + + constructor(config: CloudSessionConfig) { + this.id = `cloud:${config.sessionId}`; + + // Create cloud terminal runtime + this.terminalRuntime = new CloudTerminalRuntime(config); + this.terminal = this.terminalRuntime; + + // Aggregate capabilities + this.capabilities = { + terminal: this.terminal.capabilities, + }; + } + + /** + * Initialize the cloud connection. + * Must be called before using the runtime. + */ + async initialize(): Promise { + await this.terminalRuntime.initialize(); + } + + /** + * Get the current session state. + */ + getState(): CloudSessionState | null { + return (this.terminalRuntime as CloudTerminalRuntime).getSessionState(); + } +} diff --git a/apps/desktop/src/main/lib/workspace-runtime/index.ts b/apps/desktop/src/main/lib/workspace-runtime/index.ts index 1b7a962fe73..a8f37db8d94 100644 --- a/apps/desktop/src/main/lib/workspace-runtime/index.ts +++ b/apps/desktop/src/main/lib/workspace-runtime/index.ts @@ -13,6 +13,12 @@ * ``` */ +export { + type CloudEvent, + type CloudSessionConfig, + type CloudSessionState, + CloudWorkspaceRuntime, +} from "./cloud"; export { LocalWorkspaceRuntime } from "./local"; export { getWorkspaceRuntimeRegistry, diff --git a/apps/desktop/src/renderer/lib/api-trpc-client.ts b/apps/desktop/src/renderer/lib/api-trpc-client.ts index 261da0b9af0..2235b13e764 100644 --- a/apps/desktop/src/renderer/lib/api-trpc-client.ts +++ b/apps/desktop/src/renderer/lib/api-trpc-client.ts @@ -2,27 +2,40 @@ import type { AppRouter } from "@superset/trpc"; import { createTRPCProxyClient, httpBatchLink } from "@trpc/client"; import { env } from "renderer/env.renderer"; import superjson from "superjson"; +import { apiTrpc } from "./api-trpc"; import { getAuthToken } from "./auth-client"; /** - * HTTP tRPC client for calling the API server. - * Uses bearer token authentication like the auth client. - * For mutations only - for fetching data we already have electric + * Shared httpBatchLink configuration for API server communication. + */ +const apiLink = httpBatchLink({ + url: `${env.NEXT_PUBLIC_API_URL}/api/trpc`, + transformer: superjson, + headers: () => { + const token = getAuthToken(); + if (token) { + return { + Authorization: `Bearer ${token}`, + }; + } + return {}; + }, +}); + +/** + * HTTP tRPC proxy client for calling the API server. + * Uses bearer token authentication. + * For imperative calls from stores/utilities. */ export const apiTrpcClient = createTRPCProxyClient({ - links: [ - httpBatchLink({ - url: `${env.NEXT_PUBLIC_API_URL}/api/trpc`, - transformer: superjson, - headers: () => { - const token = getAuthToken(); - if (token) { - return { - Authorization: `Bearer ${token}`, - }; - } - return {}; - }, - }), - ], + links: [apiLink], +}); + +/** + * HTTP tRPC React client for calling the API server. + * Uses bearer token authentication. + * For React Query hooks (used by ApiTRPCProvider). + */ +export const apiReactClient = apiTrpc.createClient({ + links: [apiLink], }); diff --git a/apps/desktop/src/renderer/lib/api-trpc.ts b/apps/desktop/src/renderer/lib/api-trpc.ts new file mode 100644 index 00000000000..70308e238f8 --- /dev/null +++ b/apps/desktop/src/renderer/lib/api-trpc.ts @@ -0,0 +1,13 @@ +import type { AppRouter } from "@superset/trpc"; +import { createTRPCReact } from "@trpc/react-query"; +import type { inferRouterOutputs } from "@trpc/server"; + +/** + * tRPC React client for HTTP communication with API server. + * For cloud features: cloud workspaces, organization data, etc. + */ +export const apiTrpc = createTRPCReact({ + abortOnUnmount: true, +}); + +export type ApiRouterOutputs = inferRouterOutputs; diff --git a/apps/desktop/src/renderer/providers/ApiTRPCProvider/ApiTRPCProvider.tsx b/apps/desktop/src/renderer/providers/ApiTRPCProvider/ApiTRPCProvider.tsx new file mode 100644 index 00000000000..ce8ecf78c81 --- /dev/null +++ b/apps/desktop/src/renderer/providers/ApiTRPCProvider/ApiTRPCProvider.tsx @@ -0,0 +1,23 @@ +import type { QueryClient } from "@tanstack/react-query"; +import { apiTrpc } from "renderer/lib/api-trpc"; +import { apiReactClient } from "renderer/lib/api-trpc-client"; + +interface ApiTRPCProviderProps { + children: React.ReactNode; + queryClient: QueryClient; +} + +/** + * Provider for API HTTP tRPC client. + * Shares QueryClient with ElectronTRPCProvider for unified caching. + */ +export function ApiTRPCProvider({ + children, + queryClient, +}: ApiTRPCProviderProps) { + return ( + + {children} + + ); +} diff --git a/apps/desktop/src/renderer/providers/ApiTRPCProvider/index.ts b/apps/desktop/src/renderer/providers/ApiTRPCProvider/index.ts new file mode 100644 index 00000000000..11b3f994689 --- /dev/null +++ b/apps/desktop/src/renderer/providers/ApiTRPCProvider/index.ts @@ -0,0 +1 @@ +export { ApiTRPCProvider } from "./ApiTRPCProvider"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx index 36e0f50e705..401862b71b4 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/layout.tsx @@ -5,8 +5,10 @@ import { useNavigate, } from "@tanstack/react-router"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { CloudWorkspaceView } from "renderer/screens/main/components/CloudWorkspaceView"; import { ResizablePanel } from "renderer/screens/main/components/ResizablePanel"; import { WorkspaceSidebar } from "renderer/screens/main/components/WorkspaceSidebar"; +import { useCloudWorkspaceStore } from "renderer/stores/cloud-workspace"; import { useAppHotkey } from "renderer/stores/hotkeys"; import { useOpenNewWorkspaceModal } from "renderer/stores/new-workspace-modal"; import { @@ -38,6 +40,9 @@ function DashboardLayout() { { enabled: !!currentWorkspaceId }, ); + // Cloud workspace state + const activeCloudSessionId = useCloudWorkspaceStore((s) => s.activeSessionId); + const { isOpen: isWorkspaceSidebarOpen, toggleCollapsed: toggleWorkspaceSidebarCollapsed, @@ -99,7 +104,8 @@ function DashboardLayout() { )} - + {/* Show cloud workspace view when a cloud session is active */} + {activeCloudSessionId ? : } ); diff --git a/apps/desktop/src/renderer/screens/main/components/CloudWorkspaceView/CloudWorkspaceView.tsx b/apps/desktop/src/renderer/screens/main/components/CloudWorkspaceView/CloudWorkspaceView.tsx new file mode 100644 index 00000000000..3569bc9835a --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/CloudWorkspaceView/CloudWorkspaceView.tsx @@ -0,0 +1,194 @@ +import { useEffect, useRef } from "react"; +import { LuCloud, LuExternalLink, LuRefreshCw, LuX } from "react-icons/lu"; +import { env } from "renderer/env.renderer"; +import { apiTrpc } from "renderer/lib/api-trpc"; +import { ApiTRPCProvider } from "renderer/providers/ApiTRPCProvider"; +import { electronQueryClient } from "renderer/providers/ElectronTRPCProvider"; +import { useCloudWorkspaceStore } from "renderer/stores/cloud-workspace"; + +const STROKE_WIDTH = 1.5; + +// Extend JSX to include webview element (Electron-specific) +declare global { + namespace JSX { + interface IntrinsicElements { + webview: React.DetailedHTMLProps< + React.HTMLAttributes & { + src?: string; + partition?: string; + allowpopups?: boolean; + webpreferences?: string; + }, + HTMLElement + >; + } + } +} + +/** + * Wrapper that provides the API tRPC context for cloud workspace view. + */ +export function CloudWorkspaceView() { + return ( + + + + ); +} + +function CloudWorkspaceViewContent() { + const { activeSessionId, clearActiveSession } = useCloudWorkspaceStore(); + const webviewRef = useRef(null); + + const { data: workspace, isLoading } = + apiTrpc.cloudWorkspace.getBySessionId.useQuery( + { sessionId: activeSessionId ?? "" }, + { enabled: !!activeSessionId }, + ); + + // Set up webview event listeners + useEffect(() => { + const webview = webviewRef.current; + if (!webview) return; + + const handleDidFailLoad = (event: Event) => { + const e = event as CustomEvent; + console.error("[CloudWorkspaceView] Failed to load:", e.detail); + }; + + const handleDidFinishLoad = () => { + console.log("[CloudWorkspaceView] Finished loading"); + }; + + webview.addEventListener("did-fail-load", handleDidFailLoad); + webview.addEventListener("did-finish-load", handleDidFinishLoad); + + return () => { + webview.removeEventListener("did-fail-load", handleDidFailLoad); + webview.removeEventListener("did-finish-load", handleDidFinishLoad); + }; + }, []); + + if (!activeSessionId) { + return null; + } + + if (isLoading) { + return ( +
+
+ + Loading cloud workspace... +
+
+ ); + } + + if (!workspace) { + return ( +
+
+ + Cloud workspace not found + +
+
+ ); + } + + // Build the cloud workspace URL + // This will be the URL to the web app's cloud workspace page + const cloudWorkspaceUrl = `${env.NEXT_PUBLIC_WEB_URL}/cloud/${workspace.sessionId}`; + + const handleRefresh = () => { + const webview = webviewRef.current as + | (HTMLElement & { reload?: () => void }) + | null; + if (webview?.reload) { + webview.reload(); + } + }; + + const handleOpenExternal = () => { + window.open(cloudWorkspaceUrl, "_blank"); + }; + + return ( +
+ {/* Header */} +
+ +
+
+ + {workspace.title} + + + {workspace.repoOwner}/{workspace.repoName} + +
+
+
+ + + +
+
+ + {/* Electron WebView for external content */} +
+ } + src={cloudWorkspaceUrl} + partition="persist:superset" + allowpopups={true} + webpreferences="contextIsolation=yes" + className="absolute inset-0 w-full h-full border-0" + style={{ display: "flex" }} + /> +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/CloudWorkspaceView/index.ts b/apps/desktop/src/renderer/screens/main/components/CloudWorkspaceView/index.ts new file mode 100644 index 00000000000..6ef181813f3 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/CloudWorkspaceView/index.ts @@ -0,0 +1 @@ +export { CloudWorkspaceView } from "./CloudWorkspaceView"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/CloudWorkspacesSection/CloudWorkspaceListItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/CloudWorkspacesSection/CloudWorkspaceListItem.tsx new file mode 100644 index 00000000000..29a827bf672 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/CloudWorkspacesSection/CloudWorkspaceListItem.tsx @@ -0,0 +1,229 @@ +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuTrigger, +} from "@superset/ui/context-menu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { cn } from "@superset/ui/utils"; +import { + LuArchive, + LuCloud, + LuExternalLink, + LuGitBranch, +} from "react-icons/lu"; +import type { ApiRouterOutputs } from "renderer/lib/api-trpc"; +import { AsciiSpinner } from "renderer/screens/main/components/AsciiSpinner"; +import { StatusIndicator } from "renderer/screens/main/components/StatusIndicator"; +import type { ActivePaneStatus } from "shared/tabs-types"; +import { STROKE_WIDTH } from "../constants"; + +type CloudWorkspace = ApiRouterOutputs["cloudWorkspace"]["list"][number]; + +interface CloudWorkspaceListItemProps { + workspace: CloudWorkspace; + isActive: boolean; + isCollapsed?: boolean; + onArchive?: () => void; + onSelect?: () => void; +} + +const SANDBOX_STATUS_TO_PANE_STATUS: Record< + string, + ActivePaneStatus | undefined +> = { + pending: undefined, + warming: "working", + syncing: "working", + ready: undefined, + running: "working", + stopped: undefined, + failed: "permission", // Use permission status for failed (shows red) +}; + +export function CloudWorkspaceListItem({ + workspace, + isActive, + isCollapsed = false, + onArchive, + onSelect, +}: CloudWorkspaceListItemProps) { + const handleClick = () => { + onSelect?.(); + }; + + const handleOpenPR = () => { + if (workspace.prUrl) { + window.open(workspace.prUrl, "_blank"); + } + }; + + const status: ActivePaneStatus | undefined = workspace.sandboxStatus + ? SANDBOX_STATUS_TO_PANE_STATUS[workspace.sandboxStatus] + : undefined; + + // Collapsed sidebar: show just the icon with tooltip + if (isCollapsed) { + return ( + + + + + + + + + {workspace.prUrl && ( + <> + + + Open PR + + + + )} + {onArchive && ( + + + Archive + + )} + + + + {workspace.title} + + {workspace.repoOwner}/{workspace.repoName} + + + + ); + } + + // Expanded sidebar: full item view + return ( + + + + + + {workspace.prUrl && ( + <> + + + Open PR + + + + )} + {onArchive && ( + + + Archive + + )} + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/CloudWorkspacesSection/CloudWorkspacesSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/CloudWorkspacesSection/CloudWorkspacesSection.tsx new file mode 100644 index 00000000000..7f56620bc13 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/CloudWorkspacesSection/CloudWorkspacesSection.tsx @@ -0,0 +1,221 @@ +import { toast } from "@superset/ui/sonner"; +import { cn } from "@superset/ui/utils"; +import { AnimatePresence, motion } from "framer-motion"; +import { LuChevronDown, LuCloud, LuPlus } from "react-icons/lu"; +import { apiTrpc } from "renderer/lib/api-trpc"; +import { ApiTRPCProvider } from "renderer/providers/ApiTRPCProvider"; +import { electronQueryClient } from "renderer/providers/ElectronTRPCProvider"; +import { useWorkspaceSidebarStore } from "renderer/stores"; +import { useCloudWorkspaceStore } from "renderer/stores/cloud-workspace"; +import { CloudWorkspaceListItem } from "./CloudWorkspaceListItem"; + +const CLOUD_SECTION_ID = "cloud-workspaces"; +const STROKE_WIDTH = 1.5; + +interface CloudWorkspacesSectionProps { + isCollapsed?: boolean; +} + +/** + * Wrapper that provides the API tRPC context for cloud workspace features. + * Uses a stable provider instance to prevent query resets on re-renders. + */ +export function CloudWorkspacesSection(props: CloudWorkspacesSectionProps) { + // Keep the provider stable across re-renders + return ( + + + + ); +} + +/** + * Inner component that handles collapse state without affecting the provider. + */ +function CloudWorkspacesSectionInner(props: CloudWorkspacesSectionProps) { + const { isProjectCollapsed } = useWorkspaceSidebarStore(); + const isCollapsed = isProjectCollapsed(CLOUD_SECTION_ID); + + // Always render content - the collapse state is handled internally + return ( + + ); +} + +interface CloudWorkspacesSectionContentProps + extends CloudWorkspacesSectionProps { + sectionCollapsed: boolean; +} + +function CloudWorkspacesSectionContent({ + isCollapsed: isSidebarCollapsed = false, + sectionCollapsed, +}: CloudWorkspacesSectionContentProps) { + const { toggleProjectCollapsed } = useWorkspaceSidebarStore(); + const { activeSessionId, setActiveSession } = useCloudWorkspaceStore(); + + const { + data: cloudWorkspaces = [], + isLoading, + isError, + isFetching, + } = apiTrpc.cloudWorkspace.list.useQuery(undefined, { + staleTime: 30_000, // 30 seconds + retry: false, // Don't retry on failure + refetchOnWindowFocus: false, // Prevent unnecessary refetches + placeholderData: (prev) => prev, // Keep previous data while refetching + }); + + const archiveMutation = apiTrpc.cloudWorkspace.archive.useMutation({ + onSuccess: () => { + toast.success("Workspace archived"); + }, + onError: (error) => { + toast.error(`Failed to archive: ${error.message}`); + }, + }); + + const utils = apiTrpc.useUtils(); + + const handleArchive = (id: string) => { + archiveMutation.mutate( + { id }, + { + onSuccess: () => { + utils.cloudWorkspace.list.invalidate(); + }, + }, + ); + }; + + const handleSelectWorkspace = (sessionId: string) => { + setActiveSession(sessionId); + }; + + // Use the passed-in collapse state + const isCollapsed = sectionCollapsed; + + // Don't render the section if API failed (server not running) or no workspaces + // But keep it visible while loading to prevent flash + if (isError || (!isLoading && cloudWorkspaces.length === 0)) { + return null; + } + + const handleNewCloudWorkspace = () => { + // TODO: Open new cloud workspace modal + toast.info("Cloud workspace creation coming soon"); + }; + + if (isSidebarCollapsed) { + return ( +
+ {/* Collapsed header */} + + + + {!isCollapsed && ( + +
+ {cloudWorkspaces.map((workspace) => ( + handleArchive(workspace.id)} + onSelect={() => handleSelectWorkspace(workspace.sessionId)} + /> + ))} +
+
+ )} +
+
+ ); + } + + return ( +
+ {/* Header */} +
+ + +
+ + + {!isCollapsed && ( + +
+ {isLoading ? ( +
+ Loading... +
+ ) : ( + cloudWorkspaces.map((workspace) => ( + handleArchive(workspace.id)} + onSelect={() => handleSelectWorkspace(workspace.sessionId)} + /> + )) + )} +
+
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/CloudWorkspacesSection/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/CloudWorkspacesSection/index.ts new file mode 100644 index 00000000000..59ab2d68740 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/CloudWorkspacesSection/index.ts @@ -0,0 +1 @@ +export { CloudWorkspacesSection } from "./CloudWorkspacesSection"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx index 087b9ff545a..c45c3643fae 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx @@ -1,5 +1,6 @@ import { useMemo } from "react"; import { useWorkspaceShortcuts } from "renderer/hooks/useWorkspaceShortcuts"; +import { CloudWorkspacesSection } from "./CloudWorkspacesSection"; import { PortsList } from "./PortsList"; import { ProjectSection } from "./ProjectSection"; import { SidebarDropZone } from "./SidebarDropZone"; @@ -48,6 +49,9 @@ export function WorkspaceSidebar({ /> ))} + {/* Cloud Workspaces Section */} + + {groups.length === 0 && !isCollapsed && (
No workspaces yet diff --git a/apps/desktop/src/renderer/stores/cloud-workspace.ts b/apps/desktop/src/renderer/stores/cloud-workspace.ts new file mode 100644 index 00000000000..f75dc836c16 --- /dev/null +++ b/apps/desktop/src/renderer/stores/cloud-workspace.ts @@ -0,0 +1,30 @@ +import { create } from "zustand"; +import { devtools } from "zustand/middleware"; + +interface CloudWorkspaceState { + /** Currently active cloud workspace session ID */ + activeSessionId: string | null; + + /** Set the active cloud workspace session */ + setActiveSession: (sessionId: string | null) => void; + + /** Clear the active session */ + clearActiveSession: () => void; +} + +export const useCloudWorkspaceStore = create()( + devtools( + (set) => ({ + activeSessionId: null, + + setActiveSession: (sessionId) => { + set({ activeSessionId: sessionId }); + }, + + clearActiveSession: () => { + set({ activeSessionId: null }); + }, + }), + { name: "CloudWorkspaceStore" }, + ), +); diff --git a/apps/web/package.json b/apps/web/package.json index 171e9b1ddf6..08e480dee3e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,6 +36,8 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-icons": "^5.5.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "require-in-the-middle": "8.0.1", "server-only": "^0.0.1", "superjson": "^2.2.5", diff --git a/apps/web/src/app/cloud/[sessionId]/components/CloudWorkspaceContent/CloudWorkspaceContent.tsx b/apps/web/src/app/cloud/[sessionId]/components/CloudWorkspaceContent/CloudWorkspaceContent.tsx new file mode 100644 index 00000000000..373c1c66711 --- /dev/null +++ b/apps/web/src/app/cloud/[sessionId]/components/CloudWorkspaceContent/CloudWorkspaceContent.tsx @@ -0,0 +1,603 @@ +"use client"; + +import { + CodeBlock, + CodeBlockCopyButton, +} from "@superset/ui/ai-elements/code-block"; +import { + Message, + MessageContent, + MessageResponse, +} from "@superset/ui/ai-elements/message"; +import { + PromptInput, + PromptInputFooter, + PromptInputSubmit, + PromptInputTextarea, + PromptInputTools, +} from "@superset/ui/ai-elements/prompt-input"; +import { Shimmer } from "@superset/ui/ai-elements/shimmer"; +import { ScrollArea } from "@superset/ui/scroll-area"; +import { SidebarInset, SidebarProvider } from "@superset/ui/sidebar"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + LuCheck, + LuGitBranch, + LuLoader, + LuTerminal, + LuX, +} from "react-icons/lu"; + +import { env } from "@/env"; +import { + CloudSidebar, + type CloudWorkspace, +} from "../../../components/CloudSidebar"; +import { type CloudEvent, useCloudSession } from "../../hooks"; +import { CloudWorkspaceHeader } from "../CloudWorkspaceHeader"; +import { ToolCallGroup } from "../ToolCallGroup"; + +type GroupedEvent = + | { type: "assistant_message"; id: string; text: string } + | { type: "user_message"; id: string; content: string } + | { + type: "tool_call_group"; + id: string; + events: CloudEvent[]; + toolName: string; + } + | { type: "other"; event: CloudEvent }; + +function groupEvents(events: CloudEvent[]): GroupedEvent[] { + const result: GroupedEvent[] = []; + let currentTokenGroup: { id: string; tokens: string[] } | null = null; + let currentToolGroup: { + id: string; + events: CloudEvent[]; + toolName: string; + } | null = null; + + const flushTokens = () => { + if (currentTokenGroup) { + result.push({ + type: "assistant_message", + id: currentTokenGroup.id, + text: currentTokenGroup.tokens.join(""), + }); + currentTokenGroup = null; + } + }; + + const flushTools = () => { + if (currentToolGroup) { + result.push({ + type: "tool_call_group", + id: currentToolGroup.id, + events: currentToolGroup.events, + toolName: currentToolGroup.toolName, + }); + currentToolGroup = null; + } + }; + + for (const event of events) { + if (event.type === "heartbeat") continue; + + if (event.type === "user_message") { + flushTokens(); + flushTools(); + const data = event.data as { content?: string }; + result.push({ + type: "user_message", + id: event.id, + content: data.content || "", + }); + } else if (event.type === "token") { + flushTools(); + // OpenCode sends cumulative content, not individual tokens + const data = event.data as { content?: string; token?: string }; + const text = data.content || data.token; + if (text) { + // Since content is cumulative, we replace rather than append + if (!currentTokenGroup) { + currentTokenGroup = { id: event.id, tokens: [] }; + } + // Clear previous tokens and set the cumulative text + currentTokenGroup.tokens = [text]; + } + } else if (event.type === "tool_call") { + flushTokens(); + const data = event.data as { name?: string }; + const toolName = data.name || "Unknown"; + + if (currentToolGroup && currentToolGroup.toolName === toolName) { + currentToolGroup.events.push(event); + } else { + flushTools(); + currentToolGroup = { + id: event.id, + events: [event], + toolName, + }; + } + } else { + flushTokens(); + flushTools(); + result.push({ type: "other", event }); + } + } + + flushTokens(); + flushTools(); + + return result; +} + +interface CloudWorkspaceContentProps { + workspace: CloudWorkspace; + workspaces: CloudWorkspace[]; +} + +const CONTROL_PLANE_URL = + env.NEXT_PUBLIC_CONTROL_PLANE_URL || + "https://superset-control-plane.avi-6ac.workers.dev"; + +export function CloudWorkspaceContent({ + workspace, + workspaces: initialWorkspaces, +}: CloudWorkspaceContentProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const initialPromptRef = useRef(null); + const hasSentInitialPrompt = useRef(false); + + const [promptInput, setPromptInput] = useState(""); + const scrollAreaRef = useRef(null); + const textareaRef = useRef(null); + + const { + isConnected, + isConnecting, + isReconnecting, + reconnectAttempt, + isLoadingHistory, + isSpawning, + isProcessing, + isSandboxReady, + isControlPlaneAvailable, + spawnAttempt, + maxSpawnAttempts, + error, + sessionState, + events, + pendingPrompts, + sendPrompt, + sendStop, + sendTyping, + spawnSandbox, + clearError, + } = useCloudSession({ + controlPlaneUrl: CONTROL_PLANE_URL, + sessionId: workspace.sessionId, + }); + + const isExecuting = isProcessing || sessionState?.sandboxStatus === "running"; + const canSendPrompt = isConnected && isSandboxReady && !isProcessing; + + // Auto-scroll to bottom when new events arrive or processing state changes + useEffect(() => { + if (scrollAreaRef.current) { + const scrollContainer = scrollAreaRef.current.querySelector( + "[data-radix-scroll-area-viewport]", + ); + if (scrollContainer) { + scrollContainer.scrollTop = scrollContainer.scrollHeight; + } + } + }, []); + + const handleSendPrompt = useCallback(() => { + if (promptInput.trim() && canSendPrompt) { + sendPrompt(promptInput.trim()); + setPromptInput(""); + textareaRef.current?.focus(); + } + }, [promptInput, canSendPrompt, sendPrompt]); + + const _handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSendPrompt(); + } + }, + [handleSendPrompt], + ); + + // Global keyboard shortcuts + useEffect(() => { + const handleGlobalKeyDown = (e: KeyboardEvent) => { + const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; + const modKey = isMac ? e.metaKey : e.ctrlKey; + + // ⌘+Enter or Ctrl+Enter to send prompt + if (modKey && e.key === "Enter") { + e.preventDefault(); + handleSendPrompt(); + return; + } + + // Escape to stop execution + if (e.key === "Escape" && isExecuting) { + e.preventDefault(); + sendStop(); + return; + } + + // ⌘+K or Ctrl+K to focus input + if (modKey && e.key === "k") { + e.preventDefault(); + textareaRef.current?.focus(); + return; + } + + // Note: ⌘+B for sidebar toggle is handled by SidebarProvider + }; + + window.addEventListener("keydown", handleGlobalKeyDown); + return () => window.removeEventListener("keydown", handleGlobalKeyDown); + }, [handleSendPrompt, isExecuting, sendStop]); + + // Auto-send initial prompt from URL when sandbox is ready + useEffect(() => { + // Capture initial prompt from URL on mount + if (initialPromptRef.current === null) { + const prompt = searchParams.get("prompt"); + initialPromptRef.current = prompt || ""; + + // If there's a prompt, pre-populate the input + if (prompt) { + setPromptInput(prompt); + // Clear the URL param to avoid re-sending on refresh + router.replace(`/cloud/${workspace.sessionId}`, { scroll: false }); + } + } + }, [searchParams, router, workspace.sessionId]); + + // Send initial prompt when sandbox becomes ready + useEffect(() => { + if ( + isSandboxReady && + isConnected && + !hasSentInitialPrompt.current && + initialPromptRef.current && + initialPromptRef.current.trim() + ) { + hasSentInitialPrompt.current = true; + const prompt = initialPromptRef.current; + console.log( + "[cloud-workspace] Auto-sending initial prompt:", + prompt.substring(0, 50), + ); + sendPrompt(prompt); + setPromptInput(""); + } + }, [isSandboxReady, isConnected, sendPrompt]); + + const groupedEvents = useMemo(() => groupEvents(events), [events]); + + return ( + + + + + + + {/* Main content area */} +
+ {/* Events display */} + +
+ {events.length === 0 && !error && ( +
+
+ +
+

+ {isSpawning + ? "Starting cloud sandbox..." + : isConnected + ? sessionState?.sandboxStatus === "ready" + ? "Ready to start" + : "Preparing workspace..." + : isConnecting + ? "Connecting..." + : "Waiting for connection..."} +

+

+ {isSpawning + ? "This may take a moment" + : isConnected && sessionState?.sandboxStatus === "ready" + ? "Send a message to start working with Claude" + : "Please wait while we set things up"} +

+
+ )} + + {isLoadingHistory && isConnected && events.length === 0 && ( +
+ + + Loading history... + +
+ )} + + {groupedEvents.map((grouped, index) => { + if (grouped.type === "user_message") { + return ( + + ); + } + if (grouped.type === "assistant_message") { + return ( + + ); + } + if (grouped.type === "tool_call_group") { + return ( +
+ +
+ ); + } + return ( + + ); + })} + {/* Processing indicator */} + {isProcessing && ( +
+
+
+
+
+ + Claude is thinking... + +
+ )} +
+ +
+ + {/* Prompt input - sticky at bottom */} +
+
+ { + if (text.trim() && canSendPrompt) { + sendPrompt(text.trim()); + setPromptInput(""); + } + }} + className="rounded-xl" + > + { + setPromptInput(e.target.value); + if (e.target.value.length > 0) { + sendTyping(); + } + }} + placeholder={ + !isConnected + ? "Connecting to cloud workspace..." + : isSpawning + ? "Starting sandbox..." + : sessionState?.sandboxStatus === "syncing" + ? "Syncing repository..." + : !isSandboxReady + ? "Waiting for sandbox..." + : isProcessing + ? "Processing..." + : "What do you want to build?" + } + disabled={!canSendPrompt} + className="min-h-12" + /> + + + + + +
+
+
+
+ ); +} + +interface EventItemProps { + event: CloudEvent; +} + +function EventItem({ event }: EventItemProps) { + const getEventContent = () => { + switch (event.type) { + case "token": { + const data = event.data as { token?: string }; + return ( + + {data.token} + + ); + } + + case "tool_result": { + const data = event.data as { result?: unknown; error?: string }; + return ( +
+ {data.error ? ( + + + + ) : ( + + + + )} +
+ ); + } + + case "error": { + const data = event.data as { message?: string }; + return ( +
+ +

{data.message || "Unknown error"}

+
+ ); + } + + case "git_sync": { + const data = event.data as { + status?: string; + action?: string; + branch?: string; + repo?: string; + }; + const action = data.status || data.action || "syncing"; + const detail = data.branch || data.repo || ""; + return ( +
+ + + {action} + {detail ? `: ${detail}` : ""} + +
+ ); + } + + case "execution_complete": { + return ( +
+ + Complete +
+ ); + } + + case "heartbeat": + case "tool_call": + // tool_call is handled by ToolCallGroup + return null; + + default: + return ( + + + + ); + } + }; + + // Don't render heartbeat or tool_call events (tool_call handled separately) + if (event.type === "heartbeat" || event.type === "tool_call") { + return null; + } + + return
{getEventContent()}
; +} + +function UserMessage({ content }: { content: string }) { + return ( + + +

{content}

+
+
+ ); +} + +function AssistantMessage({ text }: { text: string }) { + return ( + + + + {text} + + + + ); +} diff --git a/apps/web/src/app/cloud/[sessionId]/components/CloudWorkspaceContent/index.ts b/apps/web/src/app/cloud/[sessionId]/components/CloudWorkspaceContent/index.ts new file mode 100644 index 00000000000..5bdbce71cf5 --- /dev/null +++ b/apps/web/src/app/cloud/[sessionId]/components/CloudWorkspaceContent/index.ts @@ -0,0 +1 @@ +export { CloudWorkspaceContent } from "./CloudWorkspaceContent"; diff --git a/apps/web/src/app/cloud/[sessionId]/components/CloudWorkspaceHeader/CloudWorkspaceHeader.tsx b/apps/web/src/app/cloud/[sessionId]/components/CloudWorkspaceHeader/CloudWorkspaceHeader.tsx new file mode 100644 index 00000000000..17136356aaf --- /dev/null +++ b/apps/web/src/app/cloud/[sessionId]/components/CloudWorkspaceHeader/CloudWorkspaceHeader.tsx @@ -0,0 +1,437 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@superset/ui/alert-dialog"; +import { Badge } from "@superset/ui/badge"; +import { Button } from "@superset/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { Input } from "@superset/ui/input"; +import { SidebarTrigger } from "@superset/ui/sidebar"; +import { useMutation } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { + LuArchive, + LuEllipsis, + LuExternalLink, + LuFile, + LuGitBranch, + LuGithub, + LuGitPullRequest, + LuGlobe, + LuLoader, + LuPencil, + LuWifi, + LuWifiOff, +} from "react-icons/lu"; + +import { useTRPC } from "@/trpc/react"; +import type { CloudWorkspace } from "../../../components/CloudSidebar"; +import type { + Artifact, + CloudSessionState, + FileChange, + ParticipantPresence, +} from "../../hooks"; + +interface CloudWorkspaceHeaderProps { + workspace: CloudWorkspace; + sessionState: CloudSessionState | null; + isConnected: boolean; + isConnecting: boolean; + isReconnecting: boolean; + reconnectAttempt: number; + isSpawning: boolean; + spawnAttempt: number; + maxSpawnAttempts: number; +} + +export function CloudWorkspaceHeader({ + workspace, + sessionState, + isConnected, + isConnecting, + isReconnecting, + reconnectAttempt, + isSpawning, + spawnAttempt, + maxSpawnAttempts, +}: CloudWorkspaceHeaderProps) { + const trpc = useTRPC(); + const router = useRouter(); + + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [editedTitle, setEditedTitle] = useState(workspace.title); + const [isMounted, setIsMounted] = useState(false); + const [showArchiveDialog, setShowArchiveDialog] = useState(false); + const titleInputRef = useRef(null); + + // Track hydration to avoid Radix ID mismatch + useEffect(() => { + setIsMounted(true); + }, []); + + // Update title mutation + const updateMutation = useMutation( + trpc.cloudWorkspace.update.mutationOptions({ + onSuccess: () => { + setIsEditingTitle(false); + router.refresh(); + }, + }), + ); + + // Archive mutation + const archiveMutation = useMutation( + trpc.cloudWorkspace.archive.mutationOptions({ + onSuccess: () => { + router.push("/cloud"); + }, + }), + ); + + const handleTitleSave = useCallback(() => { + if (editedTitle.trim() && editedTitle !== workspace.title) { + updateMutation.mutate({ id: workspace.id, title: editedTitle.trim() }); + } else { + setIsEditingTitle(false); + setEditedTitle(workspace.title); + } + }, [editedTitle, workspace.title, workspace.id, updateMutation]); + + const handleTitleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleTitleSave(); + } else if (e.key === "Escape") { + setIsEditingTitle(false); + setEditedTitle(workspace.title); + } + }, + [handleTitleSave, workspace.title], + ); + + const handleArchive = useCallback(() => { + archiveMutation.mutate({ id: workspace.id }); + }, [archiveMutation, workspace.id]); + + // Focus title input when editing starts + useEffect(() => { + if (isEditingTitle && titleInputRef.current) { + titleInputRef.current.focus(); + titleInputRef.current.select(); + } + }, [isEditingTitle]); + + return ( + <> +
+ +
+ {isEditingTitle ? ( +
+ setEditedTitle(e.target.value)} + onKeyDown={handleTitleKeyDown} + onBlur={handleTitleSave} + className="h-7 text-sm font-semibold" + disabled={updateMutation.isPending} + /> + {updateMutation.isPending && ( + + )} +
+ ) : ( + + )} +
+ + + {workspace.repoOwner}/{workspace.repoName} + + + {workspace.branch} +
+
+
+ {/* Connection status */} + + {isConnecting || isReconnecting ? ( + + ) : isConnected ? ( + + ) : ( + + )} + {isReconnecting + ? `Reconnecting (${reconnectAttempt}/5)...` + : isConnecting + ? "Connecting..." + : isConnected + ? "Connected" + : "Disconnected"} + + {workspace.status} + {(sessionState?.sandboxStatus || + workspace.sandboxStatus || + isSpawning) && ( + + {(isSpawning || + sessionState?.sandboxStatus === "warming" || + sessionState?.sandboxStatus === "syncing") && ( + + )} + {isSpawning + ? spawnAttempt > 0 + ? `Spawning (${spawnAttempt + 1}/${maxSpawnAttempts})...` + : "Spawning..." + : sessionState?.sandboxStatus === "warming" + ? "Warming..." + : sessionState?.sandboxStatus || workspace.sandboxStatus} + + )} + {/* Artifacts - PR and Preview links */} + {sessionState?.artifacts && sessionState.artifacts.length > 0 && ( +
+ {sessionState.artifacts.map((artifact) => ( + + ))} +
+ )} + {/* Files changed indicator */} + {sessionState?.filesChanged && + sessionState.filesChanged.length > 0 && + isMounted && ( + + )} + {/* Participant avatars */} + {sessionState?.participants && + sessionState.participants.length > 0 && ( + + )} + {/* Session menu - only render after hydration to avoid Radix ID mismatch */} + {isMounted ? ( + + + + + + setIsEditingTitle(true)}> + + Rename + + + setShowArchiveDialog(true)} + className="text-destructive focus:text-destructive" + > + + Archive Session + + + + ) : ( + + )} +
+
+ + {/* Archive Confirmation Dialog */} + + + + Archive this session? + + This will archive the session and stop the cloud sandbox. You can + view and restore archived sessions from the home page. + + + + Cancel + + {archiveMutation.isPending ? ( + <> + + Archiving... + + ) : ( + "Archive" + )} + + + + + + ); +} + +function ArtifactButton({ artifact }: { artifact: Artifact }) { + if (!artifact.url) return null; + + const getIcon = () => { + switch (artifact.type) { + case "pr": + return ; + case "preview": + return ; + default: + return ; + } + }; + + const getLabel = () => { + switch (artifact.type) { + case "pr": + return artifact.title || "PR"; + case "preview": + return "Preview"; + default: + return artifact.title || "Link"; + } + }; + + return ( + + ); +} + +function ParticipantAvatars({ + participants, +}: { + participants: ParticipantPresence[]; +}) { + const onlineParticipants = participants.filter((p) => p.isOnline); + const offlineParticipants = participants.filter((p) => !p.isOnline); + + // Show up to 3 online avatars, then +N + const visibleOnline = onlineParticipants.slice(0, 3); + const remainingCount = + onlineParticipants.length - 3 + offlineParticipants.length; + + if (participants.length === 0) return null; + + return ( +
+ {visibleOnline.map((p) => ( +
+ {p.avatarUrl ? ( + {p.userName} + ) : ( +
+ {p.userName.charAt(0).toUpperCase()} +
+ )} + +
+ ))} + {remainingCount > 0 && ( +
1 ? "s" : ""}`} + > + +{remainingCount} +
+ )} +
+ ); +} + +function FilesChangedDropdown({ files }: { files: FileChange[] }) { + const getFileIcon = (type: FileChange["type"]) => { + switch (type) { + case "added": + return +; + case "modified": + return ~; + case "deleted": + return -; + default: + return ; + } + }; + + const getFileName = (path: string) => { + const parts = path.split("/"); + return parts[parts.length - 1]; + }; + + return ( + + + + + + {files.slice(0, 20).map((file) => ( + + {getFileIcon(file.type)} + {getFileName(file.path)} + + ))} + {files.length > 20 && ( +
+ +{files.length - 20} more files +
+ )} +
+
+ ); +} diff --git a/apps/web/src/app/cloud/[sessionId]/components/CloudWorkspaceHeader/index.ts b/apps/web/src/app/cloud/[sessionId]/components/CloudWorkspaceHeader/index.ts new file mode 100644 index 00000000000..8781afe8391 --- /dev/null +++ b/apps/web/src/app/cloud/[sessionId]/components/CloudWorkspaceHeader/index.ts @@ -0,0 +1 @@ +export { CloudWorkspaceHeader } from "./CloudWorkspaceHeader"; diff --git a/apps/web/src/app/cloud/[sessionId]/components/ToolCallGroup/ToolCallGroup.tsx b/apps/web/src/app/cloud/[sessionId]/components/ToolCallGroup/ToolCallGroup.tsx new file mode 100644 index 00000000000..0ff8632809e --- /dev/null +++ b/apps/web/src/app/cloud/[sessionId]/components/ToolCallGroup/ToolCallGroup.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { Tool, ToolContent } from "@superset/ui/ai-elements/tool"; +import { Badge } from "@superset/ui/badge"; +import { CollapsibleTrigger } from "@superset/ui/collapsible"; +import { cn } from "@superset/ui/utils"; +import { ChevronDownIcon } from "lucide-react"; +import { useState } from "react"; + +import type { CloudEvent } from "../../hooks"; +import { formatToolGroup } from "../../lib/tool-formatters"; +import { ToolCallItem } from "../ToolCallItem"; +import { ToolIcon } from "../ToolIcon"; + +interface ToolCallGroupProps { + events: CloudEvent[]; + groupId: string; +} + +export function ToolCallGroup({ events, groupId }: ToolCallGroupProps) { + const [isExpanded, setIsExpanded] = useState(false); + const [expandedItems, setExpandedItems] = useState>(new Set()); + + const firstEvent = events[0]; + if (!firstEvent) { + return null; + } + + const formatted = formatToolGroup(events); + + const time = new Date(firstEvent.timestamp).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + + const toggleItem = (itemId: string) => { + setExpandedItems((prev) => { + const newSet = new Set(prev); + if (newSet.has(itemId)) { + newSet.delete(itemId); + } else { + newSet.add(itemId); + } + return newSet; + }); + }; + + // For single tool call, render directly without group wrapper + if (events.length === 1) { + return ( + toggleItem(`${groupId}-0`)} + /> + ); + } + + return ( + + +
+ + + {formatted.toolName} + + + {formatted.count} + + + {formatted.summary} + +
+
+ + {time} + + +
+
+ + +
+ {events.map((event, index) => ( + toggleItem(`${groupId}-${index}`)} + showTime={false} + /> + ))} +
+
+
+ ); +} diff --git a/apps/web/src/app/cloud/[sessionId]/components/ToolCallGroup/index.ts b/apps/web/src/app/cloud/[sessionId]/components/ToolCallGroup/index.ts new file mode 100644 index 00000000000..d1f52a21aa6 --- /dev/null +++ b/apps/web/src/app/cloud/[sessionId]/components/ToolCallGroup/index.ts @@ -0,0 +1 @@ +export { ToolCallGroup } from "./ToolCallGroup"; diff --git a/apps/web/src/app/cloud/[sessionId]/components/ToolCallItem/ToolCallItem.tsx b/apps/web/src/app/cloud/[sessionId]/components/ToolCallItem/ToolCallItem.tsx new file mode 100644 index 00000000000..8aa68da9f8d --- /dev/null +++ b/apps/web/src/app/cloud/[sessionId]/components/ToolCallItem/ToolCallItem.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { + CodeBlock, + CodeBlockCopyButton, +} from "@superset/ui/ai-elements/code-block"; +import { Tool, ToolContent } from "@superset/ui/ai-elements/tool"; +import { Badge } from "@superset/ui/badge"; +import { CollapsibleTrigger } from "@superset/ui/collapsible"; +import { cn } from "@superset/ui/utils"; +import { + CheckCircleIcon, + ChevronDownIcon, + CircleIcon, + ClockIcon, +} from "lucide-react"; + +import type { CloudEvent } from "../../hooks"; +import { formatToolCall } from "../../lib/tool-formatters"; +import { ToolIcon } from "../ToolIcon"; + +interface ToolCallItemProps { + event: CloudEvent; + isExpanded: boolean; + onToggle: () => void; + showTime?: boolean; + isPending?: boolean; +} + +export function ToolCallItem({ + event, + isExpanded, + onToggle, + showTime = true, + isPending = false, +}: ToolCallItemProps) { + const formatted = formatToolCall(event); + const time = new Date(event.timestamp).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + + const { args, output } = formatted.getDetails(); + // Check for explicit error field in event data first, then fall back to string patterns + const eventData = event.data as { error?: string } | undefined; + const hasExplicitError = Boolean(eventData?.error); + // Only use string matching as fallback, and look for actual error patterns + const hasErrorPattern = + output?.startsWith("Error:") || output?.startsWith("error:"); + const hasError = hasExplicitError || hasErrorPattern; + + const getStatusBadge = () => { + if (isPending) { + return ( + + + Running + + ); + } + if (hasError) { + return ( + + + Error + + ); + } + if (output) { + return ( + + + Done + + ); + } + return null; + }; + + return ( + + +
+ + + {formatted.toolName} + + + {formatted.summary} + +
+
+ {getStatusBadge()} + {showTime && !isPending && ( + + {time} + + )} + +
+
+ + +
+ {args && Object.keys(args).length > 0 && ( +
+

+ Arguments +

+ + + +
+ )} + {output && ( +
+

+ {hasError ? "Error" : "Output"} +

+ + + +
+ )} + {!args && !output && ( +

+ No details available +

+ )} +
+
+
+ ); +} diff --git a/apps/web/src/app/cloud/[sessionId]/components/ToolCallItem/index.ts b/apps/web/src/app/cloud/[sessionId]/components/ToolCallItem/index.ts new file mode 100644 index 00000000000..5ed5cc554c4 --- /dev/null +++ b/apps/web/src/app/cloud/[sessionId]/components/ToolCallItem/index.ts @@ -0,0 +1 @@ +export { ToolCallItem } from "./ToolCallItem"; diff --git a/apps/web/src/app/cloud/[sessionId]/components/ToolIcon/ToolIcon.tsx b/apps/web/src/app/cloud/[sessionId]/components/ToolIcon/ToolIcon.tsx new file mode 100644 index 00000000000..6c02d77cc81 --- /dev/null +++ b/apps/web/src/app/cloud/[sessionId]/components/ToolIcon/ToolIcon.tsx @@ -0,0 +1,161 @@ +import type { ToolIconType } from "../../lib/tool-formatters"; + +interface ToolIconProps { + name: ToolIconType; + className?: string; +} + +export function ToolIcon({ name, className = "size-3.5" }: ToolIconProps) { + if (!name) return null; + + const iconClass = `${className} text-muted-foreground`; + + switch (name) { + case "file": + return ( + + + + ); + case "pencil": + return ( + + + + ); + case "plus": + return ( + + + + ); + case "terminal": + return ( + + + + ); + case "search": + return ( + + + + ); + case "folder": + return ( + + + + ); + case "box": + return ( + + + + ); + case "globe": + return ( + + + + ); + case "list": + return ( + + + + ); + default: + return null; + } +} diff --git a/apps/web/src/app/cloud/[sessionId]/components/ToolIcon/index.ts b/apps/web/src/app/cloud/[sessionId]/components/ToolIcon/index.ts new file mode 100644 index 00000000000..b01955c2345 --- /dev/null +++ b/apps/web/src/app/cloud/[sessionId]/components/ToolIcon/index.ts @@ -0,0 +1 @@ +export { ToolIcon } from "./ToolIcon"; diff --git a/apps/web/src/app/cloud/[sessionId]/hooks/index.ts b/apps/web/src/app/cloud/[sessionId]/hooks/index.ts new file mode 100644 index 00000000000..cd9d1b47946 --- /dev/null +++ b/apps/web/src/app/cloud/[sessionId]/hooks/index.ts @@ -0,0 +1,9 @@ +export type { + Artifact, + ArtifactType, + CloudEvent, + CloudSessionState, + FileChange, + ParticipantPresence, +} from "./useCloudSession"; +export { useCloudSession } from "./useCloudSession"; diff --git a/apps/web/src/app/cloud/[sessionId]/hooks/useCloudSession.ts b/apps/web/src/app/cloud/[sessionId]/hooks/useCloudSession.ts new file mode 100644 index 00000000000..70afc26ba99 --- /dev/null +++ b/apps/web/src/app/cloud/[sessionId]/hooks/useCloudSession.ts @@ -0,0 +1,689 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface CloudEvent { + id: string; + type: + | "tool_call" + | "tool_result" + | "token" + | "error" + | "git_sync" + | "execution_complete" + | "heartbeat" + | "user_message"; + timestamp: number; + data: unknown; + messageId?: string; +} + +export interface HistoricalMessage { + id: string; + content: string; + role: string; + status: string; + participantId: string | null; + createdAt: number; + completedAt: number | null; +} + +export type ArtifactType = "pr" | "preview" | "screenshot" | "file"; + +export interface Artifact { + id: string; + type: ArtifactType; + url: string | null; + title: string | null; + description: string | null; + metadata: Record | null; + status: "active" | "deleted"; + createdAt: number; + updatedAt: number; +} + +export interface FileChange { + path: string; + type: "added" | "modified" | "deleted"; + lastModified: number; +} + +export interface ParticipantPresence { + id: string; + userId: string; + userName: string; + avatarUrl?: string; + source: "web" | "desktop" | "slack"; + isOnline: boolean; + lastSeenAt: number; +} + +export interface CloudSessionState { + sessionId: string; + status: string; + sandboxStatus: string; + repoOwner: string; + repoName: string; + branch: string; + baseBranch: string; + model: string; + participants: ParticipantPresence[]; + artifacts: Artifact[]; + filesChanged: FileChange[]; + messageCount: number; + eventCount: number; +} + +interface UseCloudSessionOptions { + controlPlaneUrl: string; + sessionId: string; + authToken?: string; +} + +interface PendingPrompt { + content: string; + timestamp: number; +} + +interface UseCloudSessionReturn { + isConnected: boolean; + isConnecting: boolean; + isReconnecting: boolean; + reconnectAttempt: number; + isLoadingHistory: boolean; + isSpawning: boolean; + isProcessing: boolean; + isSandboxReady: boolean; + isControlPlaneAvailable: boolean; + spawnAttempt: number; + maxSpawnAttempts: number; + error: string | null; + sessionState: CloudSessionState | null; + events: CloudEvent[]; + pendingPrompts: PendingPrompt[]; + sendPrompt: (content: string) => void; + sendStop: () => void; + sendTyping: () => void; + spawnSandbox: () => Promise; + connect: () => void; + disconnect: () => void; + clearError: () => void; +} + +export function useCloudSession({ + controlPlaneUrl, + sessionId, + authToken, +}: UseCloudSessionOptions): UseCloudSessionReturn { + // Connection state + const [isConnected, setIsConnected] = useState(false); + const [isConnecting, setIsConnecting] = useState(false); + const [isReconnecting, setIsReconnecting] = useState(false); + const [reconnectAttempt, setReconnectAttempt] = useState(0); + const [isControlPlaneAvailable, setIsControlPlaneAvailable] = useState(true); + const [error, setError] = useState(null); + + // Session state + const [sessionState, setSessionState] = useState( + null, + ); + const [events, setEvents] = useState([]); + const [pendingPrompts, setPendingPrompts] = useState([]); + + // Loading states + const [isLoadingHistory, setIsLoadingHistory] = useState(true); + const [isSpawning, setIsSpawning] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const [spawnAttempt, setSpawnAttempt] = useState(0); + + // Computed + const isSandboxReady = + sessionState?.sandboxStatus === "ready" || + sessionState?.sandboxStatus === "running"; + + // Refs + const wsRef = useRef(null); + const pingIntervalRef = useRef(null); + const reconnectTimeoutRef = useRef(null); + const spawnRetryTimeoutRef = useRef(null); + const reconnectAttempts = useRef(0); + const spawnAttempts = useRef(0); + const isCleaningUp = useRef(false); + const hasAttemptedSpawn = useRef(false); + const seenEventIds = useRef>(new Set()); + const typingDebounceRef = useRef(null); + const hasTypedRef = useRef(false); + + // Config ref to avoid dependency changes + const configRef = useRef({ controlPlaneUrl, sessionId, authToken }); + configRef.current = { controlPlaneUrl, sessionId, authToken }; + + const maxReconnectAttempts = 5; + const maxSpawnAttempts = 3; + + const cleanup = useCallback(() => { + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; + } + if (reconnectTimeoutRef.current) { + clearTimeout(reconnectTimeoutRef.current); + reconnectTimeoutRef.current = null; + } + if (spawnRetryTimeoutRef.current) { + clearTimeout(spawnRetryTimeoutRef.current); + spawnRetryTimeoutRef.current = null; + } + }, []); + + const clearError = useCallback(() => { + setError(null); + }, []); + + // Simple message handler + const handleMessage = useCallback( + (message: { + type: string; + sessionId?: string; + state?: CloudSessionState; + event?: CloudEvent; + messages?: HistoricalMessage[]; + artifacts?: Artifact[]; + participants?: ParticipantPresence[]; + action?: "join" | "leave" | "idle" | "active"; + participant?: ParticipantPresence; + message?: string; + messageId?: string; + status?: "forwarded" | "queued" | "sent" | "failed"; + }) => { + switch (message.type) { + case "subscribed": + console.log( + "[cloud-session] Subscribed, sandboxStatus:", + message.state?.sandboxStatus, + ); + if (message.state) { + setSessionState(message.state); + } + break; + + case "history": + // Load historical messages + if (message.messages && message.messages.length > 0) { + const sortedMessages = [...message.messages].sort( + (a, b) => a.createdAt - b.createdAt, + ); + const historicalEvents: CloudEvent[] = sortedMessages.map((m) => ({ + id: m.id, + type: + m.role === "user" + ? ("user_message" as const) + : ("token" as const), + timestamp: m.createdAt, + data: + m.role === "user" + ? { content: m.content } + : { content: m.content }, + messageId: m.id, + })); + + for (const e of historicalEvents) { + seenEventIds.current.add(e.id); + } + + setEvents(historicalEvents); + } + setIsLoadingHistory(false); + break; + + case "event": + if (message.event) { + const event = message.event; + + // Skip duplicates + if (seenEventIds.current.has(event.id)) { + // Still handle execution_complete for state + if (event.type === "execution_complete") { + setIsProcessing(false); + } + return; + } + + seenEventIds.current.add(event.id); + + // Add event to list (skip heartbeats from display) + if (event.type !== "heartbeat") { + setEvents((prev) => [...prev, event]); + } + + // Handle processing state + if (event.type === "execution_complete" || event.type === "error") { + setIsProcessing(false); + } + } + break; + + case "state_update": + if (message.state) { + setSessionState((prev) => + prev + ? { ...prev, ...message.state } + : (message.state as CloudSessionState), + ); + } + break; + + case "artifacts_update": + if (message.artifacts) { + setSessionState((prev) => + prev + ? { ...prev, artifacts: message.artifacts as Artifact[] } + : null, + ); + } + break; + + case "presence_sync": + if (message.participants) { + setSessionState((prev) => + prev + ? { + ...prev, + participants: message.participants as ParticipantPresence[], + } + : null, + ); + } + break; + + case "presence_update": + if (message.participant && message.action) { + setSessionState((prev) => { + if (!prev) return null; + const participant = message.participant as ParticipantPresence; + const action = message.action; + + if (action === "join") { + const exists = prev.participants.some( + (p) => p.id === participant.id, + ); + if (exists) { + return { + ...prev, + participants: prev.participants.map((p) => + p.id === participant.id + ? { ...participant, isOnline: true } + : p, + ), + }; + } + return { + ...prev, + participants: [ + ...prev.participants, + { ...participant, isOnline: true }, + ], + }; + } + + if (action === "leave") { + return { + ...prev, + participants: prev.participants.map((p) => + p.id === participant.id + ? { + ...p, + isOnline: false, + lastSeenAt: participant.lastSeenAt, + } + : p, + ), + }; + } + + return prev; + }); + } + break; + + case "error": + setError(message.message || "Unknown error"); + setIsLoadingHistory(false); + setIsProcessing(false); + break; + + case "prompt_ack": + if (message.status === "queued") { + // Sandbox not ready, message queued + console.log("[cloud-session] Prompt queued:", message.messageId); + if (message.message) { + setError(message.message); + } + } else if (message.status === "forwarded") { + // Prompt sent to sandbox, keep processing true + console.log("[cloud-session] Prompt forwarded:", message.messageId); + } + break; + + case "pong": + break; + } + }, + [], + ); + + const connectInternal = useCallback(() => { + if (isCleaningUp.current) return; + if ( + wsRef.current?.readyState === WebSocket.OPEN || + wsRef.current?.readyState === WebSocket.CONNECTING + ) { + return; + } + + const { controlPlaneUrl, sessionId, authToken } = configRef.current; + + setIsConnecting(true); + setError(null); + + const wsUrl = controlPlaneUrl + .replace("https://", "wss://") + .replace("http://", "ws://"); + const url = `${wsUrl}/api/sessions/${sessionId}/ws`; + + try { + const ws = new WebSocket(url); + wsRef.current = ws; + + ws.onopen = () => { + if (isCleaningUp.current) { + ws.close(); + return; + } + + setIsConnecting(false); + setIsReconnecting(false); + setReconnectAttempt(0); + setIsConnected(true); + setIsControlPlaneAvailable(true); + reconnectAttempts.current = 0; + + ws.send(JSON.stringify({ type: "subscribe", token: authToken || "" })); + + pingIntervalRef.current = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "ping" })); + } + }, 30000); + }; + + ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data as string); + handleMessage(message); + } catch (e) { + console.error("[cloud-session] Failed to parse message:", e); + } + }; + + ws.onclose = () => { + cleanup(); + setIsConnected(false); + wsRef.current = null; + + if (isCleaningUp.current) { + setIsReconnecting(false); + setReconnectAttempt(0); + return; + } + + if (reconnectAttempts.current < maxReconnectAttempts) { + reconnectAttempts.current++; + setIsReconnecting(true); + setReconnectAttempt(reconnectAttempts.current); + const delay = 1000 * 2 ** (reconnectAttempts.current - 1); + reconnectTimeoutRef.current = setTimeout(() => { + connectInternal(); + }, delay); + } else { + setIsReconnecting(false); + setIsControlPlaneAvailable(false); + setError("Connection lost. Control plane may be unavailable."); + } + }; + + ws.onerror = () => { + setError("WebSocket connection error"); + setIsConnecting(false); + }; + } catch (_e) { + setError("Failed to create WebSocket connection"); + setIsConnecting(false); + } + }, [cleanup, handleMessage]); + + const disconnectInternal = useCallback(() => { + isCleaningUp.current = true; + cleanup(); + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + setIsConnected(false); + reconnectAttempts.current = maxReconnectAttempts; + }, [cleanup]); + + const connect = useCallback(() => { + isCleaningUp.current = false; + reconnectAttempts.current = 0; + connectInternal(); + }, [connectInternal]); + + const disconnect = useCallback(() => { + disconnectInternal(); + }, [disconnectInternal]); + + const sendPrompt = useCallback((content: string) => { + // Add user message optimistically + const localId = `local-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + const userEvent: CloudEvent = { + id: localId, + type: "user_message", + timestamp: Date.now(), + data: { content }, + }; + + seenEventIds.current.add(localId); + setEvents((prev) => [...prev, userEvent]); + setIsProcessing(true); + + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send( + JSON.stringify({ + type: "prompt", + content, + authorId: "web-user", + }), + ); + } else { + setPendingPrompts((prev) => [ + ...prev, + { content, timestamp: Date.now() }, + ]); + } + }, []); + + const sendStop = useCallback(() => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: "stop" })); + setIsProcessing(false); + } + }, []); + + const sendTyping = useCallback(() => { + if (hasTypedRef.current || isSandboxReady) return; + + if (typingDebounceRef.current) { + clearTimeout(typingDebounceRef.current); + } + + typingDebounceRef.current = setTimeout(() => { + if ( + wsRef.current?.readyState === WebSocket.OPEN && + !hasTypedRef.current && + !isSandboxReady + ) { + hasTypedRef.current = true; + wsRef.current.send(JSON.stringify({ type: "typing" })); + } + }, 500); + }, [isSandboxReady]); + + const spawnSandbox = useCallback(async () => { + const { controlPlaneUrl, sessionId } = configRef.current; + + if (isSpawning) return; + + setIsSpawning(true); + setError(null); + + try { + const response = await fetch( + `${controlPlaneUrl}/api/sessions/${sessionId}/spawn-sandbox`, + { method: "POST", headers: { "Content-Type": "application/json" } }, + ); + + if (!response.ok) { + throw new Error("Failed to spawn sandbox"); + } + + spawnAttempts.current = 0; + setSpawnAttempt(0); + } catch (e) { + console.error("[cloud-session] Error spawning sandbox:", e); + spawnAttempts.current++; + setSpawnAttempt(spawnAttempts.current); + + if (spawnAttempts.current < maxSpawnAttempts) { + const delay = 2000 * 2 ** (spawnAttempts.current - 1); + spawnRetryTimeoutRef.current = setTimeout(() => { + setIsSpawning(false); + spawnSandbox(); + }, delay); + return; + } + + setError(`Failed to spawn sandbox after ${maxSpawnAttempts} attempts.`); + } + + setIsSpawning(false); + }, [isSpawning]); + + // Auto-spawn when needed + useEffect(() => { + const status = sessionState?.sandboxStatus; + const needsSpawn = + status === "stopped" || status === "pending" || status === "failed"; + const isActive = + status === "warming" || + status === "syncing" || + status === "ready" || + status === "running"; + + if (isActive) return; + + if ( + isConnected && + needsSpawn && + !hasAttemptedSpawn.current && + !isSpawning + ) { + hasAttemptedSpawn.current = true; + spawnSandbox(); + } + }, [isConnected, sessionState?.sandboxStatus, isSpawning, spawnSandbox]); + + // Reset on session change + useEffect(() => { + hasAttemptedSpawn.current = false; + hasTypedRef.current = false; + spawnAttempts.current = 0; + setSpawnAttempt(0); + seenEventIds.current.clear(); + setEvents([]); + setIsProcessing(false); + setIsLoadingHistory(true); + if (typingDebounceRef.current) { + clearTimeout(typingDebounceRef.current); + typingDebounceRef.current = null; + } + }, []); + + // Reset typing state when sandbox ready + useEffect(() => { + if (isSandboxReady) { + hasTypedRef.current = false; + } + }, [isSandboxReady]); + + // Send pending prompts + useEffect(() => { + if ( + isConnected && + isSandboxReady && + pendingPrompts.length > 0 && + wsRef.current?.readyState === WebSocket.OPEN + ) { + const [nextPrompt, ...remaining] = pendingPrompts; + if (nextPrompt) { + setIsProcessing(true); + wsRef.current.send( + JSON.stringify({ + type: "prompt", + content: nextPrompt.content, + authorId: "web-user", + }), + ); + setPendingPrompts(remaining); + } + } + }, [isConnected, isSandboxReady, pendingPrompts]); + + // Auto-connect + useEffect(() => { + if (controlPlaneUrl && sessionId) { + isCleaningUp.current = false; + reconnectAttempts.current = 0; + connectInternal(); + } + + return () => { + disconnectInternal(); + }; + }, [controlPlaneUrl, sessionId, connectInternal, disconnectInternal]); + + return { + isConnected, + isConnecting, + isReconnecting, + reconnectAttempt, + isLoadingHistory, + isSpawning, + isProcessing, + isSandboxReady, + isControlPlaneAvailable, + spawnAttempt, + maxSpawnAttempts, + error, + sessionState, + events, + pendingPrompts, + sendPrompt, + sendStop, + sendTyping, + spawnSandbox, + connect, + disconnect, + clearError, + }; +} diff --git a/apps/web/src/app/cloud/[sessionId]/lib/tool-formatters.ts b/apps/web/src/app/cloud/[sessionId]/lib/tool-formatters.ts new file mode 100644 index 00000000000..4846daf57fa --- /dev/null +++ b/apps/web/src/app/cloud/[sessionId]/lib/tool-formatters.ts @@ -0,0 +1,319 @@ +import type { CloudEvent } from "../hooks"; + +/** + * Extract just the filename from a file path + */ +function basename(filePath: string | undefined): string { + if (!filePath) return "unknown"; + const parts = filePath.split("/"); + return parts[parts.length - 1] || filePath; +} + +/** + * Truncate a string to a maximum length with ellipsis + */ +function truncate(str: string | undefined, maxLen: number): string { + if (!str) return ""; + if (str.length <= maxLen) return str; + return `${str.slice(0, maxLen)}...`; +} + +/** + * Count lines in a string + */ +function countLines(str: string | undefined): number { + if (!str) return 0; + return str.split("\n").length; +} + +export type ToolIconType = + | "file" + | "pencil" + | "plus" + | "terminal" + | "search" + | "folder" + | "globe" + | "box" + | "list" + | null; + +export interface FormattedToolCall { + toolName: string; + summary: string; + icon: ToolIconType; + getDetails: () => { args?: Record; output?: string }; +} + +/** + * Format a tool call event for compact display + */ +export function formatToolCall(event: CloudEvent): FormattedToolCall { + const data = event.data as { + name?: string; + input?: Record; + result?: unknown; + error?: string; + }; + + const toolName = data.name || "Unknown"; + const args = data.input; + const output = + typeof data.result === "string" + ? data.result + : data.result + ? JSON.stringify(data.result, null, 2) + : data.error; + + switch (toolName) { + case "Read": { + const filePath = args?.file_path as string | undefined; + const lineCount = countLines(output); + return { + toolName: "Read", + summary: filePath + ? `${basename(filePath)}${lineCount > 0 ? ` (${lineCount} lines)` : ""}` + : "file", + icon: "file", + getDetails: () => ({ args, output }), + }; + } + + case "Edit": { + const filePath = args?.file_path as string | undefined; + return { + toolName: "Edit", + summary: filePath ? basename(filePath) : "file", + icon: "pencil", + getDetails: () => ({ args, output }), + }; + } + + case "Write": { + const filePath = args?.file_path as string | undefined; + return { + toolName: "Write", + summary: filePath ? basename(filePath) : "file", + icon: "plus", + getDetails: () => ({ args, output }), + }; + } + + case "Bash": { + const command = args?.command as string | undefined; + return { + toolName: "Bash", + summary: truncate(command, 50), + icon: "terminal", + getDetails: () => ({ args, output }), + }; + } + + case "Grep": { + const pattern = args?.pattern as string | undefined; + const matchCount = output ? countLines(output) : 0; + return { + toolName: "Grep", + summary: pattern + ? `"${truncate(pattern, 30)}"${matchCount > 0 ? ` (${matchCount} matches)` : ""}` + : "search", + icon: "search", + getDetails: () => ({ args, output }), + }; + } + + case "Glob": { + const pattern = args?.pattern as string | undefined; + const fileCount = output ? countLines(output) : 0; + return { + toolName: "Glob", + summary: pattern + ? `${truncate(pattern, 30)}${fileCount > 0 ? ` (${fileCount} files)` : ""}` + : "search", + icon: "folder", + getDetails: () => ({ args, output }), + }; + } + + case "Task": { + const description = args?.description as string | undefined; + const prompt = args?.prompt as string | undefined; + return { + toolName: "Task", + summary: description + ? truncate(description, 40) + : prompt + ? truncate(prompt, 40) + : "task", + icon: "box", + getDetails: () => ({ args, output }), + }; + } + + case "WebFetch": { + const url = args?.url as string | undefined; + return { + toolName: "WebFetch", + summary: url ? truncate(url, 40) : "url", + icon: "globe", + getDetails: () => ({ args, output }), + }; + } + + case "WebSearch": { + const query = args?.query as string | undefined; + return { + toolName: "WebSearch", + summary: query ? `"${truncate(query, 40)}"` : "search", + icon: "search", + getDetails: () => ({ args, output }), + }; + } + + case "TodoWrite": { + const todos = args?.todos as unknown[] | undefined; + return { + toolName: "TodoWrite", + summary: todos + ? `${todos.length} item${todos.length === 1 ? "" : "s"}` + : "todos", + icon: "list", + getDetails: () => ({ args, output }), + }; + } + + default: + return { + toolName, + summary: + args && Object.keys(args).length > 0 + ? truncate(JSON.stringify(args), 50) + : "", + icon: null, + getDetails: () => ({ args, output }), + }; + } +} + +/** + * Get a compact summary for a group of tool calls + */ +export function formatToolGroup(events: CloudEvent[]): { + toolName: string; + count: number; + summary: string; + icon: ToolIconType; +} { + const firstEvent = events[0]; + if (!firstEvent) { + return { toolName: "Unknown", count: 0, summary: "", icon: null }; + } + + const firstData = firstEvent.data as { name?: string }; + const toolName = firstData.name || "Unknown"; + const count = events.length; + + switch (toolName) { + case "Read": + return { + toolName: "Read", + count, + summary: `${count} file${count === 1 ? "" : "s"}`, + icon: "file", + }; + + case "Edit": + return { + toolName: "Edit", + count, + summary: `${count} file${count === 1 ? "" : "s"}`, + icon: "pencil", + }; + + case "Write": + return { + toolName: "Write", + count, + summary: `${count} file${count === 1 ? "" : "s"}`, + icon: "plus", + }; + + case "Bash": + return { + toolName: "Bash", + count, + summary: `${count} command${count === 1 ? "" : "s"}`, + icon: "terminal", + }; + + case "Grep": + return { + toolName: "Grep", + count, + summary: `${count} search${count === 1 ? "" : "es"}`, + icon: "search", + }; + + case "Glob": + return { + toolName: "Glob", + count, + summary: `${count} pattern${count === 1 ? "" : "s"}`, + icon: "folder", + }; + + default: + return { + toolName, + count, + summary: `${count} call${count === 1 ? "" : "s"}`, + icon: null, + }; + } +} + +/** + * Group consecutive tool_call events of the same type + */ +export interface ToolCallGroupData { + id: string; + events: CloudEvent[]; + toolName: string; +} + +export function groupToolCalls(events: CloudEvent[]): ToolCallGroupData[] { + const groups: ToolCallGroupData[] = []; + let currentGroup: ToolCallGroupData | null = null; + + for (const event of events) { + if (event.type !== "tool_call") { + if (currentGroup) { + groups.push(currentGroup); + currentGroup = null; + } + continue; + } + + const data = event.data as { name?: string }; + const toolName = data.name || "Unknown"; + + if (currentGroup && currentGroup.toolName === toolName) { + currentGroup.events.push(event); + } else { + if (currentGroup) { + groups.push(currentGroup); + } + currentGroup = { + id: event.id, + events: [event], + toolName, + }; + } + } + + if (currentGroup) { + groups.push(currentGroup); + } + + return groups; +} diff --git a/apps/web/src/app/cloud/[sessionId]/page.tsx b/apps/web/src/app/cloud/[sessionId]/page.tsx new file mode 100644 index 00000000000..fa2f76e20f2 --- /dev/null +++ b/apps/web/src/app/cloud/[sessionId]/page.tsx @@ -0,0 +1,39 @@ +import { auth } from "@superset/auth/server"; +import { headers } from "next/headers"; +import { notFound, redirect } from "next/navigation"; + +import { api } from "@/trpc/server"; +import { CloudWorkspaceContent } from "./components/CloudWorkspaceContent"; + +interface CloudWorkspacePageProps { + params: Promise<{ sessionId: string }>; +} + +export default async function CloudWorkspacePage({ + params, +}: CloudWorkspacePageProps) { + const { sessionId } = await params; + + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session) { + redirect("/sign-in"); + } + + const trpc = await api(); + + try { + const [workspace, workspaces] = await Promise.all([ + trpc.cloudWorkspace.getBySessionId.query({ sessionId }), + trpc.cloudWorkspace.list.query(), + ]); + + return ( + + ); + } catch { + notFound(); + } +} diff --git a/apps/web/src/app/cloud/components/CloudHomePage/CloudHomePage.tsx b/apps/web/src/app/cloud/components/CloudHomePage/CloudHomePage.tsx new file mode 100644 index 00000000000..03672de8217 --- /dev/null +++ b/apps/web/src/app/cloud/components/CloudHomePage/CloudHomePage.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@superset/ui/sidebar"; + +import { CloudPromptInput } from "../CloudPromptInput"; +import { CloudSidebar, type CloudWorkspace } from "../CloudSidebar"; + +interface GitHubRepository { + id: string; + repoId: string; + installationId: string; + owner: string; + name: string; + fullName: string; + defaultBranch: string; + isPrivate: boolean; + createdAt: Date; + updatedAt: Date; +} + +interface CloudHomePageProps { + organizationId: string; + workspaces: CloudWorkspace[]; + hasGitHubInstallation: boolean; + githubRepositories: GitHubRepository[]; +} + +export function CloudHomePage({ + organizationId, + workspaces: initialWorkspaces, + hasGitHubInstallation, + githubRepositories, +}: CloudHomePageProps) { + // Calculate stats for the main content area + const activeWorkspacesCount = initialWorkspaces.filter((w) => { + const now = new Date(); + const diff = now.getTime() - new Date(w.updatedAt).getTime(); + const days = diff / (1000 * 60 * 60 * 24); + return days <= 7; + }).length; + + const thisWeekCount = initialWorkspaces.filter((w) => { + const diff = Date.now() - new Date(w.createdAt).getTime(); + return diff < 7 * 24 * 60 * 60 * 1000; + }).length; + + return ( + + + + +
+ +
+
+ {/* Centered prompt input */} + + + {/* Stats cards */} +
+ + + +
+
+ + {/* Footer */} +
+ + + {initialWorkspaces.length} cloud sessions + +
+
+
+ ); +} + +const SPARKLINE_HEIGHTS = [45, 65, 35, 80, 50, 70, 40, 85, 55, 75, 30, 60]; + +function StatsCard({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+ {/* Mini sparkline */} +
+ {SPARKLINE_HEIGHTS.map((height, i) => ( +
+ ))} +
+
+ ); +} diff --git a/apps/web/src/app/cloud/components/CloudHomePage/index.ts b/apps/web/src/app/cloud/components/CloudHomePage/index.ts new file mode 100644 index 00000000000..9089b2f3241 --- /dev/null +++ b/apps/web/src/app/cloud/components/CloudHomePage/index.ts @@ -0,0 +1 @@ +export { CloudHomePage } from "./CloudHomePage"; diff --git a/apps/web/src/app/cloud/components/CloudPromptInput/CloudPromptInput.tsx b/apps/web/src/app/cloud/components/CloudPromptInput/CloudPromptInput.tsx new file mode 100644 index 00000000000..bfdc693436c --- /dev/null +++ b/apps/web/src/app/cloud/components/CloudPromptInput/CloudPromptInput.tsx @@ -0,0 +1,373 @@ +"use client"; + +import { Button } from "@superset/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@superset/ui/dropdown-menu"; +import { cn } from "@superset/ui/utils"; +import { useMutation } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { LuChevronDown, LuGithub, LuLoader, LuLock } from "react-icons/lu"; + +import { env } from "@/env"; +import { useTRPC } from "@/trpc/react"; +import type { CloudWorkspace } from "../CloudSidebar"; + +interface GitHubRepository { + id: string; + repoId: string; + installationId: string; + owner: string; + name: string; + fullName: string; + defaultBranch: string; + isPrivate: boolean; + createdAt: Date; + updatedAt: Date; +} + +interface CloudPromptInputProps { + organizationId: string; + hasGitHubInstallation: boolean; + githubRepositories: GitHubRepository[]; + recentWorkspaces: CloudWorkspace[]; +} + +export function CloudPromptInput({ + organizationId, + hasGitHubInstallation, + githubRepositories, + recentWorkspaces, +}: CloudPromptInputProps) { + const trpc = useTRPC(); + const router = useRouter(); + const textareaRef = useRef(null); + const [promptInput, setPromptInput] = useState(""); + const [selectedRepo, setSelectedRepo] = useState( + null, + ); + const [selectedModel, setSelectedModel] = useState< + "claude-sonnet-4" | "claude-opus-4" | "claude-haiku-3-5" + >("claude-sonnet-4"); + const [error, setError] = useState(null); + + // Get recent repos (from recent workspaces) + const recentRepos = useMemo(() => { + const repoMap = new Map(); + for (const ws of recentWorkspaces.slice(0, 5)) { + const repo = githubRepositories.find( + (r) => r.owner === ws.repoOwner && r.name === ws.repoName, + ); + if (repo && !repoMap.has(repo.id)) { + repoMap.set(repo.id, repo); + } + } + return Array.from(repoMap.values()).slice(0, 3); + }, [recentWorkspaces, githubRepositories]); + + // Preselect the most recently used repo + useEffect(() => { + if (!selectedRepo && recentRepos.length > 0) { + setSelectedRepo(recentRepos[0] ?? null); + } + }, [recentRepos, selectedRepo]); + + const createMutation = useMutation( + trpc.cloudWorkspace.create.mutationOptions({ + onSuccess: (workspace) => { + if (workspace) { + const prompt = promptInput.trim(); + const url = prompt + ? `/cloud/${workspace.sessionId}?prompt=${encodeURIComponent(prompt)}` + : `/cloud/${workspace.sessionId}`; + router.push(url); + } + }, + onError: (err) => { + console.error("[CloudPromptInput] Create error:", err); + setError(err.message || "Failed to create session"); + }, + }), + ); + + const handleSubmit = () => { + if (!selectedRepo) return; + setError(null); + + const title = + promptInput.trim() || `${selectedRepo.owner}/${selectedRepo.name}`; + + createMutation.mutate({ + repoOwner: selectedRepo.owner, + repoName: selectedRepo.name, + title, + model: selectedModel, + baseBranch: selectedRepo.defaultBranch, + }); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if ( + e.key === "Enter" && + !e.shiftKey && + selectedRepo && + promptInput.trim() + ) { + e.preventDefault(); + handleSubmit(); + } + }; + + const modelLabels = { + "claude-sonnet-4": "Sonnet 4", + "claude-opus-4": "Opus 4", + "claude-haiku-3-5": "Haiku 3.5", + }; + + const canSubmit = + selectedRepo && promptInput.trim() && !createMutation.isPending; + + // Auto-resize textarea + const adjustTextareaHeight = useCallback(() => { + const textarea = textareaRef.current; + if (!textarea) return; + + // Reset height to auto to get the correct scrollHeight + textarea.style.height = "auto"; + // Set to scrollHeight, capped at max height (150px ~ 6 lines) + const maxHeight = 150; + textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`; + }, []); + + useEffect(() => { + adjustTextareaHeight(); + }, [adjustTextareaHeight]); + + // Show connect GitHub prompt if no installation + if (!hasGitHubInstallation) { + return ( +
+
+

+ Connect GitHub to create cloud sessions with your repositories +

+ +
+
+ ); + } + + return ( +
+ {error && ( +
{error}
+ )} + +
textareaRef.current?.focus()} + > + {/* Input area */} +
+