diff --git a/.github/workflows/cursor-hygiene-webhook.yml b/.github/workflows/cursor-hygiene-webhook.yml new file mode 100644 index 000000000000..ea0f579b4ac6 --- /dev/null +++ b/.github/workflows/cursor-hygiene-webhook.yml @@ -0,0 +1,36 @@ +name: Forward to Cursor hygiene + +on: + push: + branches: [main] + pull_request: + types: [opened, reopened, ready_for_review] + issues: + types: [opened, closed, reopened] + discussion: + types: [created, closed, reopened] + +permissions: + contents: read + +jobs: + forward: + name: POST to Cursor + runs-on: ubuntu-24.04 + steps: + - name: POST to Cursor + env: + URL: ${{ secrets.CURSOR_T3CODE_WEBHOOK_URL }} + AUTH: ${{ secrets.CURSOR_T3CODE_WEBHOOK_AUTH }} + run: | + set -euo pipefail + if [ -z "${URL:-}" ] || [ -z "${AUTH:-}" ]; then + echo "Missing CURSOR_T3CODE_WEBHOOK_URL or CURSOR_T3CODE_WEBHOOK_AUTH — skipping." + exit 0 + fi + curl -fsS --max-time 60 -X POST "$URL" \ + -H "Authorization: $AUTH" \ + -H "Content-Type: application/json" \ + -H "X-GitHub-Event: ${{ github.event_name }}" \ + -H "X-GitHub-Delivery: ${{ github.run_id }}-${{ github.run_attempt }}" \ + --data-binary @"${{ github.event_path }}" diff --git a/.github/workflows/windows-tests.yml b/.github/workflows/windows-tests.yml new file mode 100644 index 000000000000..3a70ad5a26a0 --- /dev/null +++ b/.github/workflows/windows-tests.yml @@ -0,0 +1,81 @@ +# On-demand Windows test lane. Manual only: nothing in the suite passes on +# Windows yet, so this exists to give contributors (and agents) a cloud Windows +# box to iterate against. Once the suite is green here, fold it into ci.yml. +# +# gh workflow run windows-tests.yml --ref -f package=packages/shared +# gh workflow run windows-tests.yml --ref -f package=apps/server \ +# -f files="src/process/externalLauncher.test.ts src/cli/theme.test.ts" +# gh run watch && gh run view --log-failed +name: Windows Tests + +on: + workflow_dispatch: + inputs: + package: + description: "Workspace directory to test, e.g. apps/server or packages/shared. Empty runs every package except apps/server." + type: string + default: "" + files: + description: "Space-separated test files relative to the package directory. Empty runs the package's whole suite. Requires package." + type: string + default: "" + +permissions: + contents: read + +jobs: + test: + name: Test (${{ inputs.package || 'all non-server' }}) + runs-on: blacksmith-8vcpu-windows-2025 + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: | + /* + !/.repos/ + sparse-checkout-cone-mode: false + + # setup-vp's own cache restores a Linux-shaped store on Windows, which is + # slower than no cache (see #7975). Cache pnpm's Windows store directly. + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@v1 + with: + node-version-file: package.json + cache: false + run-install: false + + - name: Resolve package cache path + id: package_cache_path + shell: pwsh + run: '"path=$(vp pm cache dir)" >> $env:GITHUB_OUTPUT' + + - name: Cache packages + uses: actions/cache@v6 + with: + path: ${{ steps.package_cache_path.outputs.path }} + key: windows-tests-packages-v1-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Install + run: vp install + + - name: Ensure Electron runtime is installed + if: inputs.package == '' || inputs.package == 'apps/desktop' + run: vp run --filter "@t3tools/desktop" ensure:electron + + # `vp run ... test -- ` does not forward positional args to vitest, + # so file-scoped runs call `vp test run` inside the package instead. + - name: Test + shell: pwsh + run: | + $package = '${{ inputs.package }}' + $files = '${{ inputs.files }}' + if ($package -eq '') { + vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test + } elseif ($files -eq '') { + vp run --filter "./$package" test + } else { + Set-Location $package + vp test run $files.Split(' ') + } diff --git a/apps/desktop/scripts/ensure-electron-runtime.mjs b/apps/desktop/scripts/ensure-electron-runtime.mjs index c37838ab1836..b8b8254c9b3c 100644 --- a/apps/desktop/scripts/ensure-electron-runtime.mjs +++ b/apps/desktop/scripts/ensure-electron-runtime.mjs @@ -2,6 +2,7 @@ import * as NodeFS from "node:fs"; import * as NodeModule from "node:module"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import * as NodeChildProcess from "node:child_process"; const require = NodeModule.createRequire(import.meta.url); @@ -176,7 +177,8 @@ export function ensureElectronRuntime() { return electronPath; } -if (import.meta.url === `file://${process.argv[1]}`) { +// `file://${argv[1]}` never matches on Windows (drive letters need `file:///C:/`). +if (process.argv[1] && NodeURL.pathToFileURL(process.argv[1]).href === import.meta.url) { const electronPath = ensureElectronRuntime(); process.stdout.write(`${electronPath}\n`); } diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index d334b3635080..a7b3afabd3c3 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -677,6 +677,67 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("detaches through the pinned debugger after the webview is destroyed", () => + withManager((manager) => + Effect.gen(function* () { + // Real Electron throws on any `wc.debugger` access once the + // WebContents is destroyed, so cleanup must go through the debugger + // reference captured at attach time (electron/electron#53376). + let destroyed = false; + let attached = false; + const debuggerOff = vi.fn(); + const debuggerDetach = vi.fn(() => { + attached = false; + }); + const wcDebugger = { + isAttached: () => attached, + attach: vi.fn(() => { + attached = true; + }), + detach: debuggerDetach, + sendCommand: vi.fn(async () => undefined), + on: vi.fn(), + off: debuggerOff, + }; + fromId.mockReturnValue({ + id: 42, + isDestroyed: () => destroyed, + getType: () => "webview", + getURL: () => "http://localhost:3200/", + getTitle: () => "Preview", + isLoading: () => false, + isDevToolsOpened: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + setAudioMuted: vi.fn(), + isCurrentlyAudible: () => false, + reload: vi.fn(), + loadURL: vi.fn(async () => undefined), + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + get debugger() { + if (destroyed) throw new Error("Object has been destroyed"); + return wcDebugger; + }, + } as never); + yield* manager.createTab("tab_pinned_debugger"); + yield* manager.registerWebview("tab_pinned_debugger", 42); + yield* manager.setColorScheme("tab_pinned_debugger", "dark"); + expect(attached).toBe(true); + destroyed = true; + + yield* manager.navigate("tab_pinned_debugger", "https://example.com/"); + + expect(debuggerOff).toHaveBeenCalledWith("message", expect.any(Function)); + expect(debuggerDetach).toHaveBeenCalledOnce(); + }), + ), + ); + effectIt.effect("does not let destroyed-webview cleanup detach a same-id replacement", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 8af2a460fb3e..324b92034f36 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -437,6 +437,12 @@ interface PickSession { interface BrowserControlSession { readonly webContentsId: number; + // Pins the WebContents' Debugger wrapper for the session's lifetime. + // Electron's Debugger is GC-managed but registered with Chromium as a raw + // DevToolsAgentHostClient pointer; collecting it while attached crashes the + // browser process (electron/electron#53376). Detach must also go through + // this reference: `wc.debugger` throws once the WebContents is destroyed. + readonly debugger: Electron.Debugger; readonly semaphore: Semaphore.Semaphore; readonly scope: Scope.Closeable; readonly onMessage: ( @@ -1184,6 +1190,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const createControlSession = Effect.fn("PreviewManager.createControlSession")(function* () { const semaphore = yield* Semaphore.make(1); const scope = yield* Scope.fork(parentScope, "sequential"); + const wcDebugger = wc.debugger; const handleDebuggerMessage = Effect.fnUntraced(function* ( method: string, params: Record, @@ -1196,7 +1203,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function operation: "ackScreencastFrame", webContentsId: wc.id, }, - () => wc.debugger.sendCommand("Page.screencastFrameAck", { sessionId }), + () => wcDebugger.sendCommand("Page.screencastFrameAck", { sessionId }), ).pipe(Effect.ignore); } const tabId = yield* tabIdForWebContents(wc.id); @@ -1244,8 +1251,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ), attempt({ operation: "detachControlSession", webContentsId: wc.id }, () => { - wc.debugger.off("message", onMessage); - if (wc.debugger.isAttached()) wc.debugger.detach(); + wcDebugger.off("message", onMessage); + if (wcDebugger.isAttached()) wcDebugger.detach(); }).pipe(Effect.ignore), ], { discard: true }, @@ -1253,6 +1260,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ); const control: BrowserControlSession = { webContentsId: wc.id, + debugger: wcDebugger, semaphore, scope, onMessage, @@ -1268,15 +1276,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ); yield* attempt({ operation: "attachDebuggerListeners", webContentsId: wc.id }, () => { - wc.debugger.on("message", onMessage); - wc.debugger.attach("1.3"); + wcDebugger.on("message", onMessage); + wcDebugger.attach("1.3"); }); yield* Effect.all( ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map( (method) => attemptPromise( { operation: `initializeDebugger.${method}`, webContentsId: wc.id }, - () => wc.debugger.sendCommand(method), + () => wcDebugger.sendCommand(method), ), ), { concurrency: "unbounded", discard: true }, @@ -1365,7 +1373,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } const result = yield* attemptPromise( { operation: `${action}.${method}`, tabId, webContentsId: wc.id }, - () => wc.debugger.sendCommand(method, commandParams), + () => control.debugger.sendCommand(method, commandParams), ); const after = (yield* Ref.get(controlEpochRef)).get(tabId) ?? 0; if (after !== epoch) { @@ -1389,7 +1397,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function tabId, webContentsId: wc.id, }, - () => wc.debugger.sendCommand(method, commandParams), + () => control.debugger.sendCommand(method, commandParams), ); }, ); @@ -2578,9 +2586,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc: Electron.WebContents, colorScheme: DesktopPreviewColorScheme, ) { - yield* ensureControlSession(wc); + const control = yield* ensureControlSession(wc); yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () => - wc.debugger.sendCommand("Emulation.setEmulatedMedia", { + control.debugger.sendCommand("Emulation.setEmulatedMedia", { features: [ { name: "prefers-color-scheme", @@ -2600,7 +2608,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function Effect.gen(function* () { const beforeAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (beforeAttach?.webContentsId !== wc.id) return; - yield* ensureControlSession(wc); + const control = yield* ensureControlSession(wc); const afterAttach = (yield* SynchronizedRef.get(tabsRef)).get(tabId); if (afterAttach?.webContentsId !== wc.id) { yield* detachControlSession(wc.id); @@ -2608,7 +2616,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } if (afterAttach.colorScheme !== "system") { yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () => - wc.debugger.sendCommand("Emulation.setEmulatedMedia", { + control.debugger.sendCommand("Emulation.setEmulatedMedia", { features: [ { name: "prefers-color-scheme", diff --git a/apps/marketing/public/app-desktop.webp b/apps/marketing/public/app-desktop.webp new file mode 100644 index 000000000000..11b51331eef3 Binary files /dev/null and b/apps/marketing/public/app-desktop.webp differ diff --git a/apps/marketing/public/harnesses/antigravity.png b/apps/marketing/public/harnesses/antigravity.png new file mode 100644 index 000000000000..df1e22dbbd21 Binary files /dev/null and b/apps/marketing/public/harnesses/antigravity.png differ diff --git a/apps/marketing/public/updated-screenshot.webp b/apps/marketing/public/updated-screenshot.webp deleted file mode 100644 index c245ddb64a1a..000000000000 Binary files a/apps/marketing/public/updated-screenshot.webp and /dev/null differ diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index f5e34d0e0485..686b555fd4c0 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -14,7 +14,7 @@ interface Props { const { title = "T3 Code", - description = "T3 Code — The open-source control plane for coding agents.", + description = "T3 Code. The open-source control plane for coding agents.", pageClass, } = Astro.props; --- @@ -268,21 +268,17 @@ const { } } - @keyframes pulse { - 50% { opacity: 0.4; } + /* Page-load sequence: [data-rise] plays once with delay --d. */ + @keyframes rise { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: none; } } - - @keyframes spin { - to { transform: rotate(360deg); } - } - - @keyframes floatDrift { - 0%, 100% { translate: 0 0; } - 50% { translate: 0 -10px; } + [data-rise] { + animation: rise 0.7s cubic-bezier(0.2, 0.7, 0.2, 1) both; + animation-delay: var(--d, 0ms); } - - @keyframes blink { - 50% { opacity: 0; } + @media (prefers-reduced-motion: reduce) { + [data-rise] { animation: none; } } diff --git a/apps/marketing/src/lib/site.ts b/apps/marketing/src/lib/site.ts index 0bf89db8c0f2..4d0c6da86c43 100644 --- a/apps/marketing/src/lib/site.ts +++ b/apps/marketing/src/lib/site.ts @@ -7,6 +7,6 @@ export const ANDROID_PLAY_STORE_URL = "https://play.google.com/store/apps/details?id=com.t3tools.t3code"; export const MARKETING_STATS = { - githubStars: "14k+", - users: "100,000", + githubStars: "21k+", + users: "200,000", } as const; diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index e45cb7602873..6b45f1a1bbc3 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -26,34 +26,31 @@ const mobileEndorsementRows = [ +
+
+
+
Antigravity
+
Google sign-in
+
+
- + diff --git a/apps/web/src/bootstrap.test.ts b/apps/web/src/bootstrap.test.ts new file mode 100644 index 000000000000..d682e0150de5 --- /dev/null +++ b/apps/web/src/bootstrap.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { showBootError } from "./lib/bootError"; + +class BootElement extends EventTarget { + children: BootElement[] = []; + textContent = ""; + + constructor(readonly tagName: string) { + super(); + } + + setAttribute() {} + + append(child: BootElement) { + this.children.push(child); + } + + replaceChildren(...children: BootElement[]) { + this.children = children; + } + + get text(): string { + return this.textContent + this.children.map((child) => child.text).join(" "); + } +} + +describe("app startup failures", () => { + let bootShell: BootElement | null; + + beforeEach(() => { + vi.resetModules(); + vi.doMock("./main", () => { + throw new Error("@vitejs/plugin-react can't detect preamble. Something is wrong."); + }); + bootShell = new BootElement("div"); + vi.stubGlobal("document", { + getElementById: () => bootShell, + createElement: (tagName: string) => new BootElement(tagName), + }); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + it("shows failures from asynchronous app startup", async () => { + vi.doMock("./main", () => ({ startup: Promise.reject(new Error("Startup chunks failed")) })); + + await import("./bootstrap"); + await vi.dynamicImportSettled(); + + expect(bootShell?.text).toContain("Startup chunks failed"); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("replaces the splash when an app import throws before main can run", async () => { + const reload = vi.fn(); + vi.stubGlobal("window", { location: { reload } }); + + await import("./bootstrap"); + await vi.dynamicImportSettled(); + + expect(bootShell?.text).toContain("T3 Code could not load."); + const reloadButton = bootShell?.children[0]?.children.find( + (element) => element.tagName === "button", + ); + expect(reloadButton?.text).toBe("Reload"); + reloadButton?.dispatchEvent(new Event("click")); + expect(reload).toHaveBeenCalledOnce(); + }); + + it.each([true, false])("shows startup error details only in dev mode, DEV=%s", (dev) => { + vi.stubEnv("DEV", dev); + + showBootError(new Error("internal module path")); + + expect(bootShell?.text).toContain("T3 Code could not load."); + expect(bootShell?.text.includes("internal module path")).toBe(dev); + }); + + it("does not replace the app after React removes the splash", () => { + bootShell = null; + const createElement = vi.spyOn(document, "createElement"); + + showBootError(new Error("late failure")); + + expect(createElement).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/bootstrap.ts b/apps/web/src/bootstrap.ts new file mode 100644 index 000000000000..9a3d4a03b3ca --- /dev/null +++ b/apps/web/src/bootstrap.ts @@ -0,0 +1,5 @@ +import { showBootError } from "./lib/bootError"; + +// Bundled dev can move UI code into shared chunks. Load it only after this +// entry runs the React refresh preamble, and catch failures before React mounts. +void import("./main").then(({ startup }) => startup).catch(showBootError); diff --git a/apps/web/src/bundledDev.test.ts b/apps/web/src/bundledDev.test.ts new file mode 100644 index 000000000000..1ca5038da50e --- /dev/null +++ b/apps/web/src/bundledDev.test.ts @@ -0,0 +1,223 @@ +// @effect-diagnostics nodeBuiltinImport:off - builds and executes real dev bundles on disk. +import * as NodeChildProcess from "node:child_process"; +import * as NodeEvents from "node:events"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import * as NodeUtil from "node:util"; + +import react from "@vitejs/plugin-react"; +import { createLogger, createServer } from "vite-plus"; +import { expect, it } from "vite-plus/test"; + +import { tailwindPlugins } from "../vite/tailwind"; + +const execFile = NodeUtil.promisify(NodeChildProcess.execFile); + +it("initializes React refresh before a shared UI chunk runs in bundled dev", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-bootstrap-")); + const output = NodePath.join(root, "output"); + let resolveBundle!: (files: Map) => void; + let rejectBundle!: (error: unknown) => void; + const bundled = new Promise>((resolve, reject) => { + resolveBundle = resolve; + rejectBundle = reject; + }); + let server: Awaited> | undefined; + + try { + await NodeFSP.mkdir(NodePath.join(root, "src/lib"), { recursive: true }); + await NodeFSP.writeFile(NodePath.join(root, "package.json"), '{"type":"module"}'); + for (const file of ["index.html", "src/bootstrap.ts", "src/lib/bootError.ts"]) { + await NodeFSP.copyFile(new URL(`../${file}`, import.meta.url), NodePath.join(root, file)); + } + await NodeFSP.writeFile( + NodePath.join(root, "src/shared.tsx"), + "export function Shared() { return
ready
; }", + ); + await NodeFSP.writeFile( + NodePath.join(root, "src/main.tsx"), + `import { Shared } from "./shared"; +export const startup = Promise.resolve().then(() => globalThis.onStarted(Shared()));`, + ); + + server = await createServer({ + configFile: false, + root, + publicDir: NodeURL.fileURLToPath(new URL("../public", import.meta.url)), + logLevel: "silent", + resolve: { + alias: { react: NodePath.dirname(NodeURL.fileURLToPath(import.meta.resolve("react"))) }, + }, + experimental: { bundledDev: true }, + plugins: [ + react(), + { + name: "capture-bootstrap-bundle", + buildEnd(error) { + if (error) rejectBundle(error); + }, + generateBundle(_options, bundle) { + resolveBundle( + new Map( + Object.values(bundle) + .filter((file) => file.type === "chunk") + .map((file) => [file.fileName, file.code]), + ), + ); + }, + }, + ], + build: { + rolldownOptions: { + experimental: { devMode: { lazy: false } }, + output: { + // Reproduce the shared chunks Vite creates after lazy routes load, + // without needing a browser to trigger the lazy compiler first. + codeSplitting: { + groups: [ + { name: "vendor", test: /node_modules|@react-refresh/, priority: 10 }, + { name: "shared-ui", test: /shared\.tsx$/, includeDependenciesRecursively: false }, + ], + }, + }, + }, + }, + server: { host: "127.0.0.1", port: 0 }, + }); + await server.listen(); + for (const [file, code] of await bundled) { + const target = NodePath.join(output, file); + await NodeFSP.mkdir(NodePath.dirname(target), { recursive: true }); + await NodeFSP.writeFile(target, code); + } + + // Run the actual generated ES modules so their import order and refresh + // checks execute. These stubs replace only the browser and HMR transport. + const runner = NodePath.join(output, "check.mjs"); + await NodeFSP.writeFile( + runner, + `import assert from "node:assert/strict"; +const started = Promise.withResolvers(); +globalThis.window = globalThis; +globalThis.document = { + createElement: () => ({ relList: { supports: () => true } }), + getElementById: () => null, +}; +globalThis.__rolldown_runtime__ = { + registerGraph() {}, + registerModule() {}, + createModuleHotContext: () => ({ accept() {} }), +}; +globalThis.onStarted = started.resolve; +console.error = (_message, error) => started.reject(error); +await import("./assets/index.js"); +const element = await started.promise; +assert.equal(element.props.children, "ready"); +assert.equal(typeof window.$RefreshReg$, "function"); +console.log("App started with React refresh ready.");`, + ); + const result = await execFile("node", [runner]); + expect(result.stdout).toContain("App started with React refresh ready."); + } finally { + await server?.close(); + await NodeFSP.rm(root, { recursive: true, force: true }); + } +}); + +it("hot updates Tailwind classes when a source file changes in bundled dev", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-tailwind-")); + const events = new NodeEvents.EventEmitter(); + let server: Awaited> | undefined; + let socket: WebSocket | undefined; + let css = ""; + + try { + await NodeFSP.writeFile( + NodePath.join(root, "index.html"), + '', + ); + const source = + 'import "./style.css"; export const margin = "m-[13px]"; import.meta.hot?.accept();'; + await NodeFSP.writeFile(NodePath.join(root, "main.ts"), source); + await NodeFSP.writeFile( + NodePath.join(root, "style.css"), + '@import "tailwindcss" source(none); @source "./main.ts";', + ); + const logger = createLogger("silent"); + logger.error = (message) => events.emit("error", new Error(message)); + const built = NodeEvents.EventEmitter.once(events, "built"); + server = await createServer({ + configFile: false, + root, + customLogger: logger, + resolve: { + alias: { + tailwindcss: NodeURL.fileURLToPath( + new URL("../node_modules/tailwindcss/index.css", import.meta.url), + ), + }, + }, + experimental: { bundledDev: true }, + plugins: [ + ...tailwindPlugins(true), + { + name: "observe-tailwind-output", + enforce: "pre", + transform(code, id) { + if (id.endsWith("/style.css")) css = code; + }, + generateBundle() { + events.emit("built"); + }, + }, + ], + server: { host: "127.0.0.1", port: 0 }, + }); + await server.listen(); + await built; + const address = server.httpServer?.address(); + if (!address || typeof address === "string") throw new Error("Vite did not bind a port"); + + const connected = NodeEvents.EventEmitter.once(events, "connected"); + server.ws.on("vite:client-connected", () => events.emit("connected")); + socket = new WebSocket( + `ws://127.0.0.1:${address.port}/?token=${server.config.webSocketToken}`, + "vite-hmr", + ); + socket.addEventListener("open", () => { + socket?.send( + JSON.stringify({ + type: "custom", + event: "vite:client-connected", + data: { clientId: "tailwind-test" }, + }), + ); + }); + socket.addEventListener("message", ({ data }) => { + const message: unknown = JSON.parse(String(data)); + if ( + message !== null && + typeof message === "object" && + "type" in message && + message.type === "bundled-dev-update" + ) { + events.emit("updated"); + } + }); + await connected; + const entry = await fetch(`http://127.0.0.1:${address.port}/assets/index.js`); + expect(entry.headers.get("content-type")).toContain("javascript"); + await entry.text(); + + const updated = NodeEvents.EventEmitter.once(events, "updated"); + await NodeFSP.writeFile(NodePath.join(root, "main.ts"), source.replace("13px", "137px")); + await updated; + expect(css).toContain("margin: 137px"); + } finally { + socket?.close(); + await server?.close(); + await NodeFSP.rm(root, { recursive: true, force: true }); + } +}); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 4cc750d45236..8e9c80641f2b 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1566,6 +1566,42 @@ describe("hasServerAcknowledgedLocalDispatch", () => { expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingApproval: true })).toBe(true); expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingUserInput: true })).toBe(true); + expect( + hasServerAcknowledgedLocalDispatch({ + ...common, + latestTurnStartFailureId: "turn-start-failure-1", + }), + ).toBe(true); expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true); }); + + it("acknowledges only a new turn-start failure", () => { + const localDispatch = { + ...createLocalDispatchSnapshot(makeThread()), + latestTurnStartFailureId: "turn-start-failure-old", + }; + const common = { + localDispatch, + phase: "ready" as const, + latestTurn: null, + latestUserMessageId: localDispatch.latestUserMessageId, + session: null, + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }; + + expect( + hasServerAcknowledgedLocalDispatch({ + ...common, + latestTurnStartFailureId: "turn-start-failure-old", + }), + ).toBe(false); + expect( + hasServerAcknowledgedLocalDispatch({ + ...common, + latestTurnStartFailureId: "turn-start-failure-new", + }), + ).toBe(true); + }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 46ff8c473c6d..1a6b1b775f41 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -895,6 +895,24 @@ export interface LocalDispatchSnapshot { latestTurnCompletedAt: string | null; sessionStatus: NonNullable["status"] | null; sessionUpdatedAt: string | null; + latestTurnStartFailureId: string | null; +} + +export function latestTurnStartFailureId( + activeThread: Thread | undefined, + latestUserMessageId: ChatMessage["id"] | null, +): string | null { + if (latestUserMessageId === null) return null; + return ( + activeThread?.activities.findLast((activity) => { + if (activity.kind !== "provider.turn.start.failed") return false; + const payload = + typeof activity.payload === "object" && activity.payload !== null + ? (activity.payload as { readonly requestId?: unknown }) + : null; + return payload?.requestId === latestUserMessageId; + })?.id ?? null + ); } export function createLocalDispatchSnapshot( @@ -918,6 +936,7 @@ export function createLocalDispatchSnapshot( latestTurnCompletedAt: latestTurn?.completedAt ?? null, sessionStatus: session?.status ?? null, sessionUpdatedAt: session?.updatedAt ?? null, + latestTurnStartFailureId: latestTurnStartFailureId(activeThread, latestUserMessage?.id ?? null), }; } @@ -929,6 +948,7 @@ export function hasServerAcknowledgedLocalDispatch(input: { session: Thread["session"] | null; hasPendingApproval: boolean; hasPendingUserInput: boolean; + latestTurnStartFailureId?: string | null; threadError: string | null | undefined; }): boolean { if (!input.localDispatch) { @@ -937,6 +957,13 @@ export function hasServerAcknowledgedLocalDispatch(input: { if (input.hasPendingApproval || input.hasPendingUserInput || Boolean(input.threadError)) { return true; } + if ( + input.latestTurnStartFailureId !== undefined && + input.latestTurnStartFailureId !== null && + input.latestTurnStartFailureId !== input.localDispatch.latestTurnStartFailureId + ) { + return true; + } if (input.phase === "connecting") { return false; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index db9fb003906d..81204101c0f3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -210,8 +210,8 @@ import { getProviderModelCapabilities } from "../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, - sortProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, + sortProviderInstanceEntries, } from "../providerInstances"; import { useClientSettings, @@ -331,7 +331,7 @@ import { import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ComposerSurface } from "./chat/ComposerSurface"; import { - hasAvailableClaudeCompactionProvider, + hasAvailableCompactionProvider, hasDismissedResumeCompaction, shouldOfferResumeCompaction, } from "./chat/ContextWindowMeter.logic"; @@ -359,6 +359,7 @@ import { deriveComposerSendState, dismissBranchMismatchForSession, hasEnvironmentReconnectWarningGraceElapsed, + latestTurnStartFailureId, scheduleEnvironmentReconnectWarning, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, @@ -573,14 +574,24 @@ function eventPathContainsSelector(event: Event, selector: string): boolean { return path.some((target) => target instanceof Element && target.closest(selector)); } -function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { - if (event.defaultPrevented || event.isComposing) return false; - if (event.metaKey || event.ctrlKey || event.altKey) return false; - if (event.key.length !== 1) return false; - +/** + * Whether input that landed outside any editable or interactive element + * should be redirected into the composer. Shared by type-to-focus and + * paste-to-focus so both honour the same surfaces. + */ +function shouldRedirectInputToComposer(event: Event): boolean { + if (event.defaultPrevented) return false; if (eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR)) return false; if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false; if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false; + return true; +} + +function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { + if (event.isComposing) return false; + if (event.metaKey || event.ctrlKey || event.altKey) return false; + if (event.key.length !== 1) return false; + if (!shouldRedirectInputToComposer(event)) return false; // The right-panel surface launcher claims its shortcut letters while it is // visible (data attribute set in RightPanelTabs); those keys open surfaces @@ -593,6 +604,17 @@ function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { return true; } +/** + * Plain text pasted with nothing editable focused, such as after the resting + * composer blurred. Files are left to the composer's own paste handler. + */ +function pasteTextToFocusComposer(event: ClipboardEvent): string | null { + if (!event.clipboardData || event.clipboardData.files.length > 0) return null; + if (!shouldRedirectInputToComposer(event)) return null; + const text = event.clipboardData.getData("text/plain"); + return text.length > 0 ? text : null; +} + function formatOutgoingPrompt(params: { provider: ProviderDriverKind; model: string | null; @@ -607,6 +629,11 @@ function formatOutgoingPrompt(params: { const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; +function isCompactCommandMessage(message: ChatMessage): boolean { + const text = message.text.trim().toLowerCase(); + return message.role === "user" && text === "/compact" && !message.attachments?.length; +} + type ChatViewProps = | { environmentId: EnvironmentId; @@ -650,6 +677,10 @@ function useLocalDispatchState(input: { (message) => message.role === "user", ); const latestUserMessageId = latestUserMessage?.id ?? null; + const currentTurnStartFailureId = + localDispatch === null + ? null + : latestTurnStartFailureId(input.activeThread, latestUserMessageId); const resetLocalDispatch = useCallback(() => { setLocalDispatch(null); @@ -665,6 +696,7 @@ function useLocalDispatchState(input: { session: input.activeThread?.session ?? null, hasPendingApproval: input.activePendingApproval !== null, hasPendingUserInput: input.activePendingUserInput !== null, + latestTurnStartFailureId: currentTurnStartFailureId, threadError: input.threadError, }), [ @@ -675,6 +707,7 @@ function useLocalDispatchState(input: { input.phase, input.threadError, latestUserMessageId, + currentTurnStartFailureId, localDispatch, ], ); @@ -1846,8 +1879,7 @@ function ChatViewContent(props: ChatViewProps) { panelAnimationDurationMs, ); const rightPanelPresent = rightPanelPresence.present; - const rightPanelControlsInPanel = - rightPanelPresent && (!shouldUseRightPanelSheet || rightPanelOpen); + const rightPanelControlsInPanel = rightPanelPresent && rightPanelOpen; const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer( @@ -2572,7 +2604,33 @@ function ChatViewContent(props: ChatViewProps) { activePendingUserInput: activePendingUserInput?.requestId ?? null, threadError, }); - const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + const optimisticCompactionMessage = optimisticUserMessages.at(-1); + const pendingCompactionMessage = + isSendBusy && + optimisticCompactionMessage !== undefined && + isCompactCommandMessage(optimisticCompactionMessage) + ? optimisticCompactionMessage + : activeThread?.messages.findLast(isCompactCommandMessage); + const compactRequestIsActive = + pendingCompactionMessage !== undefined && + (pendingCompactionMessage.createdAt > + (activeLatestTurn?.requestedAt ?? pendingCompactionMessage.createdAt) || + (activeLatestTurn?.state === "running" && + pendingCompactionMessage.createdAt === activeLatestTurn.requestedAt)); + const compactionSettled = + pendingCompactionMessage !== undefined && + (latestTurnStartFailureId(activeThread, pendingCompactionMessage.id) !== null || + activeThread?.activities.some((activity) => { + if (activity.kind !== "context-compaction") return false; + const payload = activity.payload as { readonly requestId?: unknown } | null | undefined; + return payload?.requestId === pendingCompactionMessage.id; + })); + const isCompacting = + (isSendBusy || phase === "connecting" || phase === "running") && + compactRequestIsActive && + !compactionSettled; + const isWorking = + phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint || isCompacting; const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, activeThread?.session ?? null, @@ -2947,10 +3005,11 @@ function ChatViewContent(props: ChatViewProps) { }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); - const compactionProviderAvailable = useMemo( + const manualCompactionProviderAvailable = useMemo( () => - hasAvailableClaudeCompactionProvider({ + hasAvailableCompactionProvider({ providers: providerInstanceEntries, + driverKind: selectedProvider, instanceId: activeProviderInstanceId, lockedInstanceId: lockedProvider ? (activeThread?.session?.providerInstanceId ?? @@ -2964,6 +3023,7 @@ function ChatViewContent(props: ChatViewProps) { activeThread?.session?.providerInstanceId, lockedProvider, providerInstanceEntries, + selectedProvider, ], ); const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] = @@ -5428,12 +5488,16 @@ function ChatViewContent(props: ChatViewProps) { activeThread && activeContextWindow ? `${activeThread.id}:${activeContextWindow.updatedAt}` : null; - const compactDisabled = + const activeThreadHasCompactableConversation = + activeThread?.messages.some( + (message) => message.role === "user" && !isCompactCommandMessage(message), + ) ?? false; + const compactThreadUnavailable = !activeThread || + !activeThreadHasCompactableConversation || !activeProject || !isServerThread || - selectedProvider !== "claudeAgent" || - !compactionProviderAvailable || + !manualCompactionProviderAvailable || isWorking || threadDetailLoading || isPreparingWorktree || @@ -5441,15 +5505,15 @@ function ChatViewContent(props: ChatViewProps) { feedbackUploading || pendingApprovals.length > 0 || pendingUserInputs.length > 0 || - showPlanFollowUpPrompt || - composerHasUnsentContent; + showPlanFollowUpPrompt; + const compactDisabled = compactThreadUnavailable || composerHasUnsentContent; const compactDisabledReason = compactDisabled ? composerHasUnsentContent ? "Send or clear your draft before compacting" : !activeProject ? "Choose a project before compacting" - : !compactionProviderAvailable - ? "Enable a Claude provider before compacting" + : !manualCompactionProviderAvailable + ? "Compaction is unavailable for this provider" : "Compacting is unavailable right now" : null; const resumeCompactionBannerItem = useMemo(() => { @@ -5914,6 +5978,25 @@ function ChatViewContent(props: ChatViewProps) { composerRef, ]); + // Paste-to-focus: the resting composer blurs on a click into the timeline, + // so a paste that follows has no editable target and would be dropped. + // Route it to the composer like a typed key, which also expands it. + useEffect(() => { + const handler = (event: ClipboardEvent) => { + if (!activeThreadId || isCommandPaletteOpen()) return; + if (getTerminalFocusOwner() !== null) return; + if (composerRef.current?.isModelPickerOpen()) return; + const text = pasteTextToFocusComposer(event); + if (text === null) return; + if (composerRef.current?.insertTextAtEnd(text)) { + event.preventDefault(); + event.stopPropagation(); + } + }; + window.addEventListener("paste", handler, true); + return () => window.removeEventListener("paste", handler, true); + }, [activeThreadId, composerRef]); + const onRevertToTurnCount = useCallback( async (turnCount: number) => { const localApi = readLocalApi(); @@ -7470,6 +7553,15 @@ function ChatViewContent(props: ChatViewProps) {
{panelToggleControls}
); + const inlineRightPanelControls = ( +
+ + {panelToggleControls} +
+ ); const rightPanelContent = activeThreadRef ? ( renderedRightPanelSurface?.kind === "preview" ? ( @@ -7630,7 +7722,7 @@ function ChatViewContent(props: ChatViewProps) { reserveNativeControls={reserveTitleBarControlInset && !inlineRightPanelOwnsTitleBar} className="relative bg-background" > - {!shouldUseRightPanelSheet || !rightPanelControlsInPanel ? panelLayoutControls : null} + {!rightPanelControlsInPanel ? panelLayoutControls : null} - { - setThreadError(activeThread.id, null); - dismissThreadErrorBannerForSession(threadErrorBannerKey); - setThreadErrorBannerDismissTick((tick) => tick + 1); - }} - /> {/* Main content area with optional plan sidebar */}
{/* Chat column */} @@ -7694,13 +7778,21 @@ function ChatViewContent(props: ChatViewProps) {
) : null} - {/* Provider status overlays the timeline without changing its content height. */} -
+ {/* Banners overlay the timeline without changing its content height. */} +
setDismissedProviderStatusBannerKey(providerStatusBannerKey)} onOpenProviderSetup={openProviderSetup} /> + { + setThreadError(activeThread.id, null); + dismissThreadErrorBannerForSession(threadErrorBannerKey); + setThreadErrorBannerDismissTick((tick) => tick + 1); + }} + />
{/* Messages Wrapper */}
@@ -7714,6 +7806,7 @@ function ChatViewContent(props: ChatViewProps) { key={activeThread.id} isWorking={isWorking} isPreparingWorktree={isPreparingWorktree} + isCompacting={isCompacting} activeTurnStartedAt={activeWorkStartedAt} listRef={legendListRef} timelineEntries={timelineEntries} @@ -7874,6 +7967,7 @@ function ChatViewContent(props: ChatViewProps) { } activeThreadModelSelection={activeThread?.modelSelection} activeContextWindow={activeContextWindow} + compactThreadUnavailable={compactThreadUnavailable} compactDisabled={compactDisabled} compactDisabledReason={compactDisabledReason} resolvedTheme={resolvedTheme} @@ -8059,6 +8153,7 @@ function ChatViewContent(props: ChatViewProps) { mode="inline" open={rightPanelOpen} maximized={rightPanelMaximized} + layoutControls={rightPanelOpen ? inlineRightPanelControls : null} surfaces={renderedRightPanelSurfaces} environmentId={activeThreadRef.environmentId} activeSurfaceId={renderedRightPanelSurface?.id ?? null} diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 3c1c22d59e31..eba29400a7a4 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -2350,7 +2350,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 8f1fff8dc728..7f68cd543107 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -990,7 +990,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) { // controls a few pixels higher and the cluster jumps on open. props.mode === "inline" && !props.layoutControls ? "pr-28" : "pr-3", ownsDesktopTitleBar && "drag-region", - ownsDesktopTitleBar && "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]", + ownsDesktopTitleBar && + (props.layoutControls + ? "wco:pr-[var(--workspace-native-controls-inset)]" + : "wco:pr-[calc(var(--workspace-native-controls-inset)+6rem)]"), props.mode === "inline" && props.maximized && COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, )} data-right-panel-tabbar diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 03e6af60c673..3ebc8a4caaae 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -281,6 +281,7 @@ function terminalProcessLabel(count: number): string { function SidebarV2ThreadTooltip({ thread, projectTitle, + projectDisplayName, projectCwd, projectFaviconPath, projectIcon, @@ -296,6 +297,7 @@ function SidebarV2ThreadTooltip({ }: { thread: SidebarThreadSummary; projectTitle: string | null; + projectDisplayName: string | null; projectCwd: string | null; projectFaviconPath: string | null; projectIcon: ProjectIconOverride | null; @@ -326,17 +328,17 @@ function SidebarV2ThreadTooltip({ {thread.title}
- {projectTitle ? ( + {projectDisplayName ? (
-
{projectTitle}
+
{projectDisplayName}
) : null} {environmentLabel ? ( @@ -502,6 +504,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { session: DraftSessionState; composer: ComposerThreadDraftState; projectTitle: string | null; + projectDisplayName: string | null; projectCwd: string | null; projectFaviconPath: string | null; projectIcon: ProjectIconOverride | null; @@ -576,7 +579,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { className="size-4 shrink-0" /> - {props.projectTitle} + {props.projectDisplayName} @@ -614,6 +617,7 @@ interface SidebarDraftRowData { // subscription + closing divider) so per-keystroke composer updates // re-render only this block, never the whole sidebar. Vanishes at count 0. const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { + projectTitleByKey: ReadonlyMap; projectDisplayNameByKey: ReadonlyMap; projectCwdByKey: ReadonlyMap; projectFaviconPathByKey: ReadonlyMap; @@ -711,7 +715,8 @@ const SidebarDraftBlock = memo(function SidebarDraftBlock(props: { draftId={draftId} session={session} composer={composer} - projectTitle={props.projectDisplayNameByKey.get(projectKey) ?? null} + projectTitle={props.projectTitleByKey.get(projectKey) ?? null} + projectDisplayName={props.projectDisplayNameByKey.get(projectKey) ?? null} projectCwd={props.projectCwdByKey.get(projectKey) ?? null} projectFaviconPath={props.projectFaviconPathByKey.get(projectKey) ?? null} projectIcon={props.projectIconByKey.get(projectKey) ?? null} @@ -764,6 +769,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { projectFaviconPath: string | null; projectIcon: ProjectIconOverride | null; projectTitle: string | null; + projectDisplayName: string | null; providerEntryByInstanceId: ReadonlyMap; timestampFormat: TimestampFormat; onThreadClick: (event: ReactMouseEvent, threadRef: ScopedThreadRef) => void; @@ -999,6 +1005,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { - {props.projectTitle ? ( + {props.projectDisplayName ? ( - {props.projectTitle} + {props.projectDisplayName} ) : ( @@ -1689,6 +1696,7 @@ const SidebarV2SearchResultRow = memo(function SidebarV2SearchResultRow(props: { projectFaviconPath: string | null; projectIcon: ProjectIconOverride | null; projectTitle: string | null; + projectDisplayName: string | null; environmentLabel: string | null; environmentMachine: EnvironmentMachineKind; providerEntryByInstanceId: ReadonlyMap; @@ -1755,7 +1763,9 @@ const SidebarV2SearchResultRow = memo(function SidebarV2SearchResultRow(props: { aria-selected={props.isHighlighted} aria-current={props.isRouteActive ? "page" : undefined} aria-label={ - props.projectTitle ? `${thread.title}, ${props.projectTitle}` : thread.title + props.projectDisplayName + ? `${thread.title}, ${props.projectDisplayName}` + : thread.title } onMouseMove={props.onHighlight} onClick={props.onSelect} @@ -1771,7 +1781,7 @@ const SidebarV2SearchResultRow = memo(function SidebarV2SearchResultRow(props: { + new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project.title])), + [projects], + ); const projectDisplayNameByKey = useMemo( () => new Map( @@ -3682,7 +3699,7 @@ export default function SidebarV2() { { - const next = resolveRestingComposerControlsLayout({ ...measurement, hostWidth }); + const next = resolveRestingComposerControlsLayout({ + ...measurement, + hostWidth, + previous: current, + }); return next.hiddenCount === current.hiddenCount && next.visible === current.visible ? current : next; @@ -873,12 +882,14 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; size?: "sm" | "xs"; + hidden?: boolean; onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { const runtimeModeConfig = getRuntimeModeConfig(props.provider); const runtimeModeOptions = getRuntimeModeOptions(props.provider); const size = props.size ?? "sm"; + const [open, setOpen] = useComposerMenuState(props.hidden); const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; const RuntimeModeIcon = runtimeModeOption.icon; const interactionModeTooltip = @@ -936,6 +947,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop { const nextColor = event.currentTarget.value; + setHexDraft(nextColor); if (!/^#[\da-f]{6}$/i.test(nextColor)) return; setHsv(hexToHsv(nextColor)); props.onCommit(nextColor); }} + onBlur={() => setHexDraft(null)} className="h-8 rounded-md border border-input bg-background px-2 font-mono text-xs text-foreground outline-none transition-colors focus:border-ring" aria-label="Custom hex accent color" spellCheck={false} @@ -181,8 +174,8 @@ function ProviderCustomColorPanel(props: { function ProviderCustomColorPicker(props: { readonly displayName: string; readonly value: string | undefined; - readonly selected: boolean; readonly onCommit: (value: string) => void; + readonly onClear: () => void; }) { const normalized = normalizeProviderAccentColor(props.value) ?? FALLBACK_ACCENT_COLOR; @@ -193,20 +186,13 @@ function ProviderCustomColorPicker(props: { } /> @@ -217,6 +203,21 @@ function ProviderCustomColorPicker(props: { className="overflow-hidden rounded-md p-0 [--viewport-inline-padding:0px] [&_[data-slot=popover-viewport]]:p-0" > + + + Clear color + + } + /> ); @@ -297,59 +298,23 @@ export function ProviderAccentColorPicker(props: { ); const normalized = normalizeProviderAccentColor(optimisticValue); - const selectedValue = - normalized && - PROVIDER_ACCENT_SWATCHES.includes(normalized as (typeof PROVIDER_ACCENT_SWATCHES)[number]) - ? normalized - : ""; - const customSelected = Boolean(normalized && selectedValue === ""); - - const swatchRow = ( -
- - - -
+ const picker = ( + commitAccentColor("")} + /> ); if (layout === "inline") { - return swatchRow; + return picker; } return (
Accent color - {swatchRow} + {picker} {description ? {description} : null}
); diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 256db96843b3..6c2ec9b041c6 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -34,18 +34,17 @@ import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; -import { ScrollArea } from "../ui/scroll-area"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import type { DriverOption } from "./providerDriverMeta"; -import { providerSettingsTabClassName } from "./providerSettingsTabs"; import { ProviderSettingsForm } from "./ProviderSettingsForm"; import { ProviderModelsSection } from "./ProviderModelsSection"; import { ProviderInstanceIcon, providerInstanceInitials } from "../chat/ProviderInstanceIcon"; import { ProviderAccentColorPicker } from "./ProviderAccentColorPicker"; import { HermesCompanionSection } from "./HermesCompanionSection"; import { RedactedSensitiveText } from "./RedactedSensitiveText"; +import { SettingsRow, SettingsSection } from "./settingsLayout"; import { getProviderVersionAdvisoryPresentation, PROVIDER_STATUS_STYLES, @@ -56,13 +55,6 @@ import { const ENVIRONMENT_VARIABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*$/; -/** Label-left field grid for the Configuration tab: one row per field. */ -const PROVIDER_FIELD_GRID_CLASS_NAME = - "grid gap-x-4 gap-y-2.5 sm:grid-cols-[8rem_minmax(0,1fr)] sm:items-start"; -/** Full-width divider row that names the group of fields below it. */ -const PROVIDER_FIELD_GROUP_LABEL_CLASS_NAME = - "col-span-full mt-1 border-t border-border/60 pt-2.5 text-[11px] text-muted-foreground"; - let environmentVariableDraftId = 0; const nextEnvironmentVariableDraftId = () => `provider-env-${environmentVariableDraftId++}`; @@ -257,7 +249,7 @@ function ProviderEnvironmentSection(props: { ]); return ( -
+
{rows.map((variable, index) => (
))} -
+
+ {rows.length > 0 ? ( + + Sensitive values are stored separately and never returned to the app. + + ) : null} - - {rows.length === 0 - ? "API keys, base URLs, or other per-instance CLI settings." - : "Sensitive values are stored separately and never returned to the app."} -
); @@ -426,7 +418,6 @@ export function ProviderInstanceCard({ onRunUpdate, isUpdating = false, }: ProviderInstanceCardProps) { - const [activeTab, setActiveTab] = useState<"configuration" | "models">("configuration"); const enabled = resolveProviderInstanceEnabled(instance); // A locally disabled provider reads "Disabled" with a muted dot even if its // last server status is stale. Enabled providers use the server status. @@ -438,9 +429,6 @@ export function ProviderInstanceCard({ ? getProviderSummary(liveProvider) : { headline: "Disabled", detail: null }; const authEmail = liveProvider?.auth.email?.trim(); - // The editor header folds the account email into the status line — - // "Authenticated as · " — with the email redacted until its - // reveal toggle is clicked. const isAuthenticated = enabled && liveProvider?.auth.status === "authenticated"; const authLabel = enabled && liveProvider?.auth.status === "authenticated" @@ -479,8 +467,6 @@ export function ProviderInstanceCard({ const driverKind: ProviderDriverKind | null = isProviderDriverKind(instance.driver) ? instance.driver : null; - const visibleTab = driverOption === undefined ? "configuration" : activeTab; - const customModels = instance.driver === "antigravity" ? [] : readConfigStringArray(instance.config, "customModels"); // Server-returned models may lag behind settings writes. Treat probe @@ -490,10 +476,6 @@ export function ProviderInstanceCard({ liveModels: liveProvider?.models, customModels, }); - const hiddenModelCount = modelsForDisplay.filter( - (model) => !model.isCustom && hiddenModels.includes(model.slug), - ).length; - const updateDisplayName = (value: string) => { const trimmed = value.trim(); const { displayName: _omit, ...rest } = instance; @@ -566,25 +548,6 @@ export function ProviderInstanceCard({ ); - const titleHeadNode = ( - <> - {titleIconNode} -

- {displayName} -

- {String(instanceId) !== String(instance.driver) ? ( - - {instanceId} - - ) : null} - {driverOption?.badgeLabel ? ( - - {driverOption.badgeLabel} - - ) : null} - - ); - const titleTailNode = headerAction ? ( {headerAction} ) : null; @@ -598,29 +561,43 @@ export function ProviderInstanceCard({ statusKey === "warning" || statusKey === "error" ? ( ) : null; - const statusHeadlineNode = {summary.headline}; // Trouble states carry the server's explanation (a failed probe, a shadow // home entry that is not a symlink, a missing binary). Show it wherever the // headline shows so the user can act without opening the editor. const needsAttention = statusKey === "warning" || statusKey === "error"; - const statusLineClassName = - "flex min-w-0 flex-wrap items-center gap-x-1.5 text-[13px] leading-[1.45] text-muted-foreground/80"; - + const editorStatusNode = + isAuthenticated && authEmail ? ( + <> + {needsAttention ? statusDotNode : null} + Authenticated as + + {authLabel ? · {authLabel} : null} + {summary.detail ? ( + · {summary.detail} + ) : null} + + ) : ( + <> + {statusDotNode} + {summary.headline} + {summary.detail ? ( + · {summary.detail} + ) : null} + + ); if (mode === "list") { return (
+ } + /> + - {versionAdvisory ? ( - - - - - } - /> - +
+

+ Update available +

+

-

-
-

- Update available -

-

- {versionAdvisory.detail} -

-
- {onRunUpdate ? ( - - ) : null} - {onRunUpdate && updateCommand ? ( -
- - or, update manually using - -
- ) : null} - {updateCommand ? ( -
- - - {updateCommand} - - - - - copyToClipboard(updateCommand, { - providerName: displayName, - }) - } - aria-label="Copy update command" - > - - - } - /> - Copy command - -
- ) : null} -
- - - ) : null} - {titleTailNode} - -
-

- {statusDotNode} - {isAuthenticated && authEmail ? ( - <> - Authenticated as - - {authLabel ? · {authLabel} : null} - - ) : ( - statusHeadlineNode - )} - {summary.detail && !needsAttention ? · {summary.detail} : null} -

- {summary.detail && needsAttention ? ( -

- {summary.detail} -

- ) : null} -
- {onDelete ? ( - - - + {versionAdvisory.detail} +

+
+ {onRunUpdate ? ( + + ) : null} + {onRunUpdate && updateCommand ? ( +
+ + or, update manually using + +
+ ) : null} + {updateCommand ? ( +
+ + {updateCommand} + + + + copyToClipboard(updateCommand, { providerName: displayName }) + } + aria-label="Copy update command" + > + + + } + /> + Copy command + +
+ ) : null} +
+ + ) : null} -
- -
- - {driverOption !== undefined ? ( - + + ) : null} -
+ +
+ ); -
- - {driverOption !== undefined ? ( - -
+ + ) : null} + ); } diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index c94b7da9d34f..902fd408b54f 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -17,6 +17,7 @@ import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ import { Switch } from "../ui/switch"; import { Textarea } from "../ui/textarea"; import type { ProviderClientDefinition } from "./providerDriverMeta"; +import { SettingsRow } from "./settingsLayout"; export interface ProviderSettingsFieldModel { readonly key: string; @@ -167,11 +168,9 @@ interface ProviderSettingsFormProps { readonly idPrefix: string; /** * `card` stacks label over control, `dialog` is the compact wizard layout, - * `grid` emits a label cell and a control cell per field for a parent - * two-column grid (label column left, control right), with the description - * beside a fixed-width control so each field stays on one line. + * and `settings` renders the shared settings row treatment. */ - readonly variant: "card" | "dialog" | "grid"; + readonly variant: "card" | "dialog" | "settings"; readonly onChange: (nextConfig: Record | undefined) => void; } @@ -252,74 +251,64 @@ function ProviderSettingsFieldRow({ {field.description} ) : null; - if (variant === "grid") { - // Label cell, then a control cell where the description sits beside a - // fixed-width control and wraps under it when the pane is narrow. The - // description is outside the label, so the control points at it instead. + if (variant === "settings") { const descriptionId = field.description ? `${inputId}-description` : undefined; + const control = + field.control === "switch" ? ( + + onChange(nextProviderConfigWithFieldValue(value, field, Boolean(checked))) + } + aria-label={field.label} + aria-describedby={descriptionId} + /> + ) : field.control === "select" ? ( + + ) : field.control === "textarea" ? ( +