diff --git a/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts b/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts new file mode 100644 index 00000000000..864eebc95dc --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/browser-automation/index.ts @@ -0,0 +1,486 @@ +import { EventEmitter } from "node:events"; +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + browserAutomationBindings, + projects, + type SelectBrowserAutomationBinding, + workspaces, + worktrees, +} from "@superset/local-db"; +import { observable } from "@trpc/server/observable"; +import { and, eq, ne } from "drizzle-orm"; +import { localDb } from "main/lib/local-db"; +import { getProcessName, getProcessTree } from "main/lib/terminal/port-scanner"; +import { getTerminalHostClient } from "main/lib/terminal-host/client"; +import { getTodoSessionStore } from "main/todo-agent/session-store"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; + +/** + * Browser automation bindings router. + * + * Bindings persist in local-db so they survive app restarts: the terminal + * daemon re-attaches terminal panes and TODO-Agent sessions keep running, + * so losing the binding would force a re-connect on every launch. + * + * Also exposes MCP-readiness detection by reading the user's agent config + * files (Claude Code / Codex) for the `superset-browser` entry. + */ + +export type BrowserAutomationBinding = SelectBrowserAutomationBinding; + +class BindingStore { + private readonly emitter = new EventEmitter(); + + constructor() { + // One subscription per renderer hook instance; a workspace with many + // open panes can blow past Node's 10-listener default otherwise. + this.emitter.setMaxListeners(0); + } + + list(): BrowserAutomationBinding[] { + return localDb.select().from(browserAutomationBindings).all(); + } + + get(paneId: string): BrowserAutomationBinding | null { + return ( + localDb + .select() + .from(browserAutomationBindings) + .where(eq(browserAutomationBindings.paneId, paneId)) + .get() ?? null + ); + } + + getBySessionId(sessionId: string): BrowserAutomationBinding | null { + return ( + localDb + .select() + .from(browserAutomationBindings) + .where(eq(browserAutomationBindings.sessionId, sessionId)) + .get() ?? null + ); + } + + set( + paneId: string, + sessionId: string, + sessionKind: string, + ): { previousPaneId: string | null } { + // Remove any existing binding that points at the same session on a + // different pane so we enforce 1 session ↔ 1 pane. + const existingOtherPane = localDb + .select() + .from(browserAutomationBindings) + .where( + and( + eq(browserAutomationBindings.sessionId, sessionId), + ne(browserAutomationBindings.paneId, paneId), + ), + ) + .get(); + const previousPaneId = existingOtherPane?.paneId ?? null; + if (previousPaneId) { + localDb + .delete(browserAutomationBindings) + .where(eq(browserAutomationBindings.paneId, previousPaneId)) + .run(); + } + const row = { + paneId, + sessionId, + sessionKind, + connectedAt: Date.now(), + }; + // Drizzle SQLite upsert via onConflictDoUpdate + localDb + .insert(browserAutomationBindings) + .values(row) + .onConflictDoUpdate({ + target: browserAutomationBindings.paneId, + set: { + sessionId: row.sessionId, + sessionKind: row.sessionKind, + connectedAt: row.connectedAt, + }, + }) + .run(); + this.emitChange(); + return { previousPaneId }; + } + + remove(paneId: string): boolean { + const result = localDb + .delete(browserAutomationBindings) + .where(eq(browserAutomationBindings.paneId, paneId)) + .run(); + if (result.changes > 0) { + this.emitChange(); + return true; + } + return false; + } + + private emitChange() { + this.emitter.emit("change", this.list()); + } + + onChange(cb: (bindings: BrowserAutomationBinding[]) => void): () => void { + this.emitter.on("change", cb); + return () => { + this.emitter.off("change", cb); + }; + } +} + +export const bindingStore = new BindingStore(); + +const SERVER_NAME = "superset-browser"; + +function isEnabledMcpEntry(value: unknown): boolean { + if (value == null || typeof value !== "object") return false; + const entry = value as Record; + if (entry.disabled === true) return false; + // An entry needs at minimum a command/url/args hint to be usable. + return ( + typeof entry.command === "string" || + typeof entry.url === "string" || + Array.isArray(entry.args) + ); +} + +/** + * Claude Code writes MCP server definitions into several possible files: + * - `~/.claude.json` (user scope, written by `claude mcp add`) + * - `~/.claude/settings.json` (legacy / hooks-oriented) + * - `/.mcp.json` (project scope) + * We inspect all of them and accept the server if any file contains an + * enabled entry. Each file is parsed as JSON and we look under + * `mcpServers[name]`. + */ +function mcpServersInObject(obj: unknown): Record | null { + if (!obj || typeof obj !== "object") return null; + const candidate = (obj as Record).mcpServers; + if (!candidate || typeof candidate !== "object") return null; + return candidate as Record; +} + +/** + * Claude `~/.claude.json` holds MCP entries in two places: + * - top-level `mcpServers[name]` (user scope) + * - `projects[].mcpServers[name]` (local scope, default for + * `claude mcp add`) + * We accept either. Other config files (`.claude/settings.json`, + * `/.mcp.json`) only use the top-level shape. + */ +function detectClaudeMcpInFile( + filePath: string, + opts?: { workspacePaths?: readonly string[] }, +): boolean { + try { + const contents = readFileSync(filePath, "utf8"); + const parsed = JSON.parse(contents) as unknown; + const topLevel = mcpServersInObject(parsed); + if (topLevel && isEnabledMcpEntry(topLevel[SERVER_NAME])) return true; + const projects = (parsed as Record | null)?.projects; + if (projects && typeof projects === "object" && opts?.workspacePaths) { + for (const wsPath of opts.workspacePaths) { + const project = (projects as Record)[wsPath]; + const entries = mcpServersInObject(project); + if (entries && isEnabledMcpEntry(entries[SERVER_NAME])) return true; + } + } + return false; + } catch { + return false; + } +} + +function detectClaudeMcp( + paths: readonly string[], + opts?: { workspacePaths?: readonly string[] }, +): boolean { + return paths.some((p) => detectClaudeMcpInFile(p, opts)); +} + +/** + * Resolve the `.mcp.json` path for each workspace keyed by workspaceId, + * so per-project MCP definitions (output of `claude mcp add -s project`) + * can be considered per-session without letting one configured project + * make sessions from other projects look ready. + */ +interface WorkspacePathInfo { + base: string; + mcpJsonPath: string; +} + +function collectWorkspacePathsByWorkspaceId(): Record< + string, + WorkspacePathInfo +> { + try { + const rows = localDb + .select({ + workspaceId: workspaces.id, + worktreePath: worktrees.path, + mainRepoPath: projects.mainRepoPath, + }) + .from(workspaces) + .leftJoin(projects, eq(projects.id, workspaces.projectId)) + .leftJoin(worktrees, eq(worktrees.id, workspaces.worktreeId)) + .all(); + const out: Record = {}; + for (const row of rows) { + const base = row.worktreePath ?? row.mainRepoPath ?? null; + if (row.workspaceId && base) { + out[row.workspaceId] = { + base, + mcpJsonPath: join(base, ".mcp.json"), + }; + } + } + return out; + } catch { + return {}; + } +} + +/** + * Codex: ~/.codex/config.toml uses `[mcp_servers.]` table sections. + * We avoid pulling in a TOML parser just for this one check — instead we + * isolate the `[mcp_servers.superset-browser]` section and verify it has + * at least one usable field (`command`, `url`, `args`) and is not marked + * `disabled = true`. Comment lines (starting with `#`) are ignored. + */ +function detectCodexMcp(filePath: string): boolean { + try { + const contents = readFileSync(filePath, "utf8"); + // TOML accepts several equivalent header forms for the same table: + // [mcp_servers.superset-browser] + // [mcp_servers."superset-browser"] + // [mcp_servers.'superset-browser'] + // ["mcp_servers".superset-browser] (rarely used) + // The regex below matches the common shapes; it is not a full TOML + // parser but is strict enough that typos and unrelated keys don't + // match. + const q = `["']`; + const name = SERVER_NAME.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); + const sectionRe = new RegExp( + String.raw`(^|\n)\[\s*(?:mcp_servers\.(?:${name}|${q}${name}${q})|${q}mcp_servers${q}\.${name})\s*\]\s*\n([\s\S]*?)(?=\n\[|$)`, + ); + const match = contents.match(sectionRe); + if (!match) return false; + const body = match[2] + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")); + if (body.some((line) => /^disabled\s*=\s*true\b/.test(line))) return false; + return body.some((line) => /^(command|url|args)\s*=/.test(line)); + } catch { + return false; + } +} + +const CLAUDE_USER_JSON_PATH = join(homedir(), ".claude.json"); +const CLAUDE_SETTINGS_JSON_PATH = join(homedir(), ".claude", "settings.json"); +const CLAUDE_CONFIG_PATHS = [CLAUDE_USER_JSON_PATH, CLAUDE_SETTINGS_JSON_PATH]; +const CODEX_CONFIG_PATH = join(homedir(), ".codex", "config.toml"); + +export interface TerminalAgentSession { + paneId: string; + workspaceId: string; + pid: number; + provider: "Claude" | "Codex"; + command: string; + lastAttachedAt?: string; +} + +/** + * Walk every live terminal session's PTY process tree and return the ones + * that currently have a `claude` or `codex` child process. Used so the + * Browser Automation UI can treat "the claude I started in this terminal + * tab" as an LLM session that is connectable to a browser pane. + */ +async function detectTerminalAgentSessions(): Promise { + let sessions: Awaited< + ReturnType["listSessions"]> + >["sessions"]; + try { + const client = getTerminalHostClient(); + const response = await client.listSessions(); + sessions = response.sessions; + } catch (error) { + // Terminal-host daemon is intermittently unavailable (restart races, + // IPC errors). Degrade gracefully so liveness data for non-terminal + // bindings is still returned instead of rejecting the whole query. + console.warn( + "[browser-automation] terminal listSessions failed, skipping terminal probe:", + error, + ); + return []; + } + const out: TerminalAgentSession[] = []; + await Promise.all( + sessions.map(async (s) => { + if (!s.isAlive || typeof s.pid !== "number") return; + const pids = await getProcessTree(s.pid); + // Skip the shell itself (root pid) when matching names so typing + // `claude` at the prompt inside zsh does not cause the shell's + // argv to trigger a match. + const names = await Promise.all( + pids + .filter((p) => p !== s.pid) + .map(async (p) => ({ pid: p, name: await getProcessName(p) })), + ); + const match = names.find( + ({ name }) => name === "claude" || name === "codex", + ); + if (!match) return; + out.push({ + paneId: s.paneId, + workspaceId: s.workspaceId, + pid: match.pid, + provider: match.name === "codex" ? "Codex" : "Claude", + command: match.name, + lastAttachedAt: s.lastAttachedAt, + }); + }), + ); + return out; +} + +export const createBrowserAutomationRouter = () => { + return router({ + getMcpStatus: publicProcedure.query(() => { + // Claude readiness is resolved in two dimensions so a single + // configured project never makes sessions from other projects + // look ready: + // - `claudeHomeReady`: only the top-level (user-scope) + // mcpServers in $HOME files. + // - `claudeReadyByWorkspaceId`: for each workspace, check + // * `~/.claude.json` under `projects[]` + // (local scope, where `claude mcp add` lands by default) + // * `/.mcp.json` (project scope) + const claudeHomeReady = detectClaudeMcp(CLAUDE_CONFIG_PATHS); + const wsInfo = collectWorkspacePathsByWorkspaceId(); + const claudeReadyByWorkspaceId: Record = {}; + for (const [workspaceId, info] of Object.entries(wsInfo)) { + const localScope = detectClaudeMcpInFile(CLAUDE_USER_JSON_PATH, { + workspacePaths: [info.base], + }); + const projectScope = detectClaudeMcpInFile(info.mcpJsonPath); + claudeReadyByWorkspaceId[workspaceId] = localScope || projectScope; + } + const codexReady = detectCodexMcp(CODEX_CONFIG_PATH); + return { + claudeHomeReady, + claudeReadyByWorkspaceId, + codexReady, + claudeConfigPath: CLAUDE_USER_JSON_PATH, + codexConfigPath: CODEX_CONFIG_PATH, + }; + }), + + listTerminalAgentSessions: publicProcedure.query(() => + detectTerminalAgentSessions(), + ), + + listBindings: publicProcedure.query(() => bindingStore.list()), + + /** + * Cheap resolver used by the per-pane `ConnectButton` to decide + * whether a stored binding still maps to a live worker. Runs once + * per window (React Query dedupes the call) so many Connect buttons + * cost one main-process query total. Terminal bindings are resolved + * by scanning the PTY process tree; TODO-Agent bindings by matching + * against the live status whitelist. + */ + listBindingLiveness: publicProcedure.query(async () => { + const bindings = bindingStore.list(); + if (bindings.length === 0) + return [] as Array<{ + paneId: string; + sessionId: string; + sessionKind: string; + live: boolean; + }>; + const hasTerminalBinding = bindings.some( + (b) => b.sessionKind === "terminal", + ); + const hasTodoBinding = bindings.some((b) => b.sessionKind !== "terminal"); + const liveTodoIds = hasTodoBinding + ? new Set( + getTodoSessionStore() + .listAll() + .filter((s) => + ["running", "preparing", "verifying", "waiting"].includes( + s.status, + ), + ) + .map((s) => s.id), + ) + : new Set(); + // Only probe the terminal daemon when at least one binding actually + // points at a terminal — otherwise every Connect button's 15s poll + // would wake the terminal-host and walk every PTY's process tree + // just to confirm TODO-Agent liveness we already have in memory. + const liveTerminalIds = hasTerminalBinding + ? new Set( + (await detectTerminalAgentSessions()).map( + (t) => `terminal:${t.paneId}`, + ), + ) + : new Set(); + return bindings.map((b) => { + const live = + b.sessionKind === "terminal" + ? liveTerminalIds.has(b.sessionId) + : liveTodoIds.has(b.sessionId); + return { + paneId: b.paneId, + sessionId: b.sessionId, + sessionKind: b.sessionKind, + live, + }; + }); + }), + + getBindingByPane: publicProcedure + .input(z.object({ paneId: z.string() })) + .query(({ input }) => bindingStore.get(input.paneId)), + + getBindingBySession: publicProcedure + .input(z.object({ sessionId: z.string() })) + .query(({ input }) => bindingStore.getBySessionId(input.sessionId)), + + setBinding: publicProcedure + .input( + z.object({ + paneId: z.string(), + sessionId: z.string(), + sessionKind: z.enum(["todo-agent", "terminal"]).default("todo-agent"), + }), + ) + .mutation(({ input }) => + bindingStore.set(input.paneId, input.sessionId, input.sessionKind), + ), + + removeBinding: publicProcedure + .input(z.object({ paneId: z.string() })) + .mutation(({ input }) => ({ + removed: bindingStore.remove(input.paneId), + })), + + onBindingsChanged: publicProcedure.subscription(() => { + return observable((emit) => { + emit.next(bindingStore.list()); + const off = bindingStore.onChange((list) => emit.next(list)); + return () => { + off(); + }; + }); + }), + }); +}; diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index e9236595f55..c9f5aa6a341 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -9,6 +9,7 @@ import { createAnalyticsRouter } from "./analytics"; import { createAuthRouter } from "./auth"; import { createAutoUpdateRouter } from "./auto-update"; import { createBrowserRouter } from "./browser/browser"; +import { createBrowserAutomationRouter } from "./browser-automation"; import { createBrowserHistoryRouter } from "./browser-history"; import { createCacheRouter } from "./cache"; import { createChangesRouter } from "./changes"; @@ -53,6 +54,7 @@ export const createAppRouter = ( aivis: createAivisRouter(), analytics: createAnalyticsRouter(), browser: createBrowserRouter(), + browserAutomation: createBrowserAutomationRouter(), browserHistory: createBrowserHistoryRouter(), auth: createAuthRouter(), autoUpdate: createAutoUpdateRouter(), diff --git a/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts b/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts new file mode 100644 index 00000000000..8966b4bf0d4 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useBrowserAutomationData.ts @@ -0,0 +1,140 @@ +import { useMemo } from "react"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { + type AutomationSession, + formatRelativeTime, + type McpStatus, +} from "renderer/stores/browser-automation"; +import { useTabsStore } from "renderer/stores/tabs/store"; + +/** + * Aggregates browser-automation session and binding data from the + * main-process tRPC routers. Sessions come from two sources: + * + * 1. TODO-Agent sessions (Claude Code workers the supervisor is running). + * 2. Terminal panes that currently have a `claude` or `codex` child + * process. The binding key for this kind is the terminal's paneId so + * that it self-heals across shell re-spawns inside the same pane. + * + * MCP readiness is resolved against the user's Claude/Codex config files. + * + * `enabled` controls the expensive queries. The binding query and its + * subscription always run because `ConnectButton` is mounted for every + * browser pane and needs to reflect the binding state without polling. + */ +export function useBrowserAutomationData({ + enabled = true, +}: { + enabled?: boolean; +} = {}) { + const panes = useTabsStore((s) => s.panes); + + const { data: todoSessions = [], refetch: refetchSessions } = + electronTrpc.todoAgent.listAll.useQuery(undefined, { + enabled, + refetchOnWindowFocus: enabled, + refetchInterval: enabled ? 15000 : false, + }); + const { data: terminalAgents = [] } = + electronTrpc.browserAutomation.listTerminalAgentSessions.useQuery( + undefined, + { + enabled, + refetchOnWindowFocus: enabled, + refetchInterval: enabled ? 10000 : false, + }, + ); + const { data: mcpStatus } = + electronTrpc.browserAutomation.getMcpStatus.useQuery(undefined, { + enabled, + refetchOnWindowFocus: enabled, + refetchInterval: enabled ? 30000 : false, + }); + const { data: bindings = [] } = + electronTrpc.browserAutomation.listBindings.useQuery(undefined, { + // Binding changes are pushed via onBindingsChanged, so no polling. + refetchOnWindowFocus: false, + }); + // The binding subscription is centralized in `useBrowserBindingsSync` + // (mounted once in ContentView), so this hook does not open one per + // consumer. + + const sessions: AutomationSession[] = useMemo(() => { + // Only sessions that have a live worker (or are actively scheduled to + // wake up) should be connectable. Queued/paused/aborted/done/failed/ + // escalated sessions either never started or are terminal. + const liveStatuses = new Set([ + "running", + "preparing", + "verifying", + "waiting", + ]); + const claudeReadyForWorkspace = (workspaceId: string | null): McpStatus => { + if (!mcpStatus) return "unknown"; + if (mcpStatus.claudeHomeReady) return "ready"; + if (workspaceId && mcpStatus.claudeReadyByWorkspaceId[workspaceId]) + return "ready"; + return "missing"; + }; + const todo: AutomationSession[] = todoSessions + .filter((s) => liveStatuses.has(s.status)) + .map((s) => { + // Todo-agent rows always represent Claude Code workers (see + // todo-daemon/claude-code-runner.ts). + const provider = "Claude" as const; + const mcp: McpStatus = claudeReadyForWorkspace(s.workspaceId); + const displayName = s.title || `Session ${s.id.slice(0, 6)}`; + const branchOrContext = + s.workspaceBranch ?? + s.workspaceName ?? + (s.projectName ? s.projectName : "workspace"); + return { + id: s.id, + displayName, + provider, + kind: "TODO-Agent", + branchOrContextLabel: branchOrContext, + lastActiveAt: formatRelativeTime(s.updatedAt ?? s.createdAt), + mcpStatus: mcp, + }; + }); + + const terminal: AutomationSession[] = terminalAgents.map((t) => { + const pane = panes[t.paneId]; + const mcp: McpStatus = + t.provider === "Codex" + ? mcpStatus + ? mcpStatus.codexReady + ? "ready" + : "missing" + : "unknown" + : claudeReadyForWorkspace(t.workspaceId); + return { + id: `terminal:${t.paneId}`, + displayName: pane?.userTitle || pane?.name || `Terminal ${t.command}`, + provider: t.provider, + kind: "Terminal", + branchOrContextLabel: t.command, + lastActiveAt: t.lastAttachedAt + ? formatRelativeTime(Date.parse(t.lastAttachedAt)) + : "active", + mcpStatus: mcp, + }; + }); + + return [...todo, ...terminal]; + }, [todoSessions, terminalAgents, mcpStatus, panes]); + + const bindingsByPane = useMemo(() => { + const map: Record = {}; + for (const b of bindings) map[b.paneId] = b.sessionId; + return map; + }, [bindings]); + + return { + sessions, + bindingsByPane, + mcpStatus, + refetchSessions, + }; +} diff --git a/apps/desktop/src/renderer/hooks/useBrowserBindingsSync.ts b/apps/desktop/src/renderer/hooks/useBrowserBindingsSync.ts new file mode 100644 index 00000000000..8606301b900 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/useBrowserBindingsSync.ts @@ -0,0 +1,17 @@ +import { electronTrpc } from "renderer/lib/electron-trpc"; + +/** + * Centralized binding subscription — mount ONCE per window (from + * ContentView). Without this, every `useBrowserAutomationData` consumer + * (one per browser pane) would open its own subscription to the main + * process emitter and fan out an invalidation per binding mutation. + */ +export function useBrowserBindingsSync() { + const utils = electronTrpc.useUtils(); + electronTrpc.browserAutomation.onBindingsChanged.useSubscription(undefined, { + onData: () => { + utils.browserAutomation.listBindings.invalidate(); + utils.browserAutomation.listBindingLiveness.invalidate(); + }, + }); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx index 27676aa52c8..a90f7100d95 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx @@ -5,6 +5,7 @@ import { LuMinus, LuPlus } from "react-icons/lu"; import { TbDeviceDesktop } from "react-icons/tb"; import type { MosaicBranch } from "react-mosaic-component"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useBrowserAutomationStore } from "renderer/stores/browser-automation"; import { findBookmarkByUrl, useBrowserBookmarksStore, @@ -21,7 +22,9 @@ import { } from "./components/BrowserFindOverlay"; import { BrowserToolbar } from "./components/BrowserToolbar"; import { BrowserOverflowMenu } from "./components/BrowserToolbar/components/BrowserOverflowMenu"; +import { ConnectButton } from "./components/ConnectButton"; import { ExtensionToolbar } from "./components/ExtensionToolbar"; +import { SessionConnectModal } from "./components/SessionConnectModal"; import { DEFAULT_BROWSER_URL } from "./constants"; import { usePersistentWebview } from "./hooks/usePersistentWebview"; @@ -74,6 +77,14 @@ export function BrowserPane({ const isFullscreen = useBrowserFullscreenStore( (s) => s.fullscreenPaneId === paneId, ); + // Narrow the subscription so BrowserPane (and its webview tree) does not + // re-render every time the modal's selectedSessionId changes. + const isConnectOpenForThisPane = useBrowserAutomationStore( + (s) => s.connectModal.isOpen && s.connectModal.paneId === paneId, + ); + const closeConnectModal = useBrowserAutomationStore( + (s) => s.closeConnectModal, + ); const { mutate: openDevTools } = electronTrpc.browser.openDevTools.useMutation(); const { mutate: setZoomLevel } = @@ -294,6 +305,8 @@ export function BrowserPane({ onPopOut={handlers.onPopOut} leadingActions={ <> + +
@@ -423,6 +436,12 @@ export function BrowserPane({ )}
+ { + if (!open) closeConnectModal(); + }} + /> ); } diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserAutomationList/BrowserAutomationList.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserAutomationList/BrowserAutomationList.tsx new file mode 100644 index 00000000000..69cd0be9efd --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserAutomationList/BrowserAutomationList.tsx @@ -0,0 +1,166 @@ +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { cn } from "@superset/ui/utils"; +import { useMemo } from "react"; +import { useBrowserAutomationData } from "renderer/hooks/useBrowserAutomationData"; +import { useBrowserAutomationStore } from "renderer/stores/browser-automation"; +import { useTabsStore } from "renderer/stores/tabs/store"; + +interface BrowserAutomationListProps { + workspaceId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function BrowserAutomationList({ + workspaceId, + open, + onOpenChange, +}: BrowserAutomationListProps) { + const panes = useTabsStore((s) => s.panes); + const tabs = useTabsStore((s) => s.tabs); + const setFocusedPane = useTabsStore((s) => s.setFocusedPane); + const setActiveTab = useTabsStore((s) => s.setActiveTab); + const { sessions, bindingsByPane } = useBrowserAutomationData({ + enabled: open, + }); + const openConnectModal = useBrowserAutomationStore((s) => s.openConnectModal); + + const browserPanes = useMemo(() => { + const tabById = new Map(tabs.map((t) => [t.id, t])); + return Object.values(panes).filter( + (p) => + p.type === "webview" && + tabById.get(p.tabId)?.workspaceId === workspaceId, + ); + }, [panes, tabs, workspaceId]); + + // A binding only counts as "connected" if the bound session is still in + // the live session list. Stale bindings render as `Unassigned` in the + // row below, so summing raw bindings would show a misleading higher + // count. + const liveSessionIds = new Set(sessions.map((s) => s.id)); + const connectedCount = browserPanes.filter((p) => { + const sid = bindingsByPane[p.id]; + return sid && liveSessionIds.has(sid); + }).length; + const needsSetupCount = sessions.filter( + (s) => s.mcpStatus === "missing", + ).length; + + return ( + + + + Browser Automation + + All browser panes in this workspace and their bound sessions. + + + +
+ + + +
+ +
+ {browserPanes.length === 0 && ( +
+ No browser panes in this workspace. +
+ )} + {browserPanes.map((pane) => { + const sessionId = bindingsByPane[pane.id]; + const session = sessionId + ? (sessions.find((s) => s.id === sessionId) ?? null) + : null; + const url = pane.browser?.currentUrl ?? pane.url ?? "about:blank"; + return ( +
+
+
+
+ {pane.userTitle || pane.name} +
+
+ {url} +
+
+ + {session ? "Connected" : "Unassigned"} + +
+
+ {session + ? `${session.displayName} · ${session.provider} · ${session.mcpStatus === "ready" ? "MCP ready" : "MCP missing"}` + : "Pick any running LLM session"} +
+
+ + +
+
+ ); + })} +
+
+
+ ); +} + +function Metric({ label, value }: { label: string; value: number }) { + return ( +
+
{value}
+
+ {label} +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserAutomationList/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserAutomationList/index.ts new file mode 100644 index 00000000000..d1e4d30e065 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/BrowserAutomationList/index.ts @@ -0,0 +1 @@ +export { BrowserAutomationList } from "./BrowserAutomationList"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ConnectButton/ConnectButton.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ConnectButton/ConnectButton.tsx new file mode 100644 index 00000000000..04b02d7c8ba --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ConnectButton/ConnectButton.tsx @@ -0,0 +1,57 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; +import { LuPlug } from "react-icons/lu"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useBrowserAutomationStore } from "renderer/stores/browser-automation"; + +interface ConnectButtonProps { + paneId: string; +} + +export function ConnectButton({ paneId }: ConnectButtonProps) { + // Use the centralized liveness query so every Connect button across + // the window shares one fetch. React Query dedupes by key, and the + // binding subscription in `useBrowserBindingsSync` drives + // invalidation when another window mutates. + const { data: liveness = [] } = + electronTrpc.browserAutomation.listBindingLiveness.useQuery(undefined, { + refetchOnWindowFocus: true, + refetchInterval: 15000, + }); + const openConnectModal = useBrowserAutomationStore((s) => s.openConnectModal); + + const entry = liveness.find((b) => b.paneId === paneId) ?? null; + const hasBinding = Boolean(entry); + const live = entry?.live ?? false; + const connected = hasBinding && live; + const stale = hasBinding && !live; + const label = connected ? "Connected" : stale ? "Session ended" : "Connect"; + + return ( + + + + + + {connected + ? "Change or disconnect browser automation session" + : stale + ? "Bound session is no longer running — pick a new one" + : "Connect this browser pane to a running LLM session"} + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ConnectButton/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ConnectButton/index.ts new file mode 100644 index 00000000000..f0cc5bd41e3 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/ConnectButton/index.ts @@ -0,0 +1 @@ +export { ConnectButton } from "./ConnectButton"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx new file mode 100644 index 00000000000..d721db9e870 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/SessionConnectModal.tsx @@ -0,0 +1,523 @@ +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { toast } from "@superset/ui/sonner"; +import { cn } from "@superset/ui/utils"; +import { useEffect, useMemo } from "react"; +import { LuCopy, LuList } from "react-icons/lu"; +import { useBrowserAutomationData } from "renderer/hooks/useBrowserAutomationData"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { + type AutomationSession, + getSnippetForSession, + useBrowserAutomationStore, +} from "renderer/stores/browser-automation"; +import { useTabsStore } from "renderer/stores/tabs/store"; + +interface SessionConnectModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function SessionConnectModal({ + open, + onOpenChange, +}: SessionConnectModalProps) { + const paneId = useBrowserAutomationStore((s) => s.connectModal.paneId); + const selectedSessionId = useBrowserAutomationStore( + (s) => s.connectModal.selectedSessionId, + ); + const setSelectedSession = useBrowserAutomationStore( + (s) => s.setSelectedSession, + ); + const setListViewOpen = useBrowserAutomationStore((s) => s.setListViewOpen); + + const { sessions, bindingsByPane, mcpStatus } = useBrowserAutomationData({ + enabled: open, + }); + + const setBinding = electronTrpc.browserAutomation.setBinding.useMutation(); + const removeBinding = + electronTrpc.browserAutomation.removeBinding.useMutation(); + const utils = electronTrpc.useUtils(); + + const pane = useTabsStore((s) => (paneId ? s.panes[paneId] : null)); + const panes = useTabsStore((s) => s.panes); + + const session = selectedSessionId + ? (sessions.find((s) => s.id === selectedSessionId) ?? null) + : null; + const currentBinding = paneId ? bindingsByPane[paneId] : null; + const currentSession = currentBinding + ? (sessions.find((s) => s.id === currentBinding) ?? null) + : null; + + // Auto-select a sensible default when the modal opens with nothing picked, + // and reset the selection when the chosen session drops out of the live + // set (e.g. it transitioned to `done` / `aborted`). + useEffect(() => { + if (!open || !paneId) return; + const selectedStillLive = + selectedSessionId && sessions.some((s) => s.id === selectedSessionId); + if (selectedStillLive) return; + // Only fall back to the currently-bound session if it is still in the + // live set — otherwise the modal would open with a truthy selection + // whose detail view can't render and whose Connect button stays + // blocked. + const bindingIsLive = + currentBinding && sessions.some((s) => s.id === currentBinding); + const fallback = + (bindingIsLive ? currentBinding : null) ?? sessions[0]?.id ?? null; + // Avoid a re-render loop while queries are still resolving: if the + // computed fallback is already what we have selected, don't touch + // the store. Otherwise a changing `sessions` identity on each load + // pass would keep rewriting `null → null`. + if (fallback === selectedSessionId) return; + setSelectedSession(fallback); + }, [ + open, + paneId, + selectedSessionId, + currentBinding, + sessions, + setSelectedSession, + ]); + + const assignedPaneIdForSelected = useMemo(() => { + if (!selectedSessionId) return null; + const entry = Object.entries(bindingsByPane).find( + ([pid, sid]) => sid === selectedSessionId && pid !== paneId, + ); + return entry?.[0] ?? null; + }, [bindingsByPane, paneId, selectedSessionId]); + + const paneUrl = pane?.browser?.currentUrl ?? pane?.url ?? "about:blank"; + const paneName = pane?.userTitle || pane?.name || "Browser pane"; + + const handleConnect = async () => { + if (!paneId || !session || session.mcpStatus !== "ready") return; + try { + const result = await setBinding.mutateAsync({ + paneId, + sessionId: session.id, + sessionKind: session.id.startsWith("terminal:") + ? "terminal" + : "todo-agent", + }); + await utils.browserAutomation.listBindings.invalidate(); + if (result.previousPaneId) { + const fromPane = panes[result.previousPaneId]; + toast.success( + `${session.displayName} moved from ${fromPane?.name ?? "another pane"} to ${paneName}`, + ); + } else { + toast.success( + `${paneName} is now controlled by ${session.displayName}`, + ); + } + onOpenChange(false); + } catch (error) { + toast.error( + `Failed to connect session: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }; + + const handleDisconnect = async () => { + if (!paneId || !currentBinding) return; + try { + await removeBinding.mutateAsync({ paneId }); + await utils.browserAutomation.listBindings.invalidate(); + const label = currentSession?.displayName ?? "Previous session"; + toast.info(`${label} disconnected from ${paneName}`); + } catch (error) { + toast.error( + `Failed to disconnect: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }; + + const handleCopySnippet = async () => { + if (!session) return; + try { + await navigator.clipboard.writeText(getSnippetForSession(session)); + toast.success("Configuration snippet copied"); + } catch { + toast.error("Failed to copy snippet"); + } + }; + + return ( + + + + + Connect browser automation + + + Choose which running LLM session should control this browser pane. + + + +
+
+
+
+ ◎ +
+
+
{paneName}
+
+ {paneUrl} +
+
+ +
+ +
+ Running sessions +
+ + {sessions.length === 0 ? ( +
+ No running LLM sessions found. Start a TODO-Agent session or run + `claude` / `codex` in any terminal pane, then return here. +
+ ) : ( +
+ {sessions.map((s) => { + const otherPaneId = Object.entries(bindingsByPane).find( + ([pid, sid]) => sid === s.id && pid !== paneId, + )?.[0]; + const otherPaneName = otherPaneId + ? (panes[otherPaneId]?.name ?? null) + : null; + return ( + setSelectedSession(s.id)} + /> + ); + })} +
+ )} +
+ +
+ {session ? ( + session.mcpStatus === "ready" ? ( + + ) : ( + + ) + ) : ( +
+ Select a session to see details. +
+ )} +
+
+ + +
+ {session?.mcpStatus === "ready" + ? assignedPaneIdForSelected + ? `Connecting will reassign ${session.displayName} from ${panes[assignedPaneIdForSelected]?.name ?? "another pane"} to ${paneName}.` + : "Connecting binds this browser pane to the selected session only." + : session + ? "Add the MCP entry first, then reopen or restart this session." + : sessions.length === 0 + ? "Start an LLM session, then pick it here." + : "Select a session from the left."} +
+
+ {currentBinding && ( + + )} + + +
+
+
+
+ ); +} + +function SessionCard({ + session, + isSelected, + assignedElsewherePaneName, + onSelect, +}: { + session: AutomationSession; + isSelected: boolean; + assignedElsewherePaneName: string | null; + onSelect: () => void; +}) { + const pillClass = assignedElsewherePaneName + ? "bg-amber-500/15 text-amber-300" + : session.mcpStatus === "ready" + ? "bg-emerald-500/15 text-emerald-300" + : session.mcpStatus === "missing" + ? "bg-amber-500/15 text-amber-300" + : "bg-muted text-muted-foreground"; + const pillLabel = assignedElsewherePaneName + ? "Reassign" + : session.mcpStatus === "ready" + ? "Ready" + : session.mcpStatus === "missing" + ? "Needs MCP" + : "Unknown"; + const note = assignedElsewherePaneName + ? `${session.displayName} is currently controlling ${assignedElsewherePaneName}. Connecting here moves ownership.` + : session.mcpStatus === "ready" + ? "Browser MCP is configured. Connect will be immediate." + : session.mcpStatus === "missing" + ? "This session does not currently expose the required browser automation MCP entry." + : "Could not verify MCP status for this session."; + + return ( + + ); +} + +function Tag({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function ReadyPanel({ + session, + paneName, + reassigning, + previousPaneName, +}: { + session: AutomationSession; + paneName: string; + reassigning: boolean; + previousPaneName: string | null; +}) { + return ( +
+
+
+ Selected session is ready to connect +
+
+ This session already has the browser automation MCP entry, so the + connect action will immediately bind the pane to this owner. +
+
+ + {session.displayName} / {session.provider} + + Exclusive control + {paneName} + + {reassigning + ? `Moves from ${previousPaneName ?? "another pane"}` + : "Toolbar badge updates instantly"} + +
+
+
+ ); +} + +function DetailItem({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+ + {label} + + + {children} + +
+ ); +} + +function SetupPanel({ + session, + mcpConfigPath, + onCopy, +}: { + session: AutomationSession; + mcpConfigPath: string | null; + onCopy: () => void; +}) { + const snippet = getSnippetForSession(session); + return ( +
+
+
+ This session needs browser MCP setup +
+
+ The connect action will not fail silently. Add the{" "} + superset-browser MCP + server to {session.provider}, then reload this session. +
+
    + {session.provider === "Claude" ? ( + <> +
  1. + Run the command below in a terminal that has the{" "} + claude CLI installed. It will register{" "} + superset-browser in{" "} + ~/.claude.json{" "} + without hand-editing JSON. +
  2. +
  3. + Restart {session.displayName} (or run /mcp in the + session) so the new entry is picked up. +
  4. + + ) : ( + <> +
  5. + Open{" "} + {mcpConfigPath ? ( + {mcpConfigPath} + ) : ( + "your Codex config file" + )} + . +
  6. +
  7. + Append the [mcp_servers.superset-browser] section + below. TOML section-append is safe against existing content. +
  8. +
  9. + Restart {session.displayName} so the new entry is picked up. +
  10. + + )} +
+
+					{snippet}
+				
+
+ +
+
+ MCP readiness is detected by inspecting the config file for the string{" "} + superset-browser. If you prefer a managed location, the + desktop app also ships the server at packages/desktop-mcp + . +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/index.ts new file mode 100644 index 00000000000..bebcf9e5909 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/components/SessionConnectModal/index.ts @@ -0,0 +1 @@ +export { SessionConnectModal } from "./SessionConnectModal"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx index d710ad4994b..d173ba41a76 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx @@ -1,6 +1,8 @@ import type { ExternalApp } from "@superset/local-db"; +import { useBrowserBindingsSync } from "renderer/hooks/useBrowserBindingsSync"; import { isTearoffWindow } from "renderer/hooks/useTearoffInit"; import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useBrowserAutomationStore } from "renderer/stores/browser-automation"; import { useBrowserFullscreenStore } from "renderer/stores/browser-fullscreen"; import { useSidebarStore } from "renderer/stores/sidebar-state"; import { SidebarControl } from "../../SidebarControl"; @@ -10,6 +12,7 @@ import { PresetsBar } from "./components/PresetsBar"; import { TabsContent } from "./TabsContent"; import { GroupStrip } from "./TabsContent/GroupStrip"; import { BulkActionBar } from "./TabsContent/GroupStrip/components/BulkActionBar"; +import { BrowserAutomationList } from "./TabsContent/TabView/BrowserPane/components/BrowserAutomationList"; interface ContentViewProps { workspaceId: string; @@ -32,6 +35,9 @@ export function ContentView({ ); const { data: showPresetsBar } = electronTrpc.settings.getShowPresetsBar.useQuery(); + const listViewOpen = useBrowserAutomationStore((s) => s.listViewOpen); + const setListViewOpen = useBrowserAutomationStore((s) => s.setListViewOpen); + useBrowserBindingsSync(); return (
@@ -56,6 +62,11 @@ export function ContentView({ onOpenInApp={onOpenInApp} onOpenQuickOpen={onOpenQuickOpen} /> +
); } diff --git a/apps/desktop/src/renderer/stores/browser-automation.ts b/apps/desktop/src/renderer/stores/browser-automation.ts new file mode 100644 index 00000000000..f62229cb6af --- /dev/null +++ b/apps/desktop/src/renderer/stores/browser-automation.ts @@ -0,0 +1,94 @@ +import { create } from "zustand"; + +export type McpStatus = "ready" | "missing" | "unknown"; + +export interface AutomationSession { + id: string; + displayName: string; + provider: "Claude" | "Codex" | string; + kind: "Terminal" | "Chat" | string; + branchOrContextLabel: string; + lastActiveAt: string; + mcpStatus: McpStatus; +} + +/** + * UI-only state for the browser-automation feature. + * + * Real data (sessions, bindings, MCP status) lives in main-process tRPC + * routers and is consumed via `useBrowserAutomationData`. This store + * only tracks transient UI state that does not need to survive reloads + * or sync with the main process: + * - Which pane opened the Connect dialog, and which session is + * currently highlighted within it. + * - Whether the cross-pane list view dialog is open. + */ +interface BrowserAutomationUiState { + connectModal: { + isOpen: boolean; + paneId: string | null; + selectedSessionId: string | null; + }; + listViewOpen: boolean; + openConnectModal: (paneId: string, preselectSessionId?: string) => void; + closeConnectModal: () => void; + setSelectedSession: (sessionId: string | null) => void; + setListViewOpen: (open: boolean) => void; +} + +export const useBrowserAutomationStore = create( + (set) => ({ + connectModal: { isOpen: false, paneId: null, selectedSessionId: null }, + listViewOpen: false, + openConnectModal: (paneId, preselectSessionId) => + set({ + connectModal: { + isOpen: true, + paneId, + selectedSessionId: preselectSessionId ?? null, + }, + }), + closeConnectModal: () => + set({ + connectModal: { + isOpen: false, + paneId: null, + selectedSessionId: null, + }, + }), + setSelectedSession: (sessionId) => + set((s) => ({ + connectModal: { ...s.connectModal, selectedSessionId: sessionId }, + })), + setListViewOpen: (open) => set({ listViewOpen: open }), + }), +); + +export function getSnippetForSession(session: AutomationSession): string { + if (session.provider === "Codex") { + // TOML is section-based, so appending this block to an existing + // config.toml is safe. The bin comes from packages/desktop-mcp. + return `[mcp_servers.superset-browser] +command = "desktop-mcp" +args = []`; + } + // For Claude Code we recommend `claude mcp add` so the user's + // ~/.claude.json is updated in-place instead of manually merging a + // standalone JSON document (which would produce invalid JSON). + return `claude mcp add superset-browser -s local -- desktop-mcp`; +} + +function formatRelativeTime(ts: number | null | undefined): string { + if (!ts) return "unknown"; + const diffSec = Math.round((Date.now() - ts) / 1000); + if (diffSec < 5) return "just now"; + if (diffSec < 60) return `${diffSec}s ago`; + const diffMin = Math.round(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHour = Math.round(diffMin / 60); + if (diffHour < 24) return `${diffHour}h ago`; + const diffDay = Math.round(diffHour / 24); + return `${diffDay}d ago`; +} + +export { formatRelativeTime }; diff --git a/packages/local-db/drizzle/0067_add_browser_automation_bindings.sql b/packages/local-db/drizzle/0067_add_browser_automation_bindings.sql new file mode 100644 index 00000000000..62575de3a54 --- /dev/null +++ b/packages/local-db/drizzle/0067_add_browser_automation_bindings.sql @@ -0,0 +1,6 @@ +CREATE TABLE `browser_automation_bindings` ( + `pane_id` text PRIMARY KEY NOT NULL, + `session_id` text NOT NULL, + `session_kind` text DEFAULT 'todo-agent' NOT NULL, + `connected_at` integer NOT NULL +); diff --git a/packages/local-db/drizzle/meta/0067_snapshot.json b/packages/local-db/drizzle/meta/0067_snapshot.json new file mode 100644 index 00000000000..c556951b14e --- /dev/null +++ b/packages/local-db/drizzle/meta/0067_snapshot.json @@ -0,0 +1,2332 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "0cb948e3-be9a-4cd9-b376-5c4fff26d1d4", + "prevId": "0bd710ab-36c7-4906-8231-1e7818ed9d39", + "tables": { + "browser_history": { + "name": "browser_history", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "favicon_url": { + "name": "favicon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_visited_at": { + "name": "last_visited_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "visit_count": { + "name": "visit_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": { + "browser_history_url_unique": { + "name": "browser_history_url_unique", + "columns": [ + "url" + ], + "isUnique": true + }, + "browser_history_url_idx": { + "name": "browser_history_url_idx", + "columns": [ + "url" + ], + "isUnique": false + }, + "browser_history_last_visited_at_idx": { + "name": "browser_history_last_visited_at_idx", + "columns": [ + "last_visited_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "browser_site_permissions": { + "name": "browser_site_permissions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ask'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "browser_site_permissions_origin_idx": { + "name": "browser_site_permissions_origin_idx", + "columns": [ + "origin" + ], + "isUnique": false + }, + "browser_site_permissions_origin_kind_unique": { + "name": "browser_site_permissions_origin_kind_unique", + "columns": [ + "origin", + "kind" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organization_members": { + "name": "organization_members", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organization_members_organization_id_idx": { + "name": "organization_members_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "organization_members_user_id_idx": { + "name": "organization_members_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "organization_members_organization_id_organizations_id_fk": { + "name": "organization_members_organization_id_organizations_id_fk", + "tableFrom": "organization_members", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_members_user_id_users_id_fk": { + "name": "organization_members_user_id_users_id_fk", + "tableFrom": "organization_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "organizations": { + "name": "organizations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_org_id": { + "name": "clerk_org_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "github_org": { + "name": "github_org", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "organizations_clerk_org_id_unique": { + "name": "organizations_clerk_org_id_unique", + "columns": [ + "clerk_org_id" + ], + "isUnique": true + }, + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "organizations_clerk_org_id_idx": { + "name": "organizations_clerk_org_id_idx", + "columns": [ + "clerk_org_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "main_repo_path": { + "name": "main_repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_toast_dismissed": { + "name": "config_toast_dismissed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_base_branch": { + "name": "workspace_base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_owner": { + "name": "github_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hide_image": { + "name": "hide_image", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "neon_project_id": { + "name": "neon_project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_app": { + "name": "default_app", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_import_external_worktrees": { + "name": "auto_import_external_worktrees", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_remove_missing_worktrees": { + "name": "auto_remove_missing_worktrees", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_main_repo_path_idx": { + "name": "projects_main_repo_path_idx", + "columns": [ + "main_repo_path" + ], + "isUnique": false + }, + "projects_last_opened_at_idx": { + "name": "projects_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets": { + "name": "terminal_presets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets_initialized": { + "name": "terminal_presets_initialized", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_preset_overrides": { + "name": "agent_preset_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agent_custom_definitions": { + "name": "agent_custom_definitions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selected_ringtone_id": { + "name": "selected_ringtone_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "confirm_on_quit": { + "name": "confirm_on_quit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_link_behavior": { + "name": "terminal_link_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "persist_terminal": { + "name": "persist_terminal", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "auto_apply_default_preset": { + "name": "auto_apply_default_preset", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_mode": { + "name": "branch_prefix_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_prefix_custom": { + "name": "branch_prefix_custom", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_sounds_muted": { + "name": "notification_sounds_muted", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_volume": { + "name": "notification_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aivis_enabled": { + "name": "aivis_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aivis_api_key": { + "name": "aivis_api_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aivis_model_uuid": { + "name": "aivis_model_uuid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aivis_format": { + "name": "aivis_format", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aivis_format_permission": { + "name": "aivis_format_permission", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aivis_user_dictionary_uuid": { + "name": "aivis_user_dictionary_uuid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aivis_volume": { + "name": "aivis_volume", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aivis_speaking_rate": { + "name": "aivis_speaking_rate", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aivis_model_presets": { + "name": "aivis_model_presets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prevent_agent_sleep": { + "name": "prevent_agent_sleep", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "delete_local_branch": { + "name": "delete_local_branch", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_open_mode": { + "name": "file_open_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_drag_behavior": { + "name": "file_drag_behavior", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "right_sidebar_open_view_width": { + "name": "right_sidebar_open_view_width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_presets_bar": { + "name": "show_presets_bar", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_compact_terminal_add_button": { + "name": "use_compact_terminal_add_button", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_font_family": { + "name": "terminal_font_family", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_font_size": { + "name": "terminal_font_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "editor_font_family": { + "name": "editor_font_family", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "editor_font_size": { + "name": "editor_font_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_resource_monitor": { + "name": "show_resource_monitor", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "worktree_base_dir": { + "name": "worktree_base_dir", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open_links_in_app": { + "name": "open_links_in_app", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_editor": { + "name": "default_editor", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "indent_rainbow_enabled": { + "name": "indent_rainbow_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "indent_rainbow_colors": { + "name": "indent_rainbow_colors", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trailing_spaces_enabled": { + "name": "trailing_spaces_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trailing_spaces_color": { + "name": "trailing_spaces_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reference_graph_enabled": { + "name": "reference_graph_enabled", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expose_host_service_via_relay": { + "name": "expose_host_service_via_relay", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enable_smart_commit": { + "name": "enable_smart_commit", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "smart_commit_changes": { + "name": "smart_commit_changes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_stash": { + "name": "auto_stash", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch_sort_order": { + "name": "branch_sort_order", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pin_default_branch": { + "name": "pin_default_branch", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "post_commit_command": { + "name": "post_commit_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_color": { + "name": "status_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_type": { + "name": "status_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status_position": { + "name": "status_position", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "labels": { + "name": "labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_provider": { + "name": "external_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tasks_slug_unique": { + "name": "tasks_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + "slug" + ], + "isUnique": false + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + "organization_id" + ], + "isUnique": false + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + "assignee_id" + ], + "isUnique": false + }, + "tasks_status_idx": { + "name": "tasks_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "clerk_id": { + "name": "clerk_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "users_clerk_id_unique": { + "name": "users_clerk_id_unique", + "columns": [ + "clerk_id" + ], + "isUnique": true + }, + "users_email_unique": { + "name": "users_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "users_email_idx": { + "name": "users_email_idx", + "columns": [ + "email" + ], + "isUnique": false + }, + "users_clerk_id_idx": { + "name": "users_clerk_id_idx", + "columns": [ + "clerk_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_sections": { + "name": "workspace_sections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_collapsed": { + "name": "is_collapsed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspace_sections_project_id_idx": { + "name": "workspace_sections_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_sections_project_id_projects_id_fk": { + "name": "workspace_sections_project_id_projects_id_fk", + "tableFrom": "workspace_sections", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_unread": { + "name": "is_unread", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "is_unnamed": { + "name": "is_unnamed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "deleting_at": { + "name": "deleting_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "port_base": { + "name": "port_base", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_worktree_id_idx": { + "name": "workspaces_worktree_id_idx", + "columns": [ + "worktree_id" + ], + "isUnique": false + }, + "workspaces_last_opened_at_idx": { + "name": "workspaces_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + }, + "workspaces_section_id_idx": { + "name": "workspaces_section_id_idx", + "columns": [ + "section_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_worktree_id_worktrees_id_fk": { + "name": "workspaces_worktree_id_worktrees_id_fk", + "tableFrom": "workspaces", + "tableTo": "worktrees", + "columnsFrom": [ + "worktree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_section_id_workspace_sections_id_fk": { + "name": "workspaces_section_id_workspace_sections_id_fk", + "tableFrom": "workspaces", + "tableTo": "workspace_sections", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_status": { + "name": "git_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_status": { + "name": "github_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_superset": { + "name": "created_by_superset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "worktrees_project_id_idx": { + "name": "worktrees_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "worktrees_branch_idx": { + "name": "worktrees_branch_idx", + "columns": [ + "branch" + ], + "isUnique": false + } + }, + "foreignKeys": { + "worktrees_project_id_projects_id_fk": { + "name": "worktrees_project_id_projects_id_fk", + "tableFrom": "worktrees", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "browser_automation_bindings": { + "name": "browser_automation_bindings", + "columns": { + "pane_id": { + "name": "pane_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "session_kind": { + "name": "session_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'todo-agent'" + }, + "connected_at": { + "name": "connected_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "todo_prompt_presets": { + "name": "todo_prompt_presets", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "todo_prompt_presets_name_idx": { + "name": "todo_prompt_presets_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "todo_prompt_presets_updated_at_idx": { + "name": "todo_prompt_presets_updated_at_idx", + "columns": [ + "updated_at" + ], + "isUnique": false + }, + "todo_prompt_presets_kind_idx": { + "name": "todo_prompt_presets_kind_idx", + "columns": [ + "kind" + ], + "isUnique": false + }, + "todo_prompt_presets_workspace_idx": { + "name": "todo_prompt_presets_workspace_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "todo_schedules": { + "name": "todo_schedules", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "frequency": { + "name": "frequency", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "minute": { + "name": "minute", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hour": { + "name": "hour", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "weekday": { + "name": "weekday", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monthday": { + "name": "monthday", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cron_expr": { + "name": "cron_expr", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "goal": { + "name": "goal", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verify_command": { + "name": "verify_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_iterations": { + "name": "max_iterations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 10 + }, + "max_wall_clock_sec": { + "name": "max_wall_clock_sec", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1800 + }, + "custom_system_prompt": { + "name": "custom_system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claude_model": { + "name": "claude_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claude_effort": { + "name": "claude_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "overlap_mode": { + "name": "overlap_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'skip'" + }, + "auto_sync_before_fire": { + "name": "auto_sync_before_fire", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_run_session_id": { + "name": "last_run_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "todo_schedules_project_idx": { + "name": "todo_schedules_project_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "todo_schedules_workspace_idx": { + "name": "todo_schedules_workspace_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "todo_schedules_enabled_next_run_idx": { + "name": "todo_schedules_enabled_next_run_idx", + "columns": [ + "enabled", + "next_run_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "todo_schedules_project_id_projects_id_fk": { + "name": "todo_schedules_project_id_projects_id_fk", + "tableFrom": "todo_schedules", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "todo_schedules_workspace_id_workspaces_id_fk": { + "name": "todo_schedules_workspace_id_workspaces_id_fk", + "tableFrom": "todo_schedules", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "todo_sessions": { + "name": "todo_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "goal": { + "name": "goal", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verify_command": { + "name": "verify_command", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_iterations": { + "name": "max_iterations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 10 + }, + "max_wall_clock_sec": { + "name": "max_wall_clock_sec", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1800 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "iteration": { + "name": "iteration", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "attached_pane_id": { + "name": "attached_pane_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attached_tab_id": { + "name": "attached_tab_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claude_session_id": { + "name": "claude_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "final_assistant_text": { + "name": "final_assistant_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_cost_usd": { + "name": "total_cost_usd", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_num_turns": { + "name": "total_num_turns", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pending_intervention": { + "name": "pending_intervention", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "start_head_sha": { + "name": "start_head_sha", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "custom_system_prompt": { + "name": "custom_system_prompt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claude_model": { + "name": "claude_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "claude_effort": { + "name": "claude_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict_passed": { + "name": "verdict_passed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict_reason": { + "name": "verdict_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verdict_failing_test": { + "name": "verdict_failing_test", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "artifact_path": { + "name": "artifact_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "remote_control_enabled": { + "name": "remote_control_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "waiting_until": { + "name": "waiting_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "waiting_reason": { + "name": "waiting_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "todo_sessions_workspace_idx": { + "name": "todo_sessions_workspace_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "todo_sessions_status_idx": { + "name": "todo_sessions_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "todo_sessions_created_at_idx": { + "name": "todo_sessions_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "todo_sessions_project_id_projects_id_fk": { + "name": "todo_sessions_project_id_projects_id_fk", + "tableFrom": "todo_sessions", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "todo_sessions_workspace_id_workspaces_id_fk": { + "name": "todo_sessions_workspace_id_workspaces_id_fk", + "tableFrom": "todo_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/_journal.json b/packages/local-db/drizzle/meta/_journal.json index 821b1a8f34a..a7045b199a0 100644 --- a/packages/local-db/drizzle/meta/_journal.json +++ b/packages/local-db/drizzle/meta/_journal.json @@ -470,6 +470,13 @@ "when": 1776600000000, "tag": "0066_add_aivis_speaking_rate", "breakpoints": true + }, + { + "idx": 67, + "version": "6", + "when": 1776676242652, + "tag": "0067_add_browser_automation_bindings", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/local-db/src/schema/browser-automation-bindings.ts b/packages/local-db/src/schema/browser-automation-bindings.ts new file mode 100644 index 00000000000..c5fa9f7a05f --- /dev/null +++ b/packages/local-db/src/schema/browser-automation-bindings.ts @@ -0,0 +1,28 @@ +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +/** + * Browser pane <-> LLM session bindings. + * + * Stored in local-db so bindings survive app restarts: the terminal daemon + * re-attaches terminal panes on launch and TODO-Agent sessions keep running, + * so losing the binding would leave the user to re-connect every launch. + * + * `sessionKind` records where the sessionId came from so the UI can dispatch + * correctly when the bound session is no longer live. + * - "todo-agent": sessionId is `todoSessions.id` + * - "terminal": sessionId is the terminal paneId that owns the claude/codex + * process (so the binding self-heals across shell re-spawns + * in the same pane). + */ +export const browserAutomationBindings = sqliteTable( + "browser_automation_bindings", + { + paneId: text("pane_id").primaryKey(), + sessionId: text("session_id").notNull(), + sessionKind: text("session_kind").notNull().default("todo-agent"), + connectedAt: integer("connected_at").notNull(), + }, +); + +export type SelectBrowserAutomationBinding = + typeof browserAutomationBindings.$inferSelect; diff --git a/packages/local-db/src/schema/index.ts b/packages/local-db/src/schema/index.ts index adb8543f4a6..678527d49e1 100644 --- a/packages/local-db/src/schema/index.ts +++ b/packages/local-db/src/schema/index.ts @@ -1,3 +1,4 @@ +export * from "./browser-automation-bindings"; export * from "./relations"; export * from "./schema"; export * from "./todo-prompt-presets"; diff --git a/packages/local-db/src/schema/schema.ts b/packages/local-db/src/schema/schema.ts index a32ad4d12fc..541cc219487 100644 --- a/packages/local-db/src/schema/schema.ts +++ b/packages/local-db/src/schema/schema.ts @@ -479,6 +479,7 @@ export type SelectBrowserSitePermission = // Fork-local: TODO autonomous agent sessions. Re-exported so drizzle-kit // (configured with schema="./src/schema/schema.ts") picks up the table. +export * from "./browser-automation-bindings"; export * from "./todo-prompt-presets"; export * from "./todo-schedules"; export * from "./todo-sessions"; diff --git a/todo.md b/todo.md new file mode 100644 index 00000000000..3cf6e0edaf6 --- /dev/null +++ b/todo.md @@ -0,0 +1,341 @@ +# Browser Pane LLM Connect TODO + +## 何をしたいのか + +`apps/desktop` の `v1 workspace` にある browser pane 内ブラウザを、実行中の LLM セッションに明示的に接続できるようにしたい。 + +やりたい操作は次の通り。 + +- browser pane の toolbar から `Connect` を押す +- その場で実行中の LLM セッション一覧を開く +- Claude / Codex を最初の入口では分けず、後から任意の session を選ぶ +- 選んだ session に必要な browser MCP が入っていなければ、その場で導入方法を案内する +- MCP が入ったらその pane をその session に接続する +- すでに別 pane に接続済みの session を選んだ場合は、接続先を切り替えて再割当する +- 現在どの browser pane がどの session に繋がっているかを一覧ビューでも確認したい + +重要なのは、**自動化したい対象は Superset アプリ全体ではなく、browser pane 内の webview だけ** という点。 + +## 今回のスコープ + +今回の対応範囲は `v1 workspace` のみ。 + +- 対象: `apps/desktop/src/renderer/screens/main/components/WorkspaceView/...` +- 対象 UI: + - browser pane toolbar 内の `Connect` 導線 + - session 選択モーダル + - browser pane と session の割当一覧ビュー +- 対象データ: + - browser pane と running LLM session のバインディング状態 + - session ごとの MCP ready / missing 状態 + +今回やらないこと: + +- v2 workspace 対応 +- Superset 全体 UI の自動化 +- workspace shell 自体を CDP 対象にすること +- Playwright / chrome-devtools-mcp との完全統合実装 +- 複数 owner による同時共有制御 + +## 期待する UX + +### 1. Pane 側の主導導線 + +- browser pane toolbar に `Connect` ボタンを置く +- 未接続時は `Connect` +- 接続済み時は `Session 14 · Codex` のように表示 +- クリックで session 選択モーダルを開く + +### 2. セッション選択 + +- 実行中の session を一覧表示する +- provider 名で入口を分けない +- session ごとに最低限これを表示する + - session 名 + - provider or agent 名 + - branch / title のような識別情報 + - 最終アクティブ時刻 + - MCP ready / missing + - すでに別 pane に接続済みか + +### 3. MCP 未導入時のガイド + +- session を選んだ時点で MCP 未導入なら右カラムや同一モーダル内で案内を出す +- 接続ボタンは disabled にする +- どこに何を追加すればいいかを session 種別ごとに案内する +- 必要なら設定スニペットをコピーできるようにする + +### 4. 再割当 + +- すでに別 pane を持っている session を新しい pane に接続した場合: + - 旧 pane の接続を外す + - 新 pane に session を再割当する + - ユーザーに「移動した」ことが分かるフィードバックを出す + +### 5. 一覧ビュー + +- 右サイドまたは専用パネルに browser pane 一覧を出す +- 各 pane について次を表示する + - pane 名 + - URL + - 接続状態 + - 接続中 session 名 + - setup required 状態 +- 一覧から pane を選ぶとその pane にフォーカスできる + +## 画面モック + +現時点の操作感モックはルートの `mock.html`。 + +このモックで確認したいこと: + +- `Connect` の位置が自然か +- session 選択モーダルの情報量が適切か +- MCP missing ガイドを同じ導線内に置いて違和感がないか +- 一覧ビューが必要十分か +- 再割当の挙動が直感的か + +## 実装対象の起点 + +現時点で主な起点になりそうな場所: + +- browser pane 本体 + - `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/TabView/BrowserPane/BrowserPane.tsx` +- content header / content shell + - `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/index.tsx` + - `apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/ContentHeader/ContentHeader.tsx` +- workspace sidebar + - `apps/desktop/src/renderer/screens/main/components/WorkspaceSidebar/WorkspaceSidebar.tsx` + +## 設計方針 + +### 1. 制御単位 + +制御単位は `browser pane`。 + +- `1 browser pane = 1 webview target` +- 接続の主キーは `paneId` +- 実際に自動化する対象はその pane の webview のみ + +### 2. バインディング単位 + +接続先は `LLM session`。 + +- Claude / Codex は session の属性であり、最初の UI 分岐には使わない +- バインディングは `paneId -> sessionId` +- 1 pane には最大 1 session +- 1 session も最大 1 pane + +### 3. セッション状態 + +session ごとに最低限この状態を持つ。 + +- `sessionId` +- `displayName` +- `provider` +- `kind` +- `branchOrContextLabel` +- `lastActiveAt` +- `mcpStatus` +- `connectedPaneId` + +`mcpStatus` はまず次の 3 値で十分。 + +- `ready` +- `missing` +- `unknown` + +### 4. pane 状態 + +pane ごとに最低限この状態を持つ。 + +- `paneId` +- `tabId` +- `workspaceId` +- `title` +- `url` +- `connectedSessionId` +- `suggestedSessionId?` + +## UI 詳細設計 + +### A. BrowserPane toolbar + +`BrowserPane.tsx` の toolbar に接続状態 UI を追加する。 + +- 未接続: + - ラベル `Connect` +- 接続済み: + - `Session 14 · Codex` +- クリック時: + - session connect modal を開く + +必要なら secondary action: + +- `Disconnect` +- `Change` + +ただし最初は toolbar ボタン 1 個にまとめてもよい。 + +### B. Session Connect Modal + +構成は 2 カラム。 + +左カラム: + +- 現在の pane 情報 +- running session 一覧 + +右カラム: + +- 選択 session の詳細 +- ready なら接続概要 +- missing なら MCP 導入ガイド + +footer actions: + +- `Connect` +- `Disconnect current` +- `Cancel` + +### C. 一覧ビュー + +実装候補は 2 つ。 + +1. 既存右サイド領域に `Browser Automation` セクションを追加 +2. browser pane 専用の軽量 list panel を追加 + +初手は 1 が現実的。 + +理由: + +- v1 workspace のレイアウト変更が小さい +- 状態確認 UI を集約しやすい +- `mock.html` の方向性と近い + +## 状態管理案 + +まずは renderer 側 store で十分。 + +候補: + +- 既存 store 拡張 +- 新規 `browser-automation` store 追加 + +保持する状態: + +- `selectedSessionIdByPaneId` +- `sessions` +- `bindings` +- `connectModal` + - `isOpen` + - `paneId` + - `selectedSessionId` + +最初は永続化しなくてよい。 + +ただし将来的に欲しくなりそうなもの: + +- 最後に選んだ session +- pane ごとの前回接続先 + +## 実装段階 + +### Phase 1: UI モックを実装に寄せる + +- `mock.html` を基準に、実アプリの v1 UI へ落とす +- toolbar の `Connect` 導線を本実装に置き換える +- session modal を renderer に実装する +- 一覧ビューを暫定で出す + +### Phase 2: セッション一覧の実データ化 + +- 実行中 LLM session の列挙元を決める +- session ごとの provider / title / branch / MCP 状態を収集する +- session 選択 UI をダミーデータから実データへ置換する + +### Phase 3: バインディング管理 + +- `paneId -> sessionId` バインディングを store で管理 +- 接続 +- 切断 +- 再割当 +- UI 反映 + +### Phase 4: MCP 状態の扱い + +- session ごとの MCP ready 判定を実装する +- missing の時は案内を表示する +- provider ごとに設定先や表示文言を分ける + +### Phase 5: 実 automation bridge 接続 + +- session 側が使う `superset-browser` MCP の仕様を決める +- `paneId` を session に渡す方法を決める +- 必要なら Desktop 側 API を追加する + +## 技術課題 + +### 1. Running session の取得元 + +未整理ポイント。 + +- どこから「今動いている Claude/Codex session 一覧」を取るか +- renderer で直接見えるのか +- main 側管理なのか +- 既存の agent session 周りの仕組みを再利用できるか + +ここは最初に確認が必要。 + +### 2. MCP ready 判定 + +未整理ポイント。 + +- session ごとに MCP が入っているかをどう判定するか +- config 実ファイルを見るのか +- 起動時の session metadata を見るのか +- handshake 結果で見るのか + +最初は `unknown` を許容してもよい。 + +### 3. 実際の pane 制御との接続 + +最終的には browser pane の webview を session に結びたい。 + +必要になりそうなもの: + +- `paneId` 解決 +- webview / browser target 取得 +- screenshot / evaluate / navigation などの基盤 + +ただし今回の `todo.md` 段階では、まず UX と binding 設計を優先する。 + +## 受け入れ条件 + +最低限これができれば第一段階として成立。 + +- v1 workspace の browser pane に `Connect` 導線がある +- Connect から running session を選べる +- Claude / Codex で最初に分岐しない +- MCP missing の session では設定案内が出る +- ready な session は接続できる +- 接続済み session は別 pane へ再割当できる +- 現在の割当状態を一覧で見られる + +## すぐ着手する順番 + +1. v1 workspace における一覧ビューの配置場所を決める +2. running session のデータ取得元を確認する +3. renderer store の形を決める +4. BrowserPane toolbar に `Connect` を仮実装する +5. session connect modal を組み込む +6. 一覧ビューを組み込む +7. 実データ配線に進む + +## メモ + +- 入口は browser pane 側に置く +- provider ではなく session を選ばせる +- missing MCP を「失敗」ではなく「案内」に変える +- 一覧ビューは必要 +- 対応範囲は v1 workspace 限定