From d9e31038993aaf296b90703450ed6809a4145540 Mon Sep 17 00:00:00 2001 From: "Hubert L.S." <68784598+hlsitechio@users.noreply.github.com> Date: Sun, 8 Mar 2026 15:07:50 -0400 Subject: [PATCH 01/26] =?UTF-8?q?t3code(dev):=20v0.0.4-alpha.2=20=E2=80=94?= =?UTF-8?q?=20terminal=20GPU=20rendering,=20GitHub=20device=20flow,=20IDE?= =?UTF-8?q?=20integrations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Terminal: Add WebGL renderer (xterm addon-webgl) for GPU-accelerated rendering - Terminal: Defer capHistory to persist boundaries instead of every data event (O(n) → O(1) hot path) - Terminal: Optimize capHistory to use index scanning instead of split/join - Terminal: Increase persist debounce from 40ms to 250ms to reduce disk I/O - GitHub: Implement OAuth Device Flow (RFC 8628) for pairing via user code - GitHub: Add server-side device code request and token polling endpoints - GitHub: Add device flow UI component with code display, clipboard copy, and status - Editors: Add Windsurf integration (windsurf CLI, --goto support) - Editors: Add OpenCode integration (opencode -c flag) - Merge upstream codex CLI version check and managed home directory support Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/browserCdp.ts | 56 ++ apps/desktop/src/browserOperator.ts | 315 ++++++ apps/desktop/src/main.ts | 904 ++++++++++++++++- apps/desktop/src/preload.ts | 35 + apps/server/.env.example | 1 + apps/server/package.json | 4 +- apps/server/scripts/cli.ts | 16 +- apps/server/src/appOperatorMcpServer.ts | 259 +++++ apps/server/src/codexAppServerManager.ts | 150 ++- apps/server/src/git/githubDeviceFlow.ts | 170 ++++ apps/server/src/labBrowserMcpServer.ts | 452 +++++++++ apps/server/src/main.ts | 4 +- apps/server/src/open.ts | 6 +- .../Layers/ProviderRuntimeIngestion.test.ts | 3 +- .../src/provider/Layers/ProviderHealth.ts | 28 +- .../Layers/ProviderSessionDirectory.ts | 7 +- apps/server/src/terminal/Layers/Manager.ts | 69 +- apps/server/src/terminal/Services/Manager.ts | 1 + apps/server/src/wsServer.ts | 681 ++++++++++++- apps/server/tsdown.config.ts | 2 +- apps/web/.env.example | 1 + apps/web/package.json | 5 +- apps/web/src/appSettings.ts | 103 ++ apps/web/src/auth.tsx | 405 ++++++++ apps/web/src/components/AppCanvas.tsx | 224 +++++ apps/web/src/components/BrowserCanvas.tsx | 393 ++++++++ apps/web/src/components/ChatMarkdown.tsx | 147 ++- apps/web/src/components/ChatView.tsx | 635 ++++++++++-- apps/web/src/components/GitActionsControl.tsx | 19 + apps/web/src/components/Sidebar.tsx | 875 ++++++++++++----- .../src/components/ThreadTerminalDrawer.tsx | 121 ++- .../components/WorkspaceSurfaceActions.tsx | 126 +++ apps/web/src/components/ui/sidebar.tsx | 2 +- apps/web/src/diffRouteSearch.ts | 20 + apps/web/src/hooks/useProjectOnboarding.ts | 328 +++++++ .../src/hooks/useWorkspaceSurfaceLaunchers.ts | 46 + apps/web/src/index.css | 28 +- apps/web/src/routeTree.gen.ts | 101 +- apps/web/src/routes/__root.tsx | 9 +- apps/web/src/routes/_chat.$threadId.tsx | 113 ++- apps/web/src/routes/_chat.docs.tsx | 107 +++ apps/web/src/routes/_chat.index.tsx | 491 +++++++++- apps/web/src/routes/_chat.settings.tsx | 909 +++++++++++++++++- apps/web/src/routes/_chat.tsx | 14 +- apps/web/src/routes/lab.$threadId.tsx | 110 +++ apps/web/src/routes/lab.index.tsx | 65 ++ apps/web/src/routes/lab.tsx | 36 + apps/web/src/session-logic.test.ts | 25 +- apps/web/src/session-logic.ts | 7 +- apps/web/src/uiCommandIntents.test.ts | 54 ++ apps/web/src/uiCommandIntents.ts | 236 +++++ apps/web/src/vite-env.d.ts | 9 + apps/web/src/wsNativeApi.ts | 84 ++ apps/web/vite.config.ts | 41 + bun.lock | 157 ++- packages/contracts/src/canvas.ts | 40 + packages/contracts/src/editor.ts | 2 + packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 126 ++- packages/contracts/src/model.ts | 40 + packages/contracts/src/orchestration.ts | 7 +- packages/contracts/src/server.ts | 20 + packages/contracts/src/ws.ts | 23 + packages/shared/src/model.ts | 5 + scripts/dev-runner.ts | 60 +- 65 files changed, 9092 insertions(+), 441 deletions(-) create mode 100644 apps/desktop/src/browserCdp.ts create mode 100644 apps/desktop/src/browserOperator.ts create mode 100644 apps/server/.env.example create mode 100644 apps/server/src/appOperatorMcpServer.ts create mode 100644 apps/server/src/git/githubDeviceFlow.ts create mode 100644 apps/server/src/labBrowserMcpServer.ts create mode 100644 apps/web/.env.example create mode 100644 apps/web/src/auth.tsx create mode 100644 apps/web/src/components/AppCanvas.tsx create mode 100644 apps/web/src/components/BrowserCanvas.tsx create mode 100644 apps/web/src/components/WorkspaceSurfaceActions.tsx create mode 100644 apps/web/src/hooks/useProjectOnboarding.ts create mode 100644 apps/web/src/hooks/useWorkspaceSurfaceLaunchers.ts create mode 100644 apps/web/src/routes/_chat.docs.tsx create mode 100644 apps/web/src/routes/lab.$threadId.tsx create mode 100644 apps/web/src/routes/lab.index.tsx create mode 100644 apps/web/src/routes/lab.tsx create mode 100644 apps/web/src/uiCommandIntents.test.ts create mode 100644 apps/web/src/uiCommandIntents.ts create mode 100644 packages/contracts/src/canvas.ts diff --git a/apps/desktop/src/browserCdp.ts b/apps/desktop/src/browserCdp.ts new file mode 100644 index 000000000..d5b847fdd --- /dev/null +++ b/apps/desktop/src/browserCdp.ts @@ -0,0 +1,56 @@ +import type { BrowserView, WebContents } from "electron"; + +const DEBUGGER_PROTOCOL_VERSION = "1.3"; +const attachedContents = new WeakSet(); + +function ensureDebuggerAttached(webContents: WebContents): void { + if (webContents.isDestroyed()) { + throw new Error("Browser target is no longer available."); + } + + if (webContents.debugger.isAttached()) { + attachedContents.add(webContents); + return; + } + + try { + webContents.debugger.attach(DEBUGGER_PROTOCOL_VERSION); + attachedContents.add(webContents); + } catch (error) { + if ( + error instanceof Error && + error.message.toLowerCase().includes("already attached") + ) { + attachedContents.add(webContents); + return; + } + throw error; + } +} + +async function sendCommand( + webContents: WebContents, + method: string, + params?: Record, +): Promise { + ensureDebuggerAttached(webContents); + return webContents.debugger.sendCommand(method, params) as Promise; +} + +export async function captureBrowserViewScreenshot(view: BrowserView): Promise { + const webContents = view.webContents; + if (webContents.isDestroyed()) { + return null; + } + + try { + const result = await sendCommand<{ data?: string }>(webContents, "Page.captureScreenshot", { + format: "png", + captureBeyondViewport: false, + fromSurface: true, + }); + return typeof result.data === "string" && result.data.length > 0 ? result.data : null; + } catch { + return null; + } +} diff --git a/apps/desktop/src/browserOperator.ts b/apps/desktop/src/browserOperator.ts new file mode 100644 index 000000000..a29822724 --- /dev/null +++ b/apps/desktop/src/browserOperator.ts @@ -0,0 +1,315 @@ +import type { BrowserView } from "electron"; +import type { + DesktopBrowserActInput, + DesktopBrowserActionResult, + DesktopBrowserExtractResult, + DesktopBrowserObserveResult, + DesktopBrowserViewState, +} from "@t3tools/contracts"; + +function nowIso(): string { + return new Date().toISOString(); +} + +function escapeScriptJson(value: unknown): string { + return JSON.stringify(value).replace(/ { + const payload = ${escapeScriptJson(payload)}; + const normalize = (value) => + typeof value === "string" ? value.replace(/\\s+/g, " ").trim() : ""; + const lower = (value) => normalize(value).toLowerCase(); + const elementText = (element) => { + const ariaLabel = normalize(element.getAttribute("aria-label")); + const placeholder = normalize(element.getAttribute("placeholder")); + const title = normalize(element.getAttribute("title")); + const alt = normalize(element.getAttribute("alt")); + const text = normalize(element.innerText || element.textContent || ""); + const value = "value" in element ? normalize(String(element.value || "")) : ""; + return [ariaLabel, placeholder, title, alt, value, text].filter(Boolean).join(" ").trim(); + }; + const labelTextForElement = (element) => { + const ariaLabel = normalize(element.getAttribute("aria-label")); + if (ariaLabel) return ariaLabel; + const labelledBy = normalize(element.getAttribute("aria-labelledby")); + if (labelledBy) { + const label = labelledBy + .split(/\\s+/) + .map((id) => document.getElementById(id)) + .filter(Boolean) + .map((node) => normalize(node.innerText || node.textContent || "")) + .filter(Boolean) + .join(" "); + if (label) return label; + } + const id = normalize(element.id); + if (id) { + const explicit = document.querySelector(\`label[for="\${CSS.escape(id)}"]\`); + const explicitText = normalize(explicit?.innerText || explicit?.textContent || ""); + if (explicitText) return explicitText; + } + const parentLabel = element.closest("label"); + return normalize(parentLabel?.innerText || parentLabel?.textContent || ""); + }; + const isEditableElement = (element) => { + if (!(element instanceof HTMLElement)) return false; + if (element.isContentEditable) return true; + if (element instanceof HTMLTextAreaElement) return true; + if (element instanceof HTMLSelectElement) return true; + if (!(element instanceof HTMLInputElement)) return false; + const type = lower(element.type || "text"); + return !["button", "checkbox", "color", "file", "hidden", "image", "radio", "range", "reset", "submit"].includes(type); + }; + const describeElement = (element) => { + const tag = lower(element.tagName); + const role = normalize(element.getAttribute("role")) || null; + const text = normalize(element.innerText || element.textContent || "") || null; + const label = labelTextForElement(element) || null; + const placeholder = normalize(element.getAttribute("placeholder")) || null; + const name = normalize(element.getAttribute("name")) || null; + const id = normalize(element.id) || null; + const type = "type" in element ? normalize(String(element.type || "")) || null : null; + const href = element instanceof HTMLAnchorElement ? normalize(element.href) || null : null; + return { + tag, + role, + text, + label, + placeholder, + name, + id, + type, + href, + editable: isEditableElement(element), + }; + }; + const interactiveSelector = [ + "a[href]", + "button", + "input", + "textarea", + "select", + "[role='button']", + "[role='link']", + "[role='textbox']", + "[contenteditable='true']", + "[tabindex]" + ].join(","); + const collectInteractiveElements = () => { + const seen = new Set(); + const elements = []; + for (const node of document.querySelectorAll(interactiveSelector)) { + if (!(node instanceof HTMLElement)) continue; + if (seen.has(node)) continue; + seen.add(node); + const style = window.getComputedStyle(node); + if (style.display === "none" || style.visibility === "hidden") continue; + const rect = node.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) continue; + elements.push(node); + if (elements.length >= 80) break; + } + return elements; + }; + const scoreElement = (element, query, requireEditable) => { + if (requireEditable && !isEditableElement(element)) return -1; + const q = lower(query); + if (!q) return -1; + const description = describeElement(element); + const candidates = [ + description.label, + description.placeholder, + description.text, + description.name, + description.id, + description.href, + ].filter(Boolean).map((value) => lower(value)); + let score = -1; + for (const candidate of candidates) { + if (!candidate) continue; + if (candidate === q) score = Math.max(score, 100); + else if (candidate.startsWith(q)) score = Math.max(score, 80); + else if (candidate.includes(q)) score = Math.max(score, 60); + } + if (score < 0 && q.includes(".")) { + const compact = q.replace(/^https?:\\/\\//, "").replace(/^www\\./, ""); + for (const candidate of candidates) { + if (!candidate) continue; + if (candidate.includes(compact)) score = Math.max(score, 40); + } + } + return score; + }; + const findBestElement = (query, options = {}) => { + const elements = collectInteractiveElements(); + let bestElement = null; + let bestScore = -1; + for (const element of elements) { + const score = scoreElement(element, query, options.requireEditable === true); + if (score > bestScore) { + bestScore = score; + bestElement = element; + } + } + return bestElement; + }; + const setElementValue = (element, nextValue) => { + if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { + const prototype = element instanceof HTMLInputElement ? HTMLInputElement.prototype : HTMLTextAreaElement.prototype; + const descriptor = Object.getOwnPropertyDescriptor(prototype, "value"); + descriptor?.set?.call(element, nextValue); + element.dispatchEvent(new Event("input", { bubbles: true })); + element.dispatchEvent(new Event("change", { bubbles: true })); + return; + } + if (element.isContentEditable) { + element.focus(); + element.textContent = nextValue; + element.dispatchEvent(new InputEvent("input", { bubbles: true, data: nextValue, inputType: "insertText" })); + } + }; + const submitElement = (element) => { + if (element instanceof HTMLElement) { + const form = element.closest("form"); + if (form instanceof HTMLFormElement) { + form.requestSubmit(); + return; + } + } + const active = document.activeElement; + if (active instanceof HTMLElement) { + active.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + active.dispatchEvent(new KeyboardEvent("keyup", { key: "Enter", bubbles: true })); + } + }; + const observationFor = (query) => { + const elements = collectInteractiveElements().map(describeElement); + const matched = query ? findBestElement(query) : null; + return { + url: window.location.href || null, + title: document.title || null, + elements, + matchedElement: matched ? describeElement(matched) : null, + documentText: normalize(document.body?.innerText || "").slice(0, 5000), + }; + }; + const result = (() => { + switch (payload.kind) { + case "observe": + return { ok: true, detail: "Observed page state.", observation: observationFor(payload.target) }; + case "extract": + return { + ok: true, + detail: payload.query ? \`Extracted page text for "\${payload.query}".\` : "Extracted page text.", + text: observationFor(payload.query).documentText, + }; + case "act": { + const action = payload.action; + if (action.kind === "click") { + const element = findBestElement(action.target); + if (!element) return { ok: false, detail: \`Could not find "\${action.target}".\`, observation: observationFor(action.target) }; + element.click(); + return { ok: true, detail: \`Clicked "\${action.target}".\`, observation: observationFor(action.target) }; + } + if (action.kind === "type") { + const element = findBestElement(action.target, { requireEditable: true }); + if (!element) return { ok: false, detail: \`Could not find editable field "\${action.target}".\`, observation: observationFor(action.target) }; + if (!(element instanceof HTMLElement)) return { ok: false, detail: \`Target "\${action.target}" is not editable.\`, observation: observationFor(action.target) }; + element.focus(); + setElementValue(element, action.text); + if (action.submit === true) submitElement(element); + return { ok: true, detail: \`Entered text into "\${action.target}".\`, observation: observationFor(action.target) }; + } + if (action.kind === "press") { + const active = document.activeElement instanceof HTMLElement ? document.activeElement : document.body; + active?.dispatchEvent(new KeyboardEvent("keydown", { key: action.key, bubbles: true })); + active?.dispatchEvent(new KeyboardEvent("keyup", { key: action.key, bubbles: true })); + if (action.key === "Enter" && active instanceof HTMLElement) submitElement(active); + return { ok: true, detail: \`Pressed "\${action.key}".\`, observation: observationFor(null) }; + } + if (action.kind === "scroll") { + const amount = typeof action.amount === "number" && Number.isFinite(action.amount) ? Math.max(80, Math.floor(action.amount)) : 640; + window.scrollBy({ top: action.direction === "down" ? amount : -amount, behavior: "smooth" }); + return { ok: true, detail: \`Scrolled \${action.direction}.\`, observation: observationFor(null) }; + } + return { ok: false, detail: "Unsupported browser action.", observation: observationFor(null) }; + } + default: + return { ok: false, detail: "Unsupported browser operator request." }; + } + })(); + return result; +})() +`; +} + +async function executeOperatorScript(view: BrowserView, payload: unknown): Promise { + return view.webContents.executeJavaScript(buildBrowserOperatorScript(payload), true) as Promise; +} + +export async function observeBrowserView( + view: BrowserView, + threadId: string, + target?: string, +): Promise { + const result = await executeOperatorScript<{ + observation: Omit; + }>(view, { kind: "observe", ...(target ? { target } : {}) }); + return { + threadId, + ...result.observation, + lastUpdatedAt: nowIso(), + }; +} + +export async function extractBrowserView( + view: BrowserView, + threadId: string, + query?: string, +): Promise { + const result = await executeOperatorScript<{ text: string }>(view, { + kind: "extract", + ...(query ? { query } : {}), + }); + return { + threadId, + url: view.webContents.getURL() || null, + title: view.webContents.getTitle() || null, + text: result.text, + lastUpdatedAt: nowIso(), + }; +} + +export async function actOnBrowserView( + view: BrowserView, + threadId: string, + action: DesktopBrowserActInput, + state: DesktopBrowserViewState, +): Promise { + const result = await executeOperatorScript<{ + ok: boolean; + detail: string; + observation?: Omit; + }>(view, { + kind: "act", + action, + }); + return { + threadId, + ok: result.ok, + detail: result.detail, + state, + ...(result.observation + ? { + observation: { + threadId, + ...result.observation, + lastUpdatedAt: nowIso(), + }, + } + : {}), + }; +} diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 945f1d279..91d015d29 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,13 +1,33 @@ import * as ChildProcess from "node:child_process"; import * as Crypto from "node:crypto"; import * as FS from "node:fs"; +import * as Http from "node:http"; import * as OS from "node:os"; import * as Path from "node:path"; -import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell } from "electron"; +import { + app, + BrowserView, + BrowserWindow, + dialog, + ipcMain, + Menu, + nativeImage, + protocol, + shell, +} from "electron"; import type { MenuItemConstructorOptions } from "electron"; import * as Effect from "effect/Effect"; -import type { DesktopUpdateActionResult, DesktopUpdateState } from "@t3tools/contracts"; +import type { + DesktopBrowserActInput, + DesktopBrowserActionResult, + DesktopBrowserExtractResult, + DesktopBrowserObserveResult, + DesktopBrowserViewBounds, + DesktopBrowserViewState, + DesktopUpdateActionResult, + DesktopUpdateState, +} from "@t3tools/contracts"; import { autoUpdater } from "electron-updater"; import type { ContextMenuItem } from "@t3tools/contracts"; @@ -31,6 +51,8 @@ import { reduceDesktopUpdateStateOnNoUpdate, reduceDesktopUpdateStateOnUpdateAvailable, } from "./updateMachine"; +import { captureBrowserViewScreenshot } from "./browserCdp"; +import { actOnBrowserView, extractBrowserView, observeBrowserView } from "./browserOperator"; fixPath(); @@ -43,6 +65,18 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; +const BROWSER_ATTACH_CHANNEL = "desktop:browser-attach"; +const BROWSER_SET_VISIBLE_CHANNEL = "desktop:browser-set-visible"; +const BROWSER_NAVIGATE_CHANNEL = "desktop:browser-navigate"; +const BROWSER_GO_BACK_CHANNEL = "desktop:browser-go-back"; +const BROWSER_GO_FORWARD_CHANNEL = "desktop:browser-go-forward"; +const BROWSER_RELOAD_CHANNEL = "desktop:browser-reload"; +const BROWSER_GET_STATE_CHANNEL = "desktop:browser-get-state"; +const BROWSER_OBSERVE_CHANNEL = "desktop:browser-observe"; +const BROWSER_ACT_CHANNEL = "desktop:browser-act"; +const BROWSER_EXTRACT_CHANNEL = "desktop:browser-extract"; +const BROWSER_WAIT_CHANNEL = "desktop:browser-wait"; +const BROWSER_STATE_CHANNEL = "desktop:browser-state"; const STATE_DIR = process.env.T3CODE_STATE_DIR?.trim() || Path.join(OS.homedir(), ".t3", "userdata"); const DESKTOP_SCHEME = "t3"; @@ -60,6 +94,9 @@ const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000; const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; const DESKTOP_UPDATE_CHANNEL = "latest"; const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; +const DESKTOP_OPERATOR_HOST = "127.0.0.1"; +const DESKTOP_OPERATOR_PATH = "/lab-browser-operator"; +const OPEN_DEVTOOLS_IN_DEV = process.env.T3CODE_OPEN_DEVTOOLS === "1"; type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; @@ -229,6 +266,37 @@ let updateCheckInFlight = false; let updateDownloadInFlight = false; let updaterConfigured = false; let updateState: DesktopUpdateState = initialUpdateState(); +let activeBrowserThreadId: string | null = null; +let operatorApiServer: Http.Server | null = null; +let operatorApiUrl = ""; +let operatorApiToken = ""; + +type DesktopBrowserSession = { + threadId: string; + view: BrowserView; + bounds: DesktopBrowserViewBounds; + isVisible: boolean; + state: DesktopBrowserViewState; +}; + +const browserSessions = new Map(); + +interface DesktopOperatorObserveResponse { + state: DesktopBrowserViewState; + observation: DesktopBrowserObserveResult; + screenshotBase64: string | null; +} + +interface DesktopOperatorExtractResponse { + state: DesktopBrowserViewState; + extraction: DesktopBrowserExtractResult; +} + +type DesktopOperatorRpcResult = + | DesktopBrowserViewState + | DesktopBrowserActionResult + | DesktopOperatorObserveResponse + | DesktopOperatorExtractResponse; function resolveUpdaterErrorContext(): DesktopUpdateErrorContext { if (updateDownloadInFlight) return "download"; @@ -630,6 +698,517 @@ function shouldEnableAutoUpdates(): boolean { ); } +function nowIso(): string { + return new Date().toISOString(); +} + +function sanitizeBrowserBounds(bounds: DesktopBrowserViewBounds | undefined): DesktopBrowserViewBounds { + if (!bounds) { + return { x: 0, y: 0, width: 0, height: 0 }; + } + return { + x: Math.max(0, Math.floor(bounds.x)), + y: Math.max(0, Math.floor(bounds.y)), + width: Math.max(0, Math.floor(bounds.width)), + height: Math.max(0, Math.floor(bounds.height)), + }; +} + +function normalizeBrowserUrl(rawUrl: string): string { + const trimmed = rawUrl.trim(); + if (trimmed.length === 0) { + throw new Error("Browser URL cannot be empty."); + } + if (/\s/.test(trimmed)) { + return `https://duckduckgo.com/?q=${encodeURIComponent(trimmed)}`; + } + if ( + !trimmed.includes("://") && + !trimmed.includes(".") && + trimmed !== "localhost" && + !/^\d{1,3}(\.\d{1,3}){3}$/.test(trimmed) + ) { + return `https://duckduckgo.com/?q=${encodeURIComponent(trimmed)}`; + } + const withProtocol = + trimmed.startsWith("http://") || trimmed.startsWith("https://") ? trimmed : `https://${trimmed}`; + const parsed = new URL(withProtocol); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throw new Error("Only http and https URLs are supported."); + } + return parsed.toString(); +} + +function isRecoverableBrowserLoadError(error: unknown): boolean { + if (!error || typeof error !== "object") return false; + const code = "code" in error && typeof error.code === "string" ? error.code : ""; + return ( + code === "ERR_ABORTED" || + code === "ERR_NAME_NOT_RESOLVED" || + code === "ERR_INTERNET_DISCONNECTED" || + code === "ERR_CONNECTION_REFUSED" || + code === "ERR_CONNECTION_TIMED_OUT" + ); +} + +function createEmptyBrowserState(threadId: string): DesktopBrowserViewState { + return { + threadId, + url: null, + title: null, + loading: false, + canGoBack: false, + canGoForward: false, + isVisible: false, + lastUpdatedAt: nowIso(), + }; +} + +function emitBrowserState(state: DesktopBrowserViewState): void { + for (const window of BrowserWindow.getAllWindows()) { + if (window.isDestroyed()) continue; + window.webContents.send(BROWSER_STATE_CHANNEL, state); + } +} + +function readBrowserState(session: DesktopBrowserSession): DesktopBrowserViewState { + const webContents = session.view.webContents; + if (webContents.isDestroyed()) { + return { + threadId: session.threadId, + url: session.state.url, + title: session.state.title, + loading: false, + canGoBack: false, + canGoForward: false, + isVisible: session.isVisible, + lastUpdatedAt: nowIso(), + }; + } + const url = webContents.getURL(); + const title = webContents.getTitle(); + return { + threadId: session.threadId, + url: url.length > 0 ? url : null, + title: title.length > 0 ? title : null, + loading: webContents.isLoading(), + canGoBack: webContents.navigationHistory.canGoBack(), + canGoForward: webContents.navigationHistory.canGoForward(), + isVisible: session.isVisible, + lastUpdatedAt: nowIso(), + }; +} + +function updateBrowserSessionState(session: DesktopBrowserSession): DesktopBrowserViewState { + const nextState = readBrowserState(session); + session.state = nextState; + emitBrowserState(nextState); + return nextState; +} + +function attachBrowserSessionToWindow(threadId: string | null): void { + if (!mainWindow || mainWindow.isDestroyed()) { + activeBrowserThreadId = threadId; + return; + } + + if (activeBrowserThreadId === threadId && threadId !== null) { + const activeSession = browserSessions.get(threadId); + if (activeSession && !activeSession.view.webContents.isDestroyed()) { + updateBrowserSessionState(activeSession); + return; + } + } + + const currentSession = + activeBrowserThreadId !== null ? browserSessions.get(activeBrowserThreadId) : undefined; + if (currentSession && !currentSession.view.webContents.isDestroyed()) { + try { + mainWindow.removeBrowserView(currentSession.view); + } catch { + // ignore stale detach failures + } + } + + activeBrowserThreadId = threadId; + if (threadId === null) { + return; + } + + const nextSession = browserSessions.get(threadId); + if (!nextSession) { + return; + } + if (nextSession.view.webContents.isDestroyed()) { + browserSessions.delete(threadId); + return; + } + + mainWindow.addBrowserView(nextSession.view); + const { x, y, width, height } = nextSession.bounds; + nextSession.view.setBounds({ x, y, width, height }); + nextSession.view.setAutoResize({ width: false, height: false }); + updateBrowserSessionState(nextSession); +} + +function ensureBrowserSession(threadId: string): DesktopBrowserSession { + const existing = browserSessions.get(threadId); + if (existing) { + return existing; + } + + const view = new BrowserView({ + webPreferences: { + sandbox: true, + partition: `persist:t3-browser-${threadId}`, + }, + }); + view.webContents.setWindowOpenHandler(({ url }) => { + void shell.openExternal(url); + return { action: "deny" }; + }); + const session: DesktopBrowserSession = { + threadId, + view, + bounds: { x: 0, y: 0, width: 0, height: 0 }, + isVisible: false, + state: createEmptyBrowserState(threadId), + }; + + const sync = () => { + updateBrowserSessionState(session); + }; + + view.webContents.on("did-start-loading", sync); + view.webContents.on("did-stop-loading", sync); + view.webContents.on("did-navigate", sync); + view.webContents.on("did-navigate-in-page", sync); + view.webContents.on("page-title-updated", sync); + view.webContents.on("destroyed", () => { + browserSessions.delete(threadId); + if (activeBrowserThreadId === threadId) { + activeBrowserThreadId = null; + } + }); + + browserSessions.set(threadId, session); + return session; +} + +async function attachBrowserThread(threadId: string): Promise { + const session = ensureBrowserSession(threadId); + if (session.state.url === null) { + try { + await session.view.webContents.loadURL("about:blank"); + } catch (error) { + if (!isRecoverableBrowserLoadError(error)) { + throw error; + } + } + } + attachBrowserSessionToWindow(threadId); + return updateBrowserSessionState(session); +} + +function setBrowserVisibility( + threadId: string, + visible: boolean, + bounds?: DesktopBrowserViewBounds, +): DesktopBrowserViewState { + const session = ensureBrowserSession(threadId); + session.isVisible = visible; + session.bounds = sanitizeBrowserBounds(bounds ?? session.bounds); + + if (!visible) { + if (activeBrowserThreadId === threadId) { + attachBrowserSessionToWindow(null); + } + return updateBrowserSessionState(session); + } + + if (session.view.webContents.isDestroyed()) { + browserSessions.delete(threadId); + return createEmptyBrowserState(threadId); + } + attachBrowserSessionToWindow(threadId); + if (session.bounds.width > 0 && session.bounds.height > 0) { + session.view.setBounds(session.bounds); + } + return updateBrowserSessionState(session); +} + +async function navigateBrowser(threadId: string, rawUrl: string): Promise { + const session = ensureBrowserSession(threadId); + const normalizedUrl = normalizeBrowserUrl(rawUrl); + try { + await session.view.webContents.loadURL(normalizedUrl); + } catch (error) { + if (!isRecoverableBrowserLoadError(error)) { + throw error; + } + } + return updateBrowserSessionState(session); +} + +async function observeBrowser( + threadId: string, + target?: string, +): Promise { + const session = ensureBrowserSession(threadId); + return observeBrowserView(session.view, threadId, target); +} + +async function actOnBrowser( + threadId: string, + action: DesktopBrowserActInput, +): Promise { + const session = ensureBrowserSession(threadId); + const result = await actOnBrowserView( + session.view, + threadId, + action, + updateBrowserSessionState(session), + ); + return { + ...result, + state: updateBrowserSessionState(session), + }; +} + +async function extractFromBrowser( + threadId: string, + query?: string, +): Promise { + const session = ensureBrowserSession(threadId); + return extractBrowserView(session.view, threadId, query); +} + +async function observeBrowserForOperator( + threadId: string, + target?: string, +): Promise { + const session = ensureBrowserSession(threadId); + const observation = await observeBrowserView(session.view, threadId, target); + const screenshotBase64 = await captureBrowserViewScreenshot(session.view); + return { + state: updateBrowserSessionState(session), + observation, + screenshotBase64, + }; +} + +async function extractBrowserForOperator( + threadId: string, + query?: string, +): Promise { + const session = ensureBrowserSession(threadId); + const extraction = await extractBrowserView(session.view, threadId, query); + return { + state: updateBrowserSessionState(session), + extraction, + }; +} + +async function waitForBrowser( + threadId: string, + durationMs: number, +): Promise { + const session = ensureBrowserSession(threadId); + const safeDuration = Math.max(0, Math.min(30_000, Math.floor(durationMs))); + await new Promise((resolve) => setTimeout(resolve, safeDuration)); + return updateBrowserSessionState(session); +} + +function browserBack(threadId: string): DesktopBrowserViewState { + const session = ensureBrowserSession(threadId); + if (session.view.webContents.navigationHistory.canGoBack()) { + session.view.webContents.navigationHistory.goBack(); + } + return updateBrowserSessionState(session); +} + +function browserForward(threadId: string): DesktopBrowserViewState { + const session = ensureBrowserSession(threadId); + if (session.view.webContents.navigationHistory.canGoForward()) { + session.view.webContents.navigationHistory.goForward(); + } + return updateBrowserSessionState(session); +} + +function browserReload(threadId: string): DesktopBrowserViewState { + const session = ensureBrowserSession(threadId); + session.view.webContents.reload(); + return updateBrowserSessionState(session); +} + +async function readJsonRequestBody(request: Http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + if (chunks.length === 0) { + return null; + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +function writeJsonResponse( + response: Http.ServerResponse, + statusCode: number, + body: Record, +): void { + const payload = JSON.stringify(body); + response.writeHead(statusCode, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(payload), + }); + response.end(payload); +} + +function operatorUnauthorized(response: Http.ServerResponse): void { + writeJsonResponse(response, 401, { + ok: false, + error: "Unauthorized operator request.", + }); +} + +function isAuthorizedOperatorRequest(request: Http.IncomingMessage): boolean { + const authorization = request.headers.authorization; + return authorization === `Bearer ${operatorApiToken}`; +} + +async function handleOperatorRpc(method: string, params: Record): Promise { + const threadIdValue = params.threadId; + const threadId = + typeof threadIdValue === "string" && threadIdValue.trim().length > 0 + ? threadIdValue.trim() + : null; + if (!threadId) { + throw new Error("Operator request requires a threadId."); + } + + switch (method) { + case "browser.attach": + return attachBrowserThread(threadId); + case "browser.getState": + return updateBrowserSessionState(ensureBrowserSession(threadId)); + case "browser.navigate": { + const url = params.url; + if (typeof url !== "string") { + throw new Error("Operator browser.navigate requires a URL."); + } + return navigateBrowser(threadId, url); + } + case "browser.goBack": + return browserBack(threadId); + case "browser.goForward": + return browserForward(threadId); + case "browser.reload": + return browserReload(threadId); + case "browser.observe": + return observeBrowserForOperator( + threadId, + typeof params.target === "string" ? params.target : undefined, + ); + case "browser.extract": + return extractBrowserForOperator( + threadId, + typeof params.query === "string" ? params.query : undefined, + ); + case "browser.wait": { + const durationMs = params.durationMs; + if (typeof durationMs !== "number" || !Number.isFinite(durationMs)) { + throw new Error("Operator browser.wait requires a finite durationMs."); + } + return waitForBrowser(threadId, durationMs); + } + case "browser.act": { + const action = params.action; + if (!action || typeof action !== "object") { + throw new Error("Operator browser.act requires an action payload."); + } + return actOnBrowser(threadId, action as DesktopBrowserActInput); + } + default: + throw new Error(`Unsupported operator method: ${method}`); + } +} + +async function startOperatorApiServer(): Promise { + if (operatorApiServer) { + return; + } + + operatorApiToken = Crypto.randomBytes(24).toString("hex"); + const server = Http.createServer((request, response) => { + void (async () => { + try { + if (request.method !== "POST" || request.url !== DESKTOP_OPERATOR_PATH) { + writeJsonResponse(response, 404, { ok: false, error: "Not found." }); + return; + } + if (!isAuthorizedOperatorRequest(request)) { + operatorUnauthorized(response); + return; + } + const body = await readJsonRequestBody(request); + if (!body || typeof body !== "object") { + writeJsonResponse(response, 400, { ok: false, error: "Invalid operator request body." }); + return; + } + + const method = (body as { method?: unknown }).method; + const params = (body as { params?: unknown }).params; + if (typeof method !== "string") { + writeJsonResponse(response, 400, { ok: false, error: "Missing operator method." }); + return; + } + + const result = await handleOperatorRpc( + method, + params && typeof params === "object" ? (params as Record) : {}, + ); + writeJsonResponse(response, 200, { ok: true, result }); + } catch (error) { + writeJsonResponse(response, 500, { + ok: false, + error: error instanceof Error ? error.message : String(error), + }); + } + })(); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, DESKTOP_OPERATOR_HOST, () => { + server.off("error", reject); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("Failed to resolve operator API address."); + } + + operatorApiServer = server; + operatorApiUrl = `http://${DESKTOP_OPERATOR_HOST}:${address.port}${DESKTOP_OPERATOR_PATH}`; +} + +async function stopOperatorApiServer(): Promise { + const server = operatorApiServer; + operatorApiServer = null; + operatorApiUrl = ""; + operatorApiToken = ""; + if (!server) { + return; + } + await new Promise((resolve) => { + server.close(() => resolve()); + }); +} + async function checkForUpdates(reason: string): Promise { if (isQuitting || !updaterConfigured || updateCheckInFlight) return; if (updateState.status === "downloading" || updateState.status === "downloaded") { @@ -800,6 +1379,8 @@ function backendEnv(): NodeJS.ProcessEnv { T3CODE_PORT: String(backendPort), T3CODE_STATE_DIR: STATE_DIR, T3CODE_AUTH_TOKEN: backendAuthToken, + ...(operatorApiUrl ? { T3CODE_DESKTOP_OPERATOR_URL: operatorApiUrl } : {}), + ...(operatorApiToken ? { T3CODE_DESKTOP_OPERATOR_TOKEN: operatorApiToken } : {}), }; } @@ -894,6 +1475,14 @@ function stopBackend(): void { } } +function destroyBrowserSessions(): void { + attachBrowserSessionToWindow(null); + for (const session of browserSessions.values()) { + session.view.webContents.close({ waitForBeforeUnload: false }); + } + browserSessions.clear(); +} + async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise { if (restartTimer) { clearTimeout(restartTimer); @@ -1085,6 +1674,120 @@ function registerIpcHandlers(): void { state: updateState, } satisfies DesktopUpdateActionResult; }); + + ipcMain.removeHandler(BROWSER_ATTACH_CHANNEL); + ipcMain.handle(BROWSER_ATTACH_CHANNEL, async (_event, threadId: unknown) => { + if (typeof threadId !== "string" || threadId.trim().length === 0) { + throw new Error("Invalid browser thread id."); + } + return attachBrowserThread(threadId); + }); + + ipcMain.removeHandler(BROWSER_SET_VISIBLE_CHANNEL); + ipcMain.handle( + BROWSER_SET_VISIBLE_CHANNEL, + async (_event, threadId: unknown, visible: unknown, bounds: unknown) => { + if (typeof threadId !== "string" || threadId.trim().length === 0) { + throw new Error("Invalid browser thread id."); + } + if (typeof visible !== "boolean") { + throw new Error("Invalid browser visibility value."); + } + const safeBounds = + bounds && typeof bounds === "object" + ? sanitizeBrowserBounds(bounds as DesktopBrowserViewBounds) + : undefined; + return setBrowserVisibility(threadId, visible, safeBounds); + }, + ); + + ipcMain.removeHandler(BROWSER_NAVIGATE_CHANNEL); + ipcMain.handle(BROWSER_NAVIGATE_CHANNEL, async (_event, threadId: unknown, url: unknown) => { + if (typeof threadId !== "string" || threadId.trim().length === 0) { + throw new Error("Invalid browser thread id."); + } + if (typeof url !== "string") { + throw new Error("Invalid browser URL."); + } + return navigateBrowser(threadId, url); + }); + + ipcMain.removeHandler(BROWSER_GO_BACK_CHANNEL); + ipcMain.handle(BROWSER_GO_BACK_CHANNEL, async (_event, threadId: unknown) => { + if (typeof threadId !== "string" || threadId.trim().length === 0) { + throw new Error("Invalid browser thread id."); + } + return browserBack(threadId); + }); + + ipcMain.removeHandler(BROWSER_GO_FORWARD_CHANNEL); + ipcMain.handle(BROWSER_GO_FORWARD_CHANNEL, async (_event, threadId: unknown) => { + if (typeof threadId !== "string" || threadId.trim().length === 0) { + throw new Error("Invalid browser thread id."); + } + return browserForward(threadId); + }); + + ipcMain.removeHandler(BROWSER_RELOAD_CHANNEL); + ipcMain.handle(BROWSER_RELOAD_CHANNEL, async (_event, threadId: unknown) => { + if (typeof threadId !== "string" || threadId.trim().length === 0) { + throw new Error("Invalid browser thread id."); + } + return browserReload(threadId); + }); + + ipcMain.removeHandler(BROWSER_GET_STATE_CHANNEL); + ipcMain.handle(BROWSER_GET_STATE_CHANNEL, async (_event, threadId: unknown) => { + if (typeof threadId !== "string" || threadId.trim().length === 0) { + throw new Error("Invalid browser thread id."); + } + const session = ensureBrowserSession(threadId); + return updateBrowserSessionState(session); + }); + + ipcMain.removeHandler(BROWSER_OBSERVE_CHANNEL); + ipcMain.handle(BROWSER_OBSERVE_CHANNEL, async (_event, threadId: unknown, target: unknown) => { + if (typeof threadId !== "string" || threadId.trim().length === 0) { + throw new Error("Invalid browser thread id."); + } + if (target !== undefined && typeof target !== "string") { + throw new Error("Invalid browser observe target."); + } + return observeBrowser(threadId, typeof target === "string" ? target : undefined); + }); + + ipcMain.removeHandler(BROWSER_ACT_CHANNEL); + ipcMain.handle(BROWSER_ACT_CHANNEL, async (_event, threadId: unknown, action: unknown) => { + if (typeof threadId !== "string" || threadId.trim().length === 0) { + throw new Error("Invalid browser thread id."); + } + if (!action || typeof action !== "object") { + throw new Error("Invalid browser action."); + } + return actOnBrowser(threadId, action as DesktopBrowserActInput); + }); + + ipcMain.removeHandler(BROWSER_EXTRACT_CHANNEL); + ipcMain.handle(BROWSER_EXTRACT_CHANNEL, async (_event, threadId: unknown, query: unknown) => { + if (typeof threadId !== "string" || threadId.trim().length === 0) { + throw new Error("Invalid browser thread id."); + } + if (query !== undefined && typeof query !== "string") { + throw new Error("Invalid browser extract query."); + } + return extractFromBrowser(threadId, typeof query === "string" ? query : undefined); + }); + + ipcMain.removeHandler(BROWSER_WAIT_CHANNEL); + ipcMain.handle(BROWSER_WAIT_CHANNEL, async (_event, threadId: unknown, durationMs: unknown) => { + if (typeof threadId !== "string" || threadId.trim().length === 0) { + throw new Error("Invalid browser thread id."); + } + if (typeof durationMs !== "number" || !Number.isFinite(durationMs)) { + throw new Error("Invalid browser wait duration."); + } + return waitForBrowser(threadId, durationMs); + }); } function getIconOption(): { icon: string } | Record { @@ -1094,6 +1797,94 @@ function getIconOption(): { icon: string } | Record { return iconPath ? { icon: iconPath } : {}; } +function desktopBootScreenDataUrl(): string { + const html = ` + + + + + ${APP_DISPLAY_NAME} + + + +
+
Desktop Shell
+

Opening ${APP_DISPLAY_NAME}

+

Starting the local server, restoring workspace state, and preparing Codex, terminal, Lab, and Canvas.

+
+
+ +`; + + return `data:text/html;charset=UTF-8,${encodeURIComponent(html)}`; +} + function createWindow(): BrowserWindow { const window = new BrowserWindow({ width: 1100, @@ -1101,6 +1892,7 @@ function createWindow(): BrowserWindow { minWidth: 840, minHeight: 620, show: false, + backgroundColor: "#050505", autoHideMenuBar: true, ...getIconOption(), title: APP_DISPLAY_NAME, @@ -1114,25 +1906,107 @@ function createWindow(): BrowserWindow { }, }); - window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + const appUrl = isDevelopment + ? (process.env.VITE_DEV_SERVER_URL as string) + : `${DESKTOP_SCHEME}://app/index.html`; + let rendererRequested = false; + + const requestRendererLoad = () => { + if (rendererRequested) { + return; + } + rendererRequested = true; + void window.loadURL(appUrl); + }; + + window.webContents.setWindowOpenHandler(({ url }) => { + try { + const parsed = new URL(url); + const hostname = parsed.hostname.toLowerCase(); + const isHttps = parsed.protocol === "https:"; + const isAllowedAuthHost = + hostname === "accounts.google.com" || + hostname === "github.com" || + hostname.endsWith(".github.com") || + hostname.endsWith(".clerk.accounts.dev") || + hostname.endsWith(".clerk.com"); + + if (isHttps && isAllowedAuthHost) { + return { + action: "allow", + overrideBrowserWindowOptions: { + width: 520, + height: 760, + minWidth: 420, + minHeight: 620, + autoHideMenuBar: true, + titleBarStyle: "default", + backgroundColor: "#050505", + modal: false, + webPreferences: { + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + }, + }, + }; + } + } catch { + if (url === "about:blank") { + return { + action: "allow", + overrideBrowserWindowOptions: { + width: 520, + height: 760, + minWidth: 420, + minHeight: 620, + autoHideMenuBar: true, + title: `${APP_DISPLAY_NAME} Auth`, + titleBarStyle: "default", + backgroundColor: "#050505", + modal: false, + webPreferences: { + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + }, + }, + }; + } + } + + return { action: "deny" }; + }); window.on("page-title-updated", (event) => { event.preventDefault(); window.setTitle(APP_DISPLAY_NAME); }); window.webContents.on("did-finish-load", () => { + const currentUrl = window.webContents.getURL(); + + if (currentUrl.startsWith("data:text/html")) { + window.setTitle(APP_DISPLAY_NAME); + if (!window.isVisible()) { + window.show(); + } + requestRendererLoad(); + return; + } + window.setTitle(APP_DISPLAY_NAME); emitUpdateState(); - }); - window.once("ready-to-show", () => { - window.show(); + if (activeBrowserThreadId) { + attachBrowserSessionToWindow(activeBrowserThreadId); + } + if (!window.isVisible()) { + window.show(); + } + if (isDevelopment && OPEN_DEVTOOLS_IN_DEV) { + window.webContents.openDevTools({ mode: "detach" }); + } }); - if (isDevelopment) { - void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); - window.webContents.openDevTools({ mode: "detach" }); - } else { - void window.loadURL(`${DESKTOP_SCHEME}://app/index.html`); - } + void window.loadURL(desktopBootScreenDataUrl()); window.on("closed", () => { if (mainWindow === window) { @@ -1157,6 +2031,8 @@ async function bootstrap(): Promise { backendWsUrl = `ws://127.0.0.1:${backendPort}/?token=${encodeURIComponent(backendAuthToken)}`; process.env.T3CODE_DESKTOP_WS_URL = backendWsUrl; writeDesktopLogHeader(`bootstrap resolved websocket url=${backendWsUrl}`); + await startOperatorApiServer(); + writeDesktopLogHeader(`bootstrap operator api url=${operatorApiUrl}`); registerIpcHandlers(); writeDesktopLogHeader("bootstrap ipc handlers registered"); @@ -1170,7 +2046,9 @@ app.on("before-quit", () => { isQuitting = true; writeDesktopLogHeader("before-quit received"); clearUpdatePollTimer(); + destroyBrowserSessions(); stopBackend(); + void stopOperatorApiServer(); restoreStdIoCapture?.(); }); @@ -1209,6 +2087,7 @@ if (process.platform !== "win32") { writeDesktopLogHeader("SIGINT received"); clearUpdatePollTimer(); stopBackend(); + void stopOperatorApiServer(); restoreStdIoCapture?.(); app.quit(); }); @@ -1219,6 +2098,7 @@ if (process.platform !== "win32") { writeDesktopLogHeader("SIGTERM received"); clearUpdatePollTimer(); stopBackend(); + void stopOperatorApiServer(); restoreStdIoCapture?.(); app.quit(); }); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index c941f2ed0..4973c40c1 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -10,6 +10,18 @@ const UPDATE_STATE_CHANNEL = "desktop:update-state"; const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state"; const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; +const BROWSER_ATTACH_CHANNEL = "desktop:browser-attach"; +const BROWSER_SET_VISIBLE_CHANNEL = "desktop:browser-set-visible"; +const BROWSER_NAVIGATE_CHANNEL = "desktop:browser-navigate"; +const BROWSER_GO_BACK_CHANNEL = "desktop:browser-go-back"; +const BROWSER_GO_FORWARD_CHANNEL = "desktop:browser-go-forward"; +const BROWSER_RELOAD_CHANNEL = "desktop:browser-reload"; +const BROWSER_GET_STATE_CHANNEL = "desktop:browser-get-state"; +const BROWSER_OBSERVE_CHANNEL = "desktop:browser-observe"; +const BROWSER_ACT_CHANNEL = "desktop:browser-act"; +const BROWSER_EXTRACT_CHANNEL = "desktop:browser-extract"; +const BROWSER_WAIT_CHANNEL = "desktop:browser-wait"; +const BROWSER_STATE_CHANNEL = "desktop:browser-state"; const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; contextBridge.exposeInMainWorld("desktopBridge", { @@ -43,4 +55,27 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(UPDATE_STATE_CHANNEL, wrappedListener); }; }, + browserAttach: (threadId) => ipcRenderer.invoke(BROWSER_ATTACH_CHANNEL, threadId), + browserSetVisible: (threadId, visible, bounds) => + ipcRenderer.invoke(BROWSER_SET_VISIBLE_CHANNEL, threadId, visible, bounds), + browserNavigate: (threadId, url) => ipcRenderer.invoke(BROWSER_NAVIGATE_CHANNEL, threadId, url), + browserGoBack: (threadId) => ipcRenderer.invoke(BROWSER_GO_BACK_CHANNEL, threadId), + browserGoForward: (threadId) => ipcRenderer.invoke(BROWSER_GO_FORWARD_CHANNEL, threadId), + browserReload: (threadId) => ipcRenderer.invoke(BROWSER_RELOAD_CHANNEL, threadId), + browserGetState: (threadId) => ipcRenderer.invoke(BROWSER_GET_STATE_CHANNEL, threadId), + browserObserve: (threadId, target) => ipcRenderer.invoke(BROWSER_OBSERVE_CHANNEL, threadId, target), + browserAct: (threadId, action) => ipcRenderer.invoke(BROWSER_ACT_CHANNEL, threadId, action), + browserExtract: (threadId, query) => ipcRenderer.invoke(BROWSER_EXTRACT_CHANNEL, threadId, query), + browserWait: (threadId, durationMs) => ipcRenderer.invoke(BROWSER_WAIT_CHANNEL, threadId, durationMs), + onBrowserState: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, state: unknown) => { + if (typeof state !== "object" || state === null) return; + listener(state as Parameters[0]); + }; + + ipcRenderer.on(BROWSER_STATE_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(BROWSER_STATE_CHANNEL, wrappedListener); + }; + }, } satisfies DesktopBridge); diff --git a/apps/server/.env.example b/apps/server/.env.example new file mode 100644 index 000000000..666aa1a07 --- /dev/null +++ b/apps/server/.env.example @@ -0,0 +1 @@ +CLERK_SECRET_KEY= diff --git a/apps/server/package.json b/apps/server/package.json index d1fea10b6..de953b0ef 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -22,13 +22,15 @@ "test": "vitest run" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.22.0", "@effect/platform-node": "catalog:", "@effect/sql-sqlite-bun": "catalog:", "@pierre/diffs": "^1.1.0-beta.16", "effect": "catalog:", "node-pty": "^1.1.0", "open": "^10.1.0", - "ws": "^8.18.0" + "ws": "^8.18.0", + "zod": "^3.24.2" }, "devDependencies": { "@effect/language-service": "catalog:", diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index ccf1f5a91..ced1cf3e6 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -125,6 +125,7 @@ const buildCmd = Command.make( const fs = yield* FileSystem.FileSystem; const repoRoot = yield* RepoRoot; const serverDir = path.join(repoRoot, "apps/server"); + const webDir = path.join(repoRoot, "apps/web"); yield* Effect.log("[cli] Running tsdown..."); yield* runCommand( @@ -137,8 +138,21 @@ const buildCmd = Command.make( const webDist = path.join(repoRoot, "apps/web/dist"); const clientTarget = path.join(serverDir, "dist/client"); + let webDistExists = yield* fs.exists(webDist); + + if (!webDistExists) { + yield* Effect.log("[cli] Web dist not found — building web app..."); + yield* runCommand( + ChildProcess.make({ + cwd: webDir, + stdout: config.verbose ? "inherit" : "ignore", + stderr: "inherit", + })`bun run build`, + ); + webDistExists = yield* fs.exists(webDist); + } - if (yield* fs.exists(webDist)) { + if (webDistExists) { yield* fs.copy(webDist, clientTarget); yield* applyDevelopmentIconOverrides(repoRoot, serverDir); yield* Effect.log("[cli] Bundled web app into dist/client"); diff --git a/apps/server/src/appOperatorMcpServer.ts b/apps/server/src/appOperatorMcpServer.ts new file mode 100644 index 000000000..a578c7cb5 --- /dev/null +++ b/apps/server/src/appOperatorMcpServer.ts @@ -0,0 +1,259 @@ +#!/usr/bin/env node + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import * as z from "zod/v4"; + +interface ProjectAction { + id: string; + name: string; + command: string; + icon: "play" | "test" | "lint" | "configure" | "build" | "debug"; + runOnWorktreeCreate: boolean; +} + +interface AppOperatorContextResult { + thread: { + id: string; + title: string; + model: string; + runtimeMode: string; + interactionMode: string; + }; + project: { + id: string; + title: string; + workspaceRoot: string; + defaultModel: string | null; + actions: ProjectAction[]; + }; + canvas: { + title: string; + framework: "react"; + fileCount: number; + lastUpdatedAt: string; + }; +} + +interface ActionMutationResult { + projectId: string; + action: ProjectAction; + actionCount: number; +} + +interface CanvasFile { + path: string; + language: "jsx" | "css" | "md"; + contents: string; +} + +interface CanvasState { + threadId: string; + title: string; + framework: "react"; + prompt: string; + files: CanvasFile[]; + lastUpdatedAt: string; +} + +type OperatorResponse = + | { ok: true; result: T } + | { ok: false; error: string }; + +function readFlag(flag: string): string | null { + const index = process.argv.indexOf(flag); + if (index < 0) { + return null; + } + const next = process.argv[index + 1]; + return typeof next === "string" && next.trim().length > 0 ? next.trim() : null; +} + +function requireFlag(flag: string): string { + const value = readFlag(flag); + if (value) { + return value; + } + throw new Error(`app-operator-mcp requires the ${flag} argument.`); +} + +const serverUrl = requireFlag("--server-url"); +const serverToken = requireFlag("--server-token"); +const threadId = requireFlag("--thread-id"); + +async function operatorCall( + method: string, + params: Record = {}, +): Promise { + const response = await fetch(serverUrl, { + method: "POST", + headers: { + authorization: `Bearer ${serverToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + method, + params: { + threadId, + ...params, + }, + }), + }); + + const payload = (await response.json()) as OperatorResponse; + if (!response.ok || !payload.ok) { + const message = payload.ok ? `Operator request failed (${response.status}).` : payload.error; + throw new Error(message); + } + return payload.result; +} + +const server = new McpServer({ + name: "t3code-app-operator", + version: "0.1.0", +}); + +server.registerTool( + "app_get_context", + { + description: + "Read the current T3 Code app context for this thread, including the current project and project actions.", + }, + async () => { + const result = await operatorCall("app.getContext"); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, +); + +server.registerTool( + "actions_list", + { + description: "List project actions available for the current thread's project.", + }, + async () => { + const result = await operatorCall("actions.list"); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, +); + +server.registerTool( + "actions_create", + { + description: + "Create a new project action in T3 Code for the current thread's project. Use this instead of asking the user to fill the Add Action dialog manually.", + inputSchema: { + name: z.string().min(1).describe("Action name shown in the top bar and menus."), + command: z.string().min(1).describe("Shell command to run for the action."), + icon: z + .enum(["play", "test", "lint", "configure", "build", "debug"]) + .optional() + .describe("Optional action icon."), + keybinding: z + .string() + .optional() + .describe("Optional keybinding like mod+shift+t."), + runOnWorktreeCreate: z + .boolean() + .optional() + .describe("Whether this action should run automatically on worktree creation."), + }, + }, + async ({ command, icon, keybinding, name, runOnWorktreeCreate }) => { + const result = await operatorCall("actions.create", { + command, + ...(icon ? { icon } : {}), + ...(keybinding ? { keybinding } : {}), + name, + ...(runOnWorktreeCreate !== undefined ? { runOnWorktreeCreate } : {}), + }); + return { + content: [ + { + type: "text", + text: `Created action ${result.action.name}.\n${JSON.stringify(result, null, 2)}`, + }, + ], + }; + }, +); + +server.registerTool( + "canvas_get_state", + { + description: + "Read the current thread canvas state, including React files and prompt, for the in-app Canvas surface.", + }, + async () => { + const result = await operatorCall("canvas.getState"); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, +); + +server.registerTool( + "canvas_update", + { + description: + "Update the current thread canvas used by T3 Code's Canvas surface. This is how the agent should create or reshape the generated app surface instead of treating Canvas like a browser tab.", + inputSchema: { + title: z.string().optional().describe("Optional canvas title."), + prompt: z.string().optional().describe("Optional canvas brief or prompt."), + files: z + .array( + z.object({ + path: z.string().min(1), + language: z.enum(["jsx", "css", "md"]), + contents: z.string(), + }), + ) + .optional() + .describe("Optional full file list to replace the current canvas files."), + }, + }, + async ({ files, prompt, title }) => { + const result = await operatorCall("canvas.update", { + ...(typeof title === "string" ? { title } : {}), + ...(typeof prompt === "string" ? { prompt } : {}), + ...(files ? { files } : {}), + }); + return { + content: [ + { + type: "text", + text: `Updated canvas ${result.title}.\n${JSON.stringify(result, null, 2)}`, + }, + ], + }; + }, +); + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error(`t3code-app-operator MCP ready for thread ${threadId}`); +} + +main().catch((error) => { + console.error("app-operator-mcp failed:", error); + process.exit(1); +}); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index a8a8ce460..36e53fabc 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -1,7 +1,11 @@ import { type ChildProcessWithoutNullStreams, spawn, spawnSync } from "node:child_process"; import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; +import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import readline from "node:readline"; +import { fileURLToPath } from "node:url"; import { ApprovalRequestId, @@ -71,6 +75,7 @@ interface CodexSessionContext { pendingApprovals: Map; pendingUserInputs: Map; nextRequestId: number; + managedCodexHomePath?: string; stopping: boolean; } @@ -542,7 +547,10 @@ export class CodexAppServerManager extends EventEmitter undefined); + } this.sessions.delete(threadId); } @@ -1566,6 +1578,142 @@ function assertSupportedCodexCliVersion(input: { } } +function currentRuntimeScriptPath(basename: string): string { + const currentFilePath = fileURLToPath(import.meta.url); + const extension = path.extname(currentFilePath); + return path.join(path.dirname(currentFilePath), `${basename}${extension}`); +} + +function tomlString(value: string): string { + return JSON.stringify(value); +} + +function tomlStringArray(values: readonly string[]): string { + return `[${values.map((value) => tomlString(value)).join(", ")}]`; +} + +function resolveMcpLauncher(scriptPath: string): { command: string; args: string[] } { + if (process.versions.bun) { + return { + command: process.execPath, + args: ["run", scriptPath], + }; + } + + return { + command: process.execPath, + args: [scriptPath], + }; +} + +function readDesktopBrowserOperatorEnv(): + | { readonly url: string; readonly token: string } + | null { + const url = process.env.T3CODE_DESKTOP_OPERATOR_URL?.trim(); + const token = process.env.T3CODE_DESKTOP_OPERATOR_TOKEN?.trim(); + if (!url || !token) { + return null; + } + return { url, token }; +} + +function readAppOperatorEnv(): + | { readonly url: string; readonly token: string } + | null { + const port = process.env.T3CODE_PORT?.trim(); + const token = process.env.T3CODE_AUTH_TOKEN?.trim(); + const host = process.env.T3CODE_HOST?.trim() || "127.0.0.1"; + if (!port || !token) { + return null; + } + return { + url: `http://${host}:${port}/__t3_operator`, + token, + }; +} + +async function maybeReadFile(pathLike: string): Promise { + try { + return await readFile(pathLike, "utf8"); + } catch { + return null; + } +} + +async function prepareManagedCodexHome(input: { + baseHomePath?: string; + threadId: ThreadId; +}): Promise { + const desktopBrowserOperator = readDesktopBrowserOperatorEnv(); + const appOperator = readAppOperatorEnv(); + + if (!desktopBrowserOperator && !appOperator) { + return input.baseHomePath; + } + + const baseHomePath = + input.baseHomePath?.trim() || process.env.CODEX_HOME?.trim() || path.join(os.homedir(), ".codex"); + const managedRoot = await mkdtemp(path.join(os.tmpdir(), "t3code-codex-home-")); + + try { + await cp(baseHomePath, managedRoot, { + recursive: true, + force: true, + errorOnExist: false, + }); + } catch { + // Start from an empty managed home when there is no existing Codex home to copy. + } + + const existingConfig = (await maybeReadFile(path.join(managedRoot, "config.toml"))) ?? ""; + const mcpSections: string[] = []; + + if (desktopBrowserOperator) { + const scriptPath = currentRuntimeScriptPath("labBrowserMcpServer"); + const launcher = resolveMcpLauncher(scriptPath); + mcpSections.push( + [ + "[mcp_servers.t3_lab_browser]", + `command = ${tomlString(launcher.command)}`, + `args = ${tomlStringArray([ + ...launcher.args, + "--operator-url", + desktopBrowserOperator.url, + "--operator-token", + desktopBrowserOperator.token, + "--thread-id", + input.threadId, + ])}`, + ].join("\n"), + ); + } + + if (appOperator) { + const scriptPath = currentRuntimeScriptPath("appOperatorMcpServer"); + const launcher = resolveMcpLauncher(scriptPath); + mcpSections.push( + [ + "[mcp_servers.t3_app_operator]", + `command = ${tomlString(launcher.command)}`, + `args = ${tomlStringArray([ + ...launcher.args, + "--server-url", + appOperator.url, + "--server-token", + appOperator.token, + "--thread-id", + input.threadId, + ])}`, + ].join("\n"), + ); + } + + const nextConfig = [existingConfig.trimEnd(), ...mcpSections].filter((part) => part.length > 0).join("\n\n"); + await writeFile(path.join(managedRoot, "config.toml"), `${nextConfig}\n`, "utf8"); + + return managedRoot; +} + function readResumeCursorThreadId(resumeCursor: unknown): string | undefined { if (!resumeCursor || typeof resumeCursor !== "object" || Array.isArray(resumeCursor)) { return undefined; diff --git a/apps/server/src/git/githubDeviceFlow.ts b/apps/server/src/git/githubDeviceFlow.ts new file mode 100644 index 000000000..3a8f04f9e --- /dev/null +++ b/apps/server/src/git/githubDeviceFlow.ts @@ -0,0 +1,170 @@ +/** + * GitHub OAuth Device Flow implementation. + * + * Implements the standard GitHub device authorization grant: + * 1. Request a device code from GitHub + * 2. Return user_code + verification_uri to the client + * 3. Poll GitHub for token exchange while user authorizes + * 4. Return the access token on success + * + * @see https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow + */ + +// GitHub OAuth App client ID for T3Code (public by design — not a secret). +// Device Flow apps do not use a client_secret per RFC 8628. +// Override via the clientId parameter for self-hosted deployments. +const GITHUB_CLIENT_ID = "Ov23liUkPZWQWkOyadxd"; + +const DEVICE_CODE_URL = "https://github.com/login/device/code"; +const ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token"; +const SCOPES = "repo read:org read:user"; + +export interface GitHubDeviceCodeResult { + deviceCode: string; + userCode: string; + verificationUri: string; + expiresIn: number; + interval: number; +} + +export interface GitHubDeviceFlowTokenResult { + accessToken: string; + tokenType: string; + scope: string; +} + +/** + * Request a device code from GitHub. + * Returns both the device_code (kept server-side) and user_code (shown to user). + */ +export async function requestGitHubDeviceCode( + clientId?: string, +): Promise { + const effectiveClientId = clientId || GITHUB_CLIENT_ID; + + const response = await fetch(DEVICE_CODE_URL, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: effectiveClientId, + scope: SCOPES, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`GitHub device code request failed (${response.status}): ${text}`); + } + + const data = (await response.json()) as { + device_code?: string; + user_code?: string; + verification_uri?: string; + expires_in?: number; + interval?: number; + }; + + if (!data.user_code || !data.device_code) { + throw new Error("GitHub returned an invalid device code response."); + } + + return { + deviceCode: data.device_code, + userCode: data.user_code, + verificationUri: data.verification_uri ?? "https://github.com/login/device", + expiresIn: data.expires_in ?? 900, + interval: data.interval ?? 5, + }; +} + +/** + * Poll GitHub for the access token after the user has entered their code. + * Resolves when the user completes authorization or rejects on expiry/error. + */ +export async function pollGitHubDeviceFlow( + deviceCode: string, + interval: number, + expiresIn: number, + clientId?: string, +): Promise { + const effectiveClientId = clientId || GITHUB_CLIENT_ID; + const expiresAt = Date.now() + expiresIn * 1000; + let pollIntervalMs = Math.max(interval, 5) * 1000; + + return new Promise((resolve, reject) => { + const poll = async () => { + if (Date.now() >= expiresAt) { + reject(new Error("Device code expired. Please restart the authorization flow.")); + return; + } + + try { + const response = await fetch(ACCESS_TOKEN_URL, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: effectiveClientId, + device_code: deviceCode, + grant_type: "urn:ietf:params:oauth:grant-type:device_code", + }), + }); + + const data = (await response.json()) as { + access_token?: string; + token_type?: string; + scope?: string; + error?: string; + error_description?: string; + interval?: number; + }; + + if (data.access_token) { + resolve({ + accessToken: data.access_token, + tokenType: data.token_type ?? "bearer", + scope: data.scope ?? "", + }); + return; + } + + if (data.error === "authorization_pending") { + setTimeout(poll, pollIntervalMs); + return; + } + + if (data.error === "slow_down") { + pollIntervalMs += 5000; + setTimeout(poll, pollIntervalMs); + return; + } + + if (data.error === "expired_token") { + reject(new Error("Device code expired. Please restart the authorization flow.")); + return; + } + + if (data.error === "access_denied") { + reject(new Error("Authorization was denied by the user.")); + return; + } + + reject( + new Error( + data.error_description ?? data.error ?? "Unknown error during GitHub authorization.", + ), + ); + } catch { + // Network error — retry after interval + setTimeout(poll, pollIntervalMs); + } + }; + + setTimeout(poll, pollIntervalMs); + }); +} diff --git a/apps/server/src/labBrowserMcpServer.ts b/apps/server/src/labBrowserMcpServer.ts new file mode 100644 index 000000000..63def88e3 --- /dev/null +++ b/apps/server/src/labBrowserMcpServer.ts @@ -0,0 +1,452 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import * as z from "zod/v4"; + +interface BrowserViewState { + threadId: string; + url: string | null; + title: string | null; + loading: boolean; + canGoBack: boolean; + canGoForward: boolean; + isVisible: boolean; + lastUpdatedAt: string; +} + +interface BrowserObservedElement { + tag: string; + role: string | null; + text: string | null; + label: string | null; + placeholder: string | null; + name: string | null; + id: string | null; + type: string | null; + href: string | null; + editable: boolean; +} + +interface BrowserObserveResult { + state: BrowserViewState; + observation: { + threadId: string; + url: string | null; + title: string | null; + elements: BrowserObservedElement[]; + matchedElement: BrowserObservedElement | null; + documentText: string; + lastUpdatedAt: string; + }; + screenshotBase64: string | null; +} + +interface BrowserExtractResult { + state: BrowserViewState; + extraction: { + threadId: string; + url: string | null; + title: string | null; + text: string; + lastUpdatedAt: string; + }; +} + +interface BrowserActionResult { + threadId: string; + ok: boolean; + detail: string; + state: BrowserViewState; + observation?: BrowserObserveResult["observation"]; +} + +type OperatorResponse = + | { ok: true; result: T } + | { ok: false; error: string }; + +function readFlag(flag: string): string | null { + const index = process.argv.indexOf(flag); + if (index < 0) { + return null; + } + const next = process.argv[index + 1]; + return typeof next === "string" && next.trim().length > 0 ? next.trim() : null; +} + +function requireFlag(flag: string): string { + const value = readFlag(flag); + if (value) { + return value; + } + throw new Error(`lab-browser-mcp requires the ${flag} argument.`); +} + +const operatorUrl = requireFlag("--operator-url"); +const operatorToken = requireFlag("--operator-token"); +const threadId = requireFlag("--thread-id"); + +async function operatorCall( + method: string, + params: Record = {}, +): Promise { + const response = await fetch(operatorUrl, { + method: "POST", + headers: { + authorization: `Bearer ${operatorToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + method, + params: { + threadId, + ...params, + }, + }), + }); + + const payload = (await response.json()) as OperatorResponse; + if (!response.ok || !payload.ok) { + const message = payload.ok ? `Operator request failed (${response.status}).` : payload.error; + throw new Error(message); + } + return payload.result; +} + +function formatState(state: BrowserViewState): string { + return JSON.stringify( + { + threadId: state.threadId, + url: state.url, + title: state.title, + loading: state.loading, + canGoBack: state.canGoBack, + canGoForward: state.canGoForward, + isVisible: state.isVisible, + lastUpdatedAt: state.lastUpdatedAt, + }, + null, + 2, + ); +} + +function summarizeObservedElements(elements: BrowserObservedElement[]): string { + if (elements.length === 0) { + return "No interactive elements detected."; + } + + return elements + .slice(0, 25) + .map((element, index) => { + const parts = [ + element.label, + element.text, + element.placeholder, + element.name, + element.id, + ].filter((value): value is string => typeof value === "string" && value.trim().length > 0); + const descriptor = parts.length > 0 ? parts.join(" | ") : "(unlabeled)"; + const role = element.role ?? element.tag; + return `${index + 1}. ${role}: ${descriptor}`; + }) + .join("\n"); +} + +const server = new McpServer({ + name: "t3code-lab-browser", + version: "0.1.0", +}); + +server.registerTool( + "browser_get_state", + { + description: "Read the current state of the T3 Code Lab browser for this thread.", + }, + async () => { + const state = await operatorCall("browser.getState"); + return { + content: [ + { + type: "text", + text: formatState(state), + }, + ], + }; + }, +); + +server.registerTool( + "browser_goto", + { + description: "Navigate the T3 Code Lab browser to a URL.", + inputSchema: { + url: z.string().min(1).describe("The http or https URL to open."), + }, + }, + async ({ url }) => { + const state = await operatorCall("browser.navigate", { url }); + return { + content: [ + { + type: "text", + text: `Navigated the Lab browser.\n${formatState(state)}`, + }, + ], + }; + }, +); + +server.registerTool( + "browser_observe", + { + description: + "Observe the current Lab browser page. Returns page text, interactive elements, and a screenshot when available.", + inputSchema: { + target: z + .string() + .optional() + .describe("Optional target to bias observation toward a specific element or label."), + }, + }, + async ({ target }) => { + const result = await operatorCall("browser.observe", target ? { target } : {}); + const summary = [ + formatState(result.state), + "", + `Matched element: ${ + result.observation.matchedElement + ? JSON.stringify(result.observation.matchedElement, null, 2) + : "none" + }`, + "", + "Interactive elements:", + summarizeObservedElements(result.observation.elements), + "", + "Document text excerpt:", + result.observation.documentText.slice(0, 6000), + ].join("\n"); + + return { + content: [ + { type: "text", text: summary }, + ...(result.screenshotBase64 + ? [ + { + type: "image" as const, + data: result.screenshotBase64, + mimeType: "image/png", + }, + ] + : []), + ], + }; + }, +); + +server.registerTool( + "browser_click", + { + description: "Click a visible element in the Lab browser by fuzzy matching its label, text, or placeholder.", + inputSchema: { + target: z.string().min(1).describe("Element label, text, placeholder, or other identifying text."), + }, + }, + async ({ target }) => { + const result = await operatorCall("browser.act", { + action: { kind: "click", target }, + }); + return { + content: [ + { + type: "text", + text: `${result.detail}\n${formatState(result.state)}`, + }, + ], + }; + }, +); + +server.registerTool( + "browser_type", + { + description: "Type text into an editable field in the Lab browser.", + inputSchema: { + target: z.string().min(1).describe("Editable field label, placeholder, or other identifying text."), + text: z.string().describe("Text to enter."), + submit: z.boolean().optional().describe("Submit the field or enclosing form after typing."), + }, + }, + async ({ target, text, submit }) => { + const result = await operatorCall("browser.act", { + action: { kind: "type", target, text, ...(submit ? { submit: true } : {}) }, + }); + return { + content: [ + { + type: "text", + text: `${result.detail}\n${formatState(result.state)}`, + }, + ], + }; + }, +); + +server.registerTool( + "browser_press", + { + description: "Press a keyboard key in the Lab browser.", + inputSchema: { + key: z.string().min(1).describe("The key to press, for example Enter, Tab, Escape, ArrowDown."), + }, + }, + async ({ key }) => { + const result = await operatorCall("browser.act", { + action: { kind: "press", key }, + }); + return { + content: [ + { + type: "text", + text: `${result.detail}\n${formatState(result.state)}`, + }, + ], + }; + }, +); + +server.registerTool( + "browser_scroll", + { + description: "Scroll the Lab browser viewport.", + inputSchema: { + direction: z.enum(["up", "down"]).describe("Scroll direction."), + amount: z.number().int().positive().optional().describe("Optional scroll amount in pixels."), + }, + }, + async ({ direction, amount }) => { + const result = await operatorCall("browser.act", { + action: { kind: "scroll", direction, ...(typeof amount === "number" ? { amount } : {}) }, + }); + return { + content: [ + { + type: "text", + text: `${result.detail}\n${formatState(result.state)}`, + }, + ], + }; + }, +); + +server.registerTool( + "browser_extract", + { + description: "Extract text from the current Lab browser page.", + inputSchema: { + query: z + .string() + .optional() + .describe("Optional extraction focus or question to guide the extraction."), + }, + }, + async ({ query }) => { + const result = await operatorCall("browser.extract", query ? { query } : {}); + return { + content: [ + { + type: "text", + text: `${formatState(result.state)}\n\n${result.extraction.text}`, + }, + ], + }; + }, +); + +server.registerTool( + "browser_wait", + { + description: "Wait for the current Lab browser page to settle for a short duration.", + inputSchema: { + durationMs: z + .number() + .int() + .min(0) + .max(30000) + .describe("How long to wait in milliseconds."), + }, + }, + async ({ durationMs }) => { + const state = await operatorCall("browser.wait", { durationMs }); + return { + content: [ + { + type: "text", + text: `Waited ${durationMs}ms.\n${formatState(state)}`, + }, + ], + }; + }, +); + +server.registerTool( + "browser_back", + { + description: "Navigate back in the Lab browser history.", + }, + async () => { + const state = await operatorCall("browser.goBack"); + return { + content: [ + { + type: "text", + text: `Navigated back.\n${formatState(state)}`, + }, + ], + }; + }, +); + +server.registerTool( + "browser_forward", + { + description: "Navigate forward in the Lab browser history.", + }, + async () => { + const state = await operatorCall("browser.goForward"); + return { + content: [ + { + type: "text", + text: `Navigated forward.\n${formatState(state)}`, + }, + ], + }; + }, +); + +server.registerTool( + "browser_reload", + { + description: "Reload the current Lab browser page.", + }, + async () => { + const state = await operatorCall("browser.reload"); + return { + content: [ + { + type: "text", + text: `Reloaded the page.\n${formatState(state)}`, + }, + ], + }; + }, +); + +async function main() { + const transport = new StdioServerTransport(); + await server.connect(transport); + console.error(`t3code-lab-browser MCP ready for thread ${threadId}`); +} + +main().catch((error) => { + console.error("lab-browser-mcp failed:", error); + process.exit(1); +}); diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index 0a33be0cb..127c87cbf 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -123,7 +123,7 @@ const CliEnvConfig = Config.all({ }); const resolveBooleanFlag = (flag: Option.Option, envValue: boolean) => - Option.getOrElse(Option.filter(flag, Boolean), () => envValue); + Option.getOrElse(flag, () => envValue); const ServerConfigLive = (input: CliInput) => Layer.effect( @@ -164,7 +164,7 @@ const ServerConfigLive = (input: CliInput) => ); const logWebSocketEvents = resolveBooleanFlag( input.logWebSocketEvents, - env.logWebSocketEvents ?? Boolean(devUrl), + env.logWebSocketEvents ?? false, ); const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir; const { join } = yield* Path.Path; diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index 5c742fba9..694c55e9b 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -40,7 +40,8 @@ interface CommandAvailabilityOptions { const LINE_COLUMN_SUFFIX_PATTERN = /:\d+(?::\d+)?$/; function shouldUseGotoFlag(editorId: EditorId, target: string): boolean { - return (editorId === "cursor" || editorId === "vscode") && LINE_COLUMN_SUFFIX_PATTERN.test(target); + return (editorId === "cursor" || editorId === "vscode" || editorId === "windsurf") && + LINE_COLUMN_SUFFIX_PATTERN.test(target); } function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { @@ -211,6 +212,9 @@ export const resolveEditorLaunch = Effect.fnUntraced(function* ( } if (editorDef.command) { + if (editorDef.id === "opencode") { + return { command: editorDef.command, args: ["-c", input.cwd] }; + } return shouldUseGotoFlag(editorDef.id, input.cwd) ? { command: editorDef.command, args: ["--goto", input.cwd] } : { command: editorDef.command, args: [input.cwd] }; diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 96242b846..2eaf5386b 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -45,7 +45,7 @@ const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); type LegacyProviderRuntimeEvent = { readonly type: string; readonly eventId: EventId; - readonly provider: "codex"; + readonly provider: ProviderRuntimeEvent["provider"]; readonly createdAt: string; readonly threadId: ThreadId; readonly turnId?: string | undefined; @@ -1491,3 +1491,4 @@ describe("ProviderRuntimeIngestion", () => { expect(thread.session?.lastError).toBe("runtime still processed"); }); }); + diff --git a/apps/server/src/provider/Layers/ProviderHealth.ts b/apps/server/src/provider/Layers/ProviderHealth.ts index 59f41edf8..e8b3c4a54 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.ts @@ -206,10 +206,20 @@ export const checkCodexProviderStatus: Effect.Effect< > = Effect.gen(function* () { const checkedAt = new Date().toISOString(); - // Probe 1: `codex --version` — is the CLI reachable? - const versionProbe = yield* runCodexCommand(["--version"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, + const [versionProbe, authProbe] = yield* Effect.all( + [ + // Probe 1: `codex --version` — is the CLI reachable? + runCodexCommand(["--version"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ), + // Probe 2: `codex login status` — is the user authenticated? + runCodexCommand(["login", "status"]).pipe( + Effect.timeoutOption(DEFAULT_TIMEOUT_MS), + Effect.result, + ), + ], + { concurrency: "unbounded" }, ); if (Result.isFailure(versionProbe)) { @@ -264,11 +274,6 @@ export const checkCodexProviderStatus: Effect.Effect< }; } - // Probe 2: `codex login status` — is the user authenticated? - const authProbe = yield* runCodexCommand(["login", "status"]).pipe( - Effect.timeoutOption(DEFAULT_TIMEOUT_MS), - Effect.result, - ); if (Result.isFailure(authProbe)) { const error = authProbe.failure; @@ -312,9 +317,10 @@ export const checkCodexProviderStatus: Effect.Effect< export const ProviderHealthLive = Layer.effect( ProviderHealth, Effect.gen(function* () { - const codexStatus = yield* checkCodexProviderStatus; + const status = yield* checkCodexProviderStatus; return { - getStatuses: Effect.succeed([codexStatus]), + getStatuses: Effect.succeed([status]), } satisfies ProviderHealthShape; }), ); + diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 69e1e439b..b1f66efda 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -25,7 +25,12 @@ function decodeProviderKind( providerName: string, operation: string, ): Effect.Effect { - if (providerName === "codex") { + if ( + providerName === "codex" || + providerName === "claude-code" || + providerName === "gemini-cli" || + providerName === "github-copilot-cli" + ) { return Effect.succeed(providerName); } return Effect.fail( diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index 771c56b28..1c8fc26a3 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -28,7 +28,7 @@ import { } from "../Services/Manager"; const DEFAULT_HISTORY_LINE_LIMIT = 5_000; -const DEFAULT_PERSIST_DEBOUNCE_MS = 40; +const DEFAULT_PERSIST_DEBOUNCE_MS = 250; const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; @@ -242,14 +242,26 @@ async function defaultSubprocessChecker(terminalPid: number): Promise { function capHistory(history: string, maxLines: number): string { if (history.length === 0) return history; - const hasTrailingNewline = history.endsWith("\n"); - const lines = history.split("\n"); - if (hasTrailingNewline) { - lines.pop(); + + // Fast path: count newlines without splitting the entire string. + // Only split+slice when we actually exceed the limit. + let newlineCount = 0; + for (let i = 0; i < history.length; i++) { + if (history.charCodeAt(i) === 10) newlineCount++; + } + + const hasTrailingNewline = history.charCodeAt(history.length - 1) === 10; + const lineCount = hasTrailingNewline ? newlineCount : newlineCount + 1; + if (lineCount <= maxLines) return history; + + // Find the index of the (lineCount - maxLines)th newline to skip leading lines + let linesToSkip = lineCount - maxLines; + let offset = 0; + while (linesToSkip > 0 && offset < history.length) { + if (history.charCodeAt(offset) === 10) linesToSkip--; + offset++; } - if (lines.length <= maxLines) return history; - const capped = lines.slice(lines.length - maxLines).join("\n"); - return hasTrailingNewline ? `${capped}\n` : capped; + return history.slice(offset); } function legacySafeThreadId(threadId: string): string { @@ -376,6 +388,7 @@ export class TerminalManagerRuntime extends EventEmitter status: "starting", pid: null, history, + historyDirty: false, exitCode: null, exitSignal: null, updatedAt: new Date().toISOString(), @@ -495,6 +508,7 @@ export class TerminalManagerRuntime extends EventEmitter status: "starting", pid: null, history: "", + historyDirty: false, exitCode: null, exitSignal: null, updatedAt: new Date().toISOString(), @@ -692,14 +706,17 @@ export class TerminalManagerRuntime extends EventEmitter } private onProcessData(session: TerminalSessionState, data: string): void { - session.history = capHistory(`${session.history}${data}`, this.historyLineLimit); - session.updatedAt = new Date().toISOString(); - this.queuePersist(session.threadId, session.terminalId, session.history); + session.history += data; + // Defer the expensive capHistory to persist time instead of every data event + session.historyDirty = true; + const now = new Date().toISOString(); + session.updatedAt = now; + this.queuePersist(session.threadId, session.terminalId); this.emitEvent({ type: "output", threadId: session.threadId, terminalId: session.terminalId, - createdAt: new Date().toISOString(), + createdAt: now, data, }); } @@ -817,9 +834,7 @@ export class TerminalManagerRuntime extends EventEmitter } } - private queuePersist(threadId: string, terminalId: string, history: string): void { - const persistenceKey = toSessionKey(threadId, terminalId); - this.pendingPersistHistory.set(persistenceKey, history); + private queuePersist(threadId: string, terminalId: string): void { this.schedulePersist(threadId, terminalId); } @@ -859,8 +874,9 @@ export class TerminalManagerRuntime extends EventEmitter if (this.persistQueues.get(persistenceKey) === next) { this.persistQueues.delete(persistenceKey); } + const session = this.sessions.get(persistenceKey); if ( - this.pendingPersistHistory.has(persistenceKey) && + session?.historyDirty && !this.persistTimers.has(persistenceKey) ) { this.schedulePersist(threadId, terminalId); @@ -875,10 +891,14 @@ export class TerminalManagerRuntime extends EventEmitter if (this.persistTimers.has(persistenceKey)) return; const timer = setTimeout(() => { this.persistTimers.delete(persistenceKey); - const pendingHistory = this.pendingPersistHistory.get(persistenceKey); - if (pendingHistory === undefined) return; - this.pendingPersistHistory.delete(persistenceKey); - void this.enqueuePersistWrite(threadId, terminalId, pendingHistory); + const session = this.sessions.get(persistenceKey); + if (!session) return; + // Cap history only at persist boundaries, not on every data event + if (session.historyDirty) { + session.history = capHistory(session.history, this.historyLineLimit); + session.historyDirty = false; + } + void this.enqueuePersistWrite(threadId, terminalId, session.history); }, this.persistDebounceMs); this.persistTimers.set(persistenceKey, timer); } @@ -956,10 +976,11 @@ export class TerminalManagerRuntime extends EventEmitter this.clearPersistTimer(threadId, terminalId); while (true) { - const pendingHistory = this.pendingPersistHistory.get(persistenceKey); - if (pendingHistory !== undefined) { - this.pendingPersistHistory.delete(persistenceKey); - await this.enqueuePersistWrite(threadId, terminalId, pendingHistory); + const session = this.sessions.get(persistenceKey); + if (session?.historyDirty) { + session.history = capHistory(session.history, this.historyLineLimit); + session.historyDirty = false; + await this.enqueuePersistWrite(threadId, terminalId, session.history); } const pending = this.persistQueues.get(persistenceKey); diff --git a/apps/server/src/terminal/Services/Manager.ts b/apps/server/src/terminal/Services/Manager.ts index 9cff32399..d39b3300b 100644 --- a/apps/server/src/terminal/Services/Manager.ts +++ b/apps/server/src/terminal/Services/Manager.ts @@ -31,6 +31,7 @@ export interface TerminalSessionState { status: TerminalSessionStatus; pid: number | null; history: string; + historyDirty: boolean; exitCode: number | null; exitSignal: number | null; updatedAt: string; diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index d8859c2fa..e82391837 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -7,20 +7,26 @@ * @module Server */ import http from "node:http"; +import { spawn } from "node:child_process"; import type { Duplex } from "node:stream"; import Mime from "@effect/platform-node/Mime"; import { + type CanvasFile, CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, type ClientOrchestrationCommand, + KeybindingRule, + MAX_SCRIPT_ID_LENGTH, type OrchestrationCommand, ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, ProjectId, ThreadId, + type ThreadCanvasState, TerminalEvent, + type ServerProviderStatus, WS_CHANNELS, WS_METHODS, WebSocketRequest, @@ -34,6 +40,7 @@ import { Exit, FileSystem, Layer, + Option, Path, Ref, Schema, @@ -106,6 +113,192 @@ const isServerNotRunningError = (error: unknown): boolean => { ); }; +const OPERATOR_ROUTE_PATH = "/__t3_operator"; +const CANVAS_STATE_DIRECTORY = "canvas"; + +const DEFAULT_CANVAS_FILES: readonly CanvasFile[] = [ + { + path: "src/App.jsx", + language: "jsx", + contents: `export default function App() { + return ( +
+
+ T3 Canvas +

Build the next React surface here.

+

+ This canvas is separate from the Lab browser. Use it for generated UI, app concepts, and + interactive React previews. +

+
+ + +
+
+
+ ); +} +`, + }, + { + path: "src/styles.css", + language: "css", + contents: `.app-shell { + min-height: 100vh; + display: grid; + place-items: center; + padding: 40px; + background: + radial-gradient(circle at top, rgba(90, 120, 255, 0.18), transparent 34%), + linear-gradient(180deg, #09090b 0%, #0f1115 100%); + color: #f8fafc; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; +} + +.hero-card { + width: min(720px, 100%); + padding: 32px; + border-radius: 28px; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(12, 14, 18, 0.88); + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.42); +} + +.eyebrow { + display: inline-flex; + margin-bottom: 16px; + border-radius: 999px; + padding: 6px 12px; + background: rgba(255, 255, 255, 0.06); + color: rgba(248, 250, 252, 0.72); + font-size: 12px; + letter-spacing: 0.18em; + text-transform: uppercase; +} + +.hero-card h1 { + margin: 0; + font-size: clamp(2rem, 4vw, 3.75rem); + line-height: 1.05; +} + +.hero-card p { + margin: 16px 0 0; + max-width: 56ch; + color: rgba(248, 250, 252, 0.76); + line-height: 1.7; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 28px; +} + +.hero-actions button { + border: 0; + border-radius: 999px; + padding: 12px 18px; + background: #4f46e5; + color: white; + font: inherit; +} + +.hero-actions .secondary { + background: rgba(255, 255, 255, 0.08); +} +`, + }, + { + path: "canvas.md", + language: "md", + contents: + "# Canvas brief\n\nDescribe the app you want here, then let the agent reshape the React files and preview.\n", + }, +] as const; + +function defaultThreadCanvasState(threadId: ThreadId): ThreadCanvasState { + return { + threadId, + title: "Canvas App", + framework: "react", + prompt: "", + files: [...DEFAULT_CANVAS_FILES], + lastUpdatedAt: new Date().toISOString(), + }; +} + +function canvasStatePath(stateDir: string, path: Path.Path, threadId: ThreadId): string { + return path.join(stateDir, CANVAS_STATE_DIRECTORY, `${threadId}.json`); +} + +function normalizeProjectScriptId(value: string): string { + const cleaned = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + if (cleaned.length === 0) { + return "script"; + } + if (cleaned.length <= MAX_SCRIPT_ID_LENGTH) { + return cleaned; + } + return cleaned.slice(0, MAX_SCRIPT_ID_LENGTH).replace(/-+$/g, "") || "script"; +} + +function nextProjectScriptId(name: string, existingIds: Iterable): string { + const taken = new Set(Array.from(existingIds)); + const baseId = normalizeProjectScriptId(name); + if (!taken.has(baseId)) return baseId; + + let suffix = 2; + while (suffix < 10_000) { + const candidate = `${baseId}-${suffix}`; + const safeCandidate = + candidate.length <= MAX_SCRIPT_ID_LENGTH + ? candidate + : `${baseId.slice(0, Math.max(1, MAX_SCRIPT_ID_LENGTH - String(suffix).length - 1))}-${suffix}`; + if (!taken.has(safeCandidate)) { + return safeCandidate; + } + suffix += 1; + } + + return `${baseId}-${Date.now()}`.slice(0, MAX_SCRIPT_ID_LENGTH); +} + +function commandForProjectScript(scriptId: string): `script.${string}.run` { + return `script.${scriptId}.run`; +} + +async function readJsonRequestBody(request: http.IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + if (chunks.length === 0) { + return null; + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +function writeJsonResponse( + response: http.ServerResponse, + statusCode: number, + body: Record, +): void { + const payload = JSON.stringify(body); + response.writeHead(statusCode, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(payload), + }); + response.end(payload); +} + function rejectUpgrade(socket: Duplex, statusCode: number, message: string): void { socket.end( `HTTP/1.1 ${statusCode} ${statusCode === 401 ? "Unauthorized" : "Bad Request"}\r\n` + @@ -202,6 +395,95 @@ function messageFromCause(cause: Cause.Cause): string { return message.length > 0 ? message : Cause.pretty(cause); } +interface CliProbeDescriptor { + id: "github-cli" | "claude-cli" | "gemini-cli"; + commands: readonly string[]; + versionArgs: readonly string[]; +} + +function runCommandCapture(command: string, args: readonly string[]): Promise<{ + exitCode: number; + stdout: string; + stderr: string; +}> { + return new Promise((resolve) => { + const child = spawn(command, [...args], { stdio: ["ignore", "pipe", "pipe"] }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + child.stdout.on("data", (chunk) => { + stdoutChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.stderr.on("data", (chunk) => { + stderrChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + child.on("error", () => { + resolve({ exitCode: -1, stdout: "", stderr: "" }); + }); + child.on("close", (code) => { + resolve({ + exitCode: typeof code === "number" ? code : -1, + stdout: Buffer.concat(stdoutChunks).toString("utf8"), + stderr: Buffer.concat(stderrChunks).toString("utf8"), + }); + }); + }); +} + +async function detectCommandPath(command: string): Promise { + const locator = process.platform === "win32" ? "where.exe" : "which"; + const result = await runCommandCapture(locator, [command]); + if (result.exitCode !== 0) { + return null; + } + const firstLine = result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0); + return firstLine ?? null; +} + +async function detectCliInstallation(descriptor: CliProbeDescriptor): Promise<{ + id: CliProbeDescriptor["id"]; + found: boolean; + command: string; + path?: string; + version?: string; + authenticated?: boolean; + message?: string; +}> { + for (const command of descriptor.commands) { + const foundPath = await detectCommandPath(command); + if (!foundPath) continue; + const versionResult = await runCommandCapture(foundPath, descriptor.versionArgs); + const versionLine = versionResult.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0); + let authenticated: boolean | undefined; + if (descriptor.id === "github-cli") { + const authResult = await runCommandCapture(foundPath, ["auth", "status"]); + authenticated = authResult.exitCode === 0; + } + + return { + id: descriptor.id, + found: true, + command, + path: foundPath, + ...(versionLine ? { version: versionLine } : {}), + ...(authenticated !== undefined ? { authenticated } : {}), + ...(versionResult.exitCode !== 0 ? { message: "Found, but version check failed." } : {}), + }; + } + return { + id: descriptor.id, + found: false, + command: descriptor.commands[0] ?? "unknown", + message: "CLI not found in PATH.", + }; +} + export type ServerCoreRuntimeServices = | OrchestrationEngineService | ProjectionSnapshotQuery @@ -268,7 +550,24 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ), ); - const providerStatuses = yield* providerHealth.getStatuses; + const fallbackProviderStatuses: readonly ServerProviderStatus[] = [ + { + provider: "codex", + status: "warning", + available: false, + authStatus: "unknown", + checkedAt: new Date().toISOString(), + message: "Provider health check pending.", + }, + ]; + + const providerStatuses = yield* providerHealth.getStatuses.pipe( + Effect.timeoutOption(800), + Effect.map((maybeStatuses) => + Option.getOrElse(maybeStatuses, () => fallbackProviderStatuses), + ), + Effect.catch(() => Effect.succeed(fallbackProviderStatuses)), + ); const clients = yield* Ref.make(new Set()); const logger = createLogger("ws"); @@ -434,6 +733,272 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< void Effect.runPromise( Effect.gen(function* () { const url = new URL(req.url ?? "/", `http://localhost:${port}`); + if (url.pathname === OPERATOR_ROUTE_PATH) { + if (req.method !== "POST") { + respond(405, { "Content-Type": "text/plain" }, "Method Not Allowed"); + return; + } + const expectedAuthToken = serverConfig.authToken; + const authorization = req.headers.authorization; + if (!expectedAuthToken || authorization !== `Bearer ${expectedAuthToken}`) { + writeJsonResponse(res, 401, { + ok: false, + error: "Unauthorized operator request.", + }); + return; + } + + const requestBody = yield* Effect.tryPromise({ + try: () => readJsonRequestBody(req), + catch: () => null, + }); + if (!requestBody || typeof requestBody !== "object") { + writeJsonResponse(res, 400, { ok: false, error: "Invalid operator request body." }); + return; + } + + const method = + "method" in requestBody && typeof requestBody.method === "string" + ? requestBody.method + : null; + const params = + "params" in requestBody && requestBody.params && typeof requestBody.params === "object" + ? (requestBody.params as Record) + : {}; + if (!method) { + writeJsonResponse(res, 400, { ok: false, error: "Missing operator method." }); + return; + } + + const threadId = + typeof params.threadId === "string" && params.threadId.trim().length > 0 + ? ThreadId.makeUnsafe(params.threadId.trim()) + : null; + if (!threadId) { + writeJsonResponse(res, 400, { ok: false, error: "Operator request requires a threadId." }); + return; + } + + const snapshot = yield* projectionReadModelQuery.getSnapshot(); + const thread = snapshot.threads.find((entry) => entry.id === threadId && entry.deletedAt === null); + if (!thread) { + writeJsonResponse(res, 404, { ok: false, error: `Unknown thread '${threadId}'.` }); + return; + } + const project = snapshot.projects.find( + (entry) => entry.id === thread.projectId && entry.deletedAt === null, + ); + if (!project) { + writeJsonResponse(res, 404, { ok: false, error: `Unknown project '${thread.projectId}'.` }); + return; + } + + const readOperatorCanvasState = (): Effect.Effect => + Effect.gen(function* () { + const filePath = canvasStatePath(serverConfig.stateDir, path, thread.id); + const persisted = yield* fileSystem.readFileString(filePath).pipe( + Effect.catch(() => Effect.succeed(null)), + ); + if (!persisted) { + return defaultThreadCanvasState(thread.id); + } + try { + const parsed = JSON.parse(persisted) as Partial; + return { + ...defaultThreadCanvasState(thread.id), + ...parsed, + threadId: thread.id, + lastUpdatedAt: + typeof parsed.lastUpdatedAt === "string" && parsed.lastUpdatedAt.length > 0 + ? parsed.lastUpdatedAt + : new Date().toISOString(), + files: Array.isArray(parsed.files) + ? parsed.files.filter( + (file): file is CanvasFile => + !!file && + typeof file === "object" && + typeof file.path === "string" && + (file.language === "jsx" || + file.language === "css" || + file.language === "md") && + typeof file.contents === "string", + ) + : [...DEFAULT_CANVAS_FILES], + }; + } catch { + return defaultThreadCanvasState(thread.id); + } + }); + + const writeOperatorCanvasState = (canvasState: ThreadCanvasState) => + Effect.gen(function* () { + const filePath = canvasStatePath(serverConfig.stateDir, path, canvasState.threadId); + yield* fileSystem.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fileSystem.writeFileString(filePath, JSON.stringify(canvasState, null, 2)); + }); + + switch (method) { + case "app.getContext": { + const canvas = yield* readOperatorCanvasState(); + writeJsonResponse(res, 200, { + ok: true, + result: { + thread: { + id: thread.id, + title: thread.title, + model: thread.model, + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + }, + project: { + id: project.id, + title: project.title, + workspaceRoot: project.workspaceRoot, + defaultModel: project.defaultModel, + actions: project.scripts, + }, + canvas: { + title: canvas.title, + framework: canvas.framework, + fileCount: canvas.files.length, + lastUpdatedAt: canvas.lastUpdatedAt, + }, + }, + }); + return; + } + + case "actions.list": { + writeJsonResponse(res, 200, { + ok: true, + result: project.scripts, + }); + return; + } + + case "actions.create": { + const name = typeof params.name === "string" ? params.name.trim() : ""; + const command = typeof params.command === "string" ? params.command.trim() : ""; + const keybinding = + typeof params.keybinding === "string" ? params.keybinding.trim() : null; + const icon = + params.icon === "play" || + params.icon === "test" || + params.icon === "lint" || + params.icon === "configure" || + params.icon === "build" || + params.icon === "debug" + ? params.icon + : "play"; + const runOnWorktreeCreate = params.runOnWorktreeCreate === true; + + if (name.length === 0) { + writeJsonResponse(res, 400, { ok: false, error: "Action name is required." }); + return; + } + if (command.length === 0) { + writeJsonResponse(res, 400, { ok: false, error: "Action command is required." }); + return; + } + + const nextId = nextProjectScriptId( + name, + project.scripts.map((script) => script.id), + ); + const nextAction = { + id: nextId, + name, + command, + icon, + runOnWorktreeCreate, + } as const; + const nextScripts = runOnWorktreeCreate + ? [ + ...project.scripts.map((script) => + script.runOnWorktreeCreate + ? { ...script, runOnWorktreeCreate: false } + : script, + ), + nextAction, + ] + : [...project.scripts, nextAction]; + + yield* orchestrationEngine.dispatch({ + type: "project.meta.update", + commandId: CommandId.makeUnsafe(crypto.randomUUID()), + projectId: project.id, + scripts: nextScripts, + }); + + if (keybinding) { + let keybindingRule: typeof KeybindingRule.Type; + try { + keybindingRule = Schema.decodeUnknownSync(KeybindingRule)({ + key: keybinding, + command: commandForProjectScript(nextId), + }); + } catch { + writeJsonResponse(res, 400, { ok: false, error: "Invalid action keybinding." }); + return; + } + yield* keybindingsManager.upsertKeybindingRule(keybindingRule); + } + + writeJsonResponse(res, 200, { + ok: true, + result: { + projectId: project.id, + action: nextAction, + actionCount: nextScripts.length, + }, + }); + return; + } + + case "canvas.getState": { + const canvas = yield* readOperatorCanvasState(); + writeJsonResponse(res, 200, { + ok: true, + result: canvas, + }); + return; + } + + case "canvas.update": { + const existing = yield* readOperatorCanvasState(); + const title = typeof params.title === "string" ? params.title : existing.title; + const prompt = typeof params.prompt === "string" ? params.prompt : existing.prompt; + const files = Array.isArray(params.files) + ? params.files.filter( + (file): file is CanvasFile => + !!file && + typeof file === "object" && + typeof file.path === "string" && + (file.language === "jsx" || file.language === "css" || file.language === "md") && + typeof file.contents === "string", + ) + : existing.files; + const nextCanvasState: ThreadCanvasState = { + ...existing, + title, + prompt, + files: files.length > 0 ? files : existing.files, + lastUpdatedAt: new Date().toISOString(), + }; + yield* writeOperatorCanvasState(nextCanvasState); + writeJsonResponse(res, 200, { + ok: true, + result: nextCanvasState, + }); + return; + } + + default: { + writeJsonResponse(res, 404, { ok: false, error: `Unknown operator method '${method}'.` }); + return; + } + } + } if (tryHandleProjectFaviconRequest(url, res)) { return; } @@ -642,7 +1207,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< let welcomeBootstrapThreadId: ThreadId | undefined; if (autoBootstrapProjectFromCwd) { - yield* Effect.gen(function* () { + const bootstrapFromCwd = Effect.gen(function* () { const snapshot = yield* projectionReadModelQuery.getSnapshot(); const existingProject = snapshot.projects.find( (project) => project.workspaceRoot === cwd && project.deletedAt === null, @@ -695,10 +1260,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< welcomeBootstrapThreadId = existingThread.id; } }).pipe( - Effect.mapError( - (cause) => new ServerLifecycleError({ operation: "autoBootstrapProject", cause }), + Effect.catch((cause) => + Effect.logWarning("auto bootstrap from cwd failed", { cwd, cause }), ), ); + yield* bootstrapFromCwd.pipe(Effect.forkIn(subscriptionsScope)); } const runtimeServices = yield* Effect.services< @@ -727,6 +1293,47 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< ); const routeRequest = Effect.fnUntraced(function* (request: WebSocketRequest) { + const readCanvasState = Effect.fnUntraced(function* (threadId: ThreadId) { + const filePath = canvasStatePath(serverConfig.stateDir, path, threadId); + const persisted = yield* fileSystem + .readFileString(filePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!persisted) { + return defaultThreadCanvasState(threadId); + } + try { + const parsed = JSON.parse(persisted) as Partial; + return { + ...defaultThreadCanvasState(threadId), + ...parsed, + threadId, + lastUpdatedAt: + typeof parsed.lastUpdatedAt === "string" && parsed.lastUpdatedAt.length > 0 + ? parsed.lastUpdatedAt + : new Date().toISOString(), + files: Array.isArray(parsed.files) + ? parsed.files.filter( + (file): file is CanvasFile => + !!file && + typeof file === "object" && + typeof file.path === "string" && + (file.language === "jsx" || file.language === "css" || file.language === "md") && + typeof file.contents === "string", + ) + : [...DEFAULT_CANVAS_FILES], + }; + } catch { + return defaultThreadCanvasState(threadId); + } + }); + + const writeCanvasState = Effect.fnUntraced(function* (canvasState: ThreadCanvasState) { + const filePath = canvasStatePath(serverConfig.stateDir, path, canvasState.threadId); + yield* fileSystem.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fileSystem.writeFileString(filePath, JSON.stringify(canvasState, null, 2)); + return canvasState; + }); + switch (request.body._tag) { case ORCHESTRATION_WS_METHODS.getSnapshot: return yield* projectionReadModelQuery.getSnapshot(); @@ -801,6 +1408,24 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* openInEditor(body); } + case WS_METHODS.githubStartDeviceFlow: { + return yield* Effect.tryPromise({ + try: () => import("./git/githubDeviceFlow").then((m) => m.requestGitHubDeviceCode()), + catch: (cause) => new Error(cause instanceof Error ? cause.message : "Device flow failed"), + }); + } + + case WS_METHODS.githubPollDeviceFlow: { + const body = stripRequestTag(request.body); + return yield* Effect.tryPromise({ + try: () => + import("./git/githubDeviceFlow").then((m) => + m.pollGitHubDeviceFlow(body.deviceCode, body.interval, body.expiresIn), + ), + catch: (cause) => new Error(cause instanceof Error ? cause.message : "Polling failed"), + }); + } + case WS_METHODS.gitStatus: { const body = stripRequestTag(request.body); return yield* gitManager.status(body); @@ -893,6 +1518,52 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return { keybindings: keybindingsConfig, issues: [] }; } + case WS_METHODS.serverDetectCliInstallations: { + return yield* Effect.tryPromise({ + try: async () => { + const probes: readonly CliProbeDescriptor[] = [ + { + id: "github-cli", + commands: ["gh"], + versionArgs: ["--version"], + }, + { + id: "claude-cli", + commands: ["claude"], + versionArgs: ["--version"], + }, + { + id: "gemini-cli", + commands: ["gemini", "gemini-cli"], + versionArgs: ["--version"], + }, + ]; + return Promise.all(probes.map((probe) => detectCliInstallation(probe))); + }, + catch: (cause) => + new RouteRequestError({ + message: `Failed to detect CLI installations: ${String(cause)}`, + }), + }); + } + + case WS_METHODS.canvasGetState: { + const body = stripRequestTag(request.body); + return yield* readCanvasState(body.threadId); + } + + case WS_METHODS.canvasUpsertState: { + const body = stripRequestTag(request.body); + const existing = yield* readCanvasState(body.threadId); + return yield* writeCanvasState({ + ...existing, + ...(typeof body.title === "string" ? { title: body.title } : {}), + ...(typeof body.prompt === "string" ? { prompt: body.prompt } : {}), + ...(body.files ? { files: body.files } : {}), + lastUpdatedAt: new Date().toISOString(), + }); + } + default: { const _exhaustiveCheck: never = request.body; return yield* new RouteRequestError({ @@ -915,7 +1586,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return; } - const request = Schema.decodeExit(Schema.fromJsonString(WebSocketRequest))(messageText); + const request = Schema.decodeUnknownExit(Schema.fromJsonString(WebSocketRequest))(messageText); if (request._tag === "Failure") { const errorResponse = yield* encodeResponse({ id: "unknown", diff --git a/apps/server/tsdown.config.ts b/apps/server/tsdown.config.ts index f89bc7d3d..66139a4ca 100644 --- a/apps/server/tsdown.config.ts +++ b/apps/server/tsdown.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "tsdown"; export default defineConfig({ - entry: ["src/index.ts"], + entry: ["src/index.ts", "src/labBrowserMcpServer.ts"], format: ["esm", "cjs"], checks: { legacyCjs: false, diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 000000000..4d8bc119e --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1 @@ +VITE_CLERK_PUBLISHABLE_KEY= diff --git a/apps/web/package.json b/apps/web/package.json index 4372bf9e3..673427546 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.0.4-alpha.1", + "version": "0.0.4-alpha.2", "private": true, "type": "module", "scripts": { @@ -15,6 +15,7 @@ }, "dependencies": { "@base-ui/react": "^1.2.0", + "@clerk/clerk-react": "^5.61.3", "@lexical/react": "^0.41.0", "@pierre/diffs": "^1.1.0-beta.16", "@t3tools/contracts": "workspace:*", @@ -24,11 +25,13 @@ "@tanstack/react-router": "^1.160.2", "@tanstack/react-virtual": "^3.13.18", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", "effect": "catalog:", "lexical": "^0.41.0", "lucide-react": "^0.564.0", + "prettier": "^3.8.1", "react": "^19.0.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index e58f7af4a..5e2827ee6 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -24,10 +24,34 @@ export const APP_SERVICE_TIER_OPTIONS = [ }, ] as const; export type AppServiceTier = (typeof APP_SERVICE_TIER_OPTIONS)[number]["value"]; +export const CANVAS_DEFAULT_TAB_OPTIONS = [ + { value: "preview", label: "Preview" }, + { value: "code", label: "Code" }, + { value: "brief", label: "Brief" }, +] as const; +export type CanvasDefaultTab = (typeof CANVAS_DEFAULT_TAB_OPTIONS)[number]["value"]; +export const CANVAS_PREVIEW_DEVICE_OPTIONS = [ + { value: "desktop", label: "Desktop" }, + { value: "tablet", label: "Tablet" }, + { value: "mobile", label: "Mobile" }, +] as const; +export type CanvasPreviewDevice = (typeof CANVAS_PREVIEW_DEVICE_OPTIONS)[number]["value"]; +export const GITHUB_AUTH_MODE_OPTIONS = [ + { value: "gh-cli", label: "GitHub CLI" }, + { value: "token", label: "Personal Access Token" }, + { value: "oauth-device", label: "OAuth Device Flow" }, +] as const; +export type GitHubAuthMode = (typeof GITHUB_AUTH_MODE_OPTIONS)[number]["value"]; const AppServiceTierSchema = Schema.Literals(["auto", "fast", "flex"]); +const CanvasDefaultTabSchema = Schema.Literals(["preview", "code", "brief"]); +const CanvasPreviewDeviceSchema = Schema.Literals(["desktop", "tablet", "mobile"]); +const GitHubAuthModeSchema = Schema.Literals(["gh-cli", "token", "oauth-device"]); const MODELS_WITH_FAST_SUPPORT = new Set(["gpt-5.4"]); const BUILT_IN_MODEL_SLUGS_BY_PROVIDER: Record> = { codex: new Set(getModelOptions("codex").map((option) => option.slug)), + "claude-code": new Set(getModelOptions("claude-code").map((option) => option.slug)), + "gemini-cli": new Set(getModelOptions("gemini-cli").map((option) => option.slug)), + "github-copilot-cli": new Set(getModelOptions("github-copilot-cli").map((option) => option.slug)), }; const AppSettingsSchema = Schema.Struct({ @@ -41,10 +65,76 @@ const AppSettingsSchema = Schema.Struct({ enableAssistantStreaming: Schema.Boolean.pipe( Schema.withConstructorDefault(() => Option.some(false)), ), + canvasAutoOpenOnUpdate: Schema.Boolean.pipe( + Schema.withConstructorDefault(() => Option.some(true)), + ), + canvasDefaultTab: CanvasDefaultTabSchema.pipe( + Schema.withConstructorDefault(() => Option.some("preview")), + ), + canvasPreviewDevice: CanvasPreviewDeviceSchema.pipe( + Schema.withConstructorDefault(() => Option.some("desktop")), + ), codexServiceTier: AppServiceTierSchema.pipe(Schema.withConstructorDefault(() => Option.some("auto"))), customCodexModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), + githubEnabled: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(false))), + githubAuthMode: GitHubAuthModeSchema.pipe( + Schema.withConstructorDefault(() => Option.some("gh-cli")), + ), + githubToken: Schema.String.check(Schema.isMaxLength(8192)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + githubOwner: Schema.String.check(Schema.isMaxLength(256)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + githubRepo: Schema.String.check(Schema.isMaxLength(256)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + githubDefaultBaseBranch: Schema.String.check(Schema.isMaxLength(128)).pipe( + Schema.withConstructorDefault(() => Option.some("main")), + ), + githubWorkflowNameFilter: Schema.String.check(Schema.isMaxLength(512)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + githubDefaultLabels: Schema.String.check(Schema.isMaxLength(1024)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + githubAutoLinkIssues: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(true))), + githubAutoReviewOnPr: Schema.Boolean.pipe(Schema.withConstructorDefault(() => Option.some(false))), + githubActionsAutoRerunFailed: Schema.Boolean.pipe( + Schema.withConstructorDefault(() => Option.some(false)), + ), + githubSecurityScanOnPush: Schema.Boolean.pipe( + Schema.withConstructorDefault(() => Option.some(false)), + ), + githubRequirePassingChecks: Schema.Boolean.pipe( + Schema.withConstructorDefault(() => Option.some(true)), + ), + githubCreateDraftPrByDefault: Schema.Boolean.pipe( + Schema.withConstructorDefault(() => Option.some(false)), + ), + githubSidebarControllerEnabled: Schema.Boolean.pipe( + Schema.withConstructorDefault(() => Option.some(true)), + ), + githubCliPath: Schema.String.check(Schema.isMaxLength(4096)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + githubCliArgs: Schema.String.check(Schema.isMaxLength(1024)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + claudeCliPath: Schema.String.check(Schema.isMaxLength(4096)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + claudeCliArgs: Schema.String.check(Schema.isMaxLength(1024)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + geminiCliPath: Schema.String.check(Schema.isMaxLength(4096)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), + geminiCliArgs: Schema.String.check(Schema.isMaxLength(1024)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), }); export type AppSettings = typeof AppSettingsSchema.Type; export interface AppModelOption { @@ -108,6 +198,18 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { return { ...settings, customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), + githubToken: settings.githubToken.trim(), + githubOwner: settings.githubOwner.trim(), + githubRepo: settings.githubRepo.trim(), + githubDefaultBaseBranch: settings.githubDefaultBaseBranch.trim() || "main", + githubWorkflowNameFilter: settings.githubWorkflowNameFilter.trim(), + githubDefaultLabels: settings.githubDefaultLabels.trim(), + githubCliPath: settings.githubCliPath.trim(), + githubCliArgs: settings.githubCliArgs.trim(), + claudeCliPath: settings.claudeCliPath.trim(), + claudeCliArgs: settings.claudeCliArgs.trim(), + geminiCliPath: settings.geminiCliPath.trim(), + geminiCliArgs: settings.geminiCliArgs.trim(), }; } @@ -294,3 +396,4 @@ export function useAppSettings() { defaults: DEFAULT_APP_SETTINGS, } as const; } + diff --git a/apps/web/src/auth.tsx b/apps/web/src/auth.tsx new file mode 100644 index 000000000..46ad11465 --- /dev/null +++ b/apps/web/src/auth.tsx @@ -0,0 +1,405 @@ +import { + createContext, + type PropsWithChildren, + useContext, + useEffect, + useMemo, + useState, +} from "react"; + +type DesktopAuthSession = { + email: string; + signedInAt: string; +}; + +type DesktopAuthContextValue = { + session: DesktopAuthSession | null; + signIn: (session: DesktopAuthSession) => void; + signOut: () => void; +}; + +const AUTH_STORAGE_KEY = "t3coder.dev.auth.session"; +const CODE_LENGTH = 6; + +const DesktopAuthContext = createContext(null); + +function readStoredSession(): DesktopAuthSession | null { + if (typeof window === "undefined") { + return null; + } + + try { + const rawValue = window.localStorage.getItem(AUTH_STORAGE_KEY); + if (!rawValue) { + return null; + } + + const parsed = JSON.parse(rawValue) as Partial; + if (typeof parsed.email !== "string" || typeof parsed.signedInAt !== "string") { + return null; + } + + return { + email: parsed.email, + signedInAt: parsed.signedInAt, + }; + } catch { + return null; + } +} + +function writeStoredSession(session: DesktopAuthSession | null): void { + if (typeof window === "undefined") { + return; + } + + if (!session) { + window.localStorage.removeItem(AUTH_STORAGE_KEY); + return; + } + + window.localStorage.setItem(AUTH_STORAGE_KEY, JSON.stringify(session)); +} + +function generateVerificationCode(): string { + const values = new Uint32Array(1); + globalThis.crypto.getRandomValues(values); + const randomValue = values.at(0) ?? 0; + return String(randomValue % 10 ** CODE_LENGTH).padStart(CODE_LENGTH, "0"); +} + +function isValidEmail(value: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); +} + +function AuthShell({ + eyebrow, + title, + description, + children, +}: PropsWithChildren<{ + eyebrow: string; + title: string; + description: string; +}>) { + return ( +
+
+
+
+
+ +
+
+
+

+ {eyebrow} +

+

+ {title} +

+

+ {description} +

+
+
+ Codex stays visible and ready in the shell. +
+
+ Terminal opens under chat instead of leaving the workspace. +
+
+ Lab and Canvas remain first-class surfaces in the same desktop app. +
+
+
+
{children}
+
+
+
+ ); +} + +function ChatBubble({ + role, + children, +}: PropsWithChildren<{ + role: "assistant" | "user"; +}>) { + const alignment = role === "assistant" ? "items-start" : "items-end"; + const bubbleClassName = + role === "assistant" + ? "border border-white/10 bg-[#0d0d11] text-white" + : "bg-white text-black"; + + return ( +
+
+ {children} +
+
+ ); +} + +function AuthLoadingScreen() { + return ( + +
+
+

+ Loading +

+

Preparing your workspace.

+

+ Restoring session state and booting the desktop shell. +

+
+
+
+
+
+ + ); +} + +function AuthSignInScreen() { + const auth = useDesktopAuth(); + const [email, setEmail] = useState(""); + const [codeInput, setCodeInput] = useState(""); + const [pendingEmail, setPendingEmail] = useState(null); + const [verificationCode, setVerificationCode] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const [isVerifying, setIsVerifying] = useState(false); + + const stage = pendingEmail && verificationCode ? "code" : "email"; + + const resetFlow = () => { + setPendingEmail(null); + setVerificationCode(null); + setCodeInput(""); + setErrorMessage(null); + setIsVerifying(false); + }; + + const handleEmailSubmit = () => { + const normalizedEmail = email.trim().toLowerCase(); + if (!isValidEmail(normalizedEmail)) { + setErrorMessage("Enter a valid email address to continue."); + return; + } + + setPendingEmail(normalizedEmail); + setVerificationCode(generateVerificationCode()); + setCodeInput(""); + setErrorMessage(null); + setIsVerifying(false); + }; + + const handleCodeSubmit = () => { + if (!verificationCode || !pendingEmail) { + resetFlow(); + return; + } + + if (codeInput.trim() !== verificationCode) { + setErrorMessage("That code does not match. Try again or request a new one."); + return; + } + + setIsVerifying(true); + auth.signIn({ + email: pendingEmail, + signedInAt: new Date().toISOString(), + }); + }; + + return ( + +
+
+
+
+

+ Authentication +

+

Let's sign you in.

+
+ {stage === "code" ? ( + + ) : null} +
+ +
+ + Welcome to T3CODER(DEV). Start with your email and I'll issue a one-time sign-in code. + + {pendingEmail ? {pendingEmail} : null} + {stage === "code" ? ( + <> + + I generated a {CODE_LENGTH}-digit code for this desktop session. Enter it below to + continue. + +
+ Dev code: {verificationCode} +
+ + ) : null} +
+ +
+ {stage === "email" ? ( + <> + + { + setEmail(event.target.value); + setErrorMessage(null); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + handleEmailSubmit(); + } + }} + placeholder="you@t3coder.dev" + className="h-12 rounded-2xl border border-white/10 bg-[#0d0d11] px-4 text-sm text-white outline-none transition placeholder:text-zinc-500 focus:border-blue-500/70" + /> + + + ) : ( + <> + + { + setCodeInput(event.target.value.replace(/\s+/g, "")); + setErrorMessage(null); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + handleCodeSubmit(); + } + }} + placeholder="123456" + className="h-12 rounded-2xl border border-white/10 bg-[#0d0d11] px-4 text-sm tracking-[0.28em] text-white outline-none transition placeholder:tracking-normal placeholder:text-zinc-500 focus:border-blue-500/70" + /> + + + )} +
+ + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} + +
+ This is a first-party desktop auth flow. The one-time code stays local in dev mode for + now, and the shell opens only after verification. +
+
+
+
+ ); +} + +export function AuthSetupScreen() { + return ( + +
+
+

+ Passwordless +

+

Chat-first desktop sign-in.

+

+ Email-based local verification is built into the shell, so there is no external auth + gate to configure before loading the workspace. +

+
+
+
+ ); +} + +export function AppAuthGate({ children }: PropsWithChildren) { + const [isHydrated, setIsHydrated] = useState(false); + const [session, setSession] = useState(null); + + useEffect(() => { + setSession(readStoredSession()); + setIsHydrated(true); + }, []); + + const value = useMemo( + () => ({ + session, + signIn(nextSession) { + writeStoredSession(nextSession); + setSession(nextSession); + }, + signOut() { + writeStoredSession(null); + setSession(null); + }, + }), + [session], + ); + + return ( + + {!isHydrated ? : null} + {isHydrated && session ? ( + <> + {children} + + ) : null} + {isHydrated && !session ? : null} + + ); +} + +export function useDesktopAuth(): DesktopAuthContextValue { + const context = useContext(DesktopAuthContext); + if (!context) { + throw new Error("useDesktopAuth must be used inside AppAuthGate."); + } + return context; +} diff --git a/apps/web/src/components/AppCanvas.tsx b/apps/web/src/components/AppCanvas.tsx new file mode 100644 index 000000000..e449d543a --- /dev/null +++ b/apps/web/src/components/AppCanvas.tsx @@ -0,0 +1,224 @@ +import type { ThreadCanvasState, ThreadId } from "@t3tools/contracts"; +import { Code2Icon, EyeIcon, FileTextIcon, LoaderCircleIcon, RefreshCwIcon, SparklesIcon } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { + CANVAS_DEFAULT_TAB_OPTIONS, + type CanvasDefaultTab, + type CanvasPreviewDevice, + useAppSettings, +} from "../appSettings"; +import { ensureNativeApi } from "../nativeApi"; +import { cn } from "../lib/utils"; +import { Badge } from "./ui/badge"; +import { Button } from "./ui/button"; + +type CanvasTab = CanvasDefaultTab; + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function buildCanvasPreviewDocument(state: ThreadCanvasState): string { + const jsxFile = state.files.find((file) => file.path === "src/App.jsx") ?? state.files[0]; + const stylesFile = state.files.find((file) => file.path === "src/styles.css"); + const escapedTitle = escapeHtml(state.title); + + return ` + + + + + ${escapedTitle} + + + +
+ + + + + +`; +} + +function previewFrameClass(device: CanvasPreviewDevice): string { + switch (device) { + case "mobile": + return "mx-auto h-full w-[390px] max-w-full"; + case "tablet": + return "mx-auto h-full w-[820px] max-w-full"; + case "desktop": + default: + return "h-full w-full"; + } +} + +const EMPTY_CANVAS_STATE = (threadId: ThreadId): ThreadCanvasState => ({ + threadId, + title: "Canvas App", + framework: "react", + prompt: "", + files: [], + lastUpdatedAt: new Date(0).toISOString(), +}); + +export default function AppCanvas({ threadId }: { threadId: ThreadId }) { + const api = ensureNativeApi(); + const { settings } = useAppSettings(); + const [canvasState, setCanvasState] = useState(() => EMPTY_CANVAS_STATE(threadId)); + const [isLoading, setIsLoading] = useState(true); + const [activeTab, setActiveTab] = useState(settings.canvasDefaultTab); + + useEffect(() => { + setActiveTab(settings.canvasDefaultTab); + }, [settings.canvasDefaultTab]); + + useEffect(() => { + let disposed = false; + setIsLoading(true); + void api.canvas + .getState({ threadId }) + .then((state) => { + if (!disposed) { + setCanvasState(state); + } + }) + .finally(() => { + if (!disposed) { + setIsLoading(false); + } + }); + + return () => { + disposed = true; + }; + }, [api.canvas, threadId]); + + const previewDocument = useMemo(() => buildCanvasPreviewDocument(canvasState), [canvasState]); + const visibleFile = canvasState.files[0] ?? null; + + return ( +