diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 611d4cf44f75..f65c15696d2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,9 @@ jobs: - name: Check run: vp check + - name: Check className overrides on components/ui do not grow + run: vp run lint:restyle-ceiling + - name: Typecheck run: vpr typecheck diff --git a/AGENTS.md b/AGENTS.md index 38df1e94fa8c..2f3265d7cafd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -158,6 +158,7 @@ Full glossary with file links: `docs/internals/glossary.md` ## Taste - Complexity belongs at the adapter boundary. Orchestration stays pure, UI stays dumb. +- `apps/web/src/components/ui` exports own their look. Pick a `variant` or `size`; do not restyle one with `className`. If none fits, add a variant to the component, not classes at the call site. Layout classes (width, flex, margin, position) belong on the parent. `shadcn/no-restyle` reports violations and CI caps their count. - Inferred types over annotations. `any` is the enemy. - Comments describe how a thing is used, and move when the code moves. To be used mostly to describe functions, not to annotate every line of behavior. - Our users drive agents all day and notice a dropped frame, a lying spinner, and a stale label. No continuously repainting animations; they peg the GPU on high-refresh displays. diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 96d6b10d3839..601917d35864 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -7155,27 +7155,27 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { const values = Map.prototype.values; yield* Effect.acquireRelease( Effect.sync(() => - vi - .spyOn(Map.prototype, "values") - .mockImplementation(function (this: Map) { - const iterator = values.call(this); - const next = iterator.next.bind(iterator); - iterator.next = () => { - const result = next(); - const value: unknown = result.value; - if ( - typeof value === "object" && - value !== null && - "id" in value && - typeof value.id === "string" && - value.id.startsWith("history-part-") - ) { - visitedHistoryParts += 1; - } - return result; - }; - return iterator; - }), + vi.spyOn(Map.prototype, "values").mockImplementation(function ( + this: Map, + ) { + const iterator = values.call(this); + const next = iterator.next.bind(iterator); + iterator.next = () => { + const result = next(); + const value: unknown = result.value; + if ( + typeof value === "object" && + value !== null && + "id" in value && + typeof value.id === "string" && + value.id.startsWith("history-part-") + ) { + visitedHistoryParts += 1; + } + return result; + }; + return iterator; + }), ), (spy) => Effect.sync(() => spy.mockRestore()), ); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 42d5bcfb35b8..6c303afb27e9 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -156,9 +156,18 @@ export function HostedBrowserWebview(props: { } }, recovery.delayMs); }; + // A click inside the guest only reaches this document as a webview focus + // event, so open menus and popovers never see the outside press that + // would dismiss them. Replay it as a pointerdown on the webview itself. + const dismissHostPopups = () => { + webview.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" }), + ); + }; webview.addEventListener("did-attach", register); webview.addEventListener("dom-ready", register); webview.addEventListener("render-process-gone", recoverGuest); + webview.addEventListener("focus", dismissHostPopups); register(); return () => { disposed = true; @@ -166,6 +175,7 @@ export function HostedBrowserWebview(props: { webview.removeEventListener("did-attach", register); webview.removeEventListener("dom-ready", register); webview.removeEventListener("render-process-gone", recoverGuest); + webview.removeEventListener("focus", dismissHostPopups); }; }, [clientSettingsHydrated, config, initialSrc, runtimeTabId, webviewGeneration]); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 52b45ab8405c..14d871c41804 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -244,7 +244,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { side="left" collapsible="offcanvas" data-app-sidebar="" - className="border-r border-sidebar-border bg-sidebar text-sidebar-foreground" + className="border-r border-sidebar-border" resizable={{ maxWidth: sidebarMaximumWidth, minWidth: THREAD_SIDEBAR_MIN_WIDTH, diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 9838593a23f7..ed8f1cd1f026 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -861,7 +861,7 @@ export function BranchToolbarBranchSelector({
No refs found.
- + ref={branchListRef} data={filteredBranchPickerItems} @@ -905,7 +905,7 @@ export function BranchToolbarBranchSelector({ className="flex cursor-pointer items-center justify-between gap-3 border-t border-border/60 px-3 py-2 text-xs" > - - + {copy.title} {copy.description ? ( diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index fd29acbff3f7..21cd54792d11 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -856,7 +856,7 @@ export default function DiffPanel({ /> } > - + {isRefreshingDiff ? "Refreshing diff…" : "Refresh diff"} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 92c28560135e..4eca7867132e 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -851,7 +851,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { aria-live="polite" className="flex items-center gap-2 rounded-md border border-input bg-muted/40 px-3 py-2 text-xs text-muted-foreground dark:border-transparent dark:bg-white/[0.035]" > - + Publishing repository to {publishProviderLabel}...
) : null} @@ -938,7 +938,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { ); diff --git a/apps/web/src/components/device/DeviceLoadingView.tsx b/apps/web/src/components/device/DeviceLoadingView.tsx index 332d3994cca1..1a320c30f8b9 100644 --- a/apps/web/src/components/device/DeviceLoadingView.tsx +++ b/apps/web/src/components/device/DeviceLoadingView.tsx @@ -27,7 +27,7 @@ export function DeviceLoadingView(props: { ) : null}
- {!props.error ? : null} + {!props.error ? : null} {props.message}
{props.children} diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index 130353943352..f46a9fbf8ed7 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -367,7 +367,7 @@ export function DevicePanel(props: { onClick={() => void selectDevice(deviceKey(device))} action={ pendingDeviceKey === deviceKey(device) ? ( - + ) : ( {device.booted ? "Open" : "Start"} diff --git a/apps/web/src/components/device/DeviceSetup.tsx b/apps/web/src/components/device/DeviceSetup.tsx index 5ef41b4cefd9..9a77fe925715 100644 --- a/apps/web/src/components/device/DeviceSetup.tsx +++ b/apps/web/src/components/device/DeviceSetup.tsx @@ -203,7 +203,7 @@ export function DeviceHubSetupStatus({ if (!pending && state.hostStatus !== "ready") return null; return (

- {pending ? : } + {pending ? : } {pending ? state.hostStatus === "installing" ? compact @@ -235,7 +235,7 @@ function DevicePlatformSetup(props: { You can use either platform. Fixing a missing platform does not block the other one.

@@ -262,7 +262,7 @@ export function AgentDeviceSetupStatus(props: { : "Updating agent access…"; return (

- + {label}

); diff --git a/apps/web/src/components/device/DeviceToolsPanel.tsx b/apps/web/src/components/device/DeviceToolsPanel.tsx index 7f3984257ecd..79f84529aebb 100644 --- a/apps/web/src/components/device/DeviceToolsPanel.tsx +++ b/apps/web/src/components/device/DeviceToolsPanel.tsx @@ -173,7 +173,7 @@ export function DeviceToolsPanel(props: { >
Tools - {pending ? : null} + {pending ? : null}
) : null} diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 1d89d58bc67f..9e9464ca4516 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -169,7 +169,7 @@ function WorkspaceImagePreview(props: { ) : (
- +
); } @@ -214,7 +214,7 @@ function WorkspaceBrowserPreview(props: { if (assetUrl._tag !== "Success") { return (
- +
); } @@ -1232,7 +1232,7 @@ export default function FilePreviewPanel({ ) : relativePath && file.data === null ? (
- +
) : relativePath && file.data ? ( isMarkdown && renderMarkdown ? ( diff --git a/apps/web/src/components/files/fileSurfaceChrome.tsx b/apps/web/src/components/files/fileSurfaceChrome.tsx index 6c7c9eb284be..4425ba6cd5e8 100644 --- a/apps/web/src/components/files/fileSurfaceChrome.tsx +++ b/apps/web/src/components/files/fileSurfaceChrome.tsx @@ -131,7 +131,7 @@ export function FileSurfaceLoading(props: { readonly className?: string }) { props.className, )} > - + ); } diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index 5c6517e154a1..7e8dea7f2dea 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -1162,7 +1162,7 @@ function ImportStep({

Your projects

- +

Looking for projects from Claude Code and Codex…

@@ -1229,7 +1229,7 @@ function ImportStep({ ) : null} {scan.isPending && scan.data === null ? (
- + Looking for projects…
) : scan.error !== null ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestActivityUnavailableState.tsx b/apps/web/src/components/pullRequest/PullRequestActivityUnavailableState.tsx index 2aa1413438ee..388aacaf0e81 100644 --- a/apps/web/src/components/pullRequest/PullRequestActivityUnavailableState.tsx +++ b/apps/web/src/components/pullRequest/PullRequestActivityUnavailableState.tsx @@ -23,7 +23,7 @@ export function PullRequestActivityUnavailableState({

Could not load pull request activity

{error}

diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 08a46872b697..131593ab6d22 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -18,13 +18,11 @@ import { Columns2Icon, FolderTreeIcon, InfoIcon, - MessageSquareIcon, MessageSquareOffIcon, PilcrowIcon, Rows3Icon, TextWrapIcon, TriangleAlertIcon, - XIcon, } from "lucide-react"; import { useAtomRefresh } from "@effect/atom-react"; import * as Schema from "effect/Schema"; @@ -78,7 +76,6 @@ import { toastManager } from "../ui/toast"; import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { PendingReviewCommentCard, ReviewThreadCard } from "./PullRequestReviewAnnotation"; -import { PullRequestReviewBar } from "./PullRequestReviewBar"; import { isFileDiffCollapsed, isLineInFileDiff, @@ -247,9 +244,6 @@ function PullRequestCodeTab({ const [draft, setDraft] = useState(null); const [threadPending, setThreadPending] = useState(false); const [orphansOpen, setOrphansOpen] = useState(false); - // Closed by default so the review form does not permanently eat vertical space below the - // diff; opened on demand as a floating overlay instead. - const [reviewOpen, setReviewOpen] = useState(false); // Which pull request the slices belong to travels with them, so a render taken before the // reset below cannot read the previous one's slices — or send its cursor to the host. const [sliceState, setSliceState] = useState<{ @@ -378,7 +372,6 @@ function PullRequestCodeTab({ inlineComment: hostReview.inlineComment && viewer.comment, reply: hostReview.reply && viewer.comment, resolve: hostReview.resolve && viewer.resolve, - verdicts: hostReview.verdicts.filter((verdict) => viewer.verdicts.includes(verdict)), }; }, [detail.capabilities.review, detail.viewerPermissions]); // A comment is posted against the pull request's head diff, so a line number taken from one @@ -1054,70 +1047,6 @@ function PullRequestCodeTab({ ], ); - /** - * The review overlay belongs to the pull request, not to the patch: a change whose diff - * cannot be structured — or read at all — is still one a reviewer can approve or reject, so - * it survives every branch below. It floats over the scroll area rather than sitting in the - * layout flow, so the diff keeps the full height instead of permanently losing a strip to a - * footer most reviews never touch. Hidden entirely where the host offers no verdicts, same as - * the bar it wraps did. - */ - const reviewOverlay = - review.verdicts.length === 0 ? null : ( -
- {reviewOpen ? ( -
- - { - onRefresh(); - setReviewOpen(false); - }} - /> -
- ) : ( - - )} -
- ); // A rebase or a force-push can take the scoped commit out of the change. Its diff may still // be reachable on the host, but it is no longer part of what is being reviewed, so the scope // goes back to the whole change rather than sitting under a name nothing matches. @@ -1392,29 +1321,23 @@ function PullRequestCodeTab({ ); // The toolbar rides above every branch below, not just the one with a patch in it: a commit // whose diff is empty or unreadable still needs the scope dropdown that got the reader there. - const withReviewBar = (body: ReactNode) => ( + const withToolbar = (body: ReactNode) => (
{toolbar} - {/* The overlay is anchored to this wrapper, not the scroller: absolute positioning - inside an overflowing element tracks the content's bottom edge, which would carry - the trigger away with the first scroll. */} -
-
{body}
- {reviewOverlay} -
+
{body}
); // Under the toolbar rather than in place of it, so choosing a commit does not take the // dropdown that was just used off the screen while its diff loads. if (diffQuery.isPending && loadedSlices.length === 0) { - return withReviewBar(); + return withToolbar(); } // A slice that fails once there are files on screen is reported at the end of them instead: // the diff already read is worth more than the error that stopped it growing. if (diffQuery.error && loadedSlices.length === 0) { - return withReviewBar( + return withToolbar(

{diffQuery.error}

, ); } @@ -1428,7 +1351,7 @@ function PullRequestCodeTab({ ? parsedSlices.flatMap((parsed) => (parsed?.kind === "raw" ? [parsed] : [])) : []; if (files.length === 0 && rawSlices.length > 0) { - return withReviewBar( + return withToolbar(
{rawSlices.map((slice) => (
@@ -1441,7 +1364,7 @@ function PullRequestCodeTab({ } if (items.length === 0 && nextCursor === null) { - return withReviewBar( + return withToolbar(

{commit === null ? "This pull request has no file changes." @@ -1595,7 +1518,6 @@ function PullRequestCodeTab({ renderAnnotation={renderAnnotation} unsafeCSSExtra={REPLACE_FILE_COUNTS_CSS} /> - {reviewOverlay}

{fileTreeOpen ? (