From db8d60f486c5fc1a80d01b591a359f0c87f0868c Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:16:28 +0530 Subject: [PATCH 01/45] fix(web): render transparent previews on white (#9463) --- apps/web/src/browser/HostedBrowserWebview.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 564a2453b2be..582d5686ec0f 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -315,7 +315,7 @@ export function HostedBrowserWebview(props: { } aria-hidden={active ? undefined : true} className={cn( - "absolute flex overflow-hidden bg-background", + "absolute flex overflow-hidden bg-white", active && !layout.fillsPanel && "ring-1 ring-border/70 shadow-sm", )} style={{ From de025aa69ffb0ce1a45d30aed25c60454660b62d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 3 Sep 2026 12:52:50 -0700 Subject: [PATCH 02/45] fix(mobile): show loading and syncing in the working pill (#9466) Co-authored-by: Claude Fable 5 --- .../src/features/threads/ThreadComposer.tsx | 24 +----------- .../features/threads/ThreadDetailScreen.tsx | 37 +++++++++++------- .../features/threads/ThreadRouteScreen.tsx | 5 ++- .../threads/floating-working-control.tsx | 38 +++++++++++++++---- 4 files changed, 60 insertions(+), 44 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 89fc66a9375c..b4cdd43deca9 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -110,12 +110,6 @@ export interface ThreadComposerProps { readonly connectionState: RemoteClientConnectionState; readonly connectionError: string | null; readonly environmentLabel: string | null; - /** - * Message sync phase for the selected thread (drives the status pill): - * "loading" = first fetch, nothing to show yet; "syncing" = cached messages - * are on screen while they reconcile with the server. - */ - readonly threadSyncPhase?: "loading" | "syncing" | null; readonly selectedThread: OrchestrationThreadShell; readonly serverConfig: T3ServerConfig | null; readonly queueCount: number; @@ -225,7 +219,7 @@ export function ComposerSurface(props: { } type ComposerStatusPillState = { - readonly kind: "unavailable" | "reconnecting" | "syncing"; + readonly kind: "unavailable" | "reconnecting"; readonly label: string; }; @@ -233,7 +227,6 @@ function composerConnectionStatus(input: { readonly connectionError: string | null; readonly connectionState: RemoteClientConnectionState; readonly environmentLabel: string | null; - readonly threadSyncPhase?: "loading" | "syncing" | null; }): ComposerStatusPillState | null { const environmentLabel = input.environmentLabel ?? "Environment"; @@ -259,18 +252,6 @@ function composerConnectionStatus(input: { case "available": return { kind: "unavailable", label: `${environmentLabel} is not connected` }; case "connected": - break; - } - - // Connected: the pill is the single loading/sync indicator. One stable - // label per open — "Loading" when starting from scratch, "Syncing" when - // cached messages are already visible. - switch (input.threadSyncPhase) { - case "loading": - return { kind: "syncing", label: "Loading messages..." }; - case "syncing": - return { kind: "syncing", label: "Syncing messages..." }; - default: return null; } } @@ -279,7 +260,7 @@ const ComposerConnectionStatusPill = memo(function ComposerConnectionStatusPill( readonly onPress: () => void; readonly status: ComposerStatusPillState; }) { - const isReconnecting = props.status.kind !== "unavailable"; + const isReconnecting = props.status.kind === "reconnecting"; return ( { if (!props.serverConfig) return null; diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 18a51eb834e3..bf1a55a8a6ae 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -74,6 +74,7 @@ import { PendingUserInputCard } from "./PendingUserInputCard"; import { FLOATING_WORKING_CONTROL_COVERAGE, FloatingWorkingControl, + type FloatingWorkingStatus, } from "./floating-working-control"; import { derivePendingUserInputMaxHeight, @@ -301,27 +302,38 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // The raw sync status enters "synchronizing" on every full fetch, cached or // not. Whether messages are already on screen decides the pill label: no // data yet → "Loading messages", cached data reconciling → "Syncing". - const threadSyncPhase = (() => { + const threadSyncLabel = (() => { switch (props.threadSyncStatus) { case "empty": case "cached": case "synchronizing": if (contentPresentationKind === "ready") { - return "syncing" as const; + return "Syncing messages..."; } - return contentPresentationKind === "loading" ? ("loading" as const) : null; + return contentPresentationKind === "loading" ? "Loading messages..." : null; default: return null; } })(); - const showWorkingControl = - props.activeWorkStartedAt !== null && - contentPresentationKind === "ready" && - threadSyncPhase === null && - props.connectionStateLabel === "connected" && - props.activePendingApproval === null && - props.activePendingUserInput === null; - const floatingWorkingStartedAt = showWorkingControl ? props.activeWorkStartedAt : null; + // One floating pill above the composer: it reads the sync state while + // messages load, then the working timer once the feed is settled. + const floatingStatus = ((): FloatingWorkingStatus | null => { + if ( + props.connectionStateLabel !== "connected" || + props.activePendingApproval !== null || + props.activePendingUserInput !== null + ) { + return null; + } + if (threadSyncLabel !== null) { + return { kind: "syncing", label: threadSyncLabel }; + } + if (props.activeWorkStartedAt !== null && contentPresentationKind === "ready") { + return { kind: "working", startedAt: props.activeWorkStartedAt }; + } + return null; + })(); + const showWorkingControl = floatingStatus !== null; const selectedThreadFeed = props.selectedThreadFeed; const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; @@ -748,7 +760,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread @@ -807,7 +819,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread connectionState={props.connectionStateLabel} connectionError={props.connectionError} environmentLabel={props.environmentLabel} - threadSyncPhase={threadSyncPhase} selectedThread={props.selectedThread} serverConfig={props.serverConfig} queueCount={props.selectedThreadQueueCount} diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 79e898eaa1c9..53eca806cbc7 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -157,8 +157,9 @@ export function ThreadRouteScreen(props: ThreadRouteScreenProps) { // Render the full thread chrome (header, feed, composer) as soon as the // thread SHELL is known — no blocking on message detail. The feed shows a - // loading placeholder while messages fetch, and the composer's connection - // pill reports connecting/reconnecting/syncing status. + // loading placeholder while messages fetch, the floating pill above the + // composer reports loading/syncing, and the composer's connection pill + // reports connecting/reconnecting status. if (selectedThread !== null && selectedThreadKey === routeThreadKey) { return ; } diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index bdfa19a9eeaf..a62a3c9d17bc 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -1,6 +1,6 @@ import { GlassContainer, GlassView } from "expo-glass-effect"; import { useEffect, useState } from "react"; -import { Text as SystemText, View } from "react-native"; +import { ActivityIndicator, Text as SystemText, View } from "react-native"; import Animated, { Easing, FadeIn, @@ -40,9 +40,17 @@ const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView); export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_HEIGHT + CONTROL_COMPOSER_GAP; +/** + * What the floating pill says. Syncing and working share one element so the + * label swaps in place instead of one pill fading out for another. + */ +export type FloatingWorkingStatus = + | { readonly kind: "working"; readonly startedAt: string } + | { readonly kind: "syncing"; readonly label: string }; + export function FloatingWorkingControl(props: { readonly colorScheme: "light" | "dark"; - readonly startedAt: string | null; + readonly status: FloatingWorkingStatus | null; readonly showScrollToEnd: boolean; readonly onScrollToEnd: () => void; }) { @@ -62,7 +70,7 @@ export function FloatingWorkingControl(props: { opacity: separationProgress.value, })); - if (props.startedAt === null && !props.showScrollToEnd) { + if (props.status === null && !props.showScrollToEnd) { return null; } @@ -74,7 +82,7 @@ export function FloatingWorkingControl(props: { entering={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_ENTERING} exiting={NATIVE_LIQUID_GLASS_SUPPORTED ? undefined : CONTROL_EXITING} > - {props.startedAt !== null && NATIVE_LIQUID_GLASS_SUPPORTED ? ( + {props.status !== null && NATIVE_LIQUID_GLASS_SUPPORTED ? ( - + - ) : props.startedAt !== null ? ( + ) : props.status !== null ? ( - + + + {props.status.label} + + ); + } + return ; +} + function WorkingDuration(props: { readonly startedAt: string }) { const [nowMs, setNowMs] = useState(() => Date.now()); From 36c4e9cf5c0123e33d65f2af9497ee090404b532 Mon Sep 17 00:00:00 2001 From: Igor Makowski <56691628+Mnigos@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:23:53 +0200 Subject: [PATCH 03/45] fix(server): keep a/ and b/ prefixes in rendered git patches (#9438) --- .../src/checkpointing/CheckpointStore.test.ts | 31 +++++++++++++++++++ apps/server/src/vcs/GitVcsDriver.ts | 7 ++++- apps/server/src/vcs/GitVcsDriverCore.test.ts | 28 +++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 7 +++++ 4 files changed, 72 insertions(+), 1 deletion(-) diff --git a/apps/server/src/checkpointing/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts index bf332d20d0da..2f46858986aa 100644 --- a/apps/server/src/checkpointing/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/CheckpointStore.test.ts @@ -147,6 +147,37 @@ it.layer(TestLayer)("CheckpointStore.layer", (it) => { }), ); + it.effect("keeps a/ and b/ patch prefixes when the repository disables them", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + yield* git(tmp, ["config", "diff.noprefix", "true"]); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const threadId = ThreadId.make("thread-checkpoint-store-noprefix"); + const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); + const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); + + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: fromCheckpointRef, + }); + yield* writeTextFile(NodePath.join(tmp, "README.md"), "# changed\n"); + yield* checkpointStore.captureCheckpoint({ + cwd: tmp, + checkpointRef: toCheckpointRef, + }); + + const diff = yield* checkpointStore.diffCheckpoints({ + cwd: tmp, + fromCheckpointRef, + toCheckpointRef, + ignoreWhitespace: false, + }); + + expect(diff).toContain("diff --git a/README.md b/README.md"); + }), + ); + it.effect("can hide indentation churn when changes wrap existing lines", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 1ab424347637..08b474cf42da 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -30,7 +30,11 @@ import { type VcsStatusInput, type VcsStatusResult, } from "@t3tools/contracts"; -import { makeGitVcsDriverCore, splitNullSeparatedGitStdoutPaths } from "./GitVcsDriverCore.ts"; +import { + makeGitVcsDriverCore, + PATCH_RENDER_PREFIX_ARGS, + splitNullSeparatedGitStdoutPaths, +} from "./GitVcsDriverCore.ts"; import * as VcsDriver from "./VcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; @@ -869,6 +873,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "--no-color", "--no-ext-diff", "--no-textconv", + ...PATCH_RENDER_PREFIX_ARGS, ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), `${fromRevision}^{commit}`, `${input.toCheckpointRef}^{commit}`, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index c0621f2c99d7..8e76413496b7 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -819,6 +819,34 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("keeps a/ and b/ patch prefixes when the repository disables them", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["config", "diff.noprefix", "true"]); + yield* git(cwd, ["config", "diff.mnemonicPrefix", "true"]); + yield* git(cwd, ["checkout", "-b", "feature/noprefix"]); + yield* writeTextFile(cwd, "README.md", "# committed change\n"); + yield* git(cwd, ["add", "README.md"]); + yield* git(cwd, ["commit", "-m", "committed change"]); + yield* writeTextFile(cwd, "README.md", "# dirty change\n"); + yield* writeTextFile(cwd, "untracked.txt", "untracked\n"); + + const preview = yield* driver.getReviewDiffPreview({ + cwd, + baseRef: initialBranch, + ignoreWhitespace: false, + }); + + const workingTree = preview.sources.find((source) => source.kind === "working-tree")?.diff; + const branchRange = preview.sources.find((source) => source.kind === "branch-range")?.diff; + assert.include(workingTree, "diff --git a/README.md b/README.md"); + assert.include(workingTree, "+++ b/untracked.txt"); + assert.include(branchRange, "diff --git a/README.md b/README.md"); + }), + ); + it.effect("loads full file contents for working-tree diff expansion", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index f1fb1b7a7b18..3a4a172a6436 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -52,6 +52,10 @@ const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000; const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000; const REVIEW_UNTRACKED_DIFF_MAX_OUTPUT_BYTES = 80_000; const REVIEW_DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; +// Patches the clients render are parsed against git's default a/ and b/ path +// prefixes. A repository or global diff.noprefix or diff.mnemonicPrefix would +// otherwise leak into the patch and leave every parsed file unnamed. +export const PATCH_RENDER_PREFIX_ARGS = ["--src-prefix=a/", "--dst-prefix=b/"] as const; const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 120_000; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5); @@ -2205,6 +2209,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "--no-ext-diff", "--no-textconv", "--minimal", + ...PATCH_RENDER_PREFIX_ARGS, "--", "/dev/null", relativePath, @@ -2257,6 +2262,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "--no-ext-diff", "--no-textconv", "--minimal", + ...PATCH_RENDER_PREFIX_ARGS, ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), "HEAD", "--", @@ -2293,6 +2299,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* "--no-ext-diff", "--no-textconv", "--minimal", + ...PATCH_RENDER_PREFIX_ARGS, ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), `${baseRef}...HEAD`, ], From d2b6f3b9296f682c6158b894ab33d98d0c4bfb2b Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 4 Sep 2026 02:08:12 +0530 Subject: [PATCH 04/45] fix(server): full-access OpenCode threads no longer ask for approvals (#9282) Co-authored-by: Claude Fable 5.1 Co-authored-by: Julius Marminge --- .../provider/Layers/OpenCodeAdapter.test.ts | 207 ++++++++++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 71 +++++- docs/internals/providers.md | 6 + 3 files changed, 277 insertions(+), 7 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 7f327cae8fb3..01baf92db73e 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -89,6 +89,7 @@ const runtimeMock = { subscribedEvents: [] as Array>, eventSubscribeObserved: null as (() => void) | null, permissionReplyCalls: [] as Array<{ requestID: string; reply: string }>, + permissionReplyImplementation: null as (() => Promise) | null, questionReplyCalls: [] as Array<{ requestID: string; answers: ReadonlyArray>; @@ -139,6 +140,7 @@ const runtimeMock = { this.state.subscribedEvents = []; this.state.eventSubscribeObserved = null; this.state.permissionReplyCalls.length = 0; + this.state.permissionReplyImplementation = null; this.state.questionReplyCalls.length = 0; this.state.sessionStatus = "idle"; this.state.sessionStatusFailures = 0; @@ -377,6 +379,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }, reply: async ({ requestID, reply }: { requestID: string; reply: string }) => { runtimeMock.state.permissionReplyCalls.push({ requestID, reply }); + if (runtimeMock.state.permissionReplyImplementation) { + await runtimeMock.state.permissionReplyImplementation(); + } }, }, question: { @@ -2623,6 +2628,208 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect.each([ + { + name: "a doom-loop ask on the parent session", + requestId: "per_doom_loop", + sessionID: "http://127.0.0.1:9999/session", + permission: "doom_loop", + patterns: ["bash"], + always: [] as string[], + }, + { + name: "a child-session ask", + requestId: "per_child_full", + sessionID: "ses_child_full", + permission: "read", + patterns: ["/repo/settings.env"], + always: ["/repo/settings.env"], + }, + ])( + "auto-approves $name in full access", + ({ requestId, sessionID, permission, patterns, always }) => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId(`thread-full-access-${requestId}`); + runtimeMock.state.subscribedEvents = [ + { + id: "evt-child-created", + type: "session.created", + properties: { + sessionID: "ses_child_full", + info: { + id: "ses_child_full", + parentID: "http://127.0.0.1:9999/session", + title: "Child session", + }, + }, + }, + { + id: "evt-permission", + type: "permission.asked", + properties: { id: requestId, sessionID, permission, patterns, metadata: {}, always }, + }, + { + id: "evt-permission-replied", + type: "permission.replied", + properties: { sessionID, requestID: requestId, reply: "once" }, + }, + // The suppressed ask emits nothing, so an empty question serves as a + // sentinel that closes the collected stream once the pump is past it. + { + id: "evt-sentinel-question", + type: "question.asked", + properties: { + id: "que_sentinel", + sessionID: "http://127.0.0.1:9999/session", + questions: [], + }, + }, + ]; + + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "user-input.requested"), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const events = Array.from(yield* Fiber.join(eventsFiber).pipe(Effect.timeout("1 second"))); + + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ + { requestID: requestId, reply: "once" }, + ]); + NodeAssert.equal( + events.some((event) => event.type === "request.opened"), + false, + ); + NodeAssert.equal( + events.some((event) => event.type === "request.resolved"), + false, + ); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("surfaces the approval when the full-access auto-reply fails", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-full-access-reply-failed"); + runtimeMock.state.permissionReplyImplementation = async () => { + throw new Error("reply failed"); + }; + runtimeMock.state.subscribedEvents = [ + { + id: "evt-doom-loop", + type: "permission.asked", + properties: { + id: "per_doom_loop_failed", + sessionID: "http://127.0.0.1:9999/session", + permission: "doom_loop", + patterns: ["bash"], + metadata: {}, + always: [], + }, + }, + ]; + + const openedFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "request.opened"), + Stream.take(1), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + const opened = Option.getOrUndefined( + yield* Fiber.join(openedFiber).pipe(Effect.timeout("1 second")), + ); + NodeAssert.equal(opened?.requestId, "per_doom_loop_failed"); + // Exactly one auto-reply attempt: the fallback surfaces the dialog + // instead of retrying the reply. + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ + { requestID: "per_doom_loop_failed", reply: "once" }, + ]); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("does not reopen a failed full-access auto-reply after its terminal reply", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-full-access-reply-failed-after-terminal"); + const childId = "ses_full_access_terminal_child"; + const request = permissionRequest("per_failed_after_terminal", childId); + const ancestryAttempted = promiseWithResolvers(); + const releaseReply = promiseWithResolvers(); + // The ask arrives from a child whose ancestry lookup is failing, so it + // is handled on a retry fiber. The terminal reply lands while that + // fiber's auto-reply is still in flight; the reply then fails. The + // request must neither reopen nor emit a stray resolution. + runtimeMock.state.sessionParentById.set(childId, "http://127.0.0.1:9999/session"); + runtimeMock.state.transientErrorSessionIds.add(childId); + runtimeMock.state.sessionGetObserved = (sessionID) => { + if (sessionID === childId) { + ancestryAttempted.resolve(undefined); + } + }; + runtimeMock.state.permissionReplyImplementation = async () => { + await releaseReply.promise; + throw new Error("reply failed"); + }; + const terminalEvent = promiseWithResolvers(); + runtimeMock.state.subscribedEvents = [ + { id: "evt-ask", type: "permission.asked", properties: request }, + terminalEvent.promise, + ]; + + const requestEventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter( + (event) => + event.threadId === threadId && + (event.type === "request.opened" || event.type === "request.resolved"), + ), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + yield* Effect.promise(() => ancestryAttempted.promise); + runtimeMock.state.transientErrorSessionIds.delete(childId); + yield* advanceTestClock(250); + NodeAssert.deepEqual(runtimeMock.state.permissionReplyCalls, [ + { requestID: request.id, reply: "once" }, + ]); + + // Drain the microtask queue so the pump has consumed the terminal reply + // before the in-flight auto-reply is allowed to fail. + terminalEvent.resolve({ + id: "evt-reply", + type: "permission.replied", + properties: { sessionID: childId, requestID: request.id, reply: "once" }, + }); + yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); + releaseReply.resolve(undefined); + yield* advanceTestClock(250); + + NodeAssert.equal(requestEventsFiber.pollUnsafe(), undefined); + yield* Fiber.interrupt(requestEventsFiber); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("routes child-session questions and replies through the parent thread", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index d0b4f0de78ce..6d94e0c09a04 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -328,6 +328,7 @@ interface OpenCodeSessionContext { readonly openCodeSessionId: string; readonly relatedSessionIds: Set; readonly resolvedRequestIds: Set; + readonly autoRepliedRequestIds: Set; readonly emittedTerminalRequestIds: Set; readonly requestRelationRetries: Map; readonly pendingPermissions: Map; @@ -1000,6 +1001,12 @@ export function makeOpenCodeAdapter( const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); + // Synchronous publish for callers that must not yield between a state + // check and the enqueue, e.g. reopening an approval only if its terminal + // event has not landed yet. + const emitUnsafe = (event: ProviderRuntimeEvent) => { + Queue.offerUnsafe(runtimeEvents, event); + }; const writeNativeEvent = ( threadId: ThreadId, event: { @@ -1602,6 +1609,39 @@ export function makeOpenCodeAdapter( return false; }); + // Full access means the user already granted everything, but two upstream + // paths never consult the session ruleset we send: doom-loop detection + // (evaluated against the agent ruleset only) and subagent sessions (which + // keep only deny and external-directory rules). Answer those asks here. + // + // Reply "once", not "always": OpenCode stores "always" grants per + // directory, so on a shared external server an "always" from a full-access + // thread would silently widen what a supervised thread on the same + // directory is allowed to do. + const autoReplyFullAccess = Effect.fn("autoReplyFullAccess")(function* ( + context: OpenCodeSessionContext, + request: PermissionRequest, + ) { + // Mark before awaiting: retry and recovery fibers re-enter the ask path, + // and the matching `permission.replied` can arrive, while the SDK call + // is in flight. Marked ids skip the ask and swallow the terminal event. + context.resolvedRequestIds.add(request.id); + context.autoRepliedRequestIds.add(request.id); + const replied = yield* runOpenCodeSdk("permission.reply", () => + context.client.permission.reply({ requestID: request.id, reply: "once" }), + ).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (!replied) { + // Fall back to the dialog. The id stays resolved so a recovered copy + // of this ask cannot reopen after the user answers; + // `pendingPermissions` gates re-asks while the dialog is open. + context.autoRepliedRequestIds.delete(request.id); + } + return replied; + }); + const emitPendingOpenCodeRequest = Effect.fn("emitPendingOpenCodeRequest")(function* ( context: OpenCodeSessionContext, event: OpenCodeAskedRequestEvent, @@ -1615,14 +1655,27 @@ export function makeOpenCodeAdapter( if (context.pendingPermissions.has(request.id)) { return; } + if ( + context.session.runtimeMode === "full-access" && + (yield* autoReplyFullAccess(context, request)) + ) { + return; + } + const base = yield* buildEventBase({ + threadId: context.session.threadId, + turnId: context.activeTurnId, + requestId: request.id, + raw, + }); + // No yield between this check and the publish: a terminal + // `permission.replied` delivered on the pump in between would leave a + // dialog that can never close. + if (context.emittedTerminalRequestIds.has(request.id)) { + return; + } context.pendingPermissions.set(request.id, request); - yield* emit({ - ...(yield* buildEventBase({ - threadId: context.session.threadId, - turnId: context.activeTurnId, - requestId: request.id, - raw, - })), + emitUnsafe({ + ...base, type: "request.opened", payload: { requestType: mapPermissionToRequestType(request.permission), @@ -1671,6 +1724,9 @@ export function makeOpenCodeAdapter( return; } context.emittedTerminalRequestIds.add(requestId); + if (context.autoRepliedRequestIds.delete(requestId)) { + return; + } if (event.type === "permission.replied") { yield* emit({ ...(yield* buildEventBase({ @@ -2554,6 +2610,7 @@ export function makeOpenCodeAdapter( openCodeSessionId: started.openCodeSession.id, relatedSessionIds: new Set([started.openCodeSession.id]), resolvedRequestIds: new Set(), + autoRepliedRequestIds: new Set(), emittedTerminalRequestIds: new Set(), requestRelationRetries: new Map(), pendingPermissions: new Map(), diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 7f77a4c55eac..4b2267463076 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -214,6 +214,12 @@ connection, while OpenCode stores MCP connections by directory. Sharing these ch without changing MCP routing would let two threads in one directory replace each other's connection. +Chat adapters send the runtime mode as a session ruleset, but upstream OpenCode evaluates +doom-loop and subagent asks against the agent ruleset only. In full access the adapter answers +those asks itself so the user never sees an approval they already granted. It replies `once` +rather than `always` because OpenCode stores `always` grants per directory, and on a shared +external server that would widen what a supervised thread in the same directory may do. + OpenCode loads its catalog through the HTTP API when an enabled provider instance starts. The provider registry keeps the snapshot in memory and persists it in the existing per-instance cache. Each `subscribeServerConfig` connection refreshes all providers, so a client reconnect reloads the From 493fbb58870c912b9cb2ba6c2f1dae938877a59a Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 17:28:28 -0400 Subject: [PATCH 05/45] fix(web): reuse pull request list data while loading (#9467) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../pullRequest/PullRequestDetailPanel.tsx | 16 +- .../pullRequest/PullRequestGhosts.tsx | 141 ++++++++++++++++-- apps/web/src/routes/_chat.pull-requests.tsx | 17 +++ 3 files changed, 157 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 59aa1333896b..c94e4bf38103 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -4,6 +4,7 @@ import { type EnvironmentId, type PullRequestAction, type PullRequestMergeMethod, + type PullRequestListEntry, type PullRequestUpdateMethod, type PullRequestRef, type PullRequestState, @@ -447,6 +448,7 @@ export function PullRequestDetailPanel({ environmentId, threadRef = null, reference, + listEntry = null, refreshToken: forcedRefreshToken = 0, onActed, onClose, @@ -463,6 +465,8 @@ export function PullRequestDetailPanel({ */ threadRef?: ScopedThreadRef | null; reference: PullRequestRef; + /** Row fields already loaded by the pull-request list, used while richer detail arrives. */ + listEntry?: PullRequestListEntry | null; /** * Bumped by whatever holds the panel when a reader asks for everything on screen to be read * again. The panel owns its own reads, so the page cannot refresh them for it — it says when, @@ -491,6 +495,12 @@ export function PullRequestDetailPanel({ composerDraftTarget?: ScopedThreadRef | DraftId; }) { const pullRequestKey = `${reference.projectId}:${reference.repository}#${reference.number}`; + const matchingListEntry = + listEntry?.projectId === reference.projectId && + listEntry.repository.toLowerCase() === reference.repository.toLowerCase() && + listEntry.number === reference.number + ? listEntry + : null; const [tab, setTab] = useState("summary"); const [timelineOrder, setTimelineOrder] = useState<"newest" | "oldest">("newest"); const [codeCommitScope, setCodeCommitScope] = useState<{ @@ -1296,10 +1306,10 @@ export function PullRequestDetailPanel({ ).length : 0; - // A reopen already has last time's title, author, and counts. Keep them on screen - // and let the live read replace fields — especially the diff counts — in place. + // The list already has the pull request's identity and summary. Keep them on screen + // and let the richer detail read replace the remaining placeholders in place. if (detailQuery.isPending && !detail) { - return ; + return ; } return ( diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index 2ae79c063c3c..f8c922356548 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -7,7 +7,19 @@ * both themes) and the single `animate-skeleton` pulse, applied once on the container so any * number of bars costs one opacity animation. */ +import type { PullRequestListEntry } from "@t3tools/contracts"; +import { ArrowLeftIcon } from "lucide-react"; + import { cn } from "~/lib/utils"; +import { formatRelativeTimeLabel } from "~/timestampFormat"; + +import { pullRequestLabelColor } from "./pullRequestList.logic"; +import { + PullRequestActorLabel, + PullRequestDiffStat, + pullRequestChecksStatePresentation, + resolvePullRequestState, +} from "./pullRequestPresentation"; function GhostBar({ className }: { className?: string | undefined }) { return
; @@ -60,18 +72,46 @@ export function PullRequestListGhost({ * boundaries in the ghost prevents the loaded pull request from replacing one layout with * another a moment later. */ -export function PullRequestDetailGhost() { +export function PullRequestDetailGhost({ seed }: { seed?: PullRequestListEntry | null }) { + const statePresentation = seed + ? resolvePullRequestState({ + state: seed.state, + isDraft: seed.isDraft, + }) + : null; + const checksPresentation = seed?.checksState + ? pullRequestChecksStatePresentation(seed.checksState) + : null; + return (
- - + {seed && statePresentation ? ( + <> + + {seed.repository} + + + #{seed.number} + + + ) : ( + <> + + + + )}
@@ -80,18 +120,58 @@ export function PullRequestDetailGhost() {
- + {seed ? ( +

{seed.title}

+ ) : ( + + )}
- - + {seed ? ( + <> + + + updated {formatRelativeTimeLabel(seed.updatedAt)} + + + ) : ( + <> + + + + )}
- - - + {seed ? ( + + {seed.baseBranch} + + {seed.headBranch} + + ) : ( + <> + + + + + )}
- + {seed ? ( + + ) : ( + + )}
@@ -102,7 +182,19 @@ export function PullRequestDetailGhost() {
- + {checksPresentation ? ( + + + {checksPresentation.label} + + ) : ( + + )}
@@ -125,8 +217,29 @@ export function PullRequestDetailGhost() {
- - + {seed ? ( + seed.labels.slice(0, 3).map((label) => { + const color = pullRequestLabelColor(label.color); + return ( + + + {label.name} + + ); + }) + ) : ( + <> + + + + )}
diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 29e6b05c0b19..6db4f99b1502 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -220,6 +220,9 @@ const EMPTY_TERMINAL_LABELS = new Map(); const EMPTY_PENDING_SURFACES = new Set(); const MAX_SEARCH_LABEL_CANDIDATES = 100; +const pullRequestListEntryId = (target: Parameters[0]) => + pullRequestSurfaceId({ ...target, repository: target.repository.toLowerCase() }); + function pullRequestSearchLabels(raw: unknown): Partial> { const values = (Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : []).slice( 0, @@ -1436,6 +1439,15 @@ function PullRequestsRouteView() { entry.additions + entry.deletions > 0 || statsByRow.has(pullRequestDiffStatKey(entry)), ); }, [groups, sort, statsByRow, typedParsed.text]); + const listedPullRequestsBySurface = useMemo( + () => + new Map( + displayGroups.flatMap((group) => + group.entries.map((entry) => [pullRequestListEntryId(entry), entry] as const), + ), + ), + [displayGroups], + ); const linkedSelection = useMemo( () => @@ -1939,6 +1951,11 @@ function PullRequestsRouteView() { repository: renderedPullRequestSurface.repository, number: renderedPullRequestSurface.number, }} + listEntry={ + listedPullRequestsBySurface.get( + pullRequestListEntryId(renderedPullRequestSurface), + ) ?? null + } refreshToken={detailRefreshToken} // Merging, closing or reopening changes the row this panel was opened from, so // the list behind it is out of date the moment the host takes the action. From 03728361aa7beb9c13da320097450e6fe65aac3e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 3 Sep 2026 14:28:36 -0700 Subject: [PATCH 06/45] feat(web): let users turn off composer collapse on blur and scroll (#9469) Co-authored-by: Claude Code --- .../settings/DesktopClientSettings.test.ts | 2 + apps/web/src/components/chat/ChatComposer.tsx | 22 +++--- .../components/composerFooterLayout.test.ts | 23 ++++++ .../src/components/composerFooterLayout.ts | 15 ++-- .../components/settings/SettingsPanels.tsx | 71 +++++++++++++++++++ .../src/components/settings/settingsSearch.ts | 8 +++ apps/web/src/components/ui/select.tsx | 15 +++- docs/user/composer.md | 3 +- packages/contracts/src/settings.test.ts | 16 +++++ packages/contracts/src/settings.ts | 6 ++ 10 files changed, 163 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 97f4ca85c506..28cce3cfb507 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -28,6 +28,8 @@ const clientSettings: ClientSettings = { confirmThreadUnpin: false, continueThreadsAfterServerUpdate: true, contextWindowMeterEnabled: false, + composerCollapseOnBlur: false, + composerCollapseOnScroll: true, dismissedProviderUpdateNotificationKeys: [], diffIgnoreWhitespace: true, diffLayout: "stacked", diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c38dd1cbe054..5f8376b431f7 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3542,8 +3542,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const isComposerResting = shouldUseRestingComposerLayout({ isExistingThread: routeKind === "server" && activeThreadId !== null, isMobileViewport, - isFocused: isComposerFocused && !isComposerScrollCollapsed, + isFocused: isComposerFocused, + isScrollCollapsed: isComposerScrollCollapsed, hasExpandedChrome: composerHasExpandedChrome, + collapseOnBlur: settings.composerCollapseOnBlur, }); // The relocated controls live in the context strip whenever the composer is // collapsed for any reason, the desktop resting layout or the phone @@ -3615,8 +3617,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const canTrackComposerScrollGesture = routeKind === "server" && activeThreadId !== null && !isMobileViewport; const canScrollCollapseComposer = - canTrackComposerScrollGesture && !composerHasExpandedChrome && !showInlineTasksBadge; - composerScrollCollapseEligibleRef.current = canScrollCollapseComposer; + canTrackComposerScrollGesture && + settings.composerCollapseOnScroll && + !composerHasExpandedChrome && + !showInlineTasksBadge; + // Scrolling only has something to collapse while the composer is expanded. + // With blur collapse off that includes an unfocused composer, so the wheel + // handler keys off this rather than editor focus. + composerScrollCollapseEligibleRef.current = canScrollCollapseComposer && !isComposerResting; useEffect(() => { if (!canScrollCollapseComposer) { @@ -3657,11 +3665,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) resetComposerScrollGesture(composerScrollGestureRef.current); }; const handleTimelineWheel = (event: WheelEvent) => { - const activeElement = document.activeElement; - const isPromptEditorFocused = - activeElement instanceof HTMLElement && - activeElement.isContentEditable && - composerFormRef.current?.contains(activeElement) === true; if (event.ctrlKey || !(event.target instanceof Element)) { return; } @@ -3695,8 +3698,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) now: window.performance.now(), deltaPx, collapseThresholdPx: COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX, - collapseEligible: - targetsTimeline && composerScrollCollapseEligibleRef.current && isPromptEditorFocused, + collapseEligible: targetsTimeline && composerScrollCollapseEligibleRef.current, canScrollInGestureDirection, scrollsTowardLogicalEnd: event.deltaY > 0 && isTimelineAtLogicalEnd(), }); diff --git a/apps/web/src/components/composerFooterLayout.test.ts b/apps/web/src/components/composerFooterLayout.test.ts index 926816508ec5..ea2a1de20641 100644 --- a/apps/web/src/components/composerFooterLayout.test.ts +++ b/apps/web/src/components/composerFooterLayout.test.ts @@ -79,13 +79,36 @@ describe("shouldUseRestingComposerLayout", () => { isExistingThread: true, isMobileViewport: false, isFocused: false, + isScrollCollapsed: false, hasExpandedChrome: false, + collapseOnBlur: true, }; it("uses the resting layout for an unfocused desktop composer", () => { expect(shouldUseRestingComposerLayout(resting)).toBe(true); }); + it("keeps an unfocused composer expanded when blur collapse is off", () => { + expect(shouldUseRestingComposerLayout({ ...resting, collapseOnBlur: false })).toBe(false); + }); + + it("rests a scroll-collapsed composer even while focused", () => { + expect( + shouldUseRestingComposerLayout({ ...resting, isFocused: true, isScrollCollapsed: true }), + ).toBe(true); + }); + + it("rests a scroll-collapsed composer regardless of the blur preference", () => { + expect( + shouldUseRestingComposerLayout({ + ...resting, + isFocused: true, + isScrollCollapsed: true, + collapseOnBlur: false, + }), + ).toBe(true); + }); + it("keeps new-thread composers expanded", () => { expect(shouldUseRestingComposerLayout({ ...resting, isExistingThread: false })).toBe(false); }); diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index 2ab3b36a1b90..56a9d43de999 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -27,7 +27,9 @@ export function shouldUseRestingComposerLayout(input: { isExistingThread: boolean; isMobileViewport: boolean; isFocused: boolean; + isScrollCollapsed: boolean; hasExpandedChrome: boolean; + collapseOnBlur: boolean; }): boolean { // Passive draft content is deliberately absent here. Resting only clamps // the prompt row and overlays its actions; non-image attachment and context @@ -37,12 +39,13 @@ export function shouldUseRestingComposerLayout(input: { // deliberately absent here: resting reclaims vertical space at every // desktop width, and where the strip is missing or too narrow the controls // simply return when the composer is focused. - return ( - input.isExistingThread && - !input.isMobileViewport && - !input.isFocused && - !input.hasExpandedChrome - ); + // + // A scroll collapse rests the composer regardless of the blur preference: + // the user asked for it with the gesture, and it lifts on the next + // composer interaction. With blur collapse off, losing focus alone never + // rests the composer. + const collapsed = input.isScrollCollapsed || (input.collapseOnBlur && !input.isFocused); + return input.isExistingThread && !input.isMobileViewport && collapsed && !input.hasExpandedChrome; } export function shouldAnimateComposerRestingTransition(input: { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 35985f57a1f7..6ee1b7e2d0d6 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -172,6 +172,12 @@ const TIMESTAMP_FORMAT_LABELS = { "24-hour": "24-hour", } as const; +const COMPOSER_COLLAPSE_TRIGGER_LABELS = { + blur: "On unfocus", + scroll: "On scroll", +} as const; +type ComposerCollapseTrigger = keyof typeof COMPOSER_COLLAPSE_TRIGGER_LABELS; + const DIFF_LAYOUT_LABELS: Record = { stacked: "Stacked", split: "Split", @@ -542,6 +548,10 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.showSkillsInSlashMenu !== DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu ? ["Show skills in slash menu"] : []), + ...(settings.composerCollapseOnBlur !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur || + settings.composerCollapseOnScroll !== DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll + ? ["Collapse composer"] + : []), ...(settings.contextWindowMeterEnabled !== DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled ? ["Context window indicator"] : []), @@ -599,6 +609,8 @@ export function useSettingsRestore(onRestored?: () => void) { settings.confirmThreadArchive, settings.confirmThreadDelete, settings.confirmThreadUnpin, + settings.composerCollapseOnBlur, + settings.composerCollapseOnScroll, settings.addProjectBaseDirectory, settings.defaultThreadEnvMode, settings.newWorktreesStartFromOrigin, @@ -703,6 +715,8 @@ export function useSettingsRestore(onRestored?: () => void) { diffLayout: DEFAULT_UNIFIED_SETTINGS.diffLayout, proactivePanelsEnabled: DEFAULT_UNIFIED_SETTINGS.proactivePanelsEnabled, showSkillsInSlashMenu: DEFAULT_UNIFIED_SETTINGS.showSkillsInSlashMenu, + composerCollapseOnBlur: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur, + composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll, contextWindowMeterEnabled: DEFAULT_UNIFIED_SETTINGS.contextWindowMeterEnabled, environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, @@ -2010,6 +2024,13 @@ export function GeneralSettingsPanel() { const serverProviders = useAtomValue(primaryServerProvidersAtom); const supportsAutoSettlement = useAtomValue(primaryServerConfigAtom)?.environment.capabilities.threadAutoSettlement === true; + const composerCollapseTriggers = useMemo( + () => [ + ...(settings.composerCollapseOnBlur ? (["blur"] as const) : []), + ...(settings.composerCollapseOnScroll ? (["scroll"] as const) : []), + ], + [settings.composerCollapseOnBlur, settings.composerCollapseOnScroll], + ); const diagnosticsDescription = formatDiagnosticsDescription({ localTracingEnabled: observability?.localTracingEnabled ?? false, otlpTracesEnabled: observability?.otlpTracesEnabled ?? false, @@ -2334,6 +2355,56 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + composerCollapseOnBlur: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnBlur, + composerCollapseOnScroll: DEFAULT_UNIFIED_SETTINGS.composerCollapseOnScroll, + }) + } + /> + ) : null + } + control={ + + } + /> + + {showCheck ? ( + + + + ) : null} { }); }); +describe("ClientSettings composer collapse", () => { + it("collapses on blur and scroll by default and accepts opting out of each", () => { + const defaults = decodeClientSettings({}); + expect(defaults.composerCollapseOnBlur).toBe(true); + expect(defaults.composerCollapseOnScroll).toBe(true); + + const blurOff = decodeClientSettings({ composerCollapseOnBlur: false }); + expect(blurOff.composerCollapseOnBlur).toBe(false); + expect(blurOff.composerCollapseOnScroll).toBe(true); + + expect( + decodeClientSettingsPatch({ composerCollapseOnScroll: false }).composerCollapseOnScroll, + ).toBe(false); + }); +}); + describe("ServerSettings thread settlement", () => { it("defaults merge settlement on and inactivity settlement to three days", () => { const settings = decodeServerSettings({}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 9f1f0c846ac3..c1ee59e948df 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -319,6 +319,10 @@ export const ClientSettingsSchema = Schema.Struct({ // Legacy context window meter. The composer hides it by default; users who // still want the old usage indicator can restore it from Settings. contextWindowMeterEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Desktop resting composer. Each trigger that settles an existing thread's + // composer into its single-line layout can be turned off on its own. + composerCollapseOnBlur: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + composerCollapseOnScroll: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), // Legacy sidebar (the original per-project tree). Deliberately a fresh key @@ -1165,6 +1169,8 @@ export const ClientSettingsPatch = Schema.Struct({ ), planModeEnabled: Schema.optionalKey(Schema.Boolean), contextWindowMeterEnabled: Schema.optionalKey(Schema.Boolean), + composerCollapseOnBlur: Schema.optionalKey(Schema.Boolean), + composerCollapseOnScroll: Schema.optionalKey(Schema.Boolean), proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), From 373be93e68bf3d32207471b27e976ac84bff806a Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 17:44:05 -0400 Subject: [PATCH 07/45] fix(web): move workflow approval beside checks (#9465) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../pullRequest/PullRequestDetailPanel.tsx | 97 ++++++++++--------- 1 file changed, 50 insertions(+), 47 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index c94e4bf38103..adce43dd3344 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1478,41 +1478,6 @@ export function PullRequestDetailPanel({ ) : null} - {workflowApprovalsRequired > 0 && can("approve-workflows") ? ( - - - - - } - /> - - {pendingAction === "approve-workflows" - ? "Approving..." - : "Approve workflows to run"} - - - ) : null} {/* Said where the Merge button is, because it is the answer to why nobody has pressed it: the merge is already asked for, and the host is holding it. */} {autoMergeArmed && primaryAction !== "auto-merge-armed" ? ( @@ -2165,20 +2130,58 @@ export function PullRequestDetailPanel({ ))} {tab === "summary" ? ( - - {checksState !== null ? ( - + + {workflowApprovalsRequired > 0 && can("approve-workflows") ? ( + + + + + } + /> + + {pendingAction === "approve-workflows" + ? "Approving..." + : "Approve workflows to run"} + + ) : ( - + + {checksState !== null ? ( + + ) : ( + + )} + {checksSummary} + )} - {checksSummary} ) : tab === "timeline" ? (
From 4b8b5d9e0177002c84a6f55837670aa0ef816915 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 3 Sep 2026 14:57:59 -0700 Subject: [PATCH 08/45] fix(desktop): refresh generated annotation styles (#9488) --- apps/desktop/src/preview/AnnotationStyles.generated.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/preview/AnnotationStyles.generated.ts b/apps/desktop/src/preview/AnnotationStyles.generated.ts index 5b6b73c8ba78..aba581ab5338 100644 --- a/apps/desktop/src/preview/AnnotationStyles.generated.ts +++ b/apps/desktop/src/preview/AnnotationStyles.generated.ts @@ -1,3 +1,3 @@ // Generated by scripts/build-preview-annotation-css.mjs. Do not edit. export const previewAnnotationStyles = - '/*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: calc(var(--spacing) * 0);\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: calc(var(--spacing) * 0);\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: calc(var(--spacing) * 1);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: calc(var(--spacing) * 0);\n}\n.p-1 {\n padding: calc(var(--spacing) * 1);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: calc(var(--spacing) * 0);\n}\n.px-1 {\n padding-inline: calc(var(--spacing) * 1);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: calc(var(--spacing) * 1);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground {\n &::placeholder {\n color: var(--t3-muted-foreground);\n }\n}\n.hover\\:bg-accent {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-accent);\n }\n }\n}\n.hover\\:bg-primary\\/90 {\n &:hover {\n @media (hover: hover) {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n }\n}\n.hover\\:text-accent-foreground {\n &:hover {\n @media (hover: hover) {\n color: var(--t3-accent-foreground);\n }\n }\n}\n.focus\\:border-b-primary {\n &:focus {\n border-bottom-color: var(--t3-primary);\n }\n}\n.focus\\:ring-0 {\n &:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n }\n}\n.focus\\:outline-none {\n &:focus {\n --tw-outline-style: none;\n outline-style: none;\n }\n}\n.disabled\\:pointer-events-none {\n &:disabled {\n pointer-events: none;\n }\n}\n.disabled\\:opacity-60 {\n &:disabled {\n opacity: 60%;\n }\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: 1px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n'; + '/*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */\n@layer properties;\n:root, :host {\n --spacing: 0.25rem;\n --text-xs: 0.75rem;\n --text-xs--line-height: calc(1 / 0.75);\n --text-sm: 0.875rem;\n --text-sm--line-height: calc(1.25 / 0.875);\n --text-lg: 1.125rem;\n --text-lg--line-height: calc(1.75 / 1.125);\n --font-weight-medium: 500;\n --font-weight-semibold: 600;\n --font-weight-bold: 700;\n --blur-xl: 24px;\n --default-font-family: var(--t3-font-sans);\n --default-mono-font-family: var(--t3-font-mono);\n}\n*, ::after, ::before, ::backdrop, ::file-selector-button {\n box-sizing: border-box;\n margin: 0;\n padding: 0;\n border: 0 solid;\n}\nhtml, :host {\n line-height: 1.5;\n -webkit-text-size-adjust: 100%;\n tab-size: 4;\n font-family: var(--default-font-family, -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, \'Helvetica Neue\', \'Noto Sans\', Arial, sans-serif, \'Apple Color Emoji\', \'Segoe UI Emoji\', \'Segoe UI Symbol\', \'Noto Color Emoji\');\n font-feature-settings: var(--default-font-feature-settings, normal);\n font-variation-settings: var(--default-font-variation-settings, normal);\n -webkit-tap-highlight-color: transparent;\n}\nhr {\n height: 0;\n color: inherit;\n border-top-width: 1px;\n}\nabbr:where([title]) {\n -webkit-text-decoration: underline dotted;\n text-decoration: underline dotted;\n}\nh1, h2, h3, h4, h5, h6 {\n font-size: inherit;\n font-weight: inherit;\n}\na {\n color: inherit;\n -webkit-text-decoration: inherit;\n text-decoration: inherit;\n}\nb, strong {\n font-weight: bolder;\n}\ncode, kbd, samp, pre {\n font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \'Liberation Mono\', \'Courier New\', monospace);\n font-feature-settings: var(--default-mono-font-feature-settings, normal);\n font-variation-settings: var(--default-mono-font-variation-settings, normal);\n font-size: 1em;\n}\nsmall {\n font-size: 80%;\n}\nsub, sup {\n font-size: 75%;\n line-height: 0;\n position: relative;\n vertical-align: baseline;\n}\nsub {\n bottom: -0.25em;\n}\nsup {\n top: -0.5em;\n}\ntable {\n text-indent: 0;\n border-color: inherit;\n border-collapse: collapse;\n}\n:-moz-focusring:where(:not(iframe)) {\n outline: auto;\n}\nprogress {\n vertical-align: baseline;\n}\nsummary {\n display: list-item;\n}\nol, ul, menu {\n list-style: none;\n}\nimg, svg, video, canvas, audio, iframe, embed, object {\n display: block;\n vertical-align: middle;\n}\nimg, video {\n max-width: 100%;\n height: auto;\n}\nbutton, input, select, optgroup, textarea, ::file-selector-button {\n font: inherit;\n font-feature-settings: inherit;\n font-variation-settings: inherit;\n letter-spacing: inherit;\n color: inherit;\n border-radius: 0;\n background-color: transparent;\n opacity: 1;\n}\n:where(select:is([multiple], [size])) optgroup {\n font-weight: bolder;\n}\n:where(select:is([multiple], [size])) optgroup option {\n padding-inline-start: 20px;\n}\n::file-selector-button {\n margin-inline-end: 4px;\n}\n::placeholder {\n opacity: 1;\n}\n@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {\n ::placeholder {\n color: currentcolor;\n @supports (color: color-mix(in lab, red, red)) {\n color: color-mix(in oklab, currentcolor 50%, transparent);\n }\n }\n}\ntextarea {\n resize: vertical;\n}\n::-webkit-search-decoration {\n -webkit-appearance: none;\n}\n::-webkit-date-and-time-value {\n min-height: 1lh;\n text-align: inherit;\n}\n::-webkit-datetime-edit {\n display: inline-flex;\n}\n::-webkit-datetime-edit-fields-wrapper {\n padding: 0;\n}\n::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {\n padding-block: 0;\n}\n::-webkit-calendar-picker-indicator {\n line-height: 1;\n}\n:-moz-ui-invalid {\n box-shadow: none;\n}\nbutton, input:where([type=\'button\'], [type=\'reset\'], [type=\'submit\']), ::file-selector-button {\n appearance: button;\n}\n::-webkit-inner-spin-button, ::-webkit-outer-spin-button {\n height: auto;\n}\n[hidden]:where(:not([hidden=\'until-found\'])) {\n display: none !important;\n}\n.pointer-events-auto {\n pointer-events: auto;\n}\n.pointer-events-none {\n pointer-events: none;\n}\n.absolute {\n position: absolute;\n}\n.fixed {\n position: fixed;\n}\n.inset-0 {\n inset: 0px;\n}\n.top-1\\/2 {\n top: calc(1 / 2 * 100%);\n}\n.top-2\\.5 {\n top: calc(var(--spacing) * 2.5);\n}\n.right-2 {\n right: calc(var(--spacing) * 2);\n}\n.left-1\\/2 {\n left: calc(1 / 2 * 100%);\n}\n.z-1 {\n z-index: 1;\n}\n.block {\n display: block;\n}\n.flex {\n display: flex;\n}\n.grid {\n display: grid;\n}\n.hidden {\n display: none;\n}\n.inline-flex {\n display: inline-flex;\n}\n.h-7 {\n height: calc(var(--spacing) * 7);\n}\n.h-8 {\n height: calc(var(--spacing) * 8);\n}\n.max-h-24 {\n max-height: calc(var(--spacing) * 24);\n}\n.max-h-\\[calc\\(100vh-16px\\)\\] {\n max-height: calc(100vh - 16px);\n}\n.max-h-\\[min\\(176px\\,calc\\(100vh-180px\\)\\)\\] {\n max-height: min(176px, calc(100vh - 180px));\n}\n.min-h-7 {\n min-height: calc(var(--spacing) * 7);\n}\n.min-h-8 {\n min-height: calc(var(--spacing) * 8);\n}\n.w-6 {\n width: calc(var(--spacing) * 6);\n}\n.w-8 {\n width: calc(var(--spacing) * 8);\n}\n.w-\\[min\\(360px\\,calc\\(100vw-16px\\)\\)\\] {\n width: min(360px, calc(100vw - 16px));\n}\n.w-full {\n width: 100%;\n}\n.max-w-70 {\n max-width: calc(var(--spacing) * 70);\n}\n.min-w-0 {\n min-width: 0px;\n}\n.flex-1 {\n flex: 1;\n}\n.shrink-0 {\n flex-shrink: 0;\n}\n.-translate-x-1\\/2 {\n --tw-translate-x: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.-translate-y-1\\/2 {\n --tw-translate-y: calc(calc(1 / 2 * 100%) * -1);\n translate: var(--tw-translate-x) var(--tw-translate-y);\n}\n.cursor-grab {\n cursor: grab;\n}\n.cursor-pointer {\n cursor: pointer;\n}\n.resize {\n resize: both;\n}\n.resize-none {\n resize: none;\n}\n.appearance-none {\n appearance: none;\n}\n.grid-cols-\\[22px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 22px minmax(0,1fr);\n}\n.grid-cols-\\[82px_minmax\\(0\\,1fr\\)\\] {\n grid-template-columns: 82px minmax(0,1fr);\n}\n.flex-col {\n flex-direction: column;\n}\n.items-center {\n align-items: center;\n}\n.items-start {\n align-items: flex-start;\n}\n.justify-center {\n justify-content: center;\n}\n.gap-0\\.5 {\n gap: calc(var(--spacing) * 0.5);\n}\n.gap-1 {\n gap: var(--spacing);\n}\n.gap-2 {\n gap: calc(var(--spacing) * 2);\n}\n.overflow-auto {\n overflow: auto;\n}\n.overflow-hidden {\n overflow: hidden;\n}\n.overflow-y-hidden {\n overflow-y: hidden;\n}\n.rounded-lg {\n border-radius: var(--t3-radius);\n}\n.rounded-md {\n border-radius: calc(var(--t3-radius) - 2px);\n}\n.rounded-xl {\n border-radius: calc(var(--t3-radius) + 4px);\n}\n.border {\n border-style: var(--tw-border-style);\n border-width: 1px;\n}\n.border-0 {\n border-style: var(--tw-border-style);\n border-width: 0px;\n}\n.border-t {\n border-top-style: var(--tw-border-style);\n border-top-width: 1px;\n}\n.border-b {\n border-bottom-style: var(--tw-border-style);\n border-bottom-width: 1px;\n}\n.border-border {\n border-color: var(--t3-border);\n}\n.border-input {\n border-color: var(--t3-input);\n}\n.border-primary {\n border-color: var(--t3-primary);\n}\n.border-transparent {\n border-color: transparent;\n}\n.border-b-transparent {\n border-bottom-color: transparent;\n}\n.bg-background {\n background-color: var(--t3-background);\n}\n.bg-muted {\n background-color: var(--t3-muted);\n}\n.bg-muted\\/40 {\n background-color: var(--t3-muted);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-muted) 40%, transparent);\n }\n}\n.bg-popover\\/95 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 95%, transparent);\n }\n}\n.bg-popover\\/96 {\n background-color: var(--t3-popover);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-popover) 96%, transparent);\n }\n}\n.bg-primary {\n background-color: var(--t3-primary);\n}\n.bg-primary\\/10 {\n background-color: var(--t3-primary);\n @supports (color: color-mix(in lab, red, red)) {\n background-color: color-mix(in oklab, var(--t3-primary) 10%, transparent);\n }\n}\n.bg-transparent {\n background-color: transparent;\n}\n.p-0 {\n padding: 0px;\n}\n.p-1 {\n padding: var(--spacing);\n}\n.p-2 {\n padding: calc(var(--spacing) * 2);\n}\n.px-0 {\n padding-inline: 0px;\n}\n.px-1 {\n padding-inline: var(--spacing);\n}\n.px-2 {\n padding-inline: calc(var(--spacing) * 2);\n}\n.px-2\\.5 {\n padding-inline: calc(var(--spacing) * 2.5);\n}\n.px-3 {\n padding-inline: calc(var(--spacing) * 3);\n}\n.py-1 {\n padding-block: var(--spacing);\n}\n.py-1\\.5 {\n padding-block: calc(var(--spacing) * 1.5);\n}\n.py-2 {\n padding-block: calc(var(--spacing) * 2);\n}\n.font-mono {\n font-family: var(--t3-font-mono);\n}\n.font-sans {\n font-family: var(--t3-font-sans);\n}\n.text-lg {\n font-size: var(--text-lg);\n line-height: var(--tw-leading, var(--text-lg--line-height));\n}\n.text-sm {\n font-size: var(--text-sm);\n line-height: var(--tw-leading, var(--text-sm--line-height));\n}\n.text-xs {\n font-size: var(--text-xs);\n line-height: var(--tw-leading, var(--text-xs--line-height));\n}\n.leading-5 {\n --tw-leading: calc(var(--spacing) * 5);\n line-height: calc(var(--spacing) * 5);\n}\n.font-bold {\n --tw-font-weight: var(--font-weight-bold);\n font-weight: var(--font-weight-bold);\n}\n.font-medium {\n --tw-font-weight: var(--font-weight-medium);\n font-weight: var(--font-weight-medium);\n}\n.font-semibold {\n --tw-font-weight: var(--font-weight-semibold);\n font-weight: var(--font-weight-semibold);\n}\n.text-foreground {\n color: var(--t3-foreground);\n}\n.text-muted-foreground {\n color: var(--t3-muted-foreground);\n}\n.text-popover-foreground {\n color: var(--t3-popover-foreground);\n}\n.text-primary {\n color: var(--t3-primary);\n}\n.text-primary-foreground {\n color: var(--t3-primary-foreground);\n}\n.shadow-2xl {\n --tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-lg {\n --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-md {\n --tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 2px 4px -2px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-sm {\n --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.shadow-xs {\n --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.ring-0 {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.blur {\n --tw-blur: blur(8px);\n filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);\n}\n.backdrop-blur-xl {\n --tw-backdrop-blur: blur(var(--blur-xl));\n -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);\n}\n.outline-none {\n --tw-outline-style: none;\n outline-style: none;\n}\n.select-none {\n -webkit-user-select: none;\n user-select: none;\n}\n.placeholder\\:text-muted-foreground::placeholder {\n color: var(--t3-muted-foreground);\n}\n@media (hover: hover) {\n .hover\\:bg-accent:hover {\n background-color: var(--t3-accent);\n }\n .hover\\:bg-primary\\/90:hover {\n background-color: var(--t3-primary);\n }\n @supports (color: color-mix(in lab, red, red)) {\n .hover\\:bg-primary\\/90:hover {\n background-color: color-mix(in oklab, var(--t3-primary) 90%, transparent);\n }\n }\n .hover\\:text-accent-foreground:hover {\n color: var(--t3-accent-foreground);\n }\n}\n.focus\\:border-b-primary:focus {\n border-bottom-color: var(--t3-primary);\n}\n.focus\\:ring-0:focus {\n --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);\n box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);\n}\n.focus\\:outline-none:focus {\n --tw-outline-style: none;\n outline-style: none;\n}\n.disabled\\:pointer-events-none:disabled {\n pointer-events: none;\n}\n.disabled\\:opacity-60:disabled {\n opacity: 60%;\n}\n:host {\n --t3-font-sans: "DM Sans Variable", "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,\n sans-serif;\n --t3-font-mono: "SF Mono", "SFMono-Regular", "JetBrains Mono", Consolas, "Liberation Mono", Menlo, monospace;\n --t3-radius: 0.625rem;\n --t3-background: white;\n --t3-foreground: oklch(0.269 0 0);\n --t3-popover: white;\n --t3-popover-foreground: oklch(0.269 0 0);\n --t3-primary: oklch(0.488 0.217 264);\n --t3-primary-foreground: white;\n --t3-muted: rgb(0 0 0 / 4%);\n --t3-muted-foreground: oklch(0.556 0 0);\n --t3-accent: rgb(0 0 0 / 4%);\n --t3-accent-foreground: oklch(0.269 0 0);\n --t3-border: rgb(0 0 0 / 8%);\n --t3-input: rgb(0 0 0 / 10%);\n --t3-ring: oklch(0.488 0.217 264);\n color: var(--t3-foreground);\n font-family: var(--t3-font-sans);\n}\n* {\n box-sizing: border-box;\n border-color: var(--t3-border);\n}\nbutton, input, select, textarea {\n font: inherit;\n}\nbutton:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {\n outline: 2px solid var(--t3-ring);\n @supports (color: color-mix(in lab, red, red)) {\n outline: 2px solid color-mix(in srgb, var(--t3-ring) 72%, transparent);\n }\n outline-offset: 1px;\n}\n@property --tw-translate-x {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-y {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-translate-z {\n syntax: "*";\n inherits: false;\n initial-value: 0;\n}\n@property --tw-border-style {\n syntax: "*";\n inherits: false;\n initial-value: solid;\n}\n@property --tw-leading {\n syntax: "*";\n inherits: false;\n}\n@property --tw-font-weight {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-inset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-inset-ring-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-inset-ring-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-ring-inset {\n syntax: "*";\n inherits: false;\n}\n@property --tw-ring-offset-width {\n syntax: "";\n inherits: false;\n initial-value: 0px;\n}\n@property --tw-ring-offset-color {\n syntax: "*";\n inherits: false;\n initial-value: #fff;\n}\n@property --tw-ring-offset-shadow {\n syntax: "*";\n inherits: false;\n initial-value: 0 0 #0000;\n}\n@property --tw-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-sepia {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-color {\n syntax: "*";\n inherits: false;\n}\n@property --tw-drop-shadow-alpha {\n syntax: "";\n inherits: false;\n initial-value: 100%;\n}\n@property --tw-drop-shadow-size {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-blur {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-brightness {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-contrast {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-grayscale {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-hue-rotate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-invert {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-opacity {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-saturate {\n syntax: "*";\n inherits: false;\n}\n@property --tw-backdrop-sepia {\n syntax: "*";\n inherits: false;\n}\n@layer properties {\n @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {\n *, ::before, ::after, ::backdrop {\n --tw-translate-x: 0;\n --tw-translate-y: 0;\n --tw-translate-z: 0;\n --tw-border-style: solid;\n --tw-leading: initial;\n --tw-font-weight: initial;\n --tw-shadow: 0 0 #0000;\n --tw-shadow-color: initial;\n --tw-shadow-alpha: 100%;\n --tw-inset-shadow: 0 0 #0000;\n --tw-inset-shadow-color: initial;\n --tw-inset-shadow-alpha: 100%;\n --tw-ring-color: initial;\n --tw-ring-shadow: 0 0 #0000;\n --tw-inset-ring-color: initial;\n --tw-inset-ring-shadow: 0 0 #0000;\n --tw-ring-inset: initial;\n --tw-ring-offset-width: 0px;\n --tw-ring-offset-color: #fff;\n --tw-ring-offset-shadow: 0 0 #0000;\n --tw-blur: initial;\n --tw-brightness: initial;\n --tw-contrast: initial;\n --tw-grayscale: initial;\n --tw-hue-rotate: initial;\n --tw-invert: initial;\n --tw-opacity: initial;\n --tw-saturate: initial;\n --tw-sepia: initial;\n --tw-drop-shadow: initial;\n --tw-drop-shadow-color: initial;\n --tw-drop-shadow-alpha: 100%;\n --tw-drop-shadow-size: initial;\n --tw-backdrop-blur: initial;\n --tw-backdrop-brightness: initial;\n --tw-backdrop-contrast: initial;\n --tw-backdrop-grayscale: initial;\n --tw-backdrop-hue-rotate: initial;\n --tw-backdrop-invert: initial;\n --tw-backdrop-opacity: initial;\n --tw-backdrop-saturate: initial;\n --tw-backdrop-sepia: initial;\n }\n }\n}\n'; From 0869ad648b67a286d7a47af878f1d256d0ff689f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 3 Sep 2026 15:21:07 -0700 Subject: [PATCH 09/45] fix(web): let the PR reviewer and label search boxes take keystrokes (#9479) Co-authored-by: Claude Code --- .../PullRequestCandidatePicker.tsx | 96 ++++++++++++++----- 1 file changed, 70 insertions(+), 26 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCandidatePicker.tsx b/apps/web/src/components/pullRequest/PullRequestCandidatePicker.tsx index b42061628d1e..c8dd196f299d 100644 --- a/apps/web/src/components/pullRequest/PullRequestCandidatePicker.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCandidatePicker.tsx @@ -2,12 +2,22 @@ * The menu shell the reviewer and label pickers share: an icon trigger, a search box, and a * scrolling body that says when the list is loading, could not be read, is empty, or is not all * of it. The rows and the words are the caller's; the frame is the same either way. + * + * The same combobox as the project and branch pickers, and dressed the same, rather than a menu: + * a menu's typeahead claims every keypress to jump between rows, which a search box cannot share. */ +import { SearchIcon } from "lucide-react"; import type { ReactNode } from "react"; import { Button } from "../ui/button"; -import { Input } from "../ui/input"; -import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; +import { + Combobox, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxPopup, + ComboboxTrigger, +} from "../ui/combobox"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PullRequestPeopleGhost } from "./PullRequestGhosts"; @@ -78,27 +88,61 @@ export function PullRequestCandidatePicker({ ); } + const keys = candidates.map(candidateKey); + return ( - - { + const candidate = candidates.find((entry) => candidateKey(entry) === key); + if (candidate) onSelect(candidate); + }} + open={open} + onOpenChange={(nextOpen, details) => { + // Stays open on a pick: a change is confirmed by the row's own check turning over, and a + // second label or reviewer is usually wanted right after the first. Cancelled rather than + // ignored, so the combobox also skips its own close work: freezing the query and returning + // focus to the trigger, either of which would take the next keystroke away from the box. + if (!nextOpen && details.reason === "item-press") { + details.cancel(); + return; + } + onOpenChange(nextOpen); + }} + > + {icon} } /> - -
- onQueryChange(event.currentTarget.value)} - placeholder={searchLabel} - aria-label={searchLabel} - size="compact" - /> + +
+
+
-
+ {isPending ? ( ) : error !== null ? ( @@ -110,18 +154,18 @@ export function PullRequestCandidatePicker({ {query.length > 0 ? noMatchLabel : emptyLabel}

) : ( - candidates.map((candidate) => ( - // Stays open on press: a change is confirmed by the row's own check turning over, - // and a second label or reviewer is usually wanted right after the first. - ( + onSelect(candidate)} className="min-h-0 py-1.5 text-xs sm:min-h-0 sm:text-xs" + contentClassName="flex min-w-0 items-center gap-2" > {children(candidate)} - + )) )} {truncated ? ( @@ -129,8 +173,8 @@ export function PullRequestCandidatePicker({ // list is rather than offering a search that would find nothing further.

{truncatedLabel}

) : null} -
- -
+ + + ); } From 77138cf33194a303adfbbd69cc93efa69f84245d Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:23:01 +0300 Subject: [PATCH 10/45] fix(web): dont collapse composer when interacting with bottom row (#9490) --- apps/web/src/components/BranchToolbar.tsx | 3 ++- apps/web/src/components/BranchToolbarBranchSelector.tsx | 8 +++++++- apps/web/src/components/BranchToolbarEnvModeSelector.tsx | 3 ++- .../src/components/BranchToolbarEnvironmentSelector.tsx | 3 ++- apps/web/src/components/chat/ChatComposer.tsx | 5 ++--- apps/web/src/components/chat/composerEventScope.test.ts | 7 +++++++ apps/web/src/components/chat/composerEventScope.ts | 1 + 7 files changed, 23 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 0496bef06ef6..07407bcf21b3 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -40,6 +40,7 @@ import { } from "./ui/menu"; import { Separator } from "./ui/separator"; import { ComposerSurface } from "./chat/ComposerSurface"; +import { composerFloatingLayerProps } from "./chat/composerEventScope"; import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; import { cn } from "~/lib/utils"; @@ -160,7 +161,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ {triggerContent} - + {showEnvironmentPicker && availableEnvironments && onEnvironmentChange ? ( <> diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 67f6cbe7b9c0..e968954ec1d0 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -34,6 +34,7 @@ import { vcsEnvironment } from "../state/vcs"; import { cn } from "../lib/utils"; import { parsePullRequestReference } from "../pullRequestReference"; import { getSourceControlPresentation } from "../sourceControlPresentation"; +import { composerFloatingLayerProps } from "./chat/composerEventScope"; import { deriveLocalBranchNameFromRemoteRef, resolveBranchTriggerLabel, @@ -788,7 +789,12 @@ export function BranchToolbarBranchSelector({
- +
- + Workspace diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 6304e37cf88d..fabda55688bc 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -3,6 +3,7 @@ import { memo, useMemo } from "react"; import type { EnvironmentOption } from "./BranchToolbar.logic"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; +import { composerFloatingLayerProps } from "./chat/composerEventScope"; import { Select, SelectGroup, @@ -101,7 +102,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir - + Run on {availableEnvironments.map((env) => ( diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 5f8376b431f7..90da57b948c4 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -4283,7 +4283,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerSurface = composerSurfaceRef.current; const composerForm = composerFormRef.current; const activeElement = document.activeElement; - if (activeElement instanceof Element && isInsideComposerFloatingLayer(activeElement)) { + if (isInsideRestingComposerControlScope(activeElement)) { return; } if ( @@ -4303,8 +4303,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const isInsideDesktopComposerFocusScope = (target: EventTarget | null) => Boolean( target instanceof Node && - (composerFormRef.current?.contains(target) || - (target instanceof Element && isInsideComposerFloatingLayer(target))), + (composerFormRef.current?.contains(target) || isInsideRestingComposerControlScope(target)), ); const handleFocusIn = (event: FocusEvent) => { if (!isInsideDesktopComposerFocusScope(event.target)) { diff --git a/apps/web/src/components/chat/composerEventScope.test.ts b/apps/web/src/components/chat/composerEventScope.test.ts index b559009ed43f..365c5304aa2d 100644 --- a/apps/web/src/components/chat/composerEventScope.test.ts +++ b/apps/web/src/components/chat/composerEventScope.test.ts @@ -29,6 +29,13 @@ describe("composer event scopes", () => { expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(true); }); + it("recognizes events from the composer context strip controls", () => { + vi.stubGlobal("Element", FakeElement); + + const target = new FakeElement("[data-composer-context-control]"); + expect(isInsideRestingComposerControlScope(target as unknown as EventTarget)).toBe(true); + }); + it("keeps resting image previews focused without expanding their subtree", () => { vi.stubGlobal("Element", FakeElement); diff --git a/apps/web/src/components/chat/composerEventScope.ts b/apps/web/src/components/chat/composerEventScope.ts index 60aedb096156..88e24fd89422 100644 --- a/apps/web/src/components/chat/composerEventScope.ts +++ b/apps/web/src/components/chat/composerEventScope.ts @@ -26,6 +26,7 @@ export function isInsideRestingComposerControlScope(target: EventTarget | null): target instanceof Element && (target.closest('[data-chat-composer-resting-controls="true"]') !== null || target.closest('[data-chat-composer-resting-images="true"]') !== null || + target.closest("[data-composer-context-control]") !== null || isInsideComposerFloatingLayer(target)) ); } From 678f23a69943e3eef7171f452b44f2931d2ef21f Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 18:34:23 -0400 Subject: [PATCH 11/45] fix(desktop): restore second-press quit fallback (#9485) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/desktop/src/window/QuitHold.test.ts | 6 +++--- apps/desktop/src/window/QuitHold.ts | 8 +++----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/window/QuitHold.test.ts b/apps/desktop/src/window/QuitHold.test.ts index fb12be2162c1..c4bf2f34b0a1 100644 --- a/apps/desktop/src/window/QuitHold.test.ts +++ b/apps/desktop/src/window/QuitHold.test.ts @@ -368,14 +368,14 @@ describe("makeQuitShortcutHandler", () => { expect(harness.notifications).toEqual([DOUBLE_CLICK_DOWN, UP, DOUBLE_CLICK_DOWN]); }); - it("does not treat two quick presses as a quit in hold mode", async () => { + it("quits on a quick second press in hold mode", async () => { const harness = makeHarness(); await harness.send(makeInput({})); await harness.send(makeInput({ type: "keyUp" })); vi.advanceTimersByTime(QUIT_DOUBLE_PRESS_MS - 100); await harness.send(makeInput({})); - expect(harness.quit).not.toHaveBeenCalled(); - expect(harness.notifications).toEqual([HOLD_DOWN, UP, HOLD_DOWN]); + expect(harness.quit).toHaveBeenCalledTimes(1); + expect(harness.notifications).toEqual([HOLD_DOWN, UP]); }); it("cancels the hold when another key interrupts it", async () => { diff --git a/apps/desktop/src/window/QuitHold.ts b/apps/desktop/src/window/QuitHold.ts index 7088f4f28ce8..4095e3d4354b 100644 --- a/apps/desktop/src/window/QuitHold.ts +++ b/apps/desktop/src/window/QuitHold.ts @@ -181,11 +181,9 @@ export function makeQuitShortcutHandler( quitNow(); return; } - if ( - resolvedMode === "double-click" && - previousPressAt !== 0 && - now - previousPressAt <= QUIT_DOUBLE_PRESS_MS - ) { + // Keep a second press as an escape hatch when macOS misses the events + // that would complete a hold. + if (previousPressAt !== 0 && now - previousPressAt <= QUIT_DOUBLE_PRESS_MS) { quitNow(); return; } From c726c30a148c2add6a3ec7f31f54ae48dc5d2f0c Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 18:48:44 -0400 Subject: [PATCH 12/45] fix(web): keep opencode icon hollow in collapsed composer (#9492) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/Icons.tsx | 14 ++++++++++++-- apps/web/src/components/chat/ChatComposer.tsx | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index cd0854e176b7..edb41868879e 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -653,9 +653,19 @@ export const AntigravityIcon: Icon = (props) => ( export const OpenCodeIcon: Icon = (props) => ( - + - + diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 90da57b948c4..df2f88f80a8e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3808,7 +3808,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeProviderIconClassName: cn( composerProviderState.modelPickerIconClassName, composerControlsInStrip && - "fill-muted-foreground/70! text-muted-foreground/70! [&_path]:fill-muted-foreground/70! [&_rect]:fill-muted-foreground/70!", + "fill-muted-foreground/70! text-muted-foreground/70! [&_path]:fill-muted-foreground/70! [&_rect]:fill-muted-foreground/70! [&_[data-opencode-hole]]:fill-transparent!", ), } : {})} From 12e8997e58dbca8f1bd8c63b67d662eb69cf0e0d Mon Sep 17 00:00:00 2001 From: maria Date: Thu, 3 Sep 2026 18:50:04 -0400 Subject: [PATCH 13/45] fix(web): keep agent browser preview visible (#9484) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/browser/BrowserSurfaceSlot.tsx | 10 ++-- apps/web/src/browser/HostedBrowserWebview.tsx | 2 + .../src/browser/browserSurfaceStore.test.ts | 13 +++++ apps/web/src/browser/browserSurfaceStore.ts | 20 ++++++-- .../browser/hostedBrowserWebviewStyle.test.ts | 2 + .../src/browser/hostedBrowserWebviewStyle.ts | 4 +- .../web/src/components/ChatView.logic.test.ts | 22 +++++++++ apps/web/src/components/ChatView.logic.ts | 13 +++++ apps/web/src/components/ChatView.tsx | 22 ++++----- apps/web/src/components/RightPanelSheet.tsx | 12 ++++- .../preview/PreviewAutomationHosts.tsx | 48 ++++++++++++++++++- .../preview/ThreadPreviewMiniPlayer.tsx | 12 +++-- .../previewAutomationOpenReadiness.test.ts | 47 ++++++++++++++++++ .../preview/previewAutomationOpenReadiness.ts | 15 ++++++ .../preview/previewMiniPlayerLayout.ts | 2 + .../settings/IntegrationsSettings.tsx | 2 +- apps/web/src/components/ui/sheet.tsx | 16 ++++--- apps/web/src/rightPanelLayout.ts | 2 + packages/contracts/src/settings.ts | 2 +- 19 files changed, 228 insertions(+), 38 deletions(-) diff --git a/apps/web/src/browser/BrowserSurfaceSlot.tsx b/apps/web/src/browser/BrowserSurfaceSlot.tsx index a9d3f541ff19..3de3ed586cb0 100644 --- a/apps/web/src/browser/BrowserSurfaceSlot.tsx +++ b/apps/web/src/browser/BrowserSurfaceSlot.tsx @@ -8,6 +8,7 @@ export function BrowserSurfaceSlot(props: { readonly tabId: string; readonly visible: boolean; readonly cornerRadius?: number; + readonly zIndex?: number; readonly layoutVersion?: string | number; readonly className?: string; readonly fitSourceContent?: boolean; @@ -16,12 +17,13 @@ export function BrowserSurfaceSlot(props: { tabId, visible, cornerRadius = 0, + zIndex = 30, layoutVersion, className, fitSourceContent = false, } = props; const elementRef = useRef(null); - const presentationRef = useRef({ visible, cornerRadius }); + const presentationRef = useRef({ visible, cornerRadius, zIndex }); const updateRef = useRef<(() => void) | null>(null); useLayoutEffect(() => { @@ -40,6 +42,7 @@ export function BrowserSurfaceSlot(props: { }, presentation.visible && rect.width > 0 && rect.height > 0, presentation.cornerRadius, + presentation.zIndex, ); if (presentation.visible && !presented) { lease.release(); @@ -53,6 +56,7 @@ export function BrowserSurfaceSlot(props: { }, rect.width > 0 && rect.height > 0, presentation.cornerRadius, + presentation.zIndex, ); } }; @@ -72,9 +76,9 @@ export function BrowserSurfaceSlot(props: { }, [fitSourceContent, tabId]); useLayoutEffect(() => { - presentationRef.current = { visible, cornerRadius }; + presentationRef.current = { visible, cornerRadius, zIndex }; updateRef.current?.(); - }, [cornerRadius, layoutVersion, visible]); + }, [cornerRadius, layoutVersion, visible, zIndex]); return
; } diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 582d5686ec0f..0f01960ce52b 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -83,6 +83,7 @@ export function HostedBrowserWebview(props: { fittedSourceContent: current?.fittedSourceContent ?? null, rect: resolveBrowserSurfacePanelRect(state.byTabId, runtimeTabId), visible: current?.visible ?? false, + zIndex: current?.zIndex ?? 30, }; }), ); @@ -259,6 +260,7 @@ export function HostedBrowserWebview(props: { // suspend them, and automation continues to see the macOS guests as inactive. keepPaintableWhenInactive: isMacPlatform(navigator.platform), cornerRadius: presentation.cornerRadius, + zIndex: presentation.zIndex, rect: lastRect, hiddenSize, }); diff --git a/apps/web/src/browser/browserSurfaceStore.test.ts b/apps/web/src/browser/browserSurfaceStore.test.ts index 249d3dcb2f44..456377a4d641 100644 --- a/apps/web/src/browser/browserSurfaceStore.test.ts +++ b/apps/web/src/browser/browserSurfaceStore.test.ts @@ -107,6 +107,7 @@ describe("browserSurfaceStore", () => { hidden: { rect: staleRect, visible: false, + zIndex: 30, content: null, fittedSourceContent: null, fitSourceContent: false, @@ -117,6 +118,7 @@ describe("browserSurfaceStore", () => { active: { rect: liveRect, visible: true, + zIndex: 30, content: null, fittedSourceContent: null, fitSourceContent: false, @@ -162,6 +164,17 @@ describe("browserSurfaceStore", () => { }); }); + it("keeps the requested layer with the active surface lease", () => { + const tabId = "layered-browser-surface"; + const lease = acquireBrowserSurface(tabId); + lease.present({ x: 10, y: 20, width: 320, height: 200 }, true, 12, 48); + + expect(useBrowserSurfaceStore.getState().byTabId[tabId]).toMatchObject({ + visible: true, + zIndex: 48, + }); + }); + it("clears fitted presentation state when its lease is released", () => { const tabId = "released-fitted-browser-surface"; const fittedLease = acquireBrowserSurface(tabId, true); diff --git a/apps/web/src/browser/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts index fe85c9e38b21..a49154ed8def 100644 --- a/apps/web/src/browser/browserSurfaceStore.ts +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -10,6 +10,7 @@ export interface BrowserSurfaceRect { export interface BrowserSurfacePresentation { readonly rect: BrowserSurfaceRect | null; readonly visible: boolean; + readonly zIndex: number; readonly content: BrowserSurfaceContentPresentation | null; readonly fittedSourceContent: BrowserSurfaceContentPresentation | null; readonly fitSourceContent: boolean; @@ -39,13 +40,19 @@ interface BrowserSurfaceStoreState { rect: BrowserSurfaceRect, visible: boolean, cornerRadius: number, + zIndex: number, ) => void; readonly presentContent: (tabId: string, content: BrowserSurfaceContentPresentation) => void; readonly release: (tabId: string, owner: symbol) => void; } export interface BrowserSurfaceLease { - readonly present: (rect: BrowserSurfaceRect, visible: boolean, cornerRadius?: number) => boolean; + readonly present: ( + rect: BrowserSurfaceRect, + visible: boolean, + cornerRadius?: number, + zIndex?: number, + ) => boolean; readonly release: () => void; } @@ -97,6 +104,7 @@ export const useBrowserSurfaceStore = create()((set) = [tabId]: { rect: current?.rect ?? null, visible: false, + zIndex: current?.zIndex ?? 30, content: current?.content ?? null, fittedSourceContent: fitSourceContent ? (current?.content ?? null) : null, fitSourceContent, @@ -107,7 +115,7 @@ export const useBrowserSurfaceStore = create()((set) = }, }; }), - present: (tabId, owner, rect, visible, cornerRadius) => + present: (tabId, owner, rect, visible, cornerRadius, zIndex) => set((state) => { const current = state.byTabId[tabId]; if (current?.owner !== owner) return state; @@ -115,6 +123,7 @@ export const useBrowserSurfaceStore = create()((set) = current && current.visible === visible && current.cornerRadius === cornerRadius && + current.zIndex === zIndex && rectEquals(current.rect, rect) ) { return state; @@ -122,7 +131,7 @@ export const useBrowserSurfaceStore = create()((set) = return { byTabId: { ...state.byTabId, - [tabId]: { ...current, rect, visible, cornerRadius, updatedAt: Date.now() }, + [tabId]: { ...current, rect, visible, cornerRadius, zIndex, updatedAt: Date.now() }, }, }; }), @@ -136,6 +145,7 @@ export const useBrowserSurfaceStore = create()((set) = [tabId]: { rect: null, visible: false, + zIndex: 30, content, fittedSourceContent: null, fitSourceContent: false, @@ -206,10 +216,10 @@ export function acquireBrowserSurface( useBrowserSurfaceStore.getState().claim(tabId, owner, fitSourceContent); return { - present: (rect, visible, cornerRadius = 0) => { + present: (rect, visible, cornerRadius = 0, zIndex = 30) => { if (released) return false; if (useBrowserSurfaceStore.getState().byTabId[tabId]?.owner !== owner) return false; - useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible, cornerRadius); + useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible, cornerRadius, zIndex); return true; }, release: () => { diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts index 69216796af9f..831167095fa1 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.test.ts @@ -30,6 +30,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { active: true, renderingActive: true, cornerRadius: 12, + zIndex: 48, rect: { x: 12, y: 34, width: 360, height: 203 }, hiddenSize: { width: 1280, height: 800 }, }), @@ -39,6 +40,7 @@ describe("resolveHostedBrowserWebviewWrapperStyle", () => { width: 360, height: 203, borderRadius: 12, + zIndex: 48, }); }); diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.ts index a59a4a8b0083..5bdf9b7c4f6d 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.ts @@ -23,6 +23,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { readonly renderingActive: boolean; readonly keepPaintableWhenInactive?: boolean; readonly cornerRadius?: number; + readonly zIndex?: number; readonly rect: BrowserSurfaceRect | null; readonly hiddenSize: HostedBrowserWebviewSize; }): HostedBrowserWebviewWrapperStyle { @@ -33,6 +34,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { keepPaintableWhenInactive = false, rect, renderingActive, + zIndex = 30, } = input; if (active && rect) { return { @@ -40,7 +42,7 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { top: rect.y, width: rect.width, height: rect.height, - zIndex: 30, + zIndex, pointerEvents: "auto", ...(cornerRadius > 0 ? { borderRadius: cornerRadius } : {}), }; diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 7649d6b50e49..4cc750d45236 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -50,6 +50,7 @@ import { shouldReleaseTimelineAnchorForToolActivity, shouldOpenProactivePullRequest, shouldOpenProactiveTurnDiff, + shouldRenderPreviewMiniPlayer, shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, shouldWriteThreadErrorToCurrentServerThread, @@ -93,6 +94,27 @@ describe("agent browser close confirmation", () => { }); }); +describe("floating browser preview", () => { + it("only hides the duplicate while the same browser is rendered in the panel", () => { + expect(shouldRenderPreviewMiniPlayer(null, null)).toBe(false); + expect( + shouldRenderPreviewMiniPlayer("tab-1", { + id: "browser:one", + kind: "preview", + resourceId: "tab-1", + }), + ).toBe(false); + expect( + shouldRenderPreviewMiniPlayer("tab-1", { + id: "browser:two", + kind: "preview", + resourceId: "tab-2", + }), + ).toBe(true); + expect(shouldRenderPreviewMiniPlayer("tab-1", { id: "diff", kind: "diff" })).toBe(true); + }); +}); + describe("proactive panels", () => { it("opens a pull request only after a newly observed link appears", () => { expect(shouldOpenProactivePullRequest(undefined, "project:repo:42")).toBe(false); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index cef14f240d97..46ff8c473c6d 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -86,6 +86,19 @@ export function agentControlledBrowserCloseConfirmation( ].join("\n"); } +export function shouldRenderPreviewMiniPlayer( + miniPlayerTabId: string | null, + renderedRightPanelSurface: RightPanelSurface | null, +): boolean { + return ( + miniPlayerTabId !== null && + !( + renderedRightPanelSurface?.kind === "preview" && + renderedRightPanelSurface.resourceId === miniPlayerTabId + ) + ); +} + export function shouldOpenProactivePullRequest( previousTargetKey: string | null | undefined, targetKey: string | null, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 0d33aec7bc20..187bc1f5f984 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -366,6 +366,7 @@ import { shouldShowPlanFollowUpPrompt, shouldOpenProactivePullRequest, shouldOpenProactiveTurnDiff, + shouldRenderPreviewMiniPlayer, getStartedThreadModelChangeBlockReason, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, @@ -1847,6 +1848,10 @@ function ChatViewContent(props: ChatViewProps) { rightPanelPresent && (!shouldUseRightPanelSheet || rightPanelOpen); const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; + const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer( + activePreviewMiniPlayer?.tabId ?? null, + renderedRightPanelSurface, + ); const canMaximizeRightPanel = rightPanelOpen && !shouldUseRightPanelSheet; const rightPanelMaximized = canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; @@ -1862,20 +1867,10 @@ function ChatViewContent(props: ChatViewProps) { useEffect(() => { if (!activeThreadRef || !activePreviewMiniPlayer) return; const miniTabStillExists = Boolean(activePreviewState.sessions[activePreviewMiniPlayer.tabId]); - const sameTabOpenInPanel = - previewPanelOpen && - activeRightPanelSurface?.kind === "preview" && - activeRightPanelSurface.resourceId === activePreviewMiniPlayer.tabId; - if (!miniTabStillExists || sameTabOpenInPanel) { + if (!miniTabStillExists) { usePreviewMiniPlayerStore.getState().close(activeThreadRef); } - }, [ - activePreviewMiniPlayer, - activePreviewState.sessions, - activeRightPanelSurface, - activeThreadRef, - previewPanelOpen, - ]); + }, [activePreviewMiniPlayer, activePreviewState.sessions, activeThreadRef]); const existingOpenTerminalThreadKeys = useMemo(() => { const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); @@ -7919,7 +7914,7 @@ function ChatViewContent(props: ChatViewProps) {
- {activeThreadRef && activePreviewMiniPlayer ? ( + {activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible ? ( void; }) { return ( @@ -23,6 +27,12 @@ export function RightPanelSheet(props: { side="right" showCloseButton={false} keepMounted + {...(props.underFloatingPreview + ? { + backdropClassName: RIGHT_PANEL_SHEET_LAYER_CLASS_NAME, + viewportClassName: RIGHT_PANEL_SHEET_LAYER_CLASS_NAME, + } + : {})} className={RIGHT_PANEL_SHEET_CLASS_NAME} > {props.children} diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 1faf928b1cf5..54c2e1d9cf68 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -20,7 +20,7 @@ import { type ScopedThreadRef, } from "@t3tools/contracts"; import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; -import { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { Atom } from "effect/unstable/reactivity"; import { @@ -29,7 +29,7 @@ import { reconcilePreviewServerSessions, updatePreviewServerSnapshot, } from "~/previewStateStore"; -import { usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; +import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore } from "~/previewMiniPlayerStore"; import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; import { readActiveBrowserRecordingTargets, @@ -59,8 +59,10 @@ import { PreviewAutomationViewportTimeoutError, } from "./previewAutomationErrors"; import { + explicitlySuppressesPreviewMiniPlayer, previewAutomationDefaultViewport, previewAutomationOpenNeedsOverlay, + shouldAutoShowPreviewForAutomationUse, shouldOpenPreviewMiniPlayer, } from "./previewAutomationOpenReadiness"; import { @@ -311,6 +313,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) ); const [automationConnectionAtom] = useState(() => Atom.make(null)); const automationConnectionId = useAtomValue(automationConnectionAtom); + const presentationSuppressedRuntimeTabsRef = useRef(new Map>()); const handleRequest = useCallback( async (request: PreviewAutomationRequest): Promise => { @@ -353,6 +356,21 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) } const readyState = readThreadPreviewState(threadRef); const runtimeTabId = previewRuntimeTabId(threadRef, readyState.serverEpoch, readyTabId); + if (request.operation !== "open") { + const { autoShowFloatingPreview } = await resolveBrowserDefaults(); + if ( + shouldAutoShowPreviewForAutomationUse({ + operation: request.operation, + autoShowFloatingPreview, + presentationSuppressed: + presentationSuppressedRuntimeTabsRef.current + .get(request.threadId) + ?.has(runtimeTabId) ?? false, + }) + ) { + usePreviewMiniPlayerStore.getState().open(threadRef, readyTabId); + } + } browserActivity.release ??= acquireBrowserSurfaceActivity(runtimeTabId); await waitForDesktopOverlay( threadRef, @@ -450,6 +468,32 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) input, (await resolveBrowserDefaults()).autoShowFloatingPreview, ); + const explicitlySuppressed = explicitlySuppressesPreviewMiniPlayer(input); + const suppressedTabs = presentationSuppressedRuntimeTabsRef.current.get( + request.threadId, + ); + if (explicitlySuppressed) { + if (suppressedTabs) { + suppressedTabs.add(activeRuntimeTabId); + } else { + presentationSuppressedRuntimeTabsRef.current.set( + request.threadId, + new Set([activeRuntimeTabId]), + ); + } + const miniPlayer = selectThreadPreviewMiniPlayer( + usePreviewMiniPlayerStore.getState().byThreadKey, + threadRef, + ); + if (miniPlayer?.tabId === activeTabId) { + usePreviewMiniPlayerStore.getState().close(threadRef); + } + } else if (shouldPresentPreview) { + suppressedTabs?.delete(activeRuntimeTabId); + if (suppressedTabs?.size === 0) { + presentationSuppressedRuntimeTabsRef.current.delete(request.threadId); + } + } if (shouldPresentPreview) { usePreviewMiniPlayerStore.getState().open(threadRef, activeTabId); } diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 623928d102ef..dc2d9f5a96ea 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -19,6 +19,7 @@ import { clampPreviewMiniPlayerSize, PREVIEW_MINI_PLAYER_DEFAULT_SIZE, PREVIEW_MINI_PLAYER_EDGE_GAP, + PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX, } from "./previewMiniPlayerLayout"; interface DragState { @@ -243,7 +244,7 @@ export function ThreadPreviewMiniPlayer({ threadRef, tabId, bottomInset }: Props } } > -
+