diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 98cbc681b75d..988e223d219f 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -12,22 +12,28 @@ github:0x4bs3nt github:Adamulek123 github:adityavardhansharma +github:aoright github:arhxam github:bil0000 github:binbandit github:Brechard +github:btsouth github:chrisdeeming github:chuks-qua github:cursoragent github:D3OXY +github:dbalders github:eggfriedrice24 github:extoci +github:flamboh +github:FllipEis github:gbarros-dev github:gfsaaser24 github:github-actions[bot] github:gsimone github:GuilhermeVieiraDev github:hwanseoc +github:inayayousfi github:ipanasenko github:jakeleventhal github:jamesx0416 @@ -36,13 +42,18 @@ github:jasonLaster github:JoeEverest github:justsomelegs github:kridaydave +github:lgwacker github:lnieuwenhuis github:Lucenx9 github:mackinleysmith github:maria-rcks +github:MatthewFeroz github:maxwellyoung github:mwolson +github:myacoub91 +github:naMqe-h github:nateEc +github:naveed949 github:nmggithub github:Noojuno github:notkainoa @@ -64,6 +75,7 @@ github:tarik02 github:tris203 github:tsouth89 github:UtkarshUsername +github:vitalyiegorov github:Yash-Singh1 github:yashranaway github:Ymit24 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/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..c5c0d89597aa --- /dev/null +++ b/apps/web/src/bootstrap.test.ts @@ -0,0 +1,93 @@ +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(); + 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.doUnmock("./main"); + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("replaces the splash when an app import throws before main can run", async () => { + vi.doMock("./main", () => { + throw new Error("@vitejs/plugin-react can't detect preamble. Something is wrong."); + }); + 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..599b45d22524 --- /dev/null +++ b/apps/web/src/bundledDev.test.ts @@ -0,0 +1,228 @@ +// @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 connected = NodeEvents.EventEmitter.once(events, "connected"); + const ready = NodeEvents.EventEmitter.once(events, "ready"); + 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; + }, + async generateBundle() { + // Keep the build pending until the socket can receive Vite's ready message. + await connected; + }, + }, + ], + server: { host: "127.0.0.1", port: 0 }, + }); + await server.listen(); + const address = server.httpServer?.address(); + if (!address || typeof address === "string") throw new Error("Vite did not bind a port"); + + 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) { + // generateBundle runs before Vite stores the files for HTTP requests. + if ( + message.type === "full-reload" && + "ifFallback" in message && + message.ifFallback === true + ) { + events.emit("ready"); + } else if (message.type === "bundled-dev-update") { + events.emit("updated"); + } + } + }); + await connected; + await ready; + 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/AgentsPanel.tsx b/apps/web/src/components/AgentsPanel.tsx index 62044a8659d7..459506efc78c 100644 --- a/apps/web/src/components/AgentsPanel.tsx +++ b/apps/web/src/components/AgentsPanel.tsx @@ -140,6 +140,8 @@ function agentActivityText(agent: RuntimeSubagent): string | null { /** Flat, non-interactive agent status line. No unfold. */ function AgentRow({ agent }: { agent: RuntimeSubagent }) { const visuals = STATUS_VISUALS[agent.status]; + const statusLabel = + agent.kind === "subagent_batch" && agent.status === "idle" ? "Idle" : visuals.label; const activity = agentActivityText(agent); const modelLabel = formatSubagentModelLabel(agent.model, agent.effort); const role = @@ -180,12 +182,12 @@ function AgentRow({ agent }: { agent: RuntimeSubagent }) { agent.status === "failed" ? "text-destructive-foreground" : "text-muted-foreground", )} > - {activity ?? visuals.label} + {activity ?? statusLabel} {metadata.join(" · ")} - {visuals.label} + {statusLabel} ); } diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 1780c8b9acb8..3bef170bcf51 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,8 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { - lazy, - Suspense, useEffect, useState, useSyncExternalStore, @@ -20,6 +18,7 @@ import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../ho import { usePanelAnimationSettings } from "../panelAnimations"; import LegacyThreadSidebar from "./LegacySidebar"; import ThreadSidebar from "./Sidebar"; +import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { resolveSidebarStageFocusRingOffsetClass, @@ -45,14 +44,6 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; const MACOS_TRAFFIC_LIGHTS_LEFT_INSET = "90px"; -// The settings nav (and the Clerk profile surfaces behind it) only renders on -// settings routes; lazy-loading it keeps that subtree out of the startup chunk. -const SettingsSidebarNav = lazy(() => - import("./settings/SettingsSidebarNav").then((module) => ({ - default: module.SettingsSidebarNav, - })), -); - function subscribeToViewportWidth(onChange: () => void): () => void { window.addEventListener("resize", onChange); return () => window.removeEventListener("resize", onChange); @@ -247,9 +238,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { {isOnSettings ? ( <> - - - + ) : legacySidebarEnabled ? ( 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 4ba53f47b6a4..6cd83054b8e6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -209,8 +209,8 @@ import { getProviderModelCapabilities } from "../providerModels"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, - sortProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, + sortProviderInstanceEntries, } from "../providerInstances"; import { useClientSettings, @@ -298,6 +298,7 @@ import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline } from "./chat/MessagesTimeline"; import type { AssistantCitationRequest } from "./chat/AssistantCitationSource"; import { resolveTimelineIsAtEnd } from "./chat/MessagesTimeline.logic"; +import { resolveComposerTimelineInset } from "./composerFooterLayout"; import { ChatHeader } from "./chat/ChatHeader"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { expandedImageKey, type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; @@ -330,7 +331,7 @@ import { import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ComposerSurface } from "./chat/ComposerSurface"; import { - hasAvailableClaudeCompactionProvider, + hasAvailableCompactionProvider, hasDismissedResumeCompaction, shouldOfferResumeCompaction, } from "./chat/ContextWindowMeter.logic"; @@ -357,6 +358,7 @@ import { deriveComposerSendState, dismissBranchMismatchForSession, hasEnvironmentReconnectWarningGraceElapsed, + latestTurnStartFailureId, scheduleEnvironmentReconnectWarning, hasServerAcknowledgedLocalDispatch, isBranchMismatchDismissedForSession, @@ -626,6 +628,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; @@ -669,6 +676,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); @@ -684,6 +695,7 @@ function useLocalDispatchState(input: { session: input.activeThread?.session ?? null, hasPendingApproval: input.activePendingApproval !== null, hasPendingUserInput: input.activePendingUserInput !== null, + latestTurnStartFailureId: currentTurnStartFailureId, threadError: input.threadError, }), [ @@ -694,6 +706,7 @@ function useLocalDispatchState(input: { input.phase, input.threadError, latestUserMessageId, + currentTurnStartFailureId, localDispatch, ], ); @@ -1322,6 +1335,8 @@ function chatActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } +const ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE = 3; + /** * Drops the send-time anchored end space. That space is what holds a sent * message near the top while its turn streams, and it keeps LegendList's @@ -1616,12 +1631,19 @@ function ChatViewContent(props: ChatViewProps) { const [composerOverlayElement, setComposerOverlayElement] = useState(null); const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); const composerOverlayHeightRef = useRef(0); + // Space the timeline keeps clear above its end. Tracks the overlay while the + // composer is expanded and holds that height while it rests, so the resting + // composer never exposes rows that its expansion will cover. + const [composerTimelineInset, setComposerTimelineInset] = useState(0); + const composerTimelineInsetRef = useRef(0); + const composerRestingRef = useRef(false); const [scrollToEndClearance, setScrollToEndClearance] = useState(0); const isAtEndRef = useRef(true); const isTimelineAtLogicalEnd = useCallback(() => isAtEndRef.current, []); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); + const environmentUnavailableSendToastSlotRef = useRef(0); const feedbackUploadsInFlightRef = useRef(new Set()); const terminalUiOpenByThreadRef = useRef>({}); @@ -1865,7 +1887,8 @@ function ChatViewContent(props: ChatViewProps) { panelAnimationDurationMs, ); const rightPanelPresent = rightPanelPresence.present; - const rightPanelControlsInPanel = rightPanelPresent && rightPanelOpen; + const rightPanelControlsInPanel = shouldUseRightPanelSheet && rightPanelPresent && rightPanelOpen; + const rightPanelControlsAtRoot = rightPanelPresent && !shouldUseRightPanelSheet; const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer( @@ -2587,7 +2610,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, @@ -2957,10 +3006,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 ?? @@ -2974,6 +3024,7 @@ function ChatViewContent(props: ChatViewProps) { activeThread?.session?.providerInstanceId, lockedProvider, providerInstanceEntries, + selectedProvider, ], ); const [resumeCompactionPermanentlyDismissed, setResumeCompactionPermanentlyDismissed] = @@ -4419,11 +4470,11 @@ function ChatViewContent(props: ChatViewProps) { return getAnchoredTurnMetrics({ state, anchorIndex, - composerOverlayHeight, + composerOverlayHeight: composerTimelineInset, anchorOffset: CHAT_TIMELINE_ANCHOR_OFFSET, }); }, - [composerOverlayHeight], + [composerTimelineInset], ); const timelineRealContentOverflowsViewport = useCallback( (list?: LegendListRef | null) => { @@ -4448,11 +4499,11 @@ function ChatViewContent(props: ChatViewProps) { const realContentBottom = lastRowTop + Math.max(1, lastRowHeight); const visibleScrollLength = Math.max( 0, - (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_TIMELINE_ANCHOR_OFFSET, + (state.scrollLength ?? 0) - composerTimelineInset - CHAT_TIMELINE_ANCHOR_OFFSET, ); return realContentBottom > visibleScrollLength; }, - [composerOverlayHeight], + [composerTimelineInset], ); const pageScrollControllerRef = useRef | null>( null, @@ -4924,10 +4975,35 @@ function ChatViewContent(props: ChatViewProps) { composerOverlayHeightRef.current = nextHeight; setComposerOverlayHeight(nextHeight); } + const nextInset = resolveComposerTimelineInset({ + currentInset: composerTimelineInsetRef.current, + overlayHeight: nextHeight, + isResting: composerRestingRef.current, + }); + if (composerTimelineInsetRef.current !== nextInset) { + composerTimelineInsetRef.current = nextInset; + setComposerTimelineInset(nextInset); + } setScrollToEndClearance((currentClearance) => currentClearance === nextHeight ? currentClearance : nextHeight, ); }, []); + // The composer reports its resting flag from a layout effect, which runs + // before this component's own layout effects and before any resize + // observation, so every measurement below sees the flag for its layout. + // Only the flag is stored here: the stored height still belongs to the + // previous layout, and the composer publishes the new layout's height + // itself once it has measured it. + const onComposerRestingChange = useCallback((resting: boolean) => { + composerRestingRef.current = resting; + }, []); + // A held reservation belongs to the previous thread's draft. Rebuild it from + // this thread's overlay so a tall draft elsewhere does not pad this one. + useLayoutEffect(() => { + if (!composerOverlayElement) return; + composerTimelineInsetRef.current = 0; + publishComposerOverlayHeight(composerOverlayElement.getBoundingClientRect().height); + }, [activeThreadKey, composerOverlayElement, publishComposerOverlayHeight]); useLayoutEffect(() => { if (!composerOverlayElement) return; @@ -5393,12 +5469,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 || @@ -5406,15 +5486,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(() => { @@ -5997,13 +6077,17 @@ function ChatViewContent(props: ChatViewProps) { return; } if (activeEnvironmentUnavailable) { - toastManager.add( - stackedThreadToast({ + const toastSlot = environmentUnavailableSendToastSlotRef.current; + environmentUnavailableSendToastSlotRef.current = + (toastSlot + 1) % ENVIRONMENT_UNAVAILABLE_SEND_TOAST_TRAIL_SIZE; + toastManager.add({ + ...stackedThreadToast({ type: "warning", title: "Not connected: message not sent", description: "Reconnecting to the environment. Try again once it is connected.", }), - ); + id: `chat-send-environment-unavailable:${toastSlot}`, + }); return; } if (activePendingProgress) { @@ -7450,15 +7534,6 @@ function ChatViewContent(props: ChatViewProps) {
{panelToggleControls}
); - const inlineRightPanelControls = ( -
- - {panelToggleControls} -
- ); const rightPanelContent = activeThreadRef ? ( renderedRightPanelSurface?.kind === "preview" ? ( @@ -7605,6 +7680,7 @@ function ChatViewContent(props: ChatViewProps) { return (
+ {rightPanelControlsAtRoot ? panelLayoutControls : null}
- {!rightPanelControlsInPanel ? panelLayoutControls : null} + {isElectron && rightPanelControlsAtRoot ? ( + + ) : null} + {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null} - { - setThreadError(activeThread.id, null); - dismissThreadErrorBannerForSession(threadErrorBannerKey); - setThreadErrorBannerDismissTick((tick) => tick + 1); - }} - /> {/* Main content area with optional plan sidebar */}
{/* Chat column */} @@ -7683,13 +7757,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 */}
@@ -7703,6 +7785,7 @@ function ChatViewContent(props: ChatViewProps) { key={activeThread.id} isWorking={isWorking} isPreparingWorktree={isPreparingWorktree} + isCompacting={isCompacting} activeTurnStartedAt={activeWorkStartedAt} listRef={legendListRef} timelineEntries={timelineEntries} @@ -7730,7 +7813,7 @@ function ChatViewContent(props: ChatViewProps) { } anchorMessageId={timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReady} - contentInsetEndAdjustment={composerOverlayHeight} + contentInsetEndAdjustment={composerTimelineInset} liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChange} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} @@ -7862,6 +7945,7 @@ function ChatViewContent(props: ChatViewProps) { } activeThreadModelSelection={activeThread?.modelSelection} activeContextWindow={activeContextWindow} + compactThreadUnavailable={compactThreadUnavailable} compactDisabled={compactDisabled} compactDisabledReason={compactDisabledReason} resolvedTheme={resolvedTheme} @@ -7877,6 +7961,7 @@ function ChatViewContent(props: ChatViewProps) { getTimelineScrollableNode={getTimelineScrollableNode} isTimelineAtLogicalEnd={isTimelineAtLogicalEnd} onComposerOverlayHeightChange={publishComposerOverlayHeight} + onRestingChange={onComposerRestingChange} promptRef={promptRef} composerImagesRef={composerImagesRef} composerFilesRef={composerFilesRef} @@ -8047,7 +8132,6 @@ 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/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 7f68cd543107..e9dab0c9d2b4 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1002,10 +1002,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { ref={tabListRef} hideScrollbars scrollFade - className={cn( - "min-w-0 flex-1 rounded-none", - ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", - )} + className="min-w-0 flex-1 rounded-none" data-right-panel-tab-list >
@@ -1031,6 +1028,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onContextMenu={(event) => void handleTabContextMenu(event, surface)} className={cn( "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-md pr-2 pl-1.5 text-xs", + ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", active ? "bg-accent text-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", @@ -1235,6 +1233,12 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
) : null} {props.layoutControls} + {ownsDesktopTitleBar ? ( + + ) : null}
{props.activeSurfaceId === null ? ( diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index cf14c9614734..8b636dc6ed46 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -954,7 +954,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { linkedPullRequestStatus, }); const prStatus = prStatusIndicator(pr, prProvider); - const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state) : undefined; + const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state, pr.isDraft) : undefined; useEffect(() => { const nextSnapshot = nextThreadChangeRequestSnapshot({ threadBranch: thread.branch, diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 31b94db36d4c..439756b2466e 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -10,6 +10,7 @@ import { resolveDisplayedThreadPrProvider, resolveThreadPr, settledPrHoverColorClass, + threadChangeRequestSnapshotsEqual, threadChangeRequestSnapshotsAtom, type ThreadChangeRequestSnapshot, } from "./ThreadStatusIndicators"; @@ -559,6 +560,18 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { }); expect(displayed?.state).toBe("merged"); }); + + it("refreshes a cached snapshot when a pull request becomes ready", () => { + const readyPr = { ...mergedPr, state: "open" as const }; + const draftPr = { ...readyPr, isDraft: true }; + + expect( + threadChangeRequestSnapshotsEqual( + snapshotFor(featureBranch, draftPr), + snapshotFor(featureBranch, readyPr), + ), + ).toBe(false); + }); }); describe("threadChangeRequestSnapshotsAtom", () => { @@ -600,6 +613,17 @@ describe("prStatusIndicator", () => { "text-red-600", ); }); + + it("uses gray and draft wording for draft pull requests", () => { + const draftPr = status().pr; + if (!draftPr) throw new Error("Expected pull request fixture"); + + expect(prStatusIndicator({ ...draftPr, isDraft: true }, undefined)).toMatchObject({ + label: "PR draft", + colorClass: "text-zinc-500 dark:text-zinc-400/80", + tooltipLead: "PR #42 - Draft", + }); + }); }); describe("settledPrHoverColorClass", () => { @@ -610,4 +634,8 @@ describe("settledPrHoverColorClass", () => { ] as const)("restores the %s pull request color on row hover", (state, colorClass) => { expect(settledPrHoverColorClass(state)).toContain(`group-hover/v2-row:${colorClass}`); }); + + it("keeps draft pull requests gray on row hover", () => { + expect(settledPrHoverColorClass("open", true)).toContain("group-hover/v2-row:text-zinc-500"); + }); }); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 1ad0c3139fd8..4c830f4a4b8b 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -84,9 +84,15 @@ export function useLinkedThreadPullRequest( ); } -export function settledPrHoverColorClass(state: NonNullable["state"]): string { +export function settledPrHoverColorClass( + state: NonNullable["state"], + isDraft = false, +): string { switch (state) { case "open": + if (isDraft) { + return "group-hover/v2-row:text-zinc-500 dark:group-hover/v2-row:text-zinc-400/80"; + } return "group-hover/v2-row:text-emerald-600 dark:group-hover/v2-row:text-emerald-300/90"; case "merged": return "group-hover/v2-row:text-violet-600 dark:group-hover/v2-row:text-violet-300/90"; @@ -99,12 +105,13 @@ export function prStatusIndicator( pr: ThreadPr, provider: VcsStatusResult["sourceControlProvider"] | null | undefined, ): PrStatusIndicator | null { - function formatPrState(state: NonNullable["state"]): string { - return state.charAt(0).toUpperCase() + state.slice(1); + function formatPrState(pr: NonNullable): string { + if (pr.state === "open" && pr.isDraft === true) return "Draft"; + return pr.state.charAt(0).toUpperCase() + pr.state.slice(1); } function formatPrStatusLead(pr: NonNullable, changeRequestShortName: string): string { - return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr.state)}`; + return `${changeRequestShortName} #${pr.number} - ${formatPrState(pr)}`; } if (!pr) return null; const presentation = resolveChangeRequestPresentation(provider); @@ -113,9 +120,12 @@ export function prStatusIndicator( const tooltip = `${tooltipLead}: ${pr.title}`; if (pr.state === "open") { + const isDraft = pr.isDraft === true; return { - label: `${presentation.shortName} open`, - colorClass: "text-emerald-600 dark:text-emerald-300/90", + label: `${presentation.shortName} ${isDraft ? "draft" : "open"}`, + colorClass: isDraft + ? "text-zinc-500 dark:text-zinc-400/80" + : "text-emerald-600 dark:text-emerald-300/90", tooltip, tooltipLead, tooltipTitle: pr.title, @@ -230,6 +240,7 @@ export function threadChangeRequestSnapshotsEqual( left.pr.baseRef === right.pr.baseRef && left.pr.headRef === right.pr.headRef && left.pr.state === right.pr.state && + left.pr.isDraft === right.pr.isDraft && (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) && sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) && linkedPullRequestsEqual(left.linkedPullRequest, right.linkedPullRequest) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d5bf8524da3b..8993fbcab89d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -87,6 +87,7 @@ import { } from "../../promptStashStore"; import { ComposerStashBadge } from "./ComposerStashBadge"; import { ComposerStashMenu } from "./ComposerStashMenu"; +import { useComposerMenuState } from "./useComposerMenuState"; import { ComposerTasksBadge, ComposerTasksContent, @@ -186,7 +187,10 @@ import { renderProviderTraitsPicker, } from "./composerProviderState"; import { ContextWindowMeter } from "./ContextWindowMeter"; -import { resolveContextWindowModelDisplayName } from "./ContextWindowMeter.logic"; +import { + providerSupportsManualCompaction, + resolveContextWindowModelDisplayName, +} from "./ContextWindowMeter.logic"; import { attachVideoThumbnail, buildExpandedImagePreview, @@ -247,12 +251,14 @@ const COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX = 4; function useComposerRestingTransition( isCollapsed: boolean, + isResting: boolean, restingControlsRef: React.RefObject, onOverlayHeightChange: (height: number) => void, ) { const elementRef = useRef(null); const isCollapsedRef = useRef(isCollapsed); const previousCollapsedRef = useRef(isCollapsed); + const previousRestingRef = useRef(isResting); const previousHeightRef = useRef(null); const previousContentOffsetsRef = useRef<{ promptFromTop: number | null; @@ -351,6 +357,15 @@ function useComposerRestingTransition( const nextRect = element.getBoundingClientRect(); const nextHeight = nextRect.height; + // The chat view resize-observes the overlay to place the timeline + // inset, the scroll-to-end pill, and the mini player. Publishing the + // destination height here turns that feedback into one update instead + // of a ChatView re-render on every animation frame. + const overlay = element.closest('[data-chat-composer-overlay="true"]'); + const overlayHeight = overlay?.getBoundingClientRect().height ?? null; + if (overlayHeight !== null) { + onOverlayHeightChange(overlayHeight); + } const nextPromptRect = prompt?.getBoundingClientRect() ?? null; const nextPromptTop = nextPromptRect?.top ?? null; const nextActionTop = action?.getBoundingClientRect().top ?? null; @@ -381,18 +396,13 @@ function useComposerRestingTransition( element.style.overflow = "clip"; surface.style.height = "100%"; - // The chat view resize-observes the overlay to place the timeline - // inset, the scroll-to-end pill, and the mini player. Pinning the - // overlay at the destination height turns that feedback into one - // update instead of a ChatView re-render on every animation frame; - // bottom alignment keeps the animating surface glued to the overlay's - // stable bottom edge. The pin lasts only for the tween so later - // attachment, thread, font, and viewport changes remain natural. - const overlay = element.closest('[data-chat-composer-overlay="true"]'); - let pinnedOverlayHeight: number | null = null; - if (overlay) { - pinnedOverlayHeight = overlay.getBoundingClientRect().height; - overlay.style.height = `${String(pinnedOverlayHeight)}px`; + // Pinning the overlay at the destination height keeps the resize + // observer quiet for the tween; bottom alignment keeps the animating + // surface glued to the overlay's stable bottom edge. The pin lasts + // only for the tween so later attachment, thread, font, and viewport + // changes remain natural. + if (overlay && overlayHeight !== null) { + overlay.style.height = `${String(overlayHeight)}px`; overlay.style.display = "flex"; overlay.style.flexDirection = "column"; overlay.style.justifyContent = "flex-end"; @@ -426,11 +436,6 @@ function useComposerRestingTransition( ); animationRef.current = animation; animationTargetHeightRef.current = nextHeight; - // Publish the destination overlay geometry in the same layout pass; - // ResizeObserver remains the fallback for non-transition changes. - if (pinnedOverlayHeight !== null) { - onOverlayHeightChange(pinnedOverlayHeight); - } const animatedRect = element.getBoundingClientRect(); const previousPromptTop = @@ -608,6 +613,18 @@ function useComposerRestingTransition( }; }, [isCollapsed, transitionToCurrentGeometry]); + // The resting flag can change while the collapsed layout stays the same, + // for example when an unfocused thread crosses the phone breakpoint. The + // chat view pairs overlay heights with that flag, so republish the natural + // height for the new flag. A transition in flight publishes its own. + useLayoutEffect(() => { + if (previousRestingRef.current === isResting) return; + previousRestingRef.current = isResting; + if (animationRef.current) return; + const overlay = elementRef.current?.closest('[data-chat-composer-overlay="true"]'); + if (overlay) onOverlayHeightChange(overlay.getBoundingClientRect().height); + }, [isResting, onOverlayHeightChange]); + useLayoutEffect(() => { const element = elementRef.current; if (!element || typeof ResizeObserver === "undefined") return; @@ -908,10 +925,12 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop interactionMode: ProviderInteractionMode; runtimeMode: RuntimeMode; size?: "sm" | "xs"; + hidden?: boolean; onToggleInteractionMode: () => void; onRuntimeModeChange: (mode: RuntimeMode) => void; }) { const size = props.size ?? "sm"; + const [open, setOpen] = useComposerMenuState(props.hidden); const runtimeModeOption = runtimeModeConfig[props.runtimeMode]; const RuntimeModeIcon = runtimeModeOption.icon; const interactionModeTooltip = @@ -969,6 +988,8 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop { + expect(commandProgramName(command)).toBe(program); + }); + + it.each([ + ["cd /tmp < { + expect(commandProgramName(command)).toBe(program); + }); + + it.each(["cd apps/web && [ -f package.json ]", "cd apps/web || exit 1", "cd one && cd two"])( + "falls back when no useful program follows cd: %s", + (command) => { + expect(commandProgramName(command)).toBeNull(); + }, + ); + + it.each([ + "if test -f package.json\nthen\n npm test\nfi", + "[[ -d first && -d second ]] && npm test", + 'for file in *\ndo\n echo "$file"\ndone', + "while true\ndo\n sleep 1\ndone", + "cd /tmp; build () { npm test; }", + ])("does not label commands inside multiline shell control flow: %s", (command) => { + expect(commandProgramName(command)).toBeNull(); + }); + it("bounds nested shell unwrapping", () => { let command = "git status"; for (let depth = 0; depth < 9; depth += 1) { @@ -54,4 +454,26 @@ describe("commandProgramName", () => { } expect(commandProgramName(command)).toBeNull(); }); + + it("does not spend the shell nesting budget on setup commands", () => { + const command = [ + ...Array.from({ length: 10 }, (_, index) => `export VALUE_${index}=configured`), + "npm test", + ].join("\n"); + + expect(commandProgramName(command)).toBe("npm"); + }); + + it("bounds the number of top-level setup segments", () => { + const command = [ + ...Array.from({ length: 2_000 }, (_, index) => `export VALUE_${index}=configured`), + "npm test", + ].join(";"); + + expect(commandProgramName(command)).toBeNull(); + }); + + it("bounds nested command wrappers", () => { + expect(commandProgramName(`${"command ".repeat(9)}git status`)).toBeNull(); + }); }); diff --git a/packages/client-runtime/src/work-log/commandLabel.ts b/packages/client-runtime/src/work-log/commandLabel.ts index d3cd945bf3f5..c074def03eb7 100644 --- a/packages/client-runtime/src/work-log/commandLabel.ts +++ b/packages/client-runtime/src/work-log/commandLabel.ts @@ -1,7 +1,203 @@ type CommandWrapper = "env" | "sudo"; +type CommandProgramContext = "exec" | "shell"; + +const MAX_COMMAND_SEGMENTS = 64; const SHELL_PROGRAMS = new Set(["sh", "bash", "zsh", "dash", "ash", "ksh", "fish"]); +const WINDOWS_SHELL_PROGRAMS = new Set([ + "cmd", + "cmd.exe", + "powershell", + "powershell.exe", + "pwsh", + "pwsh.exe", +]); const SHELL_OPTIONS_WITH_VALUE = new Set(["-o", "-O", "--rcfile", "--init-file"]); +const SHELL_COMMAND_WRAPPERS = new Set(["builtin", "command", "exec"]); +const SHELL_PRECOMMAND_MODIFIERS = new Set(["nocorrect", "noglob", "time"]); +const POWERSHELL_SETUP_PROGRAMS = new Set(["pop-location", "push-location", "set-location"]); +const POWERSHELL_FLAGS = new Set(["-mta", "-nologo", "-noninteractive", "-noprofile", "-sta"]); +const POWERSHELL_OPTIONS_WITH_VALUE = new Set([ + "-configurationname", + "-executionpolicy", + "-inputformat", + "-outputformat", + "-version", + "-windowstyle", + "-workingdirectory", +]); +const START_PROCESS_FLAGS = new Set([ + "-confirm", + "-debug", + "-loaduserprofile", + "-nonewwindow", + "-passthru", + "-usenewenvironment", + "-verbose", + "-wait", + "-whatif", +]); +const START_PROCESS_OPTIONS_WITH_VALUE = new Set([ + "-argumentlist", + "-credential", + "-environment", + "-erroraction", + "-errorvariable", + "-informationaction", + "-informationvariable", + "-outbuffer", + "-outvariable", + "-pipelinevariable", + "-progressaction", + "-redirectstandarderror", + "-redirectstandardinput", + "-redirectstandardoutput", + "-verb", + "-warningaction", + "-warningvariable", + "-windowstyle", + "-workingdirectory", +]); +const SKIPPABLE_SUDO_PROBES = new Set(["[", "[[", "test", "true"]); +const NON_PROGRAM_PREFIX_CHARACTERS = "<>(){}[];|&$`#!%@:"; +const NON_PROGRAM_SUFFIX_CHARACTERS = "){]}`"; + +// These tokens describe shell syntax or shell-local control flow, not a useful +// executable name. Falling back to "command" is less misleading than labels +// such as "Ran if", "Ran [", or "Ran function". +const NON_DESCRIPTIVE_SHELL_PROGRAMS = new Set([ + "!", + "#", + ".", + ":", + "[", + "[[", + "alias", + "and", + "autoload", + "begin", + "bg", + "bind", + "bindkey", + "break", + "builtin", + "caller", + "case", + "catch", + "cd", + "command", + "compgen", + "complete", + "compopt", + "continue", + "coproc", + "declare", + "dirs", + "disown", + "do", + "done", + "elif", + "else", + "enable", + "end", + "esac", + "eval", + "exec", + "exit", + "export", + "false", + "fc", + "fg", + "fi", + "finally", + "for", + "foreach", + "function", + "getopts", + "history", + "if", + "in", + "jobs", + "let", + "local", + "logout", + "mapfile", + "nocorrect", + "noglob", + "not", + "or", + "popd", + "pushd", + "read", + "readarray", + "readonly", + "repeat", + "return", + "select", + "set", + "setopt", + "shift", + "shopt", + "source", + "switch", + "suspend", + "test", + "then", + "time", + "times", + "trap", + "try", + "true", + "type", + "typeset", + "ulimit", + "umask", + "unalias", + "until", + "unset", + "unsetopt", + "wait", + "while", +]); + +// Unlike setup builtins, these can make later segments part of control flow or +// otherwise unreachable, so do not use a later program as the command label. +const TERMINAL_SHELL_PROGRAMS = new Set([ + "and", + "begin", + "break", + "case", + "catch", + "continue", + "coproc", + "do", + "done", + "elif", + "else", + "end", + "esac", + "eval", + "exec", + "exit", + "false", + "fi", + "finally", + "for", + "foreach", + "function", + "if", + "in", + "not", + "or", + "repeat", + "return", + "select", + "switch", + "then", + "try", + "until", + "while", +]); function shellCommandArgumentIndex(tokens: ReadonlyArray, start: number): number | null { for (let index = start; index < tokens.length; index += 1) { @@ -32,7 +228,9 @@ function tokenizeShellCommand(command: string): string[] | null { let current = ""; let quote: '"' | "'" | null = null; let escaping = false; + let inBackticks = false; let substitutionDepth = 0; + let parameterExpansionDepth = 0; let tokenStarted = false; for (let index = 0; index < input.length; index += 1) { @@ -45,9 +243,9 @@ function tokenizeShellCommand(command: string): string[] | null { } if (character === "\\" && quote !== "'") { const nextCharacter = input[index + 1]; - const isWindowsDrivePath = quote === null && /^[A-Za-z]:/.test(current); + const isWindowsPath = quote === null && /^(?:[A-Za-z]:|\.{1,2})(?:\\[^\s]*)?$/u.test(current); if ( - (quote === '"' || isWindowsDrivePath) && + (quote === '"' || isWindowsPath) && nextCharacter !== undefined && nextCharacter !== '"' && nextCharacter !== "\\" && @@ -63,6 +261,12 @@ function tokenizeShellCommand(command: string): string[] | null { tokenStarted = true; continue; } + if (inBackticks) { + current += character; + if (character === "`") inBackticks = false; + tokenStarted = true; + continue; + } if (quote !== null) { if (character === quote) { quote = null; @@ -72,6 +276,31 @@ function tokenizeShellCommand(command: string): string[] | null { tokenStarted = true; continue; } + if (character === "`") { + current += character; + inBackticks = true; + tokenStarted = true; + continue; + } + if (character === "$" && input[index + 1] === "{") { + current += "${"; + parameterExpansionDepth += 1; + tokenStarted = true; + index += 1; + continue; + } + if (character === "{" && parameterExpansionDepth > 0) { + current += character; + parameterExpansionDepth += 1; + tokenStarted = true; + continue; + } + if (character === "}" && parameterExpansionDepth > 0) { + current += character; + parameterExpansionDepth -= 1; + tokenStarted = true; + continue; + } if (character === "$" && input[index + 1] === "(") { current += "$("; substitutionDepth += 1; @@ -79,6 +308,12 @@ function tokenizeShellCommand(command: string): string[] | null { index += 1; continue; } + if (character === "(") { + current += character; + substitutionDepth += 1; + tokenStarted = true; + continue; + } if (character === ")" && substitutionDepth > 0) { current += character; substitutionDepth -= 1; @@ -91,7 +326,7 @@ function tokenizeShellCommand(command: string): string[] | null { continue; } if (/\s/u.test(character)) { - if (substitutionDepth > 0) { + if (substitutionDepth > 0 || parameterExpansionDepth > 0) { current += character; tokenStarted = true; continue; @@ -107,28 +342,843 @@ function tokenizeShellCommand(command: string): string[] | null { tokenStarted = true; } - if (quote !== null || escaping || substitutionDepth > 0) return null; + if ( + quote !== null || + escaping || + inBackticks || + substitutionDepth > 0 || + parameterExpansionDepth > 0 + ) { + return null; + } if (tokenStarted) tokens.push(current); return tokens; } -export function commandProgramName(command: string, depth = 0): string | null { - if (depth >= 8) return null; - const tokens = tokenizeShellCommand(command); +type ShellCommandSplit = { + readonly firstCommand: string; + readonly remainingCommand: string | null; + readonly separator: string | null; +}; + +type Heredoc = { + readonly delimiter: string; + readonly stripTabs: boolean; +}; + +type ShellSeparator = { + readonly index: number; + readonly length: number; +}; + +type ShellCommentRange = { + readonly start: number; + readonly end: number; +}; + +function commandWithoutShellComments( + command: string, + end: number, + comments: ReadonlyArray, +): string { + let result = ""; + let cursor = 0; + for (const comment of comments) { + if (comment.start >= end) break; + result += command.slice(cursor, comment.start); + cursor = Math.min(comment.end, end); + } + return result + command.slice(cursor, end); +} + +function readHeredocDelimiter( + command: string, + start: number, + stripTabs: boolean, +): { readonly heredoc: Heredoc; readonly end: number } | null { + let index = start; + while (command[index] === " " || command[index] === "\t") index += 1; + + let delimiter = ""; + let quote: '"' | "'" | null = null; + let escaping = false; + for (; index < command.length; index += 1) { + const character = command[index]!; + if (escaping) { + delimiter += character; + escaping = false; + continue; + } + if (character === "\\" && quote !== "'") { + escaping = true; + continue; + } + if (quote !== null) { + if (character === quote) quote = null; + else delimiter += character; + continue; + } + if (character === '"' || character === "'") { + quote = character; + continue; + } + if (/\s/u.test(character) || ";&|<>()".includes(character)) break; + delimiter += character; + } + + if (!delimiter || quote !== null || escaping) return null; + return { heredoc: { delimiter, stripTabs }, end: index }; +} + +function commandAfterHeredocs( + command: string, + start: number, + heredocs: ReadonlyArray, +): string | null { + let cursor = start; + for (const heredoc of heredocs) { + let foundDelimiter = false; + while (cursor <= command.length) { + const newlineIndex = command.indexOf("\n", cursor); + const lineEnd = newlineIndex === -1 ? command.length : newlineIndex; + const line = command.slice(cursor, lineEnd).replace(/\r$/u, ""); + const comparableLine = heredoc.stripTabs ? line.replace(/^\t+/u, "") : line; + cursor = newlineIndex === -1 ? command.length : newlineIndex + 1; + if (comparableLine === heredoc.delimiter) { + foundDelimiter = true; + break; + } + if (newlineIndex === -1) break; + } + if (!foundDelimiter) return null; + } + + return command.slice(cursor).trim() || null; +} + +function splitFirstShellCommand(command: string): ShellCommandSplit { + let quote: '"' | "'" | null = null; + let powerShellHereStringQuote: '"' | "'" | null = null; + let escaping = false; + let inBackticks = false; + let inComment = false; + let substitutionDepth = 0; + let parameterExpansionDepth = 0; + const heredocs: Heredoc[] = []; + const comments: ShellCommentRange[] = []; + let commentStart = 0; + let separatorBeforeHeredocs: ShellSeparator | null = null; + + for (let index = 0; index < command.length; index += 1) { + const character = command[index]!; + if (powerShellHereStringQuote !== null) { + if ( + character === powerShellHereStringQuote && + command[index + 1] === "@" && + (index === 0 || command[index - 1] === "\n") + ) { + powerShellHereStringQuote = null; + index += 1; + } + continue; + } + if (inComment) { + if (character !== "\n") continue; + inComment = false; + comments.push({ start: commentStart, end: index }); + } + if (escaping) { + escaping = false; + continue; + } + if (character === "\\" && quote !== "'") { + escaping = true; + continue; + } + if (inBackticks) { + if (character === "`") inBackticks = false; + continue; + } + if (quote !== null) { + if (character === quote) quote = null; + continue; + } + if ( + character === "@" && + (command[index + 1] === '"' || command[index + 1] === "'") && + (command[index + 2] === "\n" || (command[index + 2] === "\r" && command[index + 3] === "\n")) + ) { + powerShellHereStringQuote = command[index + 1] as '"' | "'"; + index += 1; + continue; + } + if (character === '"' || character === "'") { + quote = character; + continue; + } + if (character === "`") { + inBackticks = true; + continue; + } + if ( + character === "#" && + (index === 0 || /\s/u.test(command[index - 1]!) || ";&|(".includes(command[index - 1]!)) + ) { + inComment = true; + commentStart = index; + continue; + } + if (character === "$" && command[index + 1] === "{") { + parameterExpansionDepth += 1; + index += 1; + continue; + } + if (character === "{" && parameterExpansionDepth > 0) { + parameterExpansionDepth += 1; + continue; + } + if (character === "}" && parameterExpansionDepth > 0) { + parameterExpansionDepth -= 1; + continue; + } + if (character === "(") { + substitutionDepth += 1; + continue; + } + if (character === ")" && substitutionDepth > 0) { + substitutionDepth -= 1; + continue; + } + if (substitutionDepth > 0 || parameterExpansionDepth > 0) continue; + + if (character === "<" && command[index + 1] === "<" && command[index + 2] !== "<") { + const stripTabs = command[index + 2] === "-"; + const delimiter = readHeredocDelimiter(command, index + (stripTabs ? 3 : 2), stripTabs); + if (delimiter === null) { + return { firstCommand: command.trim(), remainingCommand: null, separator: null }; + } + heredocs.push(delimiter.heredoc); + index = delimiter.end - 1; + continue; + } + + const isDoubleOperator = + (character === "&" && command[index + 1] === "&") || + (character === "|" && (command[index + 1] === "|" || command[index + 1] === "&")); + const isRedirectionAmpersand = + character === "&" && + (command[index - 1] === ">" || command[index - 1] === "<" || command[index + 1] === ">"); + if ((!isDoubleOperator && !";&|\n".includes(character)) || isRedirectionAmpersand) continue; + + if (character === "\n" && heredocs.length > 0) { + const separator = separatorBeforeHeredocs; + const firstCommand = commandWithoutShellComments( + command, + separator?.index ?? index, + comments, + ).trimStart(); + const commandBeforeHeredocs = separator + ? command.slice(separator.index + separator.length, index).trim() + : ""; + const commandFollowingHeredocs = commandAfterHeredocs(command, index + 1, heredocs); + const remainingCommand = [commandBeforeHeredocs, commandFollowingHeredocs] + .filter((part): part is string => Boolean(part)) + .join("\n"); + return { + firstCommand, + remainingCommand: remainingCommand || null, + separator: separator + ? command.slice(separator.index, separator.index + separator.length) + : "\n", + }; + } + if (heredocs.length > 0) { + separatorBeforeHeredocs ??= { + index, + length: isDoubleOperator ? 2 : 1, + }; + if (isDoubleOperator) index += 1; + continue; + } + + const firstCommand = commandWithoutShellComments(command, index, comments).trimStart(); + let nextCommandIndex = index + (isDoubleOperator ? 2 : 1); + while (/\s/u.test(command[nextCommandIndex] ?? "")) nextCommandIndex += 1; + const nextCommand = command.slice(nextCommandIndex).trim(); + return { + firstCommand, + remainingCommand: nextCommand || null, + separator: isDoubleOperator ? command.slice(index, index + 2) : character, + }; + } + + if (inComment) comments.push({ start: commentStart, end: command.length }); + return { + firstCommand: commandWithoutShellComments(command, command.length, comments).trim(), + remainingCommand: null, + separator: null, + }; +} + +function commandWithoutLeadingShellComments(command: string): string | null { + let remainingCommand = command.trimStart(); + while (remainingCommand.startsWith("#")) { + const newlineIndex = remainingCommand.indexOf("\n"); + if (newlineIndex === -1) return null; + remainingCommand = remainingCommand.slice(newlineIndex + 1).trimStart(); + } + return remainingCommand || null; +} + +function withoutShellLineContinuations(command: string): string { + let normalizedCommand = ""; + let quote: '"' | "'" | null = null; + let escaping = false; + + for (let index = 0; index < command.length; index += 1) { + const character = command[index]!; + if (escaping) { + normalizedCommand += character; + escaping = false; + continue; + } + if (character === "\\" && quote !== "'") { + if (command[index + 1] === "\n") { + index += 1; + continue; + } + if (command[index + 1] === "\r" && command[index + 2] === "\n") { + index += 2; + continue; + } + normalizedCommand += character; + escaping = true; + continue; + } + if (quote !== null) { + if (character === quote) quote = null; + } else if (character === '"' || character === "'") { + quote = character; + } + normalizedCommand += character; + } + + return normalizedCommand; +} + +function indexAfterShellRedirection(tokens: ReadonlyArray, index: number): number | null { + const token = tokens[index]; + if (!token || /^[<>]\(/u.test(token)) return null; + const match = token.match( + /^(?:(?:(?:\d+|\*|\{[A-Za-z_][A-Za-z0-9_]*\})?(?:<<<|<<-|<<|<>|>>|>\||<&|>&|<|>))|&>>|&>)(.*)$/u, + ); + if (!match) return null; + if (match[1]) return index + 1; + return tokens[index + 1] === undefined ? tokens.length + 1 : index + 2; +} + +function serializeShellTokens(tokens: ReadonlyArray): string { + return tokens.map((token) => `'${token.replaceAll("'", "'\\''")}'`).join(" "); +} + +function transparentWrapperCommandIndex( + wrapper: string, + tokens: ReadonlyArray, + index: number, +): number | null { + if (wrapper === "bundle") { + return tokens[index + 1] === "exec" && tokens[index + 2] !== undefined ? index + 2 : null; + } + + if (wrapper === "nohup") { + let targetIndex = index + 1; + if (tokens[targetIndex] === "--") targetIndex += 1; + const target = tokens[targetIndex]; + return target && !target.startsWith("-") ? targetIndex : null; + } + + if (wrapper === "script") { + // BSD `script` takes an output file before the optional command. Requiring + // an option and both operands avoids guessing about a plain `script file`. + return /^-[adkpqr]+$/u.test(tokens[index + 1] ?? "") && tokens[index + 3] !== undefined + ? index + 3 + : null; + } + + if (wrapper === "arch") { + if (/^-(?:arm64|arm64e|i386|x86_64)$/u.test(tokens[index + 1] ?? "")) { + return tokens[index + 2] !== undefined ? index + 2 : null; + } + return tokens[index + 1] === "-arch" && tokens[index + 3] !== undefined ? index + 3 : null; + } + + if (wrapper === "timeout" || wrapper === "gtimeout") { + return /^(?:\d+(?:\.\d*)?|\.\d+)[smhd]?$/u.test(tokens[index + 1] ?? "") && + tokens[index + 2] !== undefined + ? index + 2 + : null; + } + + return null; +} + +function staticProgramName(value: string): string | null { + const trimmedValue = value.trim(); + if (!trimmedValue || /^[A-Za-z][A-Za-z0-9+.-]*:(?![\\/])/u.test(trimmedValue)) return null; + const program = trimmedValue.split(/[\\/]/u).at(-1); + if ( + !program || + (/\s/u.test(program) && !/[\\/]/u.test(trimmedValue)) || + NON_PROGRAM_PREFIX_CHARACTERS.includes(program[0] ?? "") || + NON_PROGRAM_SUFFIX_CHARACTERS.includes(program.at(-1) ?? "") + ) { + return null; + } + return program; +} + +function leadingPowerShellLiteral(command: string): string | null { + const input = command.trimStart(); + const quote = input[0]; + if (quote !== '"' && quote !== "'") return staticProgramName(input.match(/^\S+/u)?.[0] ?? ""); + + let value = ""; + for (let index = 1; index < input.length; index += 1) { + const character = input[index]!; + if (character === "`" && input[index + 1] !== undefined) { + value += input[index + 1]; + index += 1; + continue; + } + if (character === quote) { + if (quote === "'" && input[index + 1] === "'") { + value += "'"; + index += 1; + continue; + } + return staticProgramName(value); + } + value += character; + } + return null; +} + +function powerShellCallOperatorProgramName(command: string): string | null | undefined { + const match = command.match(/^\s*&\s+([\s\S]*)$/u); + return match ? leadingPowerShellLiteral(match[1]!) : undefined; +} + +type PowerShellAssignment = { + readonly matched: boolean; + readonly program: string | null; +}; + +function powerShellAssignmentProgramName( + command: string, + depth: number, + remainingCommand: string | null, + segmentsRemaining: number, +): PowerShellAssignment { + const assignment = command.match( + /^\s*\$(?:(env|global|local|script):)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*([\s\S]*)$/iu, + ); + if (!assignment) return { matched: false, program: null }; + + // Environment assignments are setup. Their right-hand side is a value, not + // a command, so prefer the next top-level segment when one exists. + if (assignment[1]?.toLowerCase() === "env") { + return { + matched: true, + program: remainingCommand + ? commandProgramNameInternal(remainingCommand, depth, "shell", segmentsRemaining - 1) + : null, + }; + } + + const value = assignment[2]!.trim(); + // The POSIX-oriented segment splitter does not balance PowerShell arrays or + // hashtables. Do not mistake a key after an internal semicolon for a command. + if (/^(?:\[ordered\]\s*)?@\s*[{(]/iu.test(value)) { + return { matched: true, program: null }; + } + const calledProgram = powerShellCallOperatorProgramName(value); + if (calledProgram !== undefined) return { matched: true, program: calledProgram }; + + const directCommand = value.match(/^(?:@?\(\s*)?([A-Za-z][A-Za-z0-9_.-]*)\b/u)?.[1]; + if (directCommand && !NON_DESCRIPTIVE_SHELL_PROGRAMS.has(directCommand.toLowerCase())) { + const parsedCommand = commandProgramNameInternal(value, depth + 1, "shell", segmentsRemaining); + return { matched: true, program: parsedCommand ?? directCommand }; + } + + return { + matched: true, + program: remainingCommand + ? commandProgramNameInternal(remainingCommand, depth, "shell", segmentsRemaining - 1) + : null, + }; +} + +type WindowsShellPayload = { + readonly matched: boolean; + readonly program: string | null; +}; + +function windowsShellPayloadProgramName( + shell: string, + tokens: ReadonlyArray, + start: number, + depth: number, + remainingCommand: string | null, + separator: string | null, + segmentsRemaining: number, +): WindowsShellPayload { + const parsePayload = (payload: string | undefined): string | null => { + if (!payload) return null; + const command = + remainingCommand && separator ? `${payload} ${separator} ${remainingCommand}` : payload; + return commandProgramNameInternal(command, depth + 1, "shell", segmentsRemaining); + }; + + if (shell === "cmd" || shell === "cmd.exe") { + for (let index = start; index < tokens.length; index += 1) { + const option = tokens[index]!.toLowerCase(); + if (option !== "/c" && option !== "/k") continue; + const payload = tokens[index + 1]; + return { + matched: true, + program: parsePayload(payload), + }; + } + return { matched: false, program: null }; + } + + for (let index = start; index < tokens.length; index += 1) { + const option = tokens[index]!.toLowerCase(); + if (option === "-command" || option === "-c") { + const payload = tokens[index + 1]; + return { + matched: true, + program: parsePayload(payload), + }; + } + if (option === "-file" || option === "-f") { + return { matched: true, program: staticProgramName(tokens[index + 1] ?? "") }; + } + if (option === "-encodedcommand" || option === "-enc" || option === "-e") { + return { matched: true, program: null }; + } + if (POWERSHELL_OPTIONS_WITH_VALUE.has(option)) { + index += 1; + continue; + } + if (POWERSHELL_FLAGS.has(option)) continue; + if (!option.startsWith("-")) { + return { matched: true, program: staticProgramName(tokens[index]!) }; + } + } + return { matched: false, program: null }; +} + +function startProcessProgramName(tokens: ReadonlyArray, start: number): string | null { + for (let index = start; index < tokens.length; index += 1) { + const token = tokens[index]!; + const option = token.toLowerCase(); + if (option === "-filepath") return staticProgramName(tokens[index + 1] ?? ""); + if (START_PROCESS_FLAGS.has(option)) continue; + if (START_PROCESS_OPTIONS_WITH_VALUE.has(option)) { + if (tokens[index + 1] === undefined) return null; + index += 1; + continue; + } + if (token.startsWith("-")) return null; + return staticProgramName(token); + } + return null; +} + +function literalAssignmentProgram( + token: string, +): { readonly name: string; readonly program: string | null } | null { + const assignment = token.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/su); + if (!assignment) return null; + const name = assignment[1]!; + let value = assignment[2]!.trim(); + if (!value || /[$`]/u.test(value)) return { name, program: null }; + + if (value.startsWith("(") && value.endsWith(")")) { + const arrayTokens = tokenizeShellCommand(value.slice(1, -1)); + value = arrayTokens?.[0] ?? ""; + } else if (/\s/u.test(value) && !/[\\/]/u.test(value)) { + return { name, program: null }; + } + + return { name, program: staticProgramName(value) }; +} + +function referencedCommandAlias(token: string): string | null { + const reference = token.match( + /^\$(?:([A-Za-z_][A-Za-z0-9_]*)|\{([A-Za-z_][A-Za-z0-9_]*)(?:\[@\])?\})$/u, + ); + return reference?.[1] ?? reference?.[2] ?? null; +} + +// Recover only literal aliases declared by an earlier top-level shell segment. +// This covers common `SSH=(ssh ...)` and `TOOL=/path/to/tool` forms without +// evaluating expansions or trying to model general shell state. +function literalCommandAliasProgramName(command: string): string | null { + const aliases = new Map(); + let remainingCommand: string | null = command; + let controlFlowDepth = 0; + + for (let segmentCount = 0; remainingCommand && segmentCount < 64; segmentCount += 1) { + const commandWithoutComments = commandWithoutLeadingShellComments(remainingCommand); + if (commandWithoutComments === null) return null; + const commandSplit = splitFirstShellCommand(commandWithoutComments); + const tokens = tokenizeShellCommand(withoutShellLineContinuations(commandSplit.firstCommand)); + if (tokens === null) return null; + + let commandIndex = tokens[0] === "do" || tokens[0] === "then" ? 1 : 0; + while (commandIndex < tokens.length) { + const indexAfterRedirection = indexAfterShellRedirection(tokens, commandIndex); + if (indexAfterRedirection !== null && indexAfterRedirection <= tokens.length) { + commandIndex = indexAfterRedirection; + continue; + } + if (/^[A-Za-z_][A-Za-z0-9_]*\+?=/u.test(tokens[commandIndex] ?? "")) { + commandIndex += 1; + continue; + } + break; + } + + const aliasName = referencedCommandAlias(tokens[commandIndex] ?? ""); + const aliasedProgram = aliasName ? aliases.get(aliasName) : undefined; + if (aliasedProgram) return aliasedProgram; + + const leadingToken = tokens[0]; + if (leadingToken === "fi" || leadingToken === "done" || leadingToken === "esac") { + controlFlowDepth = Math.max(0, controlFlowDepth - 1); + } + if ( + leadingToken === "if" || + leadingToken === "for" || + leadingToken === "while" || + leadingToken === "until" || + leadingToken === "select" || + leadingToken === "case" + ) { + controlFlowDepth += 1; + } + + if (controlFlowDepth === 0 && leadingToken === "unset") { + for (const name of tokens.slice(1)) aliases.delete(name); + } + + const assignmentStart = tokens[0] === "export" ? 1 : 0; + const assignments = tokens.slice(assignmentStart).map(literalAssignmentProgram); + if ( + controlFlowDepth === 0 && + assignments.length > 0 && + assignments.every((assignment) => assignment !== null) + ) { + for (const assignment of assignments) { + if (assignment.program) aliases.set(assignment.name, assignment.program); + else aliases.delete(assignment.name); + } + } + + remainingCommand = commandSplit.remainingCommand; + } + + return null; +} + +function wrappedShellCommandProgramName( + wrapper: string, + tokens: ReadonlyArray, + start: number, + depth: number, + remainingCommand: string | null, + segmentsRemaining: number, +): string | null { + let index = start; + + if (wrapper === "command") { + while (index < tokens.length) { + const option = tokens[index]!; + if (option === "--") { + index += 1; + break; + } + if (!option.startsWith("-") || option === "-") break; + if (option !== "-p") return null; + index += 1; + } + } else if (wrapper === "builtin") { + if (tokens[index] === "--") index += 1; + else if (tokens[index]?.startsWith("-")) return null; + } else if (wrapper === "exec") { + while (index < tokens.length) { + const option = tokens[index]!; + if (option === "--") { + index += 1; + break; + } + if (option === "-a") { + if (tokens[index + 1] === undefined) return null; + index += 2; + continue; + } + if (/^-a.+/u.test(option) || /^-[cl]+$/u.test(option)) { + index += 1; + continue; + } + if (option.startsWith("-") && option !== "-") return null; + break; + } + } + + const wrappedTokens = tokens.slice(index); + if (wrappedTokens.length === 0) return null; + let targetIndex = 0; + while (targetIndex < wrappedTokens.length) { + const indexAfterRedirection = indexAfterShellRedirection(wrappedTokens, targetIndex); + if (indexAfterRedirection === null || indexAfterRedirection > wrappedTokens.length) break; + targetIndex = indexAfterRedirection; + } + const target = wrappedTokens[targetIndex]; + if (target && /^[A-Za-z_][A-Za-z0-9_]*\+?=/u.test(target)) return null; + + const wrappedProgram = commandProgramNameInternal( + serializeShellTokens(wrappedTokens), + depth + 1, + wrapper === "exec" ? "exec" : "shell", + segmentsRemaining, + ); + if (wrappedProgram !== null) return wrappedProgram; + + if ( + wrapper !== "exec" && + target && + target === target.toLowerCase() && + NON_DESCRIPTIVE_SHELL_PROGRAMS.has(target) && + !TERMINAL_SHELL_PROGRAMS.has(target) && + remainingCommand + ) { + return commandProgramNameInternal(remainingCommand, depth, "shell", segmentsRemaining - 1); + } + return null; +} + +function parseCommandProgramName( + command: string, + depth: number, + context: CommandProgramContext, + segmentsRemaining: number, +): string | null { + if (depth >= 8 || segmentsRemaining <= 0) return null; + const commandWithoutComments = commandWithoutLeadingShellComments(command); + if (commandWithoutComments === null) return null; + if ( + /^(?:catch|finally|for|foreach|function|if|param|switch|try|while)\s*[{(]/iu.test( + commandWithoutComments, + ) + ) { + return null; + } + if (/^[A-Za-z_][A-Za-z0-9_]*\s*\(\s*\)\s*\{/u.test(commandWithoutComments)) return null; + // `&&` and `||` inside a `[[ ... ]]` expression are not top-level command + // separators. Keep the label conservative instead of scanning the test body. + if (commandWithoutComments.startsWith("[[")) return null; + const commandSplit = splitFirstShellCommand(commandWithoutComments); + if (/^@["'](?:\r?\n)/u.test(commandSplit.firstCommand.trimStart())) { + return commandSplit.remainingCommand + ? commandProgramNameInternal( + commandSplit.remainingCommand, + depth, + "shell", + segmentsRemaining - 1, + ) + : null; + } + const powerShellAssignment = powerShellAssignmentProgramName( + commandSplit.firstCommand, + depth, + commandSplit.remainingCommand, + segmentsRemaining, + ); + if (powerShellAssignment.matched) return powerShellAssignment.program; + const windowsPath = commandSplit.firstCommand.match( + /^\s*((?:\.{1,2}|%[A-Za-z_][A-Za-z0-9_]*%|\$env:[A-Za-z_][A-Za-z0-9_]*)\\\S+)/iu, + )?.[1]; + if (windowsPath) return staticProgramName(windowsPath); + const tokens = tokenizeShellCommand(withoutShellLineContinuations(commandSplit.firstCommand)); if (tokens === null) return null; + const firstCharacter = commandSplit.firstCommand.trimStart()[0]; + if ( + tokens.length === 1 && + (firstCharacter === '"' || firstCharacter === "'") && + /[\s()=]/u.test(tokens[0] ?? "") && + !/[\\/]/u.test(tokens[0] ?? "") + ) { + return commandSplit.remainingCommand + ? commandProgramNameInternal( + commandSplit.remainingCommand, + depth, + context, + segmentsRemaining - 1, + ) + : null; + } let index = 0; let wrapper: CommandWrapper | null = null; + let executionContext = context; + let sawAssignment = false; + let sawRedirection = false; while (index < tokens.length) { const token = tokens[index]; if (!token) return null; - if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { + const indexAfterRedirection = indexAfterShellRedirection(tokens, index); + if (indexAfterRedirection !== null) { + if (indexAfterRedirection > tokens.length) return null; + sawRedirection = true; + index = indexAfterRedirection; + continue; + } + if (/^[A-Za-z_][A-Za-z0-9_]*\+?=/.test(token)) { + sawAssignment = true; index += 1; continue; } + if (executionContext === "shell" && token === ":") { + return commandSplit.remainingCommand + ? commandProgramNameInternal( + commandSplit.remainingCommand, + depth, + executionContext, + segmentsRemaining - 1, + ) + : null; + } + if ( + NON_PROGRAM_PREFIX_CHARACTERS.includes(token[0] ?? "") && + !(token.startsWith("$") && token.includes("/")) + ) { + return executionContext === "shell" && token.startsWith("[") && commandSplit.remainingCommand + ? commandProgramNameInternal( + commandSplit.remainingCommand, + depth, + executionContext, + segmentsRemaining - 1, + ) + : null; + } const tokenProgram = token.split(/[\\/]/).at(-1); + const isUnqualifiedToken = token === tokenProgram; if (tokenProgram === "env" || tokenProgram === "sudo") { wrapper = tokenProgram; + executionContext = "exec"; index += 1; continue; } @@ -140,10 +1190,17 @@ export function commandProgramName(command: string, depth = 0): string | null { if (wrapper !== null && token.startsWith("-")) { if (wrapper === "env" && (token === "-S" || token === "--split-string")) { const splitCommand = tokens[index + 1]; - return splitCommand ? commandProgramName(splitCommand, depth + 1) : null; + return splitCommand + ? commandProgramNameInternal(splitCommand, depth + 1, executionContext, segmentsRemaining) + : null; } if (wrapper === "env" && token.startsWith("--split-string=")) { - return commandProgramName(token.slice("--split-string=".length), depth + 1); + return commandProgramNameInternal( + token.slice("--split-string=".length), + depth + 1, + executionContext, + segmentsRemaining, + ); } if (COMMAND_WRAPPER_OPTIONS_WITH_VALUE[wrapper].has(token)) { if (tokens[index + 1] === undefined) return null; @@ -182,11 +1239,131 @@ export function commandProgramName(command: string, depth = 0): string | null { const scriptIndex = shellCommandArgumentIndex(tokens, index + 1); if (scriptIndex !== null) { const script = tokens[scriptIndex]; - return script ? commandProgramName(script, depth + 1) : null; + return script + ? commandProgramNameInternal(script, depth + 1, "shell", segmentsRemaining) + : null; + } + } + const lowerTokenProgram = tokenProgram?.toLowerCase(); + if (lowerTokenProgram && WINDOWS_SHELL_PROGRAMS.has(lowerTokenProgram)) { + const payload = windowsShellPayloadProgramName( + lowerTokenProgram, + tokens, + index + 1, + depth, + commandSplit.remainingCommand, + commandSplit.separator, + segmentsRemaining, + ); + if (payload.matched) return payload.program; + } + if (lowerTokenProgram === "start-process") { + const startedProgram = startProcessProgramName(tokens, index + 1); + if (startedProgram !== null) return startedProgram; + } + if ( + executionContext === "shell" && + isUnqualifiedToken && + tokenProgram && + SHELL_PRECOMMAND_MODIFIERS.has(tokenProgram) + ) { + index += 1; + if (tokenProgram === "time" && tokens[index] === "-p") index += 1; + if (tokens[index]?.startsWith("-")) return null; + continue; + } + if (isUnqualifiedToken && tokenProgram) { + const targetIndex = transparentWrapperCommandIndex(tokenProgram, tokens, index); + if (targetIndex !== null) { + const wrappedProgram = commandProgramNameInternal( + serializeShellTokens(tokens.slice(targetIndex)), + depth + 1, + "exec", + segmentsRemaining, + ); + if (wrappedProgram !== null) return wrappedProgram; } } + if (isUnqualifiedToken && tokenProgram && SHELL_COMMAND_WRAPPERS.has(tokenProgram)) { + return wrappedShellCommandProgramName( + tokenProgram, + tokens, + index + 1, + depth, + commandSplit.remainingCommand, + segmentsRemaining, + ); + } + if ( + (executionContext === "shell" || + (wrapper === "sudo" && tokenProgram && SKIPPABLE_SUDO_PROBES.has(tokenProgram))) && + isUnqualifiedToken && + tokenProgram && + NON_DESCRIPTIVE_SHELL_PROGRAMS.has(tokenProgram) && + (!TERMINAL_SHELL_PROGRAMS.has(tokenProgram) || + (tokenProgram === "false" && commandSplit.separator !== "&&")) && + commandSplit.remainingCommand + ) { + return commandProgramNameInternal( + commandSplit.remainingCommand, + depth, + "shell", + segmentsRemaining - 1, + ); + } + if ( + executionContext === "shell" && + isUnqualifiedToken && + lowerTokenProgram && + POWERSHELL_SETUP_PROGRAMS.has(lowerTokenProgram) && + commandSplit.remainingCommand + ) { + return commandProgramNameInternal( + commandSplit.remainingCommand, + depth, + "shell", + segmentsRemaining - 1, + ); + } + if ( + !tokenProgram || + (isUnqualifiedToken && NON_DESCRIPTIVE_SHELL_PROGRAMS.has(tokenProgram)) || + NON_PROGRAM_PREFIX_CHARACTERS.includes(tokenProgram[0] ?? "") || + NON_PROGRAM_SUFFIX_CHARACTERS.includes(tokenProgram.at(-1) ?? "") || + tokenProgram.endsWith("()") || + /^[A-Za-z_][A-Za-z0-9_]*\(\)\{$/u.test(tokenProgram) + ) { + return null; + } return tokenProgram || null; } + if ((sawAssignment || sawRedirection) && wrapper === null && commandSplit.remainingCommand) { + return commandProgramNameInternal( + commandSplit.remainingCommand, + depth, + executionContext, + segmentsRemaining - 1, + ); + } return null; } + +function commandProgramNameInternal( + command: string, + depth: number, + context: CommandProgramContext, + segmentsRemaining = MAX_COMMAND_SEGMENTS, +): string | null { + if (segmentsRemaining <= 0) return null; + const calledProgram = powerShellCallOperatorProgramName(command); + if (calledProgram !== undefined) return calledProgram; + return ( + parseCommandProgramName(command, depth, context, segmentsRemaining) ?? + (context === "shell" ? literalCommandAliasProgramName(command) : null) + ); +} + +export function commandProgramName(command: string, depth = 0): string | null { + return commandProgramNameInternal(command, depth, "shell", MAX_COMMAND_SEGMENTS); +} diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index d39be34bf6e9..4b63b877923f 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -198,6 +198,8 @@ const VcsStatusChangeRequest = Schema.Struct({ baseRef: TrimmedNonEmptyStringSchema, headRef: TrimmedNonEmptyStringSchema, state: VcsStatusChangeRequestState, + /** Optional for compatibility with older servers and providers. */ + isDraft: Schema.optional(Schema.Boolean), /** * Last provider-side activity (ISO). For a merged/closed change request * this bounds when it reached that state, so clients can tell a PR that diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index fb762b9c172e..2f6307d6a57c 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -299,6 +299,8 @@ export type ThreadStartedPayload = typeof ThreadStartedPayload.Type; const ThreadStateChangedPayload = Schema.Struct({ state: RuntimeThreadState, + beforeTokens: Schema.optional(NonNegativeInt), + afterTokens: Schema.optional(NonNegativeInt), detail: Schema.optional(Schema.Unknown), }); export type ThreadStateChangedPayload = typeof ThreadStateChangedPayload.Type; diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 05a54ea94574..0478b113d61d 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -6,7 +6,7 @@ import { NonNegativeInt, TrimmedNonEmptyString, } from "./baseSchemas.ts"; -import { ProviderDriverKind } from "./providerInstance.ts"; +import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; /** @@ -27,6 +27,17 @@ export const ServerProviderUsageWindow = Schema.Struct({ }); export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type; +/** + * Reset credits a provider banks on the account. Codex grants these when it + * has rate-limited the user unfairly; redeeming one clears the current + * windows. Only present when the provider reports them at all. + */ +export const ServerProviderResetCredits = Schema.Struct({ + availableCount: NonNegativeInt, + nextExpiresAt: Schema.optional(IsoDateTime), +}); +export type ServerProviderResetCredits = typeof ServerProviderResetCredits.Type; + /** * Subscription usage the provider knows about the signed-in account. * @@ -37,6 +48,7 @@ export type ServerProviderUsageWindow = typeof ServerProviderUsageWindow.Type; export const ServerProviderUsageLimits = Schema.Struct({ checkedAt: IsoDateTime, windows: ForwardCompatibleArray(ServerProviderUsageWindow), + resetCredits: Schema.optional(ServerProviderResetCredits), unavailable: Schema.optional( Schema.Struct({ reason: Schema.Literals(["unsupported", "probeFailed"]), @@ -90,3 +102,22 @@ export type UsageLimitSourceSnapshot = typeof UsageLimitSourceSnapshot.Type; export const UsageLimitSourceSnapshots = ForwardCompatibleArray(UsageLimitSourceSnapshot); export type UsageLimitSourceSnapshots = typeof UsageLimitSourceSnapshots.Type; + +export const ProviderConsumeResetCreditInput = Schema.Struct({ + instanceId: ProviderInstanceId, +}); +export type ProviderConsumeResetCreditInput = typeof ProviderConsumeResetCreditInput.Type; + +/** Mirrors Codex's own outcome set; other providers map onto it. */ +export const ProviderConsumeResetCreditOutcome = Schema.Literals([ + "reset", + "nothingToReset", + "noCredit", + "alreadyRedeemed", +]); +export type ProviderConsumeResetCreditOutcome = typeof ProviderConsumeResetCreditOutcome.Type; + +export const ProviderConsumeResetCreditResult = Schema.Struct({ + outcome: ProviderConsumeResetCreditOutcome, +}); +export type ProviderConsumeResetCreditResult = typeof ProviderConsumeResetCreditResult.Type; diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 04c9d53cf777..f766578bb1a3 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -634,6 +634,8 @@ export const PullRequestSummary = Schema.Struct({ title: TrimmedNonEmptyString, url: TrimmedNonEmptyString, state: PullRequestState, + /** Present when the host says the open pull request is still a draft. */ + isDraft: Schema.optional(Schema.Boolean), headBranch: TrimmedNonEmptyString, baseBranch: TrimmedNonEmptyString, updatedAt: IsoDateTime, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 84634664fa37..f7f2c2b6faa7 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1,7 +1,7 @@ import * as Schema from "effect/Schema"; import * as Rpc from "effect/unstable/rpc/Rpc"; import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; -import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ProviderAuthCancelInput, ProviderAuthCompleteInput, @@ -206,6 +206,10 @@ import { ResourceTelemetryRetryResult, ResourceTelemetrySnapshot, } from "./resourceTelemetry.ts"; +import { + ProviderConsumeResetCreditInput, + ProviderConsumeResetCreditResult, +} from "./providerUsageLimits.ts"; import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; import { @@ -243,6 +247,7 @@ export const WS_METHODS = { // Provider methods providerUploadFeedback: "provider.uploadFeedback", providerAuthStart: "provider.auth.start", + providerConsumeResetCredit: "provider.consumeResetCredit", providerAuthComplete: "provider.auth.complete", providerAuthCancel: "provider.auth.cancel", providerAuthLogout: "provider.auth.logout", @@ -338,6 +343,7 @@ export const WS_METHODS = { pullRequestsSetThreadResolution: "pullRequests.setThreadResolution", pullRequestsSetReaction: "pullRequests.setReaction", pullRequestsInvalidate: "pullRequests.invalidate", + pullRequestsSubscribeRefreshes: "pullRequests.subscribeRefreshes", pullRequestsReviewerCandidates: "pullRequests.reviewerCandidates", pullRequestsRequestReviewers: "pullRequests.requestReviewers", pullRequestsLabelCandidates: "pullRequests.labelCandidates", @@ -410,6 +416,12 @@ export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvide const ProviderSetupRpcError = Schema.Union([ProviderSetupError, EnvironmentAuthorizationError]); +export const WsProviderConsumeResetCreditRpc = Rpc.make(WS_METHODS.providerConsumeResetCredit, { + payload: ProviderConsumeResetCreditInput, + success: ProviderConsumeResetCreditResult, + error: ProviderSetupRpcError, +}); + export const WsProviderAuthStartRpc = Rpc.make(WS_METHODS.providerAuthStart, { payload: ProviderSetupInput, success: ProviderAuthState, @@ -703,6 +715,16 @@ export const WsPullRequestsInvalidateRpc = Rpc.make(WS_METHODS.pullRequestsInval error: PullRequestRpcError, }); +export const WsPullRequestsSubscribeRefreshesRpc = Rpc.make( + WS_METHODS.pullRequestsSubscribeRefreshes, + { + payload: Schema.Struct({}), + success: NonNegativeInt, + error: EnvironmentAuthorizationError, + stream: true, + }, +); + /** * Read on its own rather than as part of the detail: the people who may be asked are only wanted * once somebody opens the menu, and reading them with every change request would spend a request @@ -1157,6 +1179,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, WsServerRefreshProvidersRpc, WsServerUpdateProviderRpc, + WsProviderConsumeResetCreditRpc, WsProviderAuthStartRpc, WsProviderAuthCompleteRpc, WsProviderAuthCancelRpc, @@ -1203,6 +1226,7 @@ export const WsRpcGroup = RpcGroup.make( WsPullRequestsSetThreadResolutionRpc, WsPullRequestsSetReactionRpc, WsPullRequestsInvalidateRpc, + WsPullRequestsSubscribeRefreshesRpc, WsPullRequestsReviewerCandidatesRpc, WsPullRequestsRequestReviewersRpc, WsPullRequestsLabelCandidatesRpc, diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 104aadd9161f..be3d70aefadd 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -29,6 +29,8 @@ export const ChangeRequest = Schema.Struct({ baseRefName: TrimmedNonEmptyString, headRefName: TrimmedNonEmptyString, state: ChangeRequestState, + /** Present when the provider can tell that an open change request is still a draft. */ + isDraft: Schema.optional(Schema.Boolean), updatedAt: Schema.Option(Schema.DateTimeUtc), isCrossRepository: Schema.optional(Schema.Boolean), headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 33f180ac9d6c..83ac6906c61e 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -1,4 +1,11 @@ -import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type UsageLimitSourceAccount, + UsageLimitSourceId, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { @@ -141,12 +148,130 @@ describe("collectLimitsGroups", () => { describe("collectLimitSources", () => { const source = { - id: "cliproxy-hub" as never, + id: UsageLimitSourceId.make("cliproxy-hub"), kind: "cliproxy" as const, label: "hub", checkedAt: "2026-09-03T11:00:00.000Z", accounts: [], }; + const limits = { checkedAt: source.checkedAt, windows: [window] }; + const account: UsageLimitSourceAccount = { + id: "codex-personal", + driver: ProviderDriverKind.make("codex"), + email: "person@example.com", + plan: "ChatGPT Pro Subscription", + usageLimits: limits, + }; + const native = provider({ + displayName: "Personal", + auth: { status: "authenticated", email: account.email }, + usageLimits: { ...limits, resetCredits: { availableCount: 2 } }, + }); + + function presentations( + providers: readonly ServerProvider[], + accounts: readonly UsageLimitSourceAccount[] = [account], + ) { + return new Map([ + [ + EnvironmentId.make("env-a"), + { + entry: { target: { label: "Laptop" } }, + serverConfig: { providers, usageLimitSources: [{ ...source, accounts }] }, + }, + ], + ]); + } + + it.each(["codex", "claudeAgent"])( + "prefers native %s limits by email without changing provider rows or source snapshots", + (kind) => { + const driver = ProviderDriverKind.make(kind); + const first = { ...native, driver }; + const second = { ...first, instanceId: ProviderInstanceId.make("work") }; + const accounts = [{ ...account, driver, email: " Person@Example.COM " }]; + const input = presentations([first, second], accounts); + + expect(collectLimitSources(input)).toMatchObject([{ accounts: [], hiddenAccountCount: 1 }]); + expect(collectLimitsGroups(input)[0]?.providers).toEqual([first, second]); + expect(accounts).toHaveLength(1); + expect(first.usageLimits?.resetCredits?.availableCount).toBe(2); + }, + ); + + it("matches across environments even when the hub is visited before the native provider", () => { + const input = presentations([]); + input.set(EnvironmentId.make("env-b"), { + entry: { target: { label: "Desktop" } }, + serverConfig: { providers: [native], usageLimitSources: [] }, + }); + + expect(collectLimitSources(input)).toMatchObject([ + { accounts: [], hiddenAccountCount: 1, environmentId: "env-a" }, + ]); + }); + + it("keeps other providers, other emails, and unidentified accounts with the same plan", () => { + const accounts = [ + account, + { ...account, id: "other-provider", driver: ProviderDriverKind.make("claudeAgent") }, + { ...account, id: "other-email", email: "other@example.com" }, + { ...account, id: "unknown-email", email: undefined }, + ]; + + expect(collectLimitSources(presentations([native], accounts))).toMatchObject([ + { accounts: accounts.slice(1), hiddenAccountCount: 1 }, + ]); + expect( + collectLimitSources( + presentations([{ ...native, auth: { status: "authenticated" } }], accounts), + )[0]?.accounts, + ).toEqual(accounts); + }); + + it.each([ + { enabled: false }, + { installed: false }, + { availability: "unavailable" }, + { usageLimits: undefined }, + { usageLimits: { ...limits, windows: [] } }, + { usageLimits: { ...limits, unavailable: { reason: "probeFailed" } } }, + { usageLimits: { ...limits, unavailable: { reason: "unsupported" } } }, + ] satisfies Partial[])( + "retains hub limits when the native provider cannot show them: %j", + (overrides) => { + expect(collectLimitSources(presentations([{ ...native, ...overrides }]))).toMatchObject([ + { accounts: [account], hiddenAccountCount: 0 }, + ]); + }, + ); + + it("restores the hub account when the matching provider disappears", () => { + const input = presentations([native]); + expect(collectLimitSources(input)[0]?.accounts).toEqual([]); + input.delete(EnvironmentId.make("env-a")); + for (const [id, entry] of presentations([])) input.set(id, entry); + + expect(collectLimitSources(input)[0]?.accounts).toEqual([account]); + }); + + it("keeps source errors and genuinely empty sources distinguishable from hidden accounts", () => { + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + entry: { target: { label: "Laptop" } }, + serverConfig: { + providers: [native], + usageLimitSources: [{ ...source, error: "Hub unavailable" }], + }, + }, + ], + ]); + expect(collectLimitSources(input)).toMatchObject([ + { accounts: [], hiddenAccountCount: 0, error: "Hub unavailable" }, + ]); + }); it("keys sources per environment and names the environment only when several have some", () => { const one = new Map([ diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 3c5b39ea06a7..8341796e39c3 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -70,6 +70,8 @@ export function collectLimitsGroups( * Every usage-limit source across connected environments, keyed so two * environments pointing at the same hub still get their own rows. The label * carries the environment only when more than one environment has sources. + * A native provider with usable limits takes precedence over the same account + * in a source, even when it belongs to another connected environment. */ export function collectLimitSources( presentations: ReadonlyMap< @@ -77,13 +79,31 @@ export function collectLimitSources( { readonly entry: { readonly target: { readonly label: string } }; readonly serverConfig: { + readonly providers?: readonly ServerProvider[] | undefined; readonly usageLimitSources?: UsageLimitSourceSnapshots | undefined; } | null; } >, ): ReadonlyArray< - UsageLimitSourceSnapshot & { readonly key: string; readonly environmentId: EnvironmentId } + UsageLimitSourceSnapshot & { + readonly key: string; + readonly environmentId: EnvironmentId; + readonly hiddenAccountCount: number; + } > { + const nativeAccounts = new Set(); + for (const presentation of presentations.values()) { + for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { + const key = accountKey(provider.driver, provider.auth.email); + if ( + key !== null && + provider.usageLimits?.windows.length && + !provider.usageLimits.unavailable + ) { + nativeAccounts.add(key); + } + } + } const perEnvironment: Array<{ readonly environmentId: EnvironmentId; readonly environmentLabel: string; @@ -100,15 +120,28 @@ export function collectLimitSources( } const labelEnvironment = perEnvironment.length > 1; return perEnvironment.flatMap(({ environmentId, environmentLabel, sources }) => - sources.map((source) => ({ - ...source, - environmentId, - key: `${environmentId}:${source.id}`, - label: labelEnvironment ? `${environmentLabel} · ${source.label}` : source.label, - })), + sources.map((source) => { + const accounts = source.accounts.filter((account) => { + const key = accountKey(account.driver, account.email); + return key === null || !nativeAccounts.has(key); + }); + return { + ...source, + accounts, + hiddenAccountCount: source.accounts.length - accounts.length, + environmentId, + key: `${environmentId}:${source.id}`, + label: labelEnvironment ? `${environmentLabel} · ${source.label}` : source.label, + }; + }), ); } +function accountKey(driver: ServerProvider["driver"], email: string | undefined): string | null { + const normalizedEmail = email?.trim().toLowerCase(); + return normalizedEmail ? `${driver}:${normalizedEmail}` : null; +} + /** The instance's configured name, else the driver's, else its raw kind. */ export function providerLimitsLabel( provider: ServerProvider, diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index f32038763188..3e40d94dc69a 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -2863,7 +2863,7 @@ export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function readonly arch: typeof BuildArch.Type; }) { const fs = yield* FileSystem.FileSystem; - yield* Effect.tryPromise({ + const archiveStream = yield* Effect.tryPromise({ try: () => createPackageWithOptions(input.sourceDir, input.asarPath, { dot: true, @@ -2872,6 +2872,21 @@ export const packWindowsServerAsar = Effect.fn("packWindowsServerAsar")(function }), catch: (cause) => new WindowsServerSidecarPackError({ asarPath: input.asarPath, cause }), }); + yield* Effect.tryPromise({ + try: () => + new Promise((resolve, reject) => { + const stream = archiveStream as NodeJS.WritableStream & { + readonly writableFinished?: boolean; + }; + if (stream.writableFinished === true) { + resolve(); + return; + } + stream.once("finish", resolve); + stream.once("error", reject); + }), + catch: (cause) => new WindowsServerSidecarPackError({ asarPath: input.asarPath, cause }), + }); const unpackedDirPath = `${input.asarPath}.unpacked`; if (!(yield* fs.exists(unpackedDirPath))) { return yield* new WindowsServerSidecarPackError({ diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index 8f987b584d9d..d6ced75d5db2 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -806,6 +806,47 @@ async function ensureIosSimulator(device: ShowcaseIosDevice): Promise<{ }; } +async function iosSimulatorDataPath(udid: string): Promise { + const parsed = JSON.parse(await commandOutput("xcrun", ["simctl", "list", "devices", "-j"])) as { + readonly devices: Readonly< + Record> + >; + }; + const dataPath = Object.values(parsed.devices) + .flat() + .find((device) => device.udid === udid)?.dataPath; + if (!dataPath) throw new Error(`Could not resolve the data path of iOS simulator ${udid}.`); + return dataPath; +} + +// generativeexperiencesd posts a "Ready for Apple Intelligence" follow-up +// banner on an eligible device's first boot, and CoreFollowUp re-surfaces it +// on every boot until the user dismisses it. Stamping the readiness marker +// before boot makes the daemon skip the post, and dropping the CoreFollowUp +// store clears a banner that a previous boot already queued. Runs while the +// device is shut down so the files are read fresh on the next boot. +async function suppressIosSystemFollowUps(udid: string): Promise { + const dataPath = await iosSimulatorDataPath(udid); + const preferences = NodePath.join(dataPath, "Library/Preferences"); + await NodeFSP.mkdir(preferences, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(preferences, "com.apple.generativeexperiences.corefollowup.plist"), + ` + + + +\tDateOfLastAppleIntelligenceReadinessCFU +\t2020-01-01T00:00:00Z + + +`, + ); + await NodeFSP.rm(NodePath.join(dataPath, "Library/CoreFollowUp"), { + recursive: true, + force: true, + }); +} + async function normalizeIosSimulator(appearance: ShowcaseAppearance, udid: string): Promise { await runCommand("xcrun", ["simctl", "ui", udid, "appearance", appearance]); await runCommand("xcrun", [ @@ -922,6 +963,7 @@ async function captureIos( // confirmations, keyboards) without erasing the developer's simulator. await runCommand("xcrun", ["simctl", "shutdown", simulator.udid]); } + await suppressIosSystemFollowUps(simulator.udid); await runCommand("xcrun", ["simctl", "boot", simulator.udid]); await runCommand("xcrun", ["simctl", "bootstatus", simulator.udid, "-b"]); if (capture.device.orientation === "landscape") {