diff --git a/.env.example b/.env.example index 464107ea1a4..51ea90178ea 100644 --- a/.env.example +++ b/.env.example @@ -27,6 +27,12 @@ NEXT_PUBLIC_ADMIN_URL=http://localhost:3003 NEXT_PUBLIC_MARKETING_URL=http://localhost:3002 NEXT_PUBLIC_DOCS_URL=http://localhost:3004 +# ----------------------------------------------------------------------------- +# Streams Server (AI Chat) +# ----------------------------------------------------------------------------- +NEXT_PUBLIC_STREAMS_URL=http://localhost:8080 +EXPO_PUBLIC_STREAMS_URL=http://localhost:8080 + # ----------------------------------------------------------------------------- # Better Auth # ----------------------------------------------------------------------------- diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000000..041817dbb43 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Git LFS tracking for large binaries (darwin-arm64 only for now) +apps/desktop/resources/bin/darwin-arm64/claude filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index da7b0a9ec27..22247fb67cb 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,13 @@ next-env.d.ts # Reference material downloaded for agents examples + +# Streams data +apps/streams/data/ + +# Claude binaries - only darwin-arm64 tracked with Git LFS, others downloaded at build time +apps/desktop/resources/bin/VERSION +apps/desktop/resources/bin/darwin-x64/ +apps/desktop/resources/bin/linux-arm64/ +apps/desktop/resources/bin/linux-x64/ +apps/desktop/resources/bin/win32-x64/ diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index 9fb84f56634..1c422b379b4 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -56,6 +56,12 @@ const config: Configuration = { to: "resources/migrations", filter: ["**/*"], }, + // Claude Code binary - bundled for AI chat functionality + { + from: "resources/bin/${platform}-${arch}", + to: "bin", + filter: ["**/*"], + }, ], files: [ diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 912e8a51f34..9e3557b0513 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -144,6 +144,10 @@ export default defineConfig({ process.env.NEXT_PUBLIC_DOCS_URL, "https://docs.superset.sh", ), + "process.env.NEXT_PUBLIC_STREAMS_URL": defineEnv( + process.env.NEXT_PUBLIC_STREAMS_URL, + "http://localhost:8080", + ), "import.meta.env.DEV_SERVER_PORT": defineEnv(String(DEV_SERVER_PORT)), "import.meta.env.NEXT_PUBLIC_POSTHOG_KEY": defineEnv( process.env.NEXT_PUBLIC_POSTHOG_KEY, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 81cd2133661..0c04c4fb24a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -20,9 +20,11 @@ "dev": "cross-env NODE_ENV=development electron-vite dev --watch", "compile:app": "cross-env NODE_OPTIONS=--max-old-space-size=8192 electron-vite build", "copy:native-modules": "bun run scripts/copy-native-modules.ts", - "prebuild": "bun run clean:dev && bun run compile:app && bun run copy:native-modules", + "download:claude": "bun run scripts/download-claude-binary.ts", + "upgrade:claude": "bun run scripts/upgrade-claude-binary.ts", + "prebuild": "bun run clean:dev && bun run compile:app && bun run copy:native-modules && bun run download:claude", "build": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false electron-builder --publish never", - "prepackage": "bun run copy:native-modules", + "prepackage": "bun run copy:native-modules && bun run download:claude", "package": "electron-builder --config electron-builder.ts", "install:deps": "electron-builder install-app-deps", "release": "electron-builder --publish always", @@ -33,10 +35,12 @@ "test": "bun test" }, "dependencies": { + "@anthropic-ai/claude-agent-sdk": "^0.2.19", "@better-auth/stripe": "1.4.17", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@durable-streams/client": "^0.2.0", "@electric-sql/client": "https://pkg.pr.new/@electric-sql/client@3724", "@headless-tree/core": "^1.6.3", "@headless-tree/react": "^1.6.3", @@ -45,6 +49,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", "@sentry/electron": "^7.7.0", + "@superset/ai-chat": "workspace:*", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", "@superset/local-db": "workspace:*", @@ -52,9 +57,9 @@ "@superset/trpc": "workspace:*", "@superset/ui": "workspace:*", "@t3-oss/env-core": "^0.13.8", - "@tanstack/db": "0.5.22", - "@tanstack/electric-db-collection": "0.2.27", - "@tanstack/react-db": "0.1.66", + "@tanstack/db": "^0.5.24", + "@tanstack/electric-db-collection": "0.2.30", + "@tanstack/react-db": "^0.1.68", "@tanstack/react-query": "^5.90.19", "@tanstack/react-router": "^1.147.3", "@tanstack/react-table": "^8.21.3", diff --git a/apps/desktop/resources/bin/darwin-arm64/claude b/apps/desktop/resources/bin/darwin-arm64/claude new file mode 100755 index 00000000000..a23bea278ab --- /dev/null +++ b/apps/desktop/resources/bin/darwin-arm64/claude @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8ae572cf525b2eec2da17a9100355413c4d59a43c124733e5cf1356dc50a576a +size 180472304 diff --git a/apps/desktop/scripts/download-claude-binary.ts b/apps/desktop/scripts/download-claude-binary.ts new file mode 100644 index 00000000000..af7eec695b2 --- /dev/null +++ b/apps/desktop/scripts/download-claude-binary.ts @@ -0,0 +1,366 @@ +/** + * Download Claude Code binary for bundling with the desktop app. + * + * This script downloads the Claude Code CLI binary from the official distribution + * and places it in the resources/bin directory for electron-builder to package. + * + * Usage: + * bun run scripts/download-claude-binary.ts + * bun run scripts/download-claude-binary.ts --all # Download all platforms + * bun run scripts/download-claude-binary.ts --version=2.1.17 # Specific version + * + * The binary is downloaded based on the current platform and architecture. + */ + +import { createHash } from "node:crypto"; +import { + chmodSync, + createReadStream, + createWriteStream, + existsSync, + mkdirSync, + rmSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import https from "node:https"; +import { dirname, join } from "node:path"; + +// Claude Code distribution base URL (same as 1code uses) +const DIST_BASE = + "https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases"; + +// Platform mappings +const PLATFORMS: Record = { + "darwin-arm64": { dir: "darwin-arm64", binary: "claude" }, + "darwin-x64": { dir: "darwin-x64", binary: "claude" }, + "linux-arm64": { dir: "linux-arm64", binary: "claude" }, + "linux-x64": { dir: "linux-x64", binary: "claude" }, + "win32-x64": { dir: "win32-x64", binary: "claude.exe" }, +}; + +interface PlatformManifest { + checksum: string; + size: number; +} + +interface Manifest { + version: string; + platforms: Record; +} + +function getPlatformKey(): string { + return `${process.platform}-${process.arch}`; +} + +/** + * Fetch JSON from URL + */ +function fetchJson(url: string): Promise { + return new Promise((resolve, reject) => { + const request = (requestUrl: string) => { + https + .get(requestUrl, (res) => { + if (res.statusCode === 301 || res.statusCode === 302) { + const location = res.headers.location; + if (location) { + return request(location); + } + return reject(new Error("Redirect without location")); + } + if (res.statusCode !== 200) { + return reject(new Error(`HTTP ${res.statusCode}`)); + } + let data = ""; + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => resolve(JSON.parse(data) as T)); + res.on("error", reject); + }) + .on("error", reject); + }; + request(url); + }); +} + +/** + * Fetch text from URL + */ +function fetchText(url: string): Promise { + return new Promise((resolve, reject) => { + const request = (requestUrl: string) => { + https + .get(requestUrl, (res) => { + if (res.statusCode === 301 || res.statusCode === 302) { + const location = res.headers.location; + if (location) { + return request(location); + } + return reject(new Error("Redirect without location")); + } + if (res.statusCode !== 200) { + return reject(new Error(`HTTP ${res.statusCode}`)); + } + let data = ""; + res.on("data", (chunk) => { + data += chunk; + }); + res.on("end", () => resolve(data)); + res.on("error", reject); + }) + .on("error", reject); + }; + request(url); + }); +} + +/** + * Download file with progress + */ +function downloadFile(url: string, destPath: string): Promise { + return new Promise((resolve, reject) => { + // Ensure parent directory exists + const parentDir = dirname(destPath); + if (!existsSync(parentDir)) { + mkdirSync(parentDir, { recursive: true }); + } + + const file = createWriteStream(destPath); + + const request = (requestUrl: string) => { + https + .get(requestUrl, (res) => { + if (res.statusCode === 301 || res.statusCode === 302) { + file.close(); + if (existsSync(destPath)) unlinkSync(destPath); + const location = res.headers.location; + if (location) { + return request(location); + } + return reject(new Error("Redirect without location")); + } + + if (res.statusCode !== 200) { + file.close(); + if (existsSync(destPath)) unlinkSync(destPath); + return reject(new Error(`HTTP ${res.statusCode}`)); + } + + const totalSize = Number.parseInt( + res.headers["content-length"] || "0", + 10, + ); + let downloaded = 0; + let lastPercent = 0; + + res.on("data", (chunk: Buffer) => { + downloaded += chunk.length; + if (totalSize > 0) { + const percent = Math.floor((downloaded / totalSize) * 100); + if (percent !== lastPercent && percent % 10 === 0) { + process.stdout.write(`\r Progress: ${percent}%`); + lastPercent = percent; + } + } + }); + + res.pipe(file); + + file.on("finish", () => { + file.close(); + process.stdout.write("\r Progress: 100%\n"); + resolve(); + }); + + res.on("error", (err) => { + file.close(); + if (existsSync(destPath)) unlinkSync(destPath); + reject(err); + }); + }) + .on("error", (err) => { + file.close(); + if (existsSync(destPath)) unlinkSync(destPath); + reject(err); + }); + }; + + request(url); + }); +} + +/** + * Calculate SHA256 hash of file + */ +function calculateSha256(filePath: string): Promise { + return new Promise((resolve, reject) => { + const hash = createHash("sha256"); + const stream = createReadStream(filePath); + stream.on("data", (chunk) => hash.update(chunk)); + stream.on("end", () => resolve(hash.digest("hex"))); + stream.on("error", reject); + }); +} + +/** + * Get latest version from GCS bucket + */ +async function getLatestVersion(): Promise { + console.log("Fetching latest Claude Code version..."); + + try { + const version = await fetchText(`${DIST_BASE}/latest`); + return version.trim(); + } catch (error) { + console.warn( + `Failed to fetch latest version: ${error instanceof Error ? error.message : error}`, + ); + } + + // Fallback to known version + return "2.1.17"; +} + +/** + * Download binary for a specific platform + */ +async function downloadPlatform( + version: string, + platformKey: string, + manifest: Manifest, +): Promise { + const platform = PLATFORMS[platformKey]; + if (!platform) { + console.error(`Unknown platform: ${platformKey}`); + return false; + } + + const resourcesDir = join(dirname(import.meta.dirname), "resources"); + const targetDir = join(resourcesDir, "bin", platformKey); + const targetPath = join(targetDir, platform.binary); + + // Create directory + mkdirSync(targetDir, { recursive: true }); + + // Get expected hash from manifest + const platformManifest = manifest.platforms[platform.dir]; + if (!platformManifest) { + console.error(`No manifest entry for ${platform.dir}`); + return false; + } + + const expectedHash = platformManifest.checksum; + const downloadUrl = `${DIST_BASE}/${version}/${platform.dir}/${platform.binary}`; + + console.log(`\nDownloading Claude Code for ${platformKey}...`); + console.log(` URL: ${downloadUrl}`); + console.log(` Size: ${(platformManifest.size / 1024 / 1024).toFixed(1)} MB`); + + // Check if already downloaded with correct hash + if (existsSync(targetPath)) { + const existingHash = await calculateSha256(targetPath); + if (existingHash === expectedHash) { + console.log(" Already downloaded and verified"); + return true; + } + console.log(" Existing file has wrong hash, re-downloading..."); + } + + // Download + await downloadFile(downloadUrl, targetPath); + + // Verify hash + const actualHash = await calculateSha256(targetPath); + if (actualHash !== expectedHash) { + console.error(" Hash mismatch!"); + console.error(` Expected: ${expectedHash}`); + console.error(` Actual: ${actualHash}`); + rmSync(targetPath); + return false; + } + console.log(` Verified SHA256: ${actualHash.substring(0, 16)}...`); + + // Make executable (Unix) + if (process.platform !== "win32") { + chmodSync(targetPath, 0o755); + } + + console.log(` Saved to: ${targetPath}`); + return true; +} + +async function main() { + const args = process.argv.slice(2); + const downloadAll = args.includes("--all"); + const versionArg = args.find((a) => a.startsWith("--version=")); + const specifiedVersion = versionArg?.split("=")[1]; + + console.log("Claude Code Binary Downloader"); + console.log("=============================\n"); + + // Get version + const version = specifiedVersion || (await getLatestVersion()); + console.log(`Version: ${version}`); + + // Fetch manifest + const manifestUrl = `${DIST_BASE}/${version}/manifest.json`; + console.log(`Fetching manifest: ${manifestUrl}`); + + let manifest: Manifest; + try { + manifest = await fetchJson(manifestUrl); + } catch (error) { + console.error( + `Failed to fetch manifest: ${error instanceof Error ? error.message : error}`, + ); + process.exit(1); + } + + // Determine which platforms to download + let platformsToDownload: string[]; + if (downloadAll) { + platformsToDownload = Object.keys(PLATFORMS); + } else { + // Current platform only + const currentPlatform = getPlatformKey(); + if (!PLATFORMS[currentPlatform]) { + console.error(`Unsupported platform: ${currentPlatform}`); + console.log(`Supported platforms: ${Object.keys(PLATFORMS).join(", ")}`); + process.exit(1); + } + platformsToDownload = [currentPlatform]; + } + + console.log(`\nPlatforms to download: ${platformsToDownload.join(", ")}`); + + // Create bin directory + const resourcesDir = join(dirname(import.meta.dirname), "resources"); + const binDir = join(resourcesDir, "bin"); + mkdirSync(binDir, { recursive: true }); + + // Write version file + writeFileSync( + join(binDir, "VERSION"), + `${version}\n${new Date().toISOString()}\n`, + ); + + // Download each platform + let success = true; + for (const platform of platformsToDownload) { + const result = await downloadPlatform(version, platform, manifest); + if (!result) success = false; + } + + if (success) { + console.log("\n✓ All downloads completed successfully!"); + } else { + console.error("\n✗ Some downloads failed"); + process.exit(1); + } +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/apps/desktop/scripts/upgrade-claude-binary.ts b/apps/desktop/scripts/upgrade-claude-binary.ts new file mode 100644 index 00000000000..cfc75b6f4d6 --- /dev/null +++ b/apps/desktop/scripts/upgrade-claude-binary.ts @@ -0,0 +1,82 @@ +#!/usr/bin/env bun +/** + * Upgrade Claude Code binary to the latest version. + * + * This script: + * 1. Downloads the latest Claude Code binary for darwin-arm64 + * 2. Stages the updated binary in git + * + * Usage: + * bun run scripts/upgrade-claude-binary.ts + * bun run scripts/upgrade-claude-binary.ts --version=2.1.31 # Specific version + * + * After running, commit the changes: + * git commit -m "chore: upgrade claude binary to vX.X.X" + */ + +import { execSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; + +const SCRIPT_DIR = import.meta.dirname; +const DESKTOP_DIR = dirname(SCRIPT_DIR); +const BIN_DIR = join(DESKTOP_DIR, "resources", "bin"); + +function run(cmd: string, opts?: { cwd?: string }): string { + console.log(`$ ${cmd}`); + return execSync(cmd, { + encoding: "utf-8", + cwd: opts?.cwd ?? DESKTOP_DIR, + stdio: ["inherit", "pipe", "inherit"], + }).trim(); +} + +async function main() { + const args = process.argv.slice(2); + const versionArg = args.find((a) => a.startsWith("--version=")); + const versionFlag = versionArg ? ` ${versionArg}` : ""; + + console.log("Claude Code Binary Upgrader"); + console.log("===========================\n"); + + // Get current version if exists + const versionFile = join(BIN_DIR, "VERSION"); + if (existsSync(versionFile)) { + const currentVersion = readFileSync(versionFile, "utf-8") + .split("\n")[0] + .trim(); + console.log(`Current version: ${currentVersion}`); + } else { + console.log("No existing binaries found"); + } + + // Download darwin-arm64 only (tracked with Git LFS) + // Other platforms are downloaded at build time + console.log("\nDownloading binary for darwin-arm64...\n"); + run(`bun run scripts/download-claude-binary.ts${versionFlag}`); + + // Get new version + const newVersion = readFileSync(versionFile, "utf-8").split("\n")[0].trim(); + console.log(`\nNew version: ${newVersion}`); + + // Stage the binary + console.log("\nStaging binary in git..."); + run("git add resources/bin/darwin-arm64/claude"); + + // Show status + console.log("\nGit status:"); + const status = run("git status --short resources/bin/darwin-arm64/"); + console.log(status || " (no changes)"); + + console.log("\n✓ Upgrade complete!"); + console.log("\nNext steps:"); + console.log( + ` git commit -m "chore: upgrade claude binary to v${newVersion}"`, + ); + console.log(" git push"); +} + +main().catch((error) => { + console.error("Fatal error:", error); + process.exit(1); +}); diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts new file mode 100644 index 00000000000..7e356a40018 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/index.ts @@ -0,0 +1,100 @@ +/** + * AI Chat tRPC Router + * + * Provides local control of Claude sessions via tRPC. + * Uses observable pattern for streaming (required by trpc-electron). + */ + +import { observable } from "@trpc/server/observable"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; +import { + type ClaudeStreamEvent, + claudeSessionManager, +} from "./utils/session-manager"; + +export const createAiChatRouter = () => { + return router({ + /** + * Start a Claude session. + */ + startSession: publicProcedure + .input( + z.object({ + sessionId: z.string(), + cwd: z.string(), + claudeSessionId: z.string().optional(), + }), + ) + .mutation(async ({ input }) => { + await claudeSessionManager.startSession({ + sessionId: input.sessionId, + cwd: input.cwd, + claudeSessionId: input.claudeSessionId, + }); + return { success: true }; + }), + + /** + * Interrupt an active Claude session (SIGINT). + */ + interrupt: publicProcedure + .input(z.object({ sessionId: z.string() })) + .mutation(async ({ input }) => { + await claudeSessionManager.interrupt({ sessionId: input.sessionId }); + return { success: true }; + }), + + /** + * Stop a Claude session completely. + */ + stopSession: publicProcedure + .input(z.object({ sessionId: z.string() })) + .mutation(async ({ input }) => { + await claudeSessionManager.stopSession({ sessionId: input.sessionId }); + return { success: true }; + }), + + /** + * Check if a session is active. + */ + isSessionActive: publicProcedure + .input(z.object({ sessionId: z.string() })) + .query(({ input }) => { + return claudeSessionManager.isSessionActive(input.sessionId); + }), + + /** + * Get all active session IDs. + */ + getActiveSessions: publicProcedure.query(() => { + return claudeSessionManager.getActiveSessions(); + }), + + /** + * Subscribe to stream events from Claude sessions. + * + * This uses the observable pattern required by trpc-electron. + * Events are filtered by sessionId if provided. + */ + streamEvents: publicProcedure + .input(z.object({ sessionId: z.string().optional() })) + .subscription(({ input }) => { + return observable((emit) => { + const onEvent = (event: ClaudeStreamEvent) => { + // Filter by sessionId if specified + if (input.sessionId && event.sessionId !== input.sessionId) { + return; + } + emit.next(event); + }; + + claudeSessionManager.on("event", onEvent); + + return () => { + claudeSessionManager.off("event", onEvent); + }; + }); + }), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/auth/auth.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/auth/auth.ts new file mode 100644 index 00000000000..d624cea69a4 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/auth/auth.ts @@ -0,0 +1,245 @@ +/** + * Claude Code authentication resolution. + * + * Reads Claude credentials from various sources: + * 1. Environment variable (ANTHROPIC_API_KEY) + * 2. Claude config file (~/.claude.json or ~/.config/claude/credentials.json) + * 3. macOS Keychain (via security command) + */ + +import { execSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { homedir, platform } from "node:os"; +import { join } from "node:path"; + +interface ClaudeCredentials { + apiKey: string; + source: "env" | "config" | "keychain"; +} + +interface ClaudeConfigFile { + apiKey?: string; + api_key?: string; + oauthAccessToken?: string; + oauth_access_token?: string; + // Claude Code CLI format + claudeAiOauth?: { + accessToken?: string; + refreshToken?: string; + expiresAt?: number; + }; +} + +/** + * Get Claude credentials from environment variable. + */ +function getCredentialsFromEnv(): ClaudeCredentials | null { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (apiKey) { + return { apiKey, source: "env" }; + } + return null; +} + +/** + * Get Claude credentials from config file. + */ +function getCredentialsFromConfig(): ClaudeCredentials | null { + const home = homedir(); + // Check Claude Code CLI credentials first (most common case) + const configPaths = [ + join(home, ".claude", ".credentials.json"), // Claude Code CLI + join(home, ".claude.json"), + join(home, ".config", "claude", "credentials.json"), + join(home, ".config", "claude", "config.json"), + ]; + + for (const configPath of configPaths) { + if (existsSync(configPath)) { + try { + const content = readFileSync(configPath, "utf-8"); + const config: ClaudeConfigFile = JSON.parse(content); + + // Check for Claude Code CLI OAuth format first + if (config.claudeAiOauth?.accessToken) { + console.log( + `[claude/auth] Found OAuth credentials in: ${configPath}`, + ); + return { apiKey: config.claudeAiOauth.accessToken, source: "config" }; + } + + // Fall back to other formats + const apiKey = + config.apiKey || + config.api_key || + config.oauthAccessToken || + config.oauth_access_token; + + if (apiKey) { + console.log(`[claude/auth] Found credentials in: ${configPath}`); + return { apiKey, source: "config" }; + } + } catch (error) { + console.warn( + `[claude/auth] Failed to parse config at ${configPath}:`, + error, + ); + } + } + } + + return null; +} + +/** + * Get Claude credentials from macOS Keychain. + */ +function getCredentialsFromKeychain(): ClaudeCredentials | null { + if (platform() !== "darwin") { + return null; + } + + try { + // Claude CLI stores credentials in the keychain with this service/account + const result = execSync( + 'security find-generic-password -s "claude-cli" -a "api-key" -w 2>/dev/null', + { encoding: "utf-8" }, + ).trim(); + + if (result) { + console.log("[claude/auth] Found credentials in macOS Keychain"); + return { apiKey: result, source: "keychain" }; + } + } catch { + // Not found in keychain, this is fine + } + + // Try alternate keychain entry format + try { + const result = execSync( + 'security find-generic-password -s "anthropic-api-key" -w 2>/dev/null', + { encoding: "utf-8" }, + ).trim(); + + if (result) { + console.log( + "[claude/auth] Found credentials in macOS Keychain (anthropic-api-key)", + ); + return { apiKey: result, source: "keychain" }; + } + } catch { + // Not found in keychain, this is fine + } + + return null; +} + +/** + * Get existing Claude credentials from any available source. + * + * Priority: + * 1. Environment variable (ANTHROPIC_API_KEY) + * 2. Config file (~/.claude.json, ~/.config/claude/credentials.json) + * 3. macOS Keychain + */ +export function getExistingClaudeCredentials(): ClaudeCredentials | null { + // 1. Check environment variable + const envCredentials = getCredentialsFromEnv(); + if (envCredentials) { + console.log("[claude/auth] Using credentials from environment variable"); + return envCredentials; + } + + // 2. Check config file + const configCredentials = getCredentialsFromConfig(); + if (configCredentials) { + return configCredentials; + } + + // 3. Check macOS Keychain + const keychainCredentials = getCredentialsFromKeychain(); + if (keychainCredentials) { + return keychainCredentials; + } + + console.warn("[claude/auth] No Claude credentials found"); + return null; +} + +/** + * Build environment variables for running Claude CLI. + * + * IMPORTANT: We do NOT set ANTHROPIC_API_KEY when using OAuth credentials. + * The Claude binary handles its own OAuth authentication from ~/.claude/.credentials.json. + * Setting ANTHROPIC_API_KEY to an OAuth token causes authentication failure. + * + * We only set ANTHROPIC_API_KEY if: + * 1. It's already in the environment (user explicitly set it) + * 2. We found a raw API key (not OAuth) in config + */ +export function buildClaudeEnv(): Record { + const env: Record = { + ...process.env, + } as Record; + + // Check if user has OAuth credentials - if so, let the binary handle auth + const hasOAuth = hasClaudeOAuthCredentials(); + if (hasOAuth) { + console.log( + "[claude/auth] OAuth credentials found - letting binary handle authentication", + ); + // Don't set ANTHROPIC_API_KEY, let the binary use its own OAuth flow + } else { + // Only set ANTHROPIC_API_KEY if we have a raw API key (not OAuth) + const credentials = getExistingClaudeCredentials(); + if (credentials && credentials.source !== "config") { + // Only use env or keychain credentials (not OAuth from config) + env.ANTHROPIC_API_KEY = credentials.apiKey; + console.log(`[claude/auth] Using API key from ${credentials.source}`); + } + } + + // Ensure PATH includes common binary locations + const pathAdditions = ["/usr/local/bin", "/opt/homebrew/bin", "/usr/bin"]; + const currentPath = env.PATH || ""; + const pathParts = currentPath.split(":"); + + for (const addition of pathAdditions) { + if (!pathParts.includes(addition)) { + pathParts.push(addition); + } + } + + env.PATH = pathParts.join(":"); + + // Mark as SDK entry (like 1code does) + env.CLAUDE_CODE_ENTRYPOINT = "sdk-ts"; + + return env; +} + +/** + * Check if Claude OAuth credentials are available. + */ +function hasClaudeOAuthCredentials(): boolean { + const home = homedir(); + const credentialsPath = join(home, ".claude", ".credentials.json"); + + if (existsSync(credentialsPath)) { + try { + const content = readFileSync(credentialsPath, "utf-8"); + const config: ClaudeConfigFile = JSON.parse(content); + return !!config.claudeAiOauth?.accessToken; + } catch { + return false; + } + } + return false; +} + +/** + * Check if Claude credentials are available. + */ +export function hasClaudeCredentials(): boolean { + return getExistingClaudeCredentials() !== null; +} diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/auth/index.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/auth/index.ts new file mode 100644 index 00000000000..d24d7a75562 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/auth/index.ts @@ -0,0 +1 @@ +export { buildClaudeEnv } from "./auth"; diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/index.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/index.ts new file mode 100644 index 00000000000..36e6f2e4017 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/index.ts @@ -0,0 +1,7 @@ +export type { + ClaudeStreamEvent, + ErrorEvent, + SessionEndEvent, + SessionStartEvent, +} from "./session-manager"; +export { claudeSessionManager } from "./session-manager"; diff --git a/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts new file mode 100644 index 00000000000..9289cf991c7 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/ai-chat/utils/session-manager/session-manager.ts @@ -0,0 +1,500 @@ +/** + * Claude Code Session Manager + * + * Manages Claude SDK sessions using V1 query() API with streaming. + * Persists ALL raw SDKMessage objects to the durable stream. + * + * Architecture: + * - All clients POST user messages to the durable stream + * - This manager watches the stream for new user messages + * - When a user message appears, it calls query() with resume for multi-turn + * - includePartialMessages: true enables stream_event messages for live streaming + * - ALL SDK messages are persisted as raw JSON chunks + * - Client-side materialize() reconstructs UI state from chunks + */ + +import { EventEmitter } from "node:events"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { DurableStream, IdempotentProducer } from "@durable-streams/client"; +import { app } from "electron"; +import { buildClaudeEnv } from "../auth"; + +const DURABLE_STREAM_URL = + process.env.DURABLE_STREAM_URL || "http://localhost:8080"; + +// ============================================================================ +// Events (simplified — only for local IPC subscribers) +// ============================================================================ + +export interface SessionStartEvent { + type: "session_start"; + sessionId: string; +} + +export interface SessionEndEvent { + type: "session_end"; + sessionId: string; + exitCode: number | null; +} + +export interface ErrorEvent { + type: "error"; + sessionId: string; + error: string; +} + +export type ClaudeStreamEvent = + | SessionStartEvent + | SessionEndEvent + | ErrorEvent; + +// ============================================================================ +// Active Session State +// ============================================================================ + +interface ActiveSession { + sessionId: string; + cwd: string; + claudeSessionId?: string; + abortController?: AbortController; + activeQuery?: { interrupt(): Promise; close(): void }; + streamWatcher?: StreamWatcher; + processingMessageIds: Set; +} + +// ============================================================================ +// Durable Stream Producers (per-session) +// ============================================================================ + +const sessionProducers = new Map(); + +async function createProducer(sessionId: string): Promise { + const streamOpts = { + url: `${DURABLE_STREAM_URL}/streams/${sessionId}`, + contentType: "application/json", + }; + + let stream: DurableStream; + try { + stream = await DurableStream.create(streamOpts); + } catch { + // Stream may already exist — connect to it + stream = await DurableStream.connect(streamOpts); + } + + const producer = new IdempotentProducer(stream, "session-manager", { + autoClaim: true, + onError: (err: Error) => + console.error(`[durable-stream] Batch failed for ${sessionId}:`, err), + }); + + sessionProducers.set(sessionId, producer); + return producer; +} + +async function closeProducer(sessionId: string): Promise { + const producer = sessionProducers.get(sessionId); + if (!producer) return; + await producer.flush(); + await producer.close(); + sessionProducers.delete(sessionId); +} + +// ============================================================================ +// Stream Watcher +// ============================================================================ + +class StreamWatcher { + private intervalId: ReturnType | null = null; + private seenMessageIds: Set = new Set(); + private isPolling = false; + private isStopped = false; + private onNewUserMessage: (messageId: string, content: string) => void; + private sessionId = ""; + + constructor(onNewUserMessage: (messageId: string, content: string) => void) { + this.onNewUserMessage = onNewUserMessage; + } + + /** + * Fetch the current stream and seed seenMessageIds with all existing + * user_input keys, then start polling for new messages. + * This prevents reprocessing historical messages after a restart. + */ + async start(sessionId: string): Promise { + this.sessionId = sessionId; + this.seenMessageIds.clear(); + this.isStopped = false; + + // Seed with existing messages before polling + await this.seedExistingMessages(); + + this.intervalId = setInterval(() => this.poll(), 500); + console.log( + `[stream-watcher] Started polling for ${sessionId} (${this.seenMessageIds.size} existing messages seeded)`, + ); + } + + /** + * Fetch all existing events from the stream and record their keys + * so they are not treated as new messages. + */ + private async seedExistingMessages(): Promise { + try { + const response = await fetch( + `${DURABLE_STREAM_URL}/streams/${this.sessionId}`, + { headers: { Accept: "application/json" } }, + ); + + if (!response.ok) return; + + const events = (await response.json()) as Array>; + + for (const event of events) { + if (event.type !== "chunk") continue; + + const value = event.value as Record | undefined; + if (!value || value.type !== "user_input") continue; + + const key = event.key as string; + if (key) { + this.seenMessageIds.add(key); + } + } + } catch (error) { + console.warn( + `[stream-watcher] Failed to seed existing messages for ${this.sessionId}:`, + error, + ); + } + } + + private async poll(): Promise { + if (this.isStopped) return; + if (this.isPolling) return; + this.isPolling = true; + + try { + const response = await fetch( + `${DURABLE_STREAM_URL}/streams/${this.sessionId}`, + { headers: { Accept: "application/json" } }, + ); + + if (!response.ok) return; + + const events = (await response.json()) as Array>; + + for (const event of events) { + if (event.type !== "chunk") continue; + + const value = event.value as Record | undefined; + if (!value) continue; + + // Detect user input messages (new format: { type: "user_input", content, ... }) + if (value.type !== "user_input") continue; + + const key = event.key as string; + if (!key || this.seenMessageIds.has(key)) continue; + + this.seenMessageIds.add(key); + + const content = value.content as string | undefined; + if (content && !this.isStopped) { + console.log(`[stream-watcher] New user message: ${key}`); + this.onNewUserMessage(key, content); + } + } + } catch { + // Ignore poll errors + } finally { + this.isPolling = false; + } + } + + stop(): void { + this.isStopped = true; + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + this.seenMessageIds.clear(); + } +} + +// ============================================================================ +// Session Manager +// ============================================================================ + +// Cache V1 SDK query function +let cachedQuery: + | typeof import("@anthropic-ai/claude-agent-sdk").query + | null = null; + +const getSDK = async () => { + if (cachedQuery) return { query: cachedQuery }; + const sdk = await import("@anthropic-ai/claude-agent-sdk"); + cachedQuery = sdk.query; + return { query: cachedQuery }; +}; + +class ClaudeSessionManager extends EventEmitter { + private sessions: Map = new Map(); + + async startSession({ + sessionId, + cwd, + claudeSessionId, + enableDurableStream = true, + }: { + sessionId: string; + cwd: string; + claudeSessionId?: string; + enableDurableStream?: boolean; + }): Promise { + if (this.sessions.has(sessionId)) { + console.warn(`[claude/session] Session ${sessionId} already running`); + return; + } + + console.log(`[claude/session] Initializing session ${sessionId} in ${cwd}`); + + if (enableDurableStream) { + try { + await createProducer(sessionId); + console.log(`[claude/session] Durable stream created for ${sessionId}`); + } catch (error) { + console.error(`[claude/session] Failed to create stream:`, error); + } + } + + const session: ActiveSession = { + sessionId, + cwd, + claudeSessionId, + processingMessageIds: new Set(), + }; + + this.sessions.set(sessionId, session); + + if (sessionProducers.has(sessionId)) { + const watcher = new StreamWatcher((messageId, content) => { + if (session.processingMessageIds.has(messageId)) { + return; + } + session.processingMessageIds.add(messageId); + + this.processUserMessage({ sessionId, content }).finally(() => { + session.processingMessageIds.delete(messageId); + }); + }); + + session.streamWatcher = watcher; + await watcher.start(sessionId); + } + + this.emit("event", { + type: "session_start", + sessionId, + } satisfies SessionStartEvent); + } + + /** + * Process a user message through Claude using V1 query() API. + * Uses includePartialMessages for real-time streaming events. + * Persists ALL raw SDKMessage objects to the durable stream. + */ + private async processUserMessage({ + sessionId, + content, + }: { + sessionId: string; + content: string; + }): Promise { + console.log( + `[claude/session] processUserMessage for ${sessionId}: "${content.slice(0, 50)}..."`, + ); + + const session = this.sessions.get(sessionId); + if (!session) { + console.error(`[claude/session] Session ${sessionId} not found`); + this.emit("event", { + type: "error", + sessionId, + error: "Session not found", + } satisfies ErrorEvent); + return; + } + + // Abort any previous in-flight query + if (session.activeQuery) { + session.activeQuery.close(); + session.activeQuery = undefined; + } + + const abortController = new AbortController(); + session.abortController = abortController; + + const binaryName = process.platform === "win32" ? "claude.exe" : "claude"; + const binaryPath = app.isPackaged + ? join(process.resourcesPath, "bin", binaryName) + : join( + app.getAppPath(), + "resources", + "bin", + `${process.platform}-${process.arch}`, + binaryName, + ); + if (!existsSync(binaryPath)) { + this.emit("event", { + type: "error", + sessionId, + error: "Claude binary not found", + } satisfies ErrorEvent); + return; + } + + const env = buildClaudeEnv(); + + try { + const { query } = await getSDK(); + + const options = { + includePartialMessages: true, + cwd: session.cwd, + resume: session.claudeSessionId, + model: process.env.CLAUDE_MODEL || "claude-sonnet-4-5-20250929", + pathToClaudeCodeExecutable: binaryPath, + env, + permissionMode: "bypassPermissions" as const, + abortController, + }; + + console.log( + `[claude/session] Starting V1 query in ${session.cwd} (resume=${session.claudeSessionId || "none"})`, + ); + + const q = query({ prompt: content, options }); + session.activeQuery = q; + + // Stream and persist ALL SDK messages as raw passthrough. + // includePartialMessages: true gives us stream_event messages for live streaming. + // Turn detection happens client-side in materialize(). + let totalChunks = 0; + let seq = 0; + + for await (const msg of q) { + if (abortController.signal.aborted) { + console.log(`[claude/session] Stream aborted`); + break; + } + + const msgAny = msg as Record; + const msgType = msgAny.type as string; + console.log( + `[claude/session] SDK message #${seq}: type=${msgType}${msgType === "stream_event" ? ` event=${(msgAny.event as Record)?.type}` : ""}`, + ); + + // Extract session ID from init message + if (msgType === "system" && msgAny.subtype === "init") { + const sdkSessionId = msgAny.session_id as string | undefined; + if (sdkSessionId) { + session.claudeSessionId = sdkSessionId; + console.log( + `[claude/session] Got Claude session ID: ${sdkSessionId}`, + ); + } + } + + // Persist raw SDK message with ordering metadata + const producer = sessionProducers.get(sessionId); + if (producer) { + const uuid = (msgAny.uuid as string) || crypto.randomUUID(); + producer.append( + JSON.stringify({ + type: "chunk", + key: uuid, + value: { + ...(msg as Record), + createdAt: new Date().toISOString(), + _seq: seq++, + }, + headers: { operation: "upsert" }, + }), + ); + // Flush immediately so chunks are visible to clients as they arrive + await producer.flush(); + } + + totalChunks++; + } + + q.close(); + session.activeQuery = undefined; + + // Flush pending events + const flushProducer = sessionProducers.get(sessionId); + if (flushProducer) { + await flushProducer.flush(); + } + + console.log( + `[claude/session] Message processing complete, ${totalChunks} chunks persisted`, + ); + } catch (error) { + session.activeQuery = undefined; + const errorMessage = + error instanceof Error ? error.message : String(error); + console.error(`[claude/session] SDK error: ${errorMessage}`); + if (error instanceof Error && error.stack) { + console.error(`[claude/session] Stack:`, error.stack); + } + this.emit("event", { + type: "error", + sessionId, + error: errorMessage, + } satisfies ErrorEvent); + } + } + + async interrupt({ sessionId }: { sessionId: string }): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + console.warn( + `[claude/session] Session ${sessionId} not found for interrupt`, + ); + return; + } + + console.log(`[claude/session] Interrupting session ${sessionId}`); + if (session.activeQuery) { + await session.activeQuery.interrupt(); + } + session.abortController?.abort(); + } + + async stopSession({ sessionId }: { sessionId: string }): Promise { + const session = this.sessions.get(sessionId); + if (!session) { + return; + } + + console.log(`[claude/session] Stopping session ${sessionId}`); + session.activeQuery?.close(); + session.activeQuery = undefined; + session.abortController?.abort(); + session.streamWatcher?.stop(); + this.sessions.delete(sessionId); + await closeProducer(sessionId); + } + + isSessionActive(sessionId: string): boolean { + return this.sessions.has(sessionId); + } + + getActiveSessions(): string[] { + return Array.from(this.sessions.keys()); + } +} + +export const claudeSessionManager = new ClaudeSessionManager(); diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index ca340bddd4a..6020f7480cc 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -1,5 +1,6 @@ import type { BrowserWindow } from "electron"; import { router } from ".."; +import { createAiChatRouter } from "./ai-chat"; import { createAnalyticsRouter } from "./analytics"; import { createAuthRouter } from "./auth"; import { createAutoUpdateRouter } from "./auto-update"; @@ -22,6 +23,7 @@ import { createWorkspacesRouter } from "./workspaces"; export const createAppRouter = (getWindow: () => BrowserWindow | null) => { return router({ + aiChat: createAiChatRouter(), analytics: createAnalyticsRouter(), auth: createAuthRouter(), autoUpdate: createAutoUpdateRouter(), diff --git a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts index e9f19043286..17b97abbdd6 100644 --- a/apps/desktop/src/lib/trpc/routers/ui-state/index.ts +++ b/apps/desktop/src/lib/trpc/routers/ui-state/index.ts @@ -33,7 +33,7 @@ const fileViewerStateSchema = z.object({ const paneSchema = z.object({ id: z.string(), tabId: z.string(), - type: z.enum(["terminal", "webview", "file-viewer"]), + type: z.enum(["terminal", "webview", "file-viewer", "chat"]), name: z.string(), isNew: z.boolean().optional(), status: z.enum(["idle", "working", "permission", "review"]).optional(), @@ -43,6 +43,7 @@ const paneSchema = z.object({ cwd: z.string().nullable().optional(), cwdConfirmed: z.boolean().optional(), fileViewer: fileViewerStateSchema.optional(), + chat: z.object({ sessionId: z.string() }).optional(), }); /** diff --git a/apps/desktop/src/renderer/env.renderer.ts b/apps/desktop/src/renderer/env.renderer.ts index 0e41dea4a6e..0103fab71e0 100644 --- a/apps/desktop/src/renderer/env.renderer.ts +++ b/apps/desktop/src/renderer/env.renderer.ts @@ -17,6 +17,7 @@ const envSchema = z.object({ .default("development"), NEXT_PUBLIC_API_URL: z.url().default("https://api.superset.sh"), NEXT_PUBLIC_WEB_URL: z.url().default("https://app.superset.sh"), + NEXT_PUBLIC_STREAMS_URL: z.url().default("http://localhost:8080"), NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(), NEXT_PUBLIC_POSTHOG_HOST: z.string().default("https://us.i.posthog.com"), SENTRY_DSN_DESKTOP: z.string().optional(), @@ -33,6 +34,7 @@ const rawEnv = { NODE_ENV: process.env.NODE_ENV, NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WEB_URL: process.env.NEXT_PUBLIC_WEB_URL, + NEXT_PUBLIC_STREAMS_URL: process.env.NEXT_PUBLIC_STREAMS_URL, NEXT_PUBLIC_POSTHOG_KEY: import.meta.env.NEXT_PUBLIC_POSTHOG_KEY as | string | undefined, diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index 5340b8f15b5..84ef48def0a 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -11,11 +11,11 @@ - default-src 'self': Only allow resources from same origin - script-src 'self' 'wasm-unsafe-eval' https://*.posthog.com: Allow scripts from same origin + WebAssembly (for xterm ImageAddon) + PostHog - style-src 'self' 'unsafe-inline': Allow styles from same origin + inline (needed for CSS-in-JS) - - connect-src 'self' ws: wss: %NEXT_PUBLIC_API_URL% https://*.posthog.com https://*.sentry.io sentry-ipc:: Allow WebSocket + API (includes Electric proxy) + PostHog + Sentry + - connect-src 'self' ws: wss: http://localhost:* %NEXT_PUBLIC_API_URL% https://*.posthog.com https://*.sentry.io sentry-ipc:: Allow WebSocket + API (includes Electric proxy) + PostHog + Sentry + localhost for stream server - img-src 'self' data: %NEXT_PUBLIC_API_URL% https://*.public.blob.vercel-storage.com https://github.com https://avatars.githubusercontent.com: Allow images from same origin + data URIs + API (Linear image proxy) + Vercel blob storage + GitHub avatars - font-src 'self': Allow fonts from same origin --> - + diff --git a/apps/desktop/src/renderer/lib/workspace-utils.ts b/apps/desktop/src/renderer/lib/workspace-utils.ts new file mode 100644 index 00000000000..d23982acfe5 --- /dev/null +++ b/apps/desktop/src/renderer/lib/workspace-utils.ts @@ -0,0 +1,19 @@ +/** + * Get the most recently opened workspace path from grouped workspaces. + */ +export function getMostRecentWorkspacePath( + groups: Array<{ + workspaces: Array<{ + worktreePath: string; + lastOpenedAt: number; + }>; + }>, +): string | null { + const allWorkspaces = groups.flatMap((g) => g.workspaces); + if (allWorkspaces.length === 0) return null; + + const sorted = [...allWorkspaces].sort( + (a, b) => b.lastOpenedAt - a.lastOpenedAt, + ); + return sorted[0].worktreePath || null; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/$chatId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/$chatId/page.tsx new file mode 100644 index 00000000000..158b7a27544 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/$chatId/page.tsx @@ -0,0 +1,96 @@ +import { createStream } from "@superset/ai-chat/stream"; +import { Button } from "@superset/ui/button"; +import { ScrollArea } from "@superset/ui/scroll-area"; +import { cn } from "@superset/ui/utils"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useCallback, useEffect } from "react"; +import { env } from "renderer/env.renderer"; +import { ChatView } from "../components/ChatView"; +import { useChatStore } from "../stores/chatStore"; + +export const Route = createFileRoute( + "/_authenticated/_dashboard/chats/$chatId/", +)({ + component: ChatDetailPage, +}); + +function ChatDetailPage() { + const { chatId } = Route.useParams(); + const navigate = useNavigate(); + const { sessions, createSession } = useChatStore(); + + // Ensure stream exists when page loads + useEffect(() => { + createStream(env.NEXT_PUBLIC_STREAMS_URL, chatId).catch((err) => { + console.error("[chats] Failed to ensure stream exists on load:", err); + }); + }, [chatId]); + + const handleCreateChat = useCallback(async () => { + const session = createSession(); + try { + await createStream(env.NEXT_PUBLIC_STREAMS_URL, session.id); + } catch (err) { + console.error("[chats] Failed to create stream:", err); + } + navigate({ to: "/chats/$chatId", params: { chatId: session.id } }); + }, [navigate, createSession]); + + const handleSelectChat = useCallback( + async (id: string) => { + try { + await createStream(env.NEXT_PUBLIC_STREAMS_URL, id); + } catch (err) { + console.error("[chats] Failed to ensure stream exists:", err); + } + navigate({ to: "/chats/$chatId", params: { chatId: id } }); + }, + [navigate], + ); + + return ( +
+ {/* Sidebar */} +
+
+ +
+ + +
+ {sessions.length === 0 ? ( +
+ No chats yet. +
+ ) : ( + sessions.map((session) => ( + + )) + )} +
+
+
+ + {/* Chat View */} +
+ +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessage/ChatMessage.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessage/ChatMessage.tsx new file mode 100644 index 00000000000..b779959c25a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessage/ChatMessage.tsx @@ -0,0 +1,157 @@ +/** + * Individual chat message component + */ + +import type { BetaContentBlock, ToolResult } from "@superset/ai-chat/stream"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@superset/ui/collapsible"; +import { cn } from "@superset/ui/utils"; +import { useState } from "react"; +import { LuChevronRight } from "react-icons/lu"; +import ReactMarkdown from "react-markdown"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize from "rehype-sanitize"; +import remarkGfm from "remark-gfm"; +import { ToolCallPart } from "../ToolCallPart"; + +export interface ChatMessageProps { + role?: "user" | "assistant"; + content: string; + contentBlocks?: BetaContentBlock[]; + toolResults?: Map; + timestamp?: Date; + isStreaming?: boolean; +} + +function ThinkingBlock({ thinking }: { thinking: string }) { + const [isOpen, setIsOpen] = useState(false); + + return ( + + + + Thinking + + +
+ {thinking} +
+
+
+ ); +} + +function AssistantContent({ + content, + contentBlocks, + toolResults, +}: { + content: string; + contentBlocks?: BetaContentBlock[]; + toolResults?: Map; +}) { + if (!contentBlocks || contentBlocks.length === 0) { + return ( +
+ + {content} + +
+ ); + } + + return ( +
+ {contentBlocks.map((block, index) => { + const key = `${block.type}-${index}`; + switch (block.type) { + case "text": + return ( +
+ + {block.text} + +
+ ); + case "tool_use": + return ( + + ); + case "thinking": + return ; + default: + return ( +
+ {block.type} block +
+ ); + } + })} +
+ ); +} + +export function ChatMessage({ + role = "assistant", + content, + contentBlocks, + toolResults, + timestamp, + isStreaming, +}: ChatMessageProps) { + const isUser = role === "user"; + + return ( +
+
+ {isUser ? ( +

{content}

+ ) : ( + + )} + {timestamp && ( + + {timestamp.toLocaleTimeString()} + + )} + {isStreaming && ( + + )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessage/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessage/index.ts new file mode 100644 index 00000000000..33fe9a96dee --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessage/index.ts @@ -0,0 +1,2 @@ +export type { ChatMessageProps } from "./ChatMessage"; +export { ChatMessage } from "./ChatMessage"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessageList/ChatMessageList.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessageList/ChatMessageList.tsx new file mode 100644 index 00000000000..c53c8cc6d14 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessageList/ChatMessageList.tsx @@ -0,0 +1,79 @@ +/** + * Chat message list with auto-scroll + */ + +import type { BetaContentBlock, ToolResult } from "@superset/ai-chat/stream"; +import { ScrollArea } from "@superset/ui/scroll-area"; +import { cn } from "@superset/ui/utils"; +import { useEffect, useRef } from "react"; +import { ChatMessage } from "../ChatMessage"; + +export interface Message { + id: string; + role: "user" | "assistant"; + content: string; + contentBlocks?: BetaContentBlock[]; + toolResults?: Map; + createdAt: Date; +} + +export interface StreamingMessage { + type: "streaming"; + content: string; + contentBlocks?: BetaContentBlock[]; + toolResults?: Map; +} + +export interface ChatMessageListProps { + messages: Array; + className?: string; + autoScroll?: boolean; +} + +export function ChatMessageList({ + messages, + className, + autoScroll = true, +}: ChatMessageListProps) { + const scrollRef = useRef(null); + const bottomRef = useRef(null); + + useEffect(() => { + if (autoScroll && bottomRef.current) { + bottomRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [autoScroll, messages]); + + return ( + +
+ {messages.map((msg, index) => { + if ("type" in msg && msg.type === "streaming") { + return ( + + ); + } + + const message = msg as Message; + return ( + + ); + })} +
+
+ + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessageList/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessageList/index.ts new file mode 100644 index 00000000000..10ab1635ba5 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatMessageList/index.ts @@ -0,0 +1,6 @@ +export type { + ChatMessageListProps, + Message, + StreamingMessage, +} from "./ChatMessageList"; +export { ChatMessageList } from "./ChatMessageList"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatView/ChatView.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatView/ChatView.tsx new file mode 100644 index 00000000000..ecfbf4ab4d6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatView/ChatView.tsx @@ -0,0 +1,147 @@ +/** + * Chat View - desktop app chat interface + * + * Messages are materialized from the durable stream via useChatSession. + * This provides persistence and multi-client sync. + */ + +import { ChatInput, PresenceBar } from "@superset/ai-chat/components"; +import { useChatSession } from "@superset/ai-chat/stream"; +import { cn } from "@superset/ui/utils"; +import { useCallback, useMemo, useState } from "react"; +import { env } from "renderer/env.renderer"; +import { authClient } from "renderer/lib/auth-client"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { getMostRecentWorkspacePath } from "renderer/lib/workspace-utils"; +import { + ChatMessageList, + type Message, + type StreamingMessage, +} from "../ChatMessageList"; + +export interface ChatViewProps { + sessionId: string; + className?: string; +} + +export function ChatView({ sessionId, className }: ChatViewProps) { + const { data: session } = authClient.useSession(); + const user = session?.user + ? { userId: session.user.id, name: session.user.name ?? "Unknown" } + : null; + + const { users, messages, streamingMessage, draft, setDraft, sendMessage } = + useChatSession({ + proxyUrl: env.NEXT_PUBLIC_STREAMS_URL, + sessionId, + user, + autoConnect: !!user, + }); + + const startSessionMutation = electronTrpc.aiChat.startSession.useMutation(); + const { data: isActive, refetch: refetchIsActive } = + electronTrpc.aiChat.isSessionActive.useQuery({ sessionId }); + + // Query workspaces to get the most recently opened workspace path for cwd + const { data: workspaceGroups } = + electronTrpc.workspaces.getAllGrouped.useQuery(); + const mostRecentWorkspacePath = useMemo( + () => + workspaceGroups ? getMostRecentWorkspacePath(workspaceGroups) : null, + [workspaceGroups], + ); + + const handleStartSession = useCallback(async () => { + if (!mostRecentWorkspacePath) { + console.error("[ChatView] No workspace available to start session"); + return; + } + await startSessionMutation.mutateAsync({ + sessionId, + cwd: mostRecentWorkspacePath, + }); + await refetchIsActive(); + }, [ + sessionId, + startSessionMutation, + refetchIsActive, + mostRecentWorkspacePath, + ]); + + const [isSending, setIsSending] = useState(false); + const handleSend = useCallback( + async (content: string) => { + setIsSending(true); + setDraft(""); + try { + await sendMessage(content); + } finally { + setIsSending(false); + } + }, + [sendMessage, setDraft], + ); + + const allMessages = useMemo((): Array => { + const result: Array = messages.map((m) => ({ + id: m.id, + role: m.role as "user" | "assistant", + content: m.content, + contentBlocks: m.contentBlocks, + toolResults: m.toolResults, + createdAt: m.createdAt, + })); + if (streamingMessage) { + result.push({ + type: "streaming", + content: streamingMessage.content, + contentBlocks: streamingMessage.contentBlocks, + toolResults: streamingMessage.toolResults, + }); + } + return result; + }, [messages, streamingMessage]); + + return ( +
+ + + + +
+ {isActive ? ( + + ) : ( +
+ + {mostRecentWorkspacePath + ? "Session not active" + : "No workspace available"} + + +
+ )} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatView/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatView/index.ts new file mode 100644 index 00000000000..a6ee6b7ea81 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ChatView/index.ts @@ -0,0 +1,2 @@ +export type { ChatViewProps } from "./ChatView"; +export { ChatView } from "./ChatView"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ToolCallPart/ToolCallPart.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ToolCallPart/ToolCallPart.tsx new file mode 100644 index 00000000000..b2ae47bf164 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ToolCallPart/ToolCallPart.tsx @@ -0,0 +1,59 @@ +import type { BetaToolUseBlock, ToolResult } from "@superset/ai-chat/stream"; +import { Badge } from "@superset/ui/badge"; +import { cn } from "@superset/ui/utils"; + +interface ToolCallPartProps { + block: BetaToolUseBlock; + result?: ToolResult; +} + +function getToolState(result?: ToolResult) { + if (!result) return { label: "Running", variant: "secondary" as const }; + if (result.isError) + return { label: "Error", variant: "destructive" as const }; + return { label: "Completed", variant: "default" as const }; +} + +export function ToolCallPart({ block, result }: ToolCallPartProps) { + const state = getToolState(result); + + return ( +
+
+ {block.name} + + {state.label} + +
+ {block.input != null && + typeof block.input === "object" && + Object.keys(block.input).length > 0 && ( +
+

+ Input +

+
+							{JSON.stringify(block.input, null, 2)}
+						
+
+ )} + {result && ( +
+

+ Output +

+
+						{result.output || "(empty)"}
+					
+
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ToolCallPart/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ToolCallPart/index.ts new file mode 100644 index 00000000000..3ef8afecd22 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/components/ToolCallPart/index.ts @@ -0,0 +1 @@ +export { ToolCallPart } from "./ToolCallPart"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/page.tsx new file mode 100644 index 00000000000..9f59779cc83 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/page.tsx @@ -0,0 +1,89 @@ +import { createStream } from "@superset/ai-chat/stream"; +import { Button } from "@superset/ui/button"; +import { ScrollArea } from "@superset/ui/scroll-area"; +import { cn } from "@superset/ui/utils"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useCallback } from "react"; +import { env } from "renderer/env.renderer"; +import { useChatStore } from "./stores/chatStore"; + +export const Route = createFileRoute("/_authenticated/_dashboard/chats/")({ + component: ChatIndexPage, +}); + +function ChatIndexPage() { + const navigate = useNavigate(); + const { sessions, createSession } = useChatStore(); + + const handleCreateChat = useCallback(async () => { + const session = createSession(); + try { + await createStream(env.NEXT_PUBLIC_STREAMS_URL, session.id); + } catch (err) { + console.error("[chats] Failed to create stream:", err); + } + navigate({ to: "/chats/$chatId", params: { chatId: session.id } }); + }, [navigate, createSession]); + + const handleSelectChat = useCallback( + async (chatId: string) => { + try { + await createStream(env.NEXT_PUBLIC_STREAMS_URL, chatId); + } catch (err) { + console.error("[chats] Failed to ensure stream exists:", err); + } + navigate({ to: "/chats/$chatId", params: { chatId } }); + }, + [navigate], + ); + + return ( +
+ {/* Sidebar */} +
+
+ +
+ + +
+ {sessions.length === 0 ? ( +
+ No chats yet. +
+ Click "+ New Chat" to start. +
+ ) : ( + sessions.map((session) => ( + + )) + )} +
+
+
+ + {/* Main area - empty state */} +
+
+

💬

+

Select a chat or create a new one

+
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/stores/chatStore.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/stores/chatStore.ts new file mode 100644 index 00000000000..28dd5d8db2f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/chats/stores/chatStore.ts @@ -0,0 +1,90 @@ +/** + * Local Chat Store + * + * Stores chat session metadata using zustand with localStorage persistence. + * Also syncs sessions with the streams server for cross-device access. + */ + +import { env } from "renderer/env.renderer"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +export interface ChatSession { + id: string; + name: string; + createdAt: string; + updatedAt: string; +} + +interface ChatStore { + sessions: ChatSession[]; + createSession: (name?: string) => ChatSession; + deleteSession: (id: string) => void; + renameSession: (id: string, name: string) => void; +} + +/** + * Sync session to streams server (fire-and-forget) + */ +function syncToStreamsServer(session: ChatSession): void { + const streamsUrl = env.NEXT_PUBLIC_STREAMS_URL; + if (!streamsUrl) return; + + fetch(`${streamsUrl}/sessions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sessionId: session.id, + title: session.name, + }), + }).catch((err) => { + console.warn("[chatStore] Failed to sync session to streams server:", err); + }); +} + +export const useChatStore = create()( + persist( + (set, get) => ({ + sessions: [], + + createSession: (name?: string) => { + const id = crypto.randomUUID(); + const now = new Date().toISOString(); + const session: ChatSession = { + id, + name: name ?? `Chat ${get().sessions.length + 1}`, + createdAt: now, + updatedAt: now, + }; + + set((state) => ({ + sessions: [session, ...state.sessions], + })); + + // Sync to streams server for mobile access + syncToStreamsServer(session); + + return session; + }, + + deleteSession: (id: string) => { + set((state) => ({ + sessions: state.sessions.filter((s) => s.id !== id), + })); + }, + + renameSession: (id: string, name: string) => { + set((state) => ({ + sessions: state.sessions.map((s) => + s.id === id + ? { ...s, name, updatedAt: new Date().toISOString() } + : s, + ), + })); + }, + }), + { + name: "chat-sessions", + }, + ), +); diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ChatInputButton/ChatInputButton.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ChatInputButton/ChatInputButton.tsx new file mode 100644 index 00000000000..ce17ffc6116 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ChatInputButton/ChatInputButton.tsx @@ -0,0 +1,38 @@ +import { createStream } from "@superset/ai-chat/stream"; +import { cn } from "@superset/ui/utils"; +import { useNavigate } from "@tanstack/react-router"; +import { Sparkles } from "lucide-react"; +import { useCallback } from "react"; +import { env } from "renderer/env.renderer"; +import { useChatStore } from "../../../../chats/stores/chatStore"; + +const STREAM_SERVER_URL = env.NEXT_PUBLIC_STREAMS_URL; + +export function ChatInputButton() { + const navigate = useNavigate(); + const { createSession } = useChatStore(); + + const handleClick = useCallback(async () => { + const session = createSession(); + await createStream(STREAM_SERVER_URL, session.id); + navigate({ to: "/chats/$chatId", params: { chatId: session.id } }); + }, [navigate, createSession]); + + return ( + + ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ChatInputButton/index.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ChatInputButton/index.ts new file mode 100644 index 00000000000..1217b5c6885 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/components/TopBar/components/ChatInputButton/index.ts @@ -0,0 +1 @@ +export { ChatInputButton } from "./ChatInputButton"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx index 36db8570ed5..f3aee925d81 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebarHeader/WorkspaceSidebarHeader.tsx @@ -1,8 +1,10 @@ +import { FEATURE_FLAGS } from "@superset/shared/constants"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { cn } from "@superset/ui/utils"; import { useMatchRoute, useNavigate } from "@tanstack/react-router"; +import { useFeatureFlagEnabled } from "posthog-js/react"; import { HiOutlineClipboardDocumentList } from "react-icons/hi2"; -import { LuLayers } from "react-icons/lu"; +import { LuLayers, LuMessageSquare } from "react-icons/lu"; import { GATED_FEATURES, usePaywall, @@ -20,9 +22,11 @@ export function WorkspaceSidebarHeader({ const navigate = useNavigate(); const matchRoute = useMatchRoute(); const { gateFeature } = usePaywall(); + const showChat = useFeatureFlagEnabled(FEATURE_FLAGS.AI_CHAT); const isWorkspacesListOpen = !!matchRoute({ to: "/workspaces" }); const isTasksOpen = !!matchRoute({ to: "/tasks", fuzzy: true }); + const isChatOpen = !!matchRoute({ to: "/chats" }); const handleWorkspacesClick = () => { if (isWorkspacesListOpen) { @@ -39,6 +43,10 @@ export function WorkspaceSidebarHeader({ }); }; + const handleChatClick = () => { + navigate({ to: "/chats" }); + }; + if (isCollapsed) { return (
@@ -81,6 +89,29 @@ export function WorkspaceSidebarHeader({ Tasks + {showChat && ( + + + + + Chat + + )} +
); @@ -123,6 +154,24 @@ export function WorkspaceSidebarHeader({ Tasks + {showChat && ( + + )} +
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx index 6924e6c9571..cb32a2c9d9b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupStrip/GroupStrip.tsx @@ -1,3 +1,4 @@ +import { createStream } from "@superset/ai-chat/stream"; import { Button } from "@superset/ui/button"; import { DropdownMenu, @@ -8,6 +9,7 @@ import { } from "@superset/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; import { useNavigate, useParams } from "@tanstack/react-router"; +import { MessageCircle } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { HiMiniChevronDown, @@ -21,7 +23,9 @@ import { useIsDarkTheme, } from "renderer/assets/app-icons/preset-icons"; import { HotkeyTooltipContent } from "renderer/components/HotkeyTooltipContent"; +import { env } from "renderer/env.renderer"; import { usePresets } from "renderer/react-query/presets"; +import { useChatStore } from "renderer/routes/_authenticated/_dashboard/chats/stores/chatStore"; import { useTabsStore } from "renderer/stores/tabs/store"; import { useTabsWithPresets } from "renderer/stores/tabs/useTabsWithPresets"; import { @@ -33,6 +37,9 @@ import { PresetMenuItemShortcut } from "./components/PresetMenuItemShortcut"; import { GroupItem } from "./GroupItem"; import { NewTabDropZone } from "./NewTabDropZone"; +const STREAM_SERVER_URL = + env.NEXT_PUBLIC_STREAMS_URL || "http://localhost:8080"; + export function GroupStrip() { const { workspaceId: activeWorkspaceId } = useParams({ strict: false }); @@ -47,7 +54,9 @@ export function GroupStrip() { const movePaneToTab = useTabsStore((s) => s.movePaneToTab); const movePaneToNewTab = useTabsStore((s) => s.movePaneToNewTab); const reorderTabs = useTabsStore((s) => s.reorderTabs); + const addChatPane = useTabsStore((s) => s.addChatPane); + const { createSession } = useChatStore(); const { presets } = usePresets(); const isDark = useIsDarkTheme(); const navigate = useNavigate(); @@ -89,6 +98,16 @@ export function GroupStrip() { addTab(activeWorkspaceId); }; + const handleAddChat = useCallback(async () => { + if (!activeWorkspaceId) return; + const session = createSession(); + await createStream(STREAM_SERVER_URL, session.id); + addChatPane(activeWorkspaceId, { + sessionId: session.id, + name: session.name, + }); + }, [activeWorkspaceId, createSession, addChatPane]); + const handleSelectPreset = (preset: Parameters[1]) => { if (!activeWorkspaceId) return; openPreset(activeWorkspaceId, preset); @@ -180,75 +199,92 @@ export function GroupStrip() { onDrop={(paneId) => movePaneToNewTab(paneId)} isLastPaneInTab={checkIsLastPaneInTab} > - -
- - +
+ + + + + + New Chat + + + +
+ + + + + + + + + - - - - - - -
+ + {presets.length > 0 && ( + <> + {presets.map((preset, index) => { + const presetIcon = getPresetIcon(preset.name, isDark); + return ( + handleSelectPreset(preset)} + className="gap-2" + > + {presetIcon ? ( + + ) : ( + + )} + + {preset.name || "default"} + + {preset.isDefault && ( + + )} + + + ); + })} + + + )} + - - - -
- - {presets.length > 0 && ( - <> - {presets.map((preset, index) => { - const presetIcon = getPresetIcon(preset.name, isDark); - return ( - handleSelectPreset(preset)} - className="gap-2" - > - {presetIcon ? ( - - ) : ( - - )} - - {preset.name || "default"} - - {preset.isDefault && ( - - )} - - - ); - })} - - - )} - - - Configure Presets - - - + + Configure Presets + + + +
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx new file mode 100644 index 00000000000..188f72c557d --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/ChatPane/ChatPane.tsx @@ -0,0 +1,408 @@ +/** + * Chat Pane - workspace chat interface using AI elements + * + * Messages are materialized from the durable stream via useChatSession. + * This mirrors the dashboard ChatView but with improved UI using AI elements. + */ + +import type { BetaContentBlock, ToolResult } from "@superset/ai-chat/stream"; +import { useChatSession } from "@superset/ai-chat/stream"; +import { + Conversation, + ConversationContent, + ConversationEmptyState, + ConversationScrollButton, +} from "@superset/ui/ai-elements/conversation"; +import { Loader } from "@superset/ui/ai-elements/loader"; +import { + Message, + MessageContent, + MessageResponse, +} from "@superset/ui/ai-elements/message"; +import { Button } from "@superset/ui/button"; +import { CornerDownLeft, MessageCircle, Sparkles } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { MosaicBranch } from "react-mosaic-component"; +import { env } from "renderer/env.renderer"; +import { authClient } from "renderer/lib/auth-client"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { getMostRecentWorkspacePath } from "renderer/lib/workspace-utils"; +import { useTabsStore } from "renderer/stores/tabs/store"; +import type { Tab } from "renderer/stores/tabs/types"; +import { BasePaneWindow, PaneToolbarActions } from "../components"; + +interface ChatPaneProps { + paneId: string; + path: MosaicBranch[]; + isActive: boolean; + tabId: string; + splitPaneAuto: ( + tabId: string, + sourcePaneId: string, + dimensions: { width: number; height: number }, + path?: MosaicBranch[], + ) => void; + splitPaneHorizontal: ( + tabId: string, + sourcePaneId: string, + path?: MosaicBranch[], + ) => void; + splitPaneVertical: ( + tabId: string, + sourcePaneId: string, + path?: MosaicBranch[], + ) => void; + removePane: (paneId: string) => void; + setFocusedPane: (tabId: string, paneId: string) => void; + availableTabs: Tab[]; + onMoveToTab: (targetTabId: string) => void; + onMoveToNewTab: () => void; +} + +interface ChatMessageItem { + id: string; + role: "user" | "assistant"; + content: string; + contentBlocks?: BetaContentBlock[]; + toolResults?: Map; + isStreaming?: boolean; +} + +function ChatMessageBlock({ msg }: { msg: ChatMessageItem }) { + if (msg.role === "user") { + return ( + + {msg.content} + + ); + } + + // Extract text from content blocks, falling back to content string + const textContent = + msg.contentBlocks + ?.filter( + (b): b is BetaContentBlock & { type: "text" } => b.type === "text", + ) + .map((b) => b.text) + .join("\n") || msg.content; + + return ( + + + + {textContent} + + + + ); +} + +export function ChatPane({ + paneId, + path, + isActive, + tabId, + splitPaneAuto, + splitPaneHorizontal: _splitPaneHorizontal, + splitPaneVertical: _splitPaneVertical, + removePane, + setFocusedPane, + availableTabs: _availableTabs, + onMoveToTab: _onMoveToTab, + onMoveToNewTab: _onMoveToNewTab, +}: ChatPaneProps) { + const sessionId = useTabsStore((s) => s.panes[paneId]?.chat?.sessionId); + const paneName = useTabsStore((s) => s.panes[paneId]?.name); + + console.log(`[ChatPane] Render paneId=${paneId} sessionId=${sessionId}`); + + const { data: session } = authClient.useSession(); + const user = session?.user + ? { userId: session.user.id, name: session.user.name ?? "Unknown" } + : null; + + // Hook up to durable stream - same as dashboard ChatView + const { + messages, + streamingMessage, + draft, + setDraft, + sendMessage, + connectionStatus, + isLoading, + } = useChatSession({ + proxyUrl: env.NEXT_PUBLIC_STREAMS_URL, + sessionId: sessionId ?? "", + user, + autoConnect: !!user && !!sessionId, + }); + + console.log( + `[ChatPane] connectionStatus=${connectionStatus} isLoading=${isLoading} messages=${messages.length} streaming=${!!streamingMessage}`, + ); + + const startSessionMutation = electronTrpc.aiChat.startSession.useMutation(); + const { data: isSessionActive, refetch: refetchIsActive } = + electronTrpc.aiChat.isSessionActive.useQuery( + { sessionId: sessionId ?? "" }, + { enabled: !!sessionId }, + ); + + console.log( + `[ChatPane] isSessionActive=${isSessionActive} user=${!!user}`, + ); + + const { data: workspaceGroups } = + electronTrpc.workspaces.getAllGrouped.useQuery(); + const mostRecentWorkspacePath = useMemo( + () => + workspaceGroups ? getMostRecentWorkspacePath(workspaceGroups) : null, + [workspaceGroups], + ); + + // Auto-start session when pane mounts and session is not active + const hasAutoStarted = useRef(false); + useEffect(() => { + if ( + hasAutoStarted.current || + !sessionId || + isSessionActive || + isSessionActive === undefined || + !mostRecentWorkspacePath || + startSessionMutation.isPending + ) { + return; + } + + hasAutoStarted.current = true; + console.log( + `[ChatPane] Auto-starting session ${sessionId} in ${mostRecentWorkspacePath}`, + ); + startSessionMutation + .mutateAsync({ sessionId, cwd: mostRecentWorkspacePath }) + .then(() => { + console.log(`[ChatPane] Session started successfully`); + return refetchIsActive(); + }) + .catch((err) => + console.error("[ChatPane] Failed to auto-start session:", err), + ); + }, [ + sessionId, + isSessionActive, + mostRecentWorkspacePath, + startSessionMutation, + refetchIsActive, + ]); + + const [isAwaitingResponse, setIsAwaitingResponse] = useState(false); + + const handleSend = useCallback( + async (content: string) => { + console.log(`[ChatPane] Sending message: "${content.slice(0, 50)}..."`); + setIsAwaitingResponse(true); + setDraft(""); + try { + await sendMessage(content); + console.log(`[ChatPane] Message sent successfully`); + } catch (err) { + console.error("[ChatPane] Failed to send message:", err); + setIsAwaitingResponse(false); + } + }, + [sendMessage, setDraft], + ); + + // Clear awaiting state when streaming starts + useEffect(() => { + if (streamingMessage) { + console.log(`[ChatPane] Streaming started, clearing awaiting state`); + setIsAwaitingResponse(false); + } + }, [streamingMessage]); + + // Handle case where response completes so fast it skips streaming + const prevAssistantCount = useRef(0); + useEffect(() => { + const assistantCount = messages.filter((m) => m.role === "assistant").length; + if (assistantCount > prevAssistantCount.current && isAwaitingResponse) { + console.log(`[ChatPane] New assistant message appeared, clearing awaiting state`); + setIsAwaitingResponse(false); + } + prevAssistantCount.current = assistantCount; + }, [messages, isAwaitingResponse]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (draft.trim() && isSessionActive && !isAwaitingResponse) { + handleSend(draft); + } + } + }, + [draft, isSessionActive, isAwaitingResponse, handleSend], + ); + + // Build allMessages array - same pattern as dashboard ChatView + const allMessages = useMemo((): ChatMessageItem[] => { + console.log( + `[ChatPane] Building allMessages: messages=${messages.length} streamingMessage=${streamingMessage ? `id=${streamingMessage.id.slice(0, 8)} role=${streamingMessage.role} content="${streamingMessage.content.slice(0, 80)}" blocks=${streamingMessage.contentBlocks.length} isComplete=${streamingMessage.isComplete} isStreaming=${streamingMessage.isStreaming}` : "null"}`, + ); + const result: ChatMessageItem[] = messages.map((m) => ({ + id: m.id, + role: m.role as "user" | "assistant", + content: m.content, + contentBlocks: m.contentBlocks, + toolResults: m.toolResults, + })); + if (streamingMessage) { + result.push({ + id: "streaming", + role: "assistant", + content: streamingMessage.content, + contentBlocks: streamingMessage.contentBlocks, + toolResults: streamingMessage.toolResults, + isStreaming: true, + }); + } + console.log( + `[ChatPane] allMessages: ${result.length} items:`, + result.map((m) => ({ + id: m.id.slice(0, 8), + role: m.role, + contentLen: m.content.length, + blocks: m.contentBlocks?.length ?? 0, + isStreaming: m.isStreaming, + })), + ); + return result; + }, [messages, streamingMessage]); + + const renderToolbar = useCallback( + (handlers: { + splitOrientation: "horizontal" | "vertical"; + onSplitPane: (e: React.MouseEvent) => void; + onClosePane: (e: React.MouseEvent) => void; + }) => ( +
+
+ + + {paneName ?? "Chat"} + +
+ +
+ ), + [paneName], + ); + + if (!sessionId) { + return ( + +
+ Session not found +
+
+ ); + } + + const hasMessages = allMessages.length > 0; + const inputDisabled = !isSessionActive || isAwaitingResponse; + + console.log( + `[ChatPane] Render state: hasMessages=${hasMessages} isAwaitingResponse=${isAwaitingResponse} isThinking=${isAwaitingResponse && !streamingMessage} inputDisabled=${inputDisabled} streamingMessage=${!!streamingMessage}`, + ); + + // Show thinking when awaiting response and no streaming response yet + const isThinking = isAwaitingResponse && !streamingMessage; + + return ( + +
+ + {hasMessages || isThinking ? ( + + {allMessages.map((msg) => ( + + ))} + {isThinking && ( + + +
+ + Thinking... +
+
+
+ )} +
+ ) : ( + } + title="Start a conversation" + description={ + isLoading + ? `Connecting... (${connectionStatus})` + : "Ask anything to get started" + } + /> + )} + +
+ +
+
+
+