From e25a4f20bd83af6946e6e589415ec41138532f49 Mon Sep 17 00:00:00 2001 From: Marco Almazan Date: Wed, 12 Aug 2026 21:39:30 -0700 Subject: [PATCH] =?UTF-8?q?fix(web,desktop,mobile):=20QA=20sweep=20?= =?UTF-8?q?=E2=80=94=20naming,=20titlebar,=20nav,=20tooltips,=20terminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One commit rather than several: the pre-commit hook re-stages the whole working tree, so splitting these by concern was not achievable without disabling it. Branding — "(Alpha)" dropped from the product name across desktop, web and mobile. An empty stage label now renders the bare name; mobile shows the stage as a separate badge, so there it hides the badge instead. Renaming the bundle moves Electron's userData directory, so legacyUserDataDirName becomes a list with "Marcode (Alpha)" alongside "T3 Code (Alpha)", oldest first. Candidates are probed concurrently — probing sequentially added enough filesystem latency for Electron's ready event to beat protocol.registerSchemesAsPrivileged, crashing the packaged app on launch. Mocked filesystems make sequential and concurrent probes indistinguishable, which is why the suite could not see it. Also collapses a duplicate stage-label branch in mobile's BrandMark that labelled nightly builds "Preview" while the shared helper called the same build "Nightly". Titlebar — settings and usage rendered their breadcrumb under the macOS traffic lights. Two causes: the inset class keys off a `data-sidebar-state` ancestor, and Marcode mounts no sidebar on those routes; and --workspace-titlebar-content-left was declared only on the sidebar wrapper, so it would not have resolved even if the selector matched. The variable moves to :root (`.wco` also lands there, so its override still feeds the calc) and sidebarless surfaces get an unconditional inset. Navigation — removes the /settings/beta entry, dead since upstream deleted that page when sidebar v2 became the default. Moves Pull Requests from Settings into the home category: it is a destination, not a preference, and getActiveCategory already resolved the route there, so the wrong pill highlighted. Usage stays under Settings. The pill could also run past the window edge — `no-scrollbar` was referenced in two places and defined nowhere, leaving a raw scrollbar visible, and the width cap was a flat max-w-[100vw] with no margin. The cap is now derived from the pill's own dock offset and scale, and the same unguarded overflow on the vertical dock axis is fixed alongside. Tooltips — 14 icon-only controls across the pill nav, the pull-request surfaces and the usage page had no hover description. Includes PullRequestReviewerPicker, where the disabled state carried a tooltip and the enabled one did not. Open-in picker — the popover used Base UI's default closeDelay of 0, so the pointer could not cross the trigger gap before it dismissed. Now uses the delay/closeDelay pair every sibling pill already uses. Rows gained the oversized watermark treatment from PillNavCard, via a token colour so it holds in both themes. Terminal — xterm.js was imported without its stylesheet, anywhere. The rule hiding `.xterm-char-measure-element` therefore never applied, and xterm's char-width scratchpad rendered its 32-repeat probe strings as visible text above the first prompt. That is the reported "333...MMM at terminal startup" garbage: never terminal data, which is why the PTY, sanitiser, history files and websocket frames were all clean. Separately, trimBufferToBytes was UTF-8-safe but ANSI-blind and could cut mid-sequence; it now cuts only at escape-sequence boundaries, found by scanning forward from a known-safe anchor. Typography — 10 text classes across the sidebar, chat, settings and clerk surfaces reconciled to Marcode's scale. Four trace to upstream commits that merged cleanly and were never reconciled; the rest are Marcode's own drift. Deliberately untouched: the unified workspace tree's mono register, the jump-hint badge tier, and the shadcn responsive `text-base sm:text-sm` convention. Also adds .marcode to .gitignore — the dev runner defaults worktree-local state there and only upstream's `.t3` was listed, leaving it one `git add -A` from being committed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EwikZhkf3NAa9KQkULRSre --- .gitignore | 4 + apps/desktop/package.json | 2 +- apps/desktop/scripts/electron-launcher.mjs | 2 +- .../src/app/DesktopAppIdentity.test.ts | 100 ++++++++++- apps/desktop/src/app/DesktopAppIdentity.ts | 66 ++++++-- apps/desktop/src/app/DesktopClerk.test.ts | 4 +- apps/desktop/src/app/DesktopEnvironment.ts | 21 ++- .../src/window/DesktopApplicationMenu.test.ts | 2 +- apps/mobile/src/components/BrandMark.tsx | 16 +- .../src/components/CompactBrandTitle.tsx | 36 ++-- apps/mobile/src/features/home/HomeHeader.tsx | 12 +- apps/mobile/src/lib/mobileBranding.test.ts | 4 +- apps/mobile/src/lib/mobileBranding.ts | 5 +- apps/web/index.html | 2 +- apps/web/src/branding.logic.ts | 3 +- apps/web/src/branding.test.ts | 36 +++- apps/web/src/branding.ts | 2 +- apps/web/src/components/ChatMarkdown.tsx | 2 +- apps/web/src/components/FloatingPillNav.tsx | 99 +++++++---- apps/web/src/components/PillNavHoverCard.tsx | 24 ++- apps/web/src/components/Sidebar.tsx | 2 +- .../ThreadTerminalDrawer.stylesheet.test.ts | 35 ++++ .../src/components/ThreadTerminalDrawer.tsx | 9 + .../chat/ComposerPendingApprovalPanel.tsx | 4 +- .../src/components/chat/MessagesTimeline.tsx | 4 +- apps/web/src/components/chat/ModelListRow.tsx | 2 +- apps/web/src/components/chat/OpenInPicker.tsx | 38 ++++- .../clerk/MobileClientsUserProfilePage.tsx | 4 +- .../pullRequest/PullRequestCodeTab.tsx | 92 +++++++---- .../pullRequest/PullRequestDetailPanel.tsx | 43 +++-- .../pullRequest/PullRequestListFilters.tsx | 40 +++-- .../PullRequestReviewAnnotation.tsx | 26 ++- .../pullRequest/PullRequestReviewerPicker.tsx | 19 ++- .../pullRequest/PullRequestTimelineTab.tsx | 26 ++- .../settings/ProjectSettingsPanel.tsx | 2 +- .../components/sidebar/SidebarUpdatePill.tsx | 2 +- apps/web/src/components/usage/UsagePage.tsx | 31 ++-- apps/web/src/index.css | 21 ++- apps/web/src/routes/_chat.pull-requests.tsx | 47 ++++-- apps/web/src/routes/settings.tsx | 7 +- apps/web/src/workspaceTitlebar.fork.test.ts | 45 +++++ apps/web/src/workspaceTitlebar.ts | 7 + .../src/state/terminalSession.test.ts | 58 +++++++ .../src/state/terminalSession.ts | 155 +++++++++++++++++- packages/contracts/src/ipc.ts | 5 +- scripts/build-desktop-artifact.test.ts | 2 +- 46 files changed, 914 insertions(+), 254 deletions(-) create mode 100644 apps/web/src/components/ThreadTerminalDrawer.stylesheet.test.ts create mode 100644 apps/web/src/workspaceTitlebar.fork.test.ts diff --git a/.gitignore b/.gitignore index 022c432affe6..a9c3f182334b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,10 @@ build/ release/ release-mock/ .t3 +# Marcode's worktree-local dev state. The dev runner defaults a linked worktree +# here, and tooling already documents it as ignored — `.t3` alone is upstream's +# name and never got renamed with the rest of the fork. +.marcode .idea/ apps/web/.playwright apps/web/playwright-report diff --git a/apps/desktop/package.json b/apps/desktop/package.json index fc9d632019dc..da7748ee58cd 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -35,5 +35,5 @@ "tailwindcss": "^4.0.0", "vite-plus": "catalog:" }, - "productName": "Marcode (Alpha)" + "productName": "Marcode" } diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 2b78371bb767..7e80baac78eb 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -15,7 +15,7 @@ const repoRoot = NodePath.resolve(desktopDir, "..", ".."); const devBundleIdSuffix = NodePath.basename(repoRoot) .toLowerCase() .replaceAll(/[^a-z0-9]+/g, ""); -export const APP_DISPLAY_NAME = isDevelopment ? "Marcode (Dev)" : "Marcode (Alpha)"; +export const APP_DISPLAY_NAME = isDevelopment ? "Marcode (Dev)" : "Marcode"; export const APP_BUNDLE_ID = isDevelopment ? `app.marcode.desktop.dev.${devBundleIdSuffix || "local"}` : "app.marcode.desktop"; diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index f4be5f596bf2..c7ddb0998827 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -1,10 +1,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; +import * as TestClock from "effect/testing/TestClock"; import type * as Electron from "electron"; @@ -107,7 +110,9 @@ const withIdentity = ( input: { readonly calls?: ElectronAppCalls; readonly environment?: TestEnvironmentInput; + readonly existsOverride?: (path: string) => Effect.Effect; readonly legacyPathExists?: boolean; + readonly legacyPathMatch?: string; readonly legacyPathProbeError?: PlatformError.PlatformError; readonly packageJson?: string; readonly pngIconPath?: Option.Option; @@ -124,12 +129,15 @@ const withIdentity = ( DesktopAppIdentity.layer.pipe( Layer.provideMerge( FileSystem.layerNoop({ - exists: (path) => - input.legacyPathProbeError - ? Effect.fail(input.legacyPathProbeError) - : Effect.succeed( - input.legacyPathExists === true && path.includes("T3 Code (Alpha)"), - ), + exists: + input.existsOverride ?? + ((path) => + input.legacyPathProbeError + ? Effect.fail(input.legacyPathProbeError) + : Effect.succeed( + input.legacyPathExists === true && + path.includes(input.legacyPathMatch ?? "T3 Code (Alpha)"), + )), readFileString: () => Effect.succeed(input.packageJson ?? '{"marcodeCommitHash":"abcdef1234567890"}'), }), @@ -143,7 +151,7 @@ const withIdentity = ( }; describe("DesktopAppIdentity", () => { - it.effect("keeps using the legacy userData path when it already exists", () => + it.effect("keeps using the legacy T3 Code userData path when it already exists", () => withIdentity( Effect.gen(function* () { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; @@ -155,6 +163,80 @@ describe("DesktopAppIdentity", () => { ), ); + // Covers the userData migration for users who installed before the + // "(Alpha)" suffix was dropped from the product name: their state must + // keep resolving to the old "Marcode (Alpha)" directory, not get orphaned + // by a fresh "marcode" directory. + it.effect("keeps using the legacy Marcode (Alpha) userData path when it already exists", () => + withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + const userDataPath = yield* identity.resolveUserDataPath; + + assert.equal(userDataPath, "/Users/alice/Library/Application Support/Marcode (Alpha)"); + }), + { legacyPathExists: true, legacyPathMatch: "Marcode (Alpha)" }, + ), + ); + + // The concurrent rewrite checks every legacy candidate at once instead of + // stopping at the first match, so priority order has to come from the + // candidate list's order (oldest first), not from whichever filesystem + // check happens to settle first. Cover an install with both legacy + // directories present: the older "T3 Code (Alpha)" must still win. + it.effect("prefers the oldest legacy userData path when multiple legacy paths exist", () => + withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + const userDataPath = yield* identity.resolveUserDataPath; + + assert.equal(userDataPath, "/Users/alice/Library/Application Support/T3 Code (Alpha)"); + }), + { + existsOverride: (path) => + Effect.succeed(path.includes("T3 Code (Alpha)") || path.includes("Marcode (Alpha)")), + }, + ), + ); + + // Regression test for a real startup crash: resolveUserDataPath runs + // before DesktopClerk creates the Clerk bridge, which synchronously calls + // Electron's protocol.registerSchemesAsPrivileged — an API that must run + // before Electron's "ready" event fires. A version of this function that + // checked legacy names one at a time (sequentially) added enough extra + // wall-clock latency on a real filesystem for "ready" to fire first, + // crashing the packaged app on launch even though every unit test (all + // running against a mocked, effectively-instant filesystem) stayed green. + // + // Prove the checks run concurrently on the (virtual) clock: give every + // legacy candidate the same 10ms probe latency, then advance the clock by + // exactly one probe's worth of time. Concurrent probing settles both + // candidates in that single tick; a sequential loop would still be + // awaiting the second candidate's own 10ms turn, so the fiber would still + // be pending — pollUnsafe() catches that without risking a hang, since + // (unlike Fiber.join) it never waits. + it.effect("probes legacy userData paths concurrently, not one at a time", () => + withIdentity( + Effect.gen(function* () { + const identity = yield* DesktopAppIdentity.DesktopAppIdentity; + const resolveFiber = yield* identity.resolveUserDataPath.pipe(Effect.forkChild); + + yield* TestClock.adjust(Duration.millis(10)); + assert.isDefined( + resolveFiber.pollUnsafe(), + "expected every legacy candidate to be probed in parallel, not queued behind each other", + ); + + const userDataPath = yield* Fiber.join(resolveFiber); + assert.equal(userDataPath, "/Users/alice/Library/Application Support/Marcode (Alpha)"); + }), + { + existsOverride: (path) => + Effect.sleep(Duration.millis(10)).pipe(Effect.as(path.includes("Marcode (Alpha)"))), + }, + ).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("preserves failures while inspecting the legacy userData path", () => { const legacyPath = "/Users/alice/Library/Application Support/T3 Code (Alpha)"; const cause = PlatformError.systemError({ @@ -194,8 +276,8 @@ describe("DesktopAppIdentity", () => { const identity = yield* DesktopAppIdentity.DesktopAppIdentity; yield* identity.configure; - assert.deepEqual(calls.setName, ["Marcode (Alpha)"]); - assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "Marcode (Alpha)"); + assert.deepEqual(calls.setName, ["Marcode"]); + assert.equal(calls.setAboutPanelOptions[0]?.applicationName, "Marcode"); assert.equal(calls.setAboutPanelOptions[0]?.applicationVersion, "1.2.3"); assert.equal(calls.setAboutPanelOptions[0]?.version, "0123456789ab"); assert.deepEqual(calls.setDockIcon, ["/icon.png"]); diff --git a/apps/desktop/src/app/DesktopAppIdentity.ts b/apps/desktop/src/app/DesktopAppIdentity.ts index 3275375e75f0..f40f488c8319 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.ts @@ -4,6 +4,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -48,22 +49,59 @@ const normalizeCommitHash = (value: string): Option.Option => { export const resolveUserDataPath = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; - const legacyPath = environment.path.join( - environment.appDataDirectory, - environment.legacyUserDataDirName, + + // Check every prior product name's userData directory, oldest first, and + // keep using the first one that already exists on disk. This is a + // continuous "stay put if found" check (not a one-time copy), so an + // install that matched a legacy name keeps using it on every launch. + // + // The checks run CONCURRENTLY, not sequentially: this effect runs before + // DesktopClerk creates the Clerk bridge (see DesktopClerk.ts), which + // synchronously calls Electron's protocol.registerSchemesAsPrivileged — + // an API that must be called before Electron's "ready" event fires, on + // the real (unmocked) filesystem. Each extra sequential await here is + // extra wall-clock time for "ready" to win that race, and that race is + // real: it cost a startup crash once already (see git blame / the + // regression test below) when this loop briefly checked candidates one + // at a time. Probing concurrently keeps resolution to roughly one + // filesystem round-trip no matter how many legacy names get added. + // + // Effect.result (never fails) rather than letting fs.exists fail the + // forEach directly: this keeps the outcome deterministic regardless of + // concurrency — we pick the first-by-priority-order match or failure + // ourselves from the settled results, instead of depending on which + // concurrent fiber happens to fail first. + const legacyPaths = environment.legacyUserDataDirNames.map((legacyUserDataDirName) => + environment.path.join(environment.appDataDirectory, legacyUserDataDirName), ); - const legacyPathExists = yield* fileSystem.exists(legacyPath).pipe( - Effect.mapError( - (cause) => - new DesktopUserDataPathResolutionError({ - legacyPath, - cause, - }), - ), + // Pair each probe with its own path up front (rather than indexing back into + // legacyPaths by position afterward) so every path stays a plain `string` — + // noUncheckedIndexedAccess types `array[i]` as `string | undefined`, and + // that undefined would otherwise leak into the resolved userData path. + const probes = yield* Effect.forEach( + legacyPaths, + (legacyPath) => + Effect.result(fileSystem.exists(legacyPath)).pipe( + Effect.map((probe) => [legacyPath, probe] as const), + ), + { concurrency: "unbounded" }, ); - return legacyPathExists - ? legacyPath - : environment.path.join(environment.appDataDirectory, environment.userDataDirName); + + for (const [legacyPath, probe] of probes) { + if (Result.isFailure(probe)) { + return yield* new DesktopUserDataPathResolutionError({ + legacyPath, + cause: probe.failure, + }); + } + } + + const matched = probes.find(([, probe]) => Result.isSuccess(probe) && probe.success); + if (matched !== undefined) { + return matched[0]; + } + + return environment.path.join(environment.appDataDirectory, environment.userDataDirName); }).pipe(Effect.withSpan("desktop.appIdentity.resolveUserDataPath")); export const make = Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index d7ffc6bc6a6d..309878a3db81 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -35,7 +35,9 @@ const makeDesktopClerkLayer = (isDevelopment = true, events: string[] = []) => { isDevelopment, appDataDirectory: "/tmp/app-data", userDataDirName: isDevelopment ? "t3code-dev" : "t3code", - legacyUserDataDirName: isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)", + legacyUserDataDirNames: isDevelopment + ? ["T3 Code (Dev)", "Marcode (Dev)"] + : ["T3 Code (Alpha)", "Marcode (Alpha)"], path: { join: (...parts: ReadonlyArray) => parts.join("/") }, } as unknown as DesktopEnvironment.DesktopEnvironment["Service"]); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index 1d593e9e1ce3..25339d766c90 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -70,7 +70,7 @@ export class DesktopEnvironment extends Context.Service< readonly linuxApplicationsDir: string; readonly appImagePath: Option.Option; readonly userDataDirName: string; - readonly legacyUserDataDirName: string; + readonly legacyUserDataDirNames: readonly string[]; readonly defaultDesktopSettings: DesktopAppSettings.DesktopSettings; readonly runtimeInfo: DesktopRuntimeInfo; readonly resolvePickFolderDefaultPath: (rawOptions: unknown) => Option.Option; @@ -79,8 +79,9 @@ export class DesktopEnvironment extends Context.Service< } >()("@t3tools/desktop/app/DesktopEnvironment") {} -// Marcode is the user-facing desktop brand. Keep the legacy T3 Code names -// below for migration and compatibility paths; they are not menu labels. +// Marcode is the user-facing desktop brand. Keep the legacy T3 Code and +// Marcode (Alpha) names below for migration and compatibility paths; they +// are not menu labels. const APP_BASE_NAME = "Marcode"; function resolveDesktopAppStageLabel(input: { @@ -91,7 +92,7 @@ function resolveDesktopAppStageLabel(input: { return "Dev"; } - return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha"; + return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : ""; } function resolveDesktopAppBranding(input: { @@ -102,7 +103,7 @@ function resolveDesktopAppBranding(input: { return { baseName: APP_BASE_NAME, stageLabel, - displayName: `${APP_BASE_NAME} (${stageLabel})`, + displayName: stageLabel === "" ? APP_BASE_NAME : `${APP_BASE_NAME} (${stageLabel})`, }; } @@ -171,7 +172,13 @@ const make = Effect.fn("desktop.environment.make")(function* ( t3Home: config.marcodeHome, }); const userDataDirName = isDevelopment ? "marcode-dev" : "marcode"; - const legacyUserDataDirName = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; + // Both prior product names, oldest first: the pre-Marcode "T3 Code" brand, + // then "Marcode (Alpha)" from before the (Alpha) suffix was dropped. A + // given install only ever matches one of these — resolveUserDataPath keeps + // using whichever legacy directory already exists on disk. + const legacyUserDataDirNames = isDevelopment + ? ["T3 Code (Dev)", "Marcode (Dev)"] + : ["T3 Code (Alpha)", "Marcode (Alpha)"]; const linuxApplicationsDir = path.join( Option.getOrElse(config.xdgDataHome, () => path.join(homeDirectory, ".local", "share")), "applications", @@ -222,7 +229,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( linuxApplicationsDir, appImagePath: config.appImagePath, userDataDirName, - legacyUserDataDirName, + legacyUserDataDirNames, defaultDesktopSettings: DesktopAppSettings.resolveDefaultDesktopSettings(input.appVersion), runtimeInfo: resolveDesktopRuntimeInfo({ platform: input.platform, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index f1bc14e178b1..9b5a57c3ab46 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -129,7 +129,7 @@ describe("DesktopApplicationMenu", () => { yield* configureMenu(selectedAction, applicationMenuTemplate); const template = yield* Deferred.await(applicationMenuTemplate); - const applicationMenu = template.find((item) => item.label === "Marcode (Alpha)"); + const applicationMenu = template.find((item) => item.label === "Marcode"); assert.isDefined(applicationMenu); const fileMenu = template.find((item) => item.label === "File"); assert.isDefined(fileMenu); diff --git a/apps/mobile/src/components/BrandMark.tsx b/apps/mobile/src/components/BrandMark.tsx index 0c402cc56165..7589fd4c293d 100644 --- a/apps/mobile/src/components/BrandMark.tsx +++ b/apps/mobile/src/components/BrandMark.tsx @@ -3,6 +3,7 @@ import { Image } from "expo-image"; import { View } from "react-native"; import { AppText as Text } from "./AppText"; +import { resolveMobileStageLabel } from "../lib/mobileBranding"; const appVariant = Constants.expoConfig?.extra?.appVariant; const BRAND_MARK_SOURCE = @@ -11,8 +12,7 @@ const BRAND_MARK_SOURCE = : appVariant === "preview" ? require("../../../../assets/nightly/nightly-ios-1024.png") : require("../../../../assets/prod/black-ios-1024.png"); -const DEFAULT_STAGE_LABEL = - appVariant === "development" ? "Dev" : appVariant === "preview" ? "Preview" : "Alpha"; +const DEFAULT_STAGE_LABEL = resolveMobileStageLabel(appVariant); export function BrandMark(props: { readonly compact?: boolean; readonly stageLabel?: string }) { const compact = props.compact ?? false; @@ -33,11 +33,13 @@ export function BrandMark(props: { readonly compact?: boolean; readonly stageLab Marcode - - - {stageLabel} - - + {stageLabel ? ( + + + {stageLabel} + + + ) : null} {!compact ? ( diff --git a/apps/mobile/src/components/CompactBrandTitle.tsx b/apps/mobile/src/components/CompactBrandTitle.tsx index 928326a5366f..3ddcb8c8f7fd 100644 --- a/apps/mobile/src/components/CompactBrandTitle.tsx +++ b/apps/mobile/src/components/CompactBrandTitle.tsx @@ -65,26 +65,28 @@ export function CompactBrandTitle( > Code - - - {stageLabel} - - + + {stageLabel} + + + ) : null} ); } diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index f2cef9825a3e..ed0efb8bce50 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -222,11 +222,13 @@ function AndroidHomeHeader(props: HomeHeaderProps) { Code - - - {stageLabel} - - + {stageLabel ? ( + + + {stageLabel} + + + ) : null} } /> diff --git a/apps/mobile/src/lib/mobileBranding.test.ts b/apps/mobile/src/lib/mobileBranding.test.ts index 48a84b3f9857..29fe865b1d82 100644 --- a/apps/mobile/src/lib/mobileBranding.test.ts +++ b/apps/mobile/src/lib/mobileBranding.test.ts @@ -6,8 +6,8 @@ describe("resolveMobileStageLabel", () => { it.each([ ["development", "Dev"], ["preview", "Nightly"], - ["production", "Alpha"], - [undefined, "Alpha"], + ["production", ""], + [undefined, ""], ])("maps %s builds to %s", (appVariant, expected) => { expect(resolveMobileStageLabel(appVariant)).toBe(expected); }); diff --git a/apps/mobile/src/lib/mobileBranding.ts b/apps/mobile/src/lib/mobileBranding.ts index 9fd6020831a1..a21151e4da05 100644 --- a/apps/mobile/src/lib/mobileBranding.ts +++ b/apps/mobile/src/lib/mobileBranding.ts @@ -1,7 +1,8 @@ -export type MobileStageLabel = "Alpha" | "Dev" | "Nightly"; +// "" means the stable/GA channel: no stage badge is shown. +export type MobileStageLabel = "" | "Dev" | "Nightly"; export function resolveMobileStageLabel(appVariant: unknown): MobileStageLabel { if (appVariant === "development") return "Dev"; if (appVariant === "preview") return "Nightly"; - return "Alpha"; + return ""; } diff --git a/apps/web/index.html b/apps/web/index.html index 614e37e85f1f..754a9ed5c9f7 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -445,7 +445,7 @@ object-fit: contain; } - Marcode (Alpha) + Marcode
diff --git a/apps/web/src/branding.logic.ts b/apps/web/src/branding.logic.ts index 056fbb76e6ab..2a662e93986d 100644 --- a/apps/web/src/branding.logic.ts +++ b/apps/web/src/branding.logic.ts @@ -4,7 +4,8 @@ export function formatAppDisplayName(input: { readonly baseName: string; readonly stageLabel: string; }): string { - if (input.stageLabel.trim().toLowerCase() === "latest") { + const normalizedStageLabel = input.stageLabel.trim().toLowerCase(); + if (normalizedStageLabel === "" || normalizedStageLabel === "latest") { return input.baseName; } diff --git a/apps/web/src/branding.test.ts b/apps/web/src/branding.test.ts index 64e0b198d7d3..0303ed208f26 100644 --- a/apps/web/src/branding.test.ts +++ b/apps/web/src/branding.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { + formatAppDisplayName, resolveServerBackedAppDisplayName, resolveServerBackedAppStageLabel, } from "./branding.logic"; @@ -76,7 +77,7 @@ describe("branding logic", () => { expect( resolveServerBackedAppStageLabel({ primaryServerVersion: "0.0.28-nightly.20260616.12", - fallbackStageLabel: "Alpha", + fallbackStageLabel: "", }), ).toBe("Nightly"); }); @@ -85,8 +86,8 @@ describe("branding logic", () => { expect( resolveServerBackedAppDisplayName({ baseName: "Marcode", - fallbackDisplayName: "Marcode (Alpha)", - fallbackStageLabel: "Alpha", + fallbackDisplayName: "Marcode", + fallbackStageLabel: "", primaryServerVersion: "0.0.28-nightly.20260616.12", }), ).toBe("Marcode (Nightly)"); @@ -96,21 +97,38 @@ describe("branding logic", () => { expect( resolveServerBackedAppDisplayName({ baseName: "Marcode", - fallbackDisplayName: "Marcode (Alpha)", - fallbackStageLabel: "Alpha", + fallbackDisplayName: "Marcode", + fallbackStageLabel: "", primaryServerVersion: "0.0.27", }), - ).toBe("Marcode (Alpha)"); + ).toBe("Marcode"); }); it("keeps the fallback display name for malformed nightly primary server versions", () => { expect( resolveServerBackedAppDisplayName({ baseName: "Marcode", - fallbackDisplayName: "Marcode (Alpha)", - fallbackStageLabel: "Alpha", + fallbackDisplayName: "Marcode", + fallbackStageLabel: "", primaryServerVersion: "0.0.28-nightly.20260616", }), - ).toBe("Marcode (Alpha)"); + ).toBe("Marcode"); + }); +}); + +describe("formatAppDisplayName", () => { + it("renders the bare base name for an empty (GA/stable) stage label", () => { + expect(formatAppDisplayName({ baseName: "Marcode", stageLabel: "" })).toBe("Marcode"); + }); + + it("renders the bare base name for the latest hosted channel label", () => { + expect(formatAppDisplayName({ baseName: "Marcode", stageLabel: "latest" })).toBe("Marcode"); + }); + + it("appends a parenthetical for any other stage label", () => { + expect(formatAppDisplayName({ baseName: "Marcode", stageLabel: "Dev" })).toBe("Marcode (Dev)"); + expect(formatAppDisplayName({ baseName: "Marcode", stageLabel: "Nightly" })).toBe( + "Marcode (Nightly)", + ); }); }); diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index def5adb64e12..30f2654a39b6 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -20,7 +20,7 @@ export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "Marcode"; export const APP_STAGE_LABEL = injectedDesktopAppBranding?.stageLabel ?? HOSTED_APP_CHANNEL_LABEL ?? - (import.meta.env.DEV ? "Dev" : "Alpha"); + (import.meta.env.DEV ? "Dev" : ""); export const APP_DISPLAY_NAME = injectedDesktopAppBranding?.displayName ?? formatAppDisplayName({ baseName: APP_BASE_NAME, stageLabel: APP_STAGE_LABEL }); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 93b678f970fe..569208d8edfd 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1295,7 +1295,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ />
{displayPath} diff --git a/apps/web/src/components/FloatingPillNav.tsx b/apps/web/src/components/FloatingPillNav.tsx index 330e0ec300d0..63f0a4283395 100644 --- a/apps/web/src/components/FloatingPillNav.tsx +++ b/apps/web/src/components/FloatingPillNav.tsx @@ -22,7 +22,7 @@ import { Code1Filled, KeySquareFilled, } from "@aliimam/icons"; -import { ChartNoAxesColumnIcon, FlaskConicalIcon, GitPullRequestIcon } from "lucide-react"; +import { ChartNoAxesColumnIcon, GitPullRequestIcon } from "lucide-react"; import { cn } from "../lib/utils"; import { useEditorStore } from "../editor/editor-store"; import { usePillNavPreferences, getPillNavShineGradient } from "../editor/pill-prefs"; @@ -114,6 +114,18 @@ const CATEGORIES: NavCategory[] = [ color: "#f59e0b", children: [ { href: "/connect", label: "Connect", icon: }, + // Upstream links /pull-requests from the sidebar footer they own and + // Marcode does not render, so this nav has to surface it. It sits here + // rather than under Settings because it is a workspace destination, not a + // preference — and `getActiveCategory` already resolves the route to this + // category, so anywhere else highlights the wrong pill. Their footer entry + // is gated on the environment's `pullRequests` capability; this one is + // not, because the route renders its own unavailable state. + { + href: "/pull-requests", + label: "Pull Requests", + icon: , + }, ], }, { @@ -144,11 +156,6 @@ const CATEGORIES: NavCategory[] = [ label: "Keybindings", icon: , }, - { - href: "/settings/beta", - label: "Beta", - icon: , - }, { href: "/settings/diagnostics", label: "Diagnostics", @@ -168,16 +175,6 @@ const CATEGORIES: NavCategory[] = [ label: "Usage", icon: , }, - // Same story as Usage: upstream links /pull-requests from the sidebar - // footer they own and Marcode does not render. Their footer entry is - // gated on the environment's `pullRequests` capability; this static - // entry is not, because the route renders its own unavailable state - // rather than depending on a caller-side gate. - { - href: "/pull-requests", - label: "Pull Requests", - icon: , - }, ], }, ]; @@ -568,6 +565,34 @@ function getDockedStyle(pos: PillPosition): React.CSSProperties { }; } +// How far (px) the pill keeps from the nearest screen edge along its main +// axis — enough that a wide pill never touches the window boundary, and a +// top-docked one never reaches under the macOS traffic lights. +const EDGE_MARGIN_PX = 20; + +/** + * The most the pill's main axis (width when docked top/bottom, height when + * docked left/right) can grow before either end would cross `EDGE_MARGIN_PX` + * short of the screen edge. Anchored to `offset` — the pill's own percentage + * along that axis, from `PillPosition` — rather than a flat viewport + * fraction: a pill dragged near one edge has less room on that side than a + * centred one does, and a flat cap would let it overrun the edge it sits + * closest to. + * + * Expressed in `vw`/`vh` rather than a `window.inner*` read so it tracks a + * live resize for free (no listener, no re-render) — the browser recomputes + * viewport units on its own. Dividing by `scale` undoes `pillScale`'s visual + * stretch (a CSS `transform`, which does not affect layout size) so the cap + * holds at any zoom level, not just 1x — both edges scale from the pill's + * own centre on this axis (see `scaleOrigin`), so the correction is uniform. + */ +function dockedMainAxisMaxExtent(offset: number, scale: number, unit: "vw" | "vh"): string { + const nearest = Math.min(offset, 100 - offset); + const factor = (2 * nearest) / scale; + const margin = (EDGE_MARGIN_PX * 2) / scale; + return `calc(${factor}${unit} - ${margin}px)`; +} + // ─── component ────────────────────────────────────────────── const MOBILE_NAV_HEIGHT = 58; @@ -1248,6 +1273,9 @@ export function FloatingPillNav() { transition: isSnapping ? "all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)" : "all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)", + ...(isVertical + ? { maxHeight: dockedMainAxisMaxExtent(position.offset, pillScale, "vh") } + : { maxWidth: dockedMainAxisMaxExtent(position.offset, pillScale, "vw") }), }; })(); @@ -1256,8 +1284,12 @@ export function FloatingPillNav() { // category, so they are the first thing to go when the row is a 390px window // onto a much wider set of controls. const hasRecents = pillPrefs.showRecents && !isMobile && visibleRecents.length > 0; - // the fades are a horizontal-scroll affordance; a vertically docked pill - // stacks its controls instead and never scrolls sideways + // `RowEdgeFade` is a horizontal-scroll affordance (it renders as a vertical + // strip pinned to the row's left/right edge); a vertically docked pill + // never scrolls sideways, so it never earns one. It can still overflow and + // scroll on its own axis — see the column's `overflow-y-auto` above — just + // without a matching fade hint; a column that tall is rare enough that the + // gap reads as a reasonable place to stop, not a missing affordance. const showEdgeFades = !vert && !isDragging; return ( @@ -1316,17 +1348,26 @@ export function FloatingPillNav() { isMobile ? "max-w-full justify-start overflow-x-auto overflow-y-hidden overscroll-x-contain no-scrollbar border-b border-border/40 bg-background dark:bg-[#0a0a0a] pl-[max(env(safe-area-inset-left,0px),0.75rem)] pr-[max(env(safe-area-inset-right,0px),0.75rem)] py-2 pt-[calc(env(safe-area-inset-top,0px)+0.5rem)] backdrop-blur-none [-webkit-overflow-scrolling:touch] [&>*]:shrink-0" : vert - ? // Docked left/right: cross-axis is width, and `max-w-[100vw]` below - // is a main-axis (horizontal-dock) rule that does nothing to bound - // it, so a wide *line* stacked into this column — the portaled - // thread-action cluster, whose own row never learned to wrap (see - // GitActionsControl/ProjectScriptsControl) — dragged the whole - // column out to that line's width instead of staying icon-width. - // Cap to one icon column (w-8 + the pill's own px-2) and let lines - // wrap inside it instead of stretching it; no horizontal scroll in - // a vertical dock. - "max-w-12 overflow-x-hidden no-scrollbar" - : "max-w-[100vw] overflow-x-auto no-scrollbar", + ? // Docked left/right: cross-axis is width, and the main-axis cap + // below does nothing to bound it, so a wide *line* stacked into + // this column — the portaled thread-action cluster, whose own row + // never learned to wrap (see GitActionsControl/ProjectScriptsControl) + // — dragged the whole column out to that line's width instead of + // staying icon-width. Cap to one icon column (w-8 + the pill's own + // px-2) and let lines wrap inside it instead of stretching it; no + // horizontal scroll in a vertical dock. The main axis is height: + // `dockedMainAxisMaxExtent` sets the precise offset-aware cap + // inline while resting; this flat one is the floor for drag/summon, + // when that inline style is not in play — and `overflow-y-auto` is + // what makes a column taller than the screen reachable at all, + // rather than just spilling past the window edge unseen. + "max-w-12 overflow-x-hidden max-h-[calc(100vh-2.5rem)] overflow-y-auto no-scrollbar" + : // Horizontal dock: `dockedMainAxisMaxExtent` sets the precise + // offset-aware width cap inline while resting (see the style + // computation above); this flat one is the floor for drag/summon, + // and keeps even those transient states off the window edge + // instead of flush against — or past — it. + "max-w-[calc(100vw-2.5rem)] overflow-x-auto no-scrollbar", )} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} diff --git a/apps/web/src/components/PillNavHoverCard.tsx b/apps/web/src/components/PillNavHoverCard.tsx index 82ff825cd6cb..257b28f3cb8a 100644 --- a/apps/web/src/components/PillNavHoverCard.tsx +++ b/apps/web/src/components/PillNavHoverCard.tsx @@ -22,6 +22,7 @@ import { Setting5Filled, SidebarLeftFilled, } from "@aliimam/icons"; +import { ChartNoAxesColumnIcon, GitPullRequestIcon } from "lucide-react"; import type { ComponentType, CSSProperties, ReactElement } from "react"; import { MarcodeMark } from "./MarcodeMark"; @@ -52,8 +53,13 @@ const UTILITY_COLOR = "#a1a1aa"; * entry keeps its card even when its label or target path changes. * * Icons must match the glyph the pill itself renders, and must be the pack's - * `*Filled` variant — the pill is filled-only. The one exception is the home - * entry, which carries the brand mark (`MarcodeMark`) rather than a pack icon. + * `*Filled` variant — the pill is filled-only. Two kinds of entry break that + * rule on purpose, both because matching the pill wins over matching the + * pack: the home entry carries the brand mark (`MarcodeMark`) rather than a + * pack icon, and Pull Requests / Usage carry their `lucide-react` glyph + * because that is what `CATEGORIES` in FloatingPillNav renders for them — + * upstream entries kept on their original icon set rather than re-matched to + * an `@aliimam/icons` lookalike. */ export const PILL_NAV_META = { "/": { @@ -70,6 +76,13 @@ export const PILL_NAV_META = { icon: KeySquareFilled, color: HOME_COLOR, }, + "/pull-requests": { + title: "Pull Requests", + description: + "Pull requests across every connected project, grouped into authored and reviewing. Filter by state or host, search across all of them, and open one into its own tab.", + icon: GitPullRequestIcon, + color: HOME_COLOR, + }, "/settings": { title: "Settings", description: @@ -126,6 +139,13 @@ export const PILL_NAV_META = { icon: ClockFilled, color: SETTINGS_COLOR, }, + "/usage": { + title: "Usage", + description: + "Cost and token usage across every provider and environment. Switch between the past day, week, month or quarter, and break it down by model or over time.", + icon: ChartNoAxesColumnIcon, + color: SETTINGS_COLOR, + }, workspace: { title: "Workspace", description: diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2d2089a604aa..9b8b0cff3ed4 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3793,7 +3793,7 @@ export default function Sidebar() {
{props.showNewBadge ? ( New diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 9c914357b3ca..fff42f994bc6 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -310,6 +310,14 @@ export const OpenInPicker = memo(function OpenInPicker({ openInEditor(value)} > + {/* Oversized, low-contrast watermark of the same glyph, the same + treatment PillNavHoverCard gives its cards, scaled down to a + menu row. `text-foreground/10` instead of a hardcoded hex: it + rides the same paired foreground/popover tokens the row text + below already uses, so it reads at one faint weight in both + themes without a separate dark: class. `overflow-hidden` on + the row (above) crops it to that row instead of bleeding into + the next one — rows here sit flush with no gap between them. */}
diff --git a/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx b/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx index 12ef7c836fcd..87555c413507 100644 --- a/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx +++ b/apps/web/src/components/clerk/MobileClientsUserProfilePage.tsx @@ -42,7 +42,7 @@ function MobileClientRow({ device }: { readonly device: RelayClientDeviceRecord

{device.label}

{mobileClientPlatformLabel(device)}

-

+

{mobileClientUpdatedAtLabel(device.updatedAt)}

@@ -115,7 +115,7 @@ export function MobileClientsUserProfilePage() {
-

Mobile clients

+

Mobile clients

Devices registered to receive Marcode Connect activity from your environments.

diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 93418e7c301d..66ac8b8196ae 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -626,24 +626,31 @@ export function PullRequestCodeTab({ // chevron follows it rather than recomputing the default here. const collapsed = item.collapsed === true; return ( - + + { + event.stopPropagation(); + toggleFile(item.id); + }} + /> + } + > + {collapsed ? ( + + ) : ( + + )} + + {collapsed ? "Expand diff" : "Collapse diff"} + ); }, [toggleFile], @@ -811,16 +818,23 @@ export function PullRequestCodeTab({
{reviewOpen ? (
- + + setReviewOpen(false)} + /> + } + > + + + Close review + - - - - - - + + } + > + + + Stacked diff view + + + } + > + + + Split diff view + - - - + + + } + > + + + More pull request actions + void refreshFromHost()}> @@ -1001,14 +1009,21 @@ export function PullRequestDetailPanel({ ) : null} {onClose ? ( - + + + } + > + + + Collapse pull request panel + ) : null}
diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index cb95a5be35b9..332772e405e1 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -21,6 +21,7 @@ import { MenuSeparator, MenuTrigger, } from "../ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; export interface PullRequestFilterOption { readonly value: Value; @@ -189,22 +190,29 @@ export function PullRequestFiltersMenu({ state !== "open" || involvement !== "all" || host !== undefined || projectId !== undefined; return ( - - - {filtered ? ( - - ) : null} - + + + } + > + + {filtered ? ( + + ) : null} + + Filter pull requests + Pending — sent when you submit the review - + + + } + > + + + Discard this comment +

{comment.body}

diff --git a/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx b/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx index 25c663794e97..2899faffa04b 100644 --- a/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx +++ b/apps/web/src/components/pullRequest/PullRequestReviewerPicker.tsx @@ -122,13 +122,18 @@ export function PullRequestReviewerPicker({ return ( - - - - } - /> + + } + /> + } + > + + + Request a review +
void }) { return url === null ? null : ( - + + onOpen(url)} + /> + } + > + + + Open activity on host + ); } diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 50cf9c318040..798c4fbbd69d 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -1019,7 +1019,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ) : null}
-

Actions

+

Actions

Saved and run only in {selectedCheckoutLabel}.

diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 503411eb68e3..fc2aa5b7841f 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -50,7 +50,7 @@ function SidebarUpdateReleaseNotesTooltip({
{index > 0 && }
-

+

{index === 0 ? "What's changed" : `Changes in ${releaseNote.version}`}

    diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 5e9034bb2af0..a34df4e3da1c 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -21,8 +21,12 @@ import { } from "@t3tools/shared/usageFormat"; import { ScrollArea } from "../ui/scroll-area"; import { SidebarInset } from "../ui/sidebar"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../WorkspaceBreadcrumb"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; +import { + COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, + SIDEBARLESS_TITLEBAR_INSET_CLASS, +} from "../../workspaceTitlebar"; import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; @@ -120,7 +124,7 @@ export function UsagePage() {
    @@ -156,14 +160,21 @@ export function UsagePage() { ))}
    - + + + } + > + + + Refresh usage data +
diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 0fbe869f158f..ad155b7703d3 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -111,7 +111,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil --glass-saturation: 1.08; } -[data-slot="sidebar-wrapper"] { +/* Declared on the root, not on the sidebar wrapper: Marcode renders no sidebar + on settings and usage, so a wrapper-scoped variable resolves to nothing there + and the titlebar content lands under the native window controls. `.wco` also + lands on the root element, so its `--workspace-controls-left` override still + feeds this calc. */ +:root { --workspace-titlebar-content-left: calc( var(--workspace-controls-left) + var(--workspace-titlebar-control-size) + var(--workspace-titlebar-control-gap) @@ -902,6 +907,20 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil padding-right: max(env(safe-area-inset-right), 0px); } +/* Scrollbar-less scroll container: the track is hidden, the scrolling itself + is not — for chrome that scrolls as an affordance (overflow fades, drag + handles) rather than as a document the reader browses with a bar. Same + three declarations `.turn-chip-strip` uses inline, promoted to a utility + because the floating pill nav now needs the identical treatment on both + its mobile rail and its desktop dock row. */ +@utility no-scrollbar { + scrollbar-width: none; + -ms-overflow-style: none; + &::-webkit-scrollbar { + display: none; + } +} + /* Grain texture for chrome surfaces that float above the body (see the --surface-grain note). Layered over the element's background-color, so it composes with bg-* utilities. */ diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 216e9ebe16bf..2744881663b1 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -63,6 +63,7 @@ import { PanelLayoutControls } from "../components/chat/PanelLayoutControls"; import { Button } from "../components/ui/button"; import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu"; import { SidebarInset } from "../components/ui/sidebar"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../components/ui/tooltip"; import { useLiveRefresh } from "../hooks/useLiveRefresh"; import { pullRequestSurfaceId, @@ -1282,14 +1283,21 @@ function ExpandableSearch({ ); } return ( - + + onOpenChange(true)} + /> + } + > + + + Search pull requests + ); } @@ -1457,14 +1465,21 @@ function PullRequestsColumn({ }} /> ) : null} - + + + } + > + + + Refresh pull requests + {rightPanelControl}
diff --git a/apps/web/src/routes/settings.tsx b/apps/web/src/routes/settings.tsx index f14793ba5446..f1a520d01096 100644 --- a/apps/web/src/routes/settings.tsx +++ b/apps/web/src/routes/settings.tsx @@ -15,7 +15,10 @@ import { Button } from "../components/ui/button"; import { SidebarInset } from "../components/ui/sidebar"; import { isElectron } from "../env"; import { cn } from "~/lib/utils"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; +import { + COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, + SIDEBARLESS_TITLEBAR_INSET_CLASS, +} from "~/workspaceTitlebar"; function RestoreDefaultsButton({ onRestored }: { onRestored: () => void }) { const { changedSettingLabels, restoreDefaults } = useSettingsRestore(onRestored); @@ -94,7 +97,7 @@ function SettingsContentLayout() {
diff --git a/apps/web/src/workspaceTitlebar.fork.test.ts b/apps/web/src/workspaceTitlebar.fork.test.ts new file mode 100644 index 000000000000..4e15d61ab314 --- /dev/null +++ b/apps/web/src/workspaceTitlebar.fork.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vite-plus/test"; + +import usageSource from "./components/usage/UsagePage.tsx?raw"; +import settingsSource from "./routes/settings.tsx?raw"; +import { + COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, + SIDEBARLESS_TITLEBAR_INSET_CLASS, +} from "./workspaceTitlebar"; + +/** + * Marcode renders no sidebar on settings and usage — `FloatingPillNav` owns + * that navigation. Upstream's only titlebar inset keys off a + * `data-sidebar-state` ancestor and reads a variable declared on the sidebar + * wrapper, so on those routes the selector never matches and the variable never + * resolves. The visible failure is the breadcrumb sitting under the macOS + * traffic lights. + * + * Both halves have to hold, and each regresses on its own: an upstream sync can + * move the variable back onto the wrapper, or swap a call site back to the + * collapsed-only class. + */ + +describe("sidebarless titlebar inset", () => { + it("applies unconditionally, unlike the collapsed-sidebar variant", () => { + expect(COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS).toContain("data-sidebar-state=collapsed"); + expect(SIDEBARLESS_TITLEBAR_INSET_CLASS).not.toContain("data-sidebar-state"); + expect(SIDEBARLESS_TITLEBAR_INSET_CLASS).toContain("--workspace-titlebar-content-left"); + }); + + for (const [name, source] of [ + ["settings", settingsSource], + ["usage", usageSource], + ] as const) { + it(`insets the ${name} desktop titlebar past the native window controls`, () => { + // The drag region is the Electron titlebar; the traffic lights sit on it. + const lines = source.split("\n"); + const dragRegion = lines.findIndex((line) => line.includes("drag-region")); + expect(dragRegion).toBeGreaterThan(-1); + + expect(lines.slice(dragRegion, dragRegion + 4).join("\n")).toContain( + "SIDEBARLESS_TITLEBAR_INSET_CLASS", + ); + }); + } +}); diff --git a/apps/web/src/workspaceTitlebar.ts b/apps/web/src/workspaceTitlebar.ts index b481221e63aa..b7e6f2ddab23 100644 --- a/apps/web/src/workspaceTitlebar.ts +++ b/apps/web/src/workspaceTitlebar.ts @@ -1,2 +1,9 @@ export const COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS = "[[data-sidebar-state=collapsed]_&]:pl-[var(--workspace-titlebar-content-left)]"; + +/** + * For surfaces that never mount a sidebar (settings, usage). The collapsed + * variant above keys off a `data-sidebar-state` ancestor, so on those routes it + * never matches and the header slides under the native window controls. + */ +export const SIDEBARLESS_TITLEBAR_INSET_CLASS = "pl-[var(--workspace-titlebar-content-left)]"; diff --git a/packages/client-runtime/src/state/terminalSession.test.ts b/packages/client-runtime/src/state/terminalSession.test.ts index 85c57592d118..330c7dc2542f 100644 --- a/packages/client-runtime/src/state/terminalSession.test.ts +++ b/packages/client-runtime/src/state/terminalSession.test.ts @@ -184,4 +184,62 @@ describe("terminal session reducers", () => { expect(state.buffer).toBe("🙂"); }); + + it("never cuts inside a CSI sequence", () => { + // Byte budget lands the cut inside "\x1b[31m" (on the "1"). A naive + // byte-level trim would leave "1mhello" — an orphaned CSI parameter and + // final byte rendering as literal text. The whole sequence must be + // dropped instead. + const state = applyTerminalAttachStreamEvent( + EMPTY_TERMINAL_BUFFER_STATE, + { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data: "\x1b[31mhello", + }, + 7, + ); + + expect(state.buffer).toBe("hello"); + }); + + it("never cuts inside an OSC sequence", () => { + // Byte budget lands the cut inside "\x1b]0;title\x07" (on the second + // "t"). A naive trim would leave "title\x07rest" rendering as literal + // text on screen. + const state = applyTerminalAttachStreamEvent( + EMPTY_TERMINAL_BUFFER_STATE, + { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data: "\x1b]0;title\x07rest", + }, + 10, + ); + + expect(state.buffer).toBe("rest"); + }); + + it("never cuts inside a bare ESC sequence, and drops orphaned repeats cleanly", () => { + // ESC M (Reverse Index) has no parameterized multi-line form, so a + // shell repeats it once per line — exactly the shape that produces a + // grouped run of a single stripped final byte if the cut lands mid + // sequence. Byte budget lands on the "M" of the second "\x1bM": a naive + // trim would leave "M\x1bMtail", a bare orphaned "M" ahead of a clean + // sequence. + const state = applyTerminalAttachStreamEvent( + EMPTY_TERMINAL_BUFFER_STATE, + { + type: "output", + threadId: TARGET.threadId, + terminalId: TARGET.terminalId, + data: "\x1bM\x1bM\x1bMtail", + }, + 7, + ); + + expect(state.buffer).toBe("\x1bMtail"); + }); }); diff --git a/packages/client-runtime/src/state/terminalSession.ts b/packages/client-runtime/src/state/terminalSession.ts index ee444e36db41..02d4c04c5e10 100644 --- a/packages/client-runtime/src/state/terminalSession.ts +++ b/packages/client-runtime/src/state/terminalSession.ts @@ -66,6 +66,146 @@ export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); +// --- ANSI escape-sequence boundary detection -------------------------- +// +// trimBufferToBytes() must never cut inside an escape sequence: a torn +// CSI/OSC/DCS/bare-ESC sequence loses its ESC byte, and the remaining +// parameter/final bytes then render as literal text (e.g. a torn +// "ESC [ 3 0 A" leaves "30A" on screen; a bare "ESC M" — Reverse Index, +// which has no parameterized multi-line form, so a shell emits it once +// per line — leaves a run of bare "M"s). +// +// This mirrors the byte classification apps/server/src/terminal/Manager.ts's +// sanitizeTerminalHistoryChunk() already uses (isCsiFinalByte, +// isEscapeIntermediateByte, isEscapeFinalByte, findStringTerminatorIndex), +// deliberately duplicated rather than imported: Manager.ts lives in +// apps/server (an app), this package is shared with apps/mobile, and its +// helpers are inlined for a keep-whole-or-drop-whole *stripping* decision, +// not exposed as a standalone "where does this sequence end" primitive — +// reusing them would mean refactoring that separately-tested file for this +// one caller. This port only needs a safe cut point, not strip/keep. + +function isCsiFinalByte(codePoint: number): boolean { + return codePoint >= 0x40 && codePoint <= 0x7e; +} + +function isEscapeIntermediateByte(codePoint: number): boolean { + return codePoint >= 0x20 && codePoint <= 0x2f; +} + +function isEscapeFinalByte(codePoint: number): boolean { + return codePoint >= 0x30 && codePoint <= 0x7e; +} + +// OSC / DCS / PM / APC are terminated by ST (ESC \), BEL, or the 8-bit ST +// (0x9c). Returns the index right after the terminator, or null if `text` +// ends first. +function findStringTerminatorEnd(text: string, start: number): number | null { + for (let index = start; index < text.length; index += 1) { + const codePoint = text.charCodeAt(index); + if (codePoint === 0x07 || codePoint === 0x9c) { + return index + 1; + } + if (codePoint === 0x1b && text.charCodeAt(index + 1) === 0x5c) { + return index + 2; + } + } + return null; +} + +// Bare "Fe/Fp/Fs" escapes, e.g. ESC M (Reverse Index) or ESC c (RIS): zero +// or more intermediate bytes then one final byte. `start` is the index +// right after ESC. Returns the index right after the final byte, or null if +// `text` ends first. +function findBareEscapeSequenceEnd(text: string, start: number): number | null { + let cursor = start; + while (cursor < text.length && isEscapeIntermediateByte(text.charCodeAt(cursor))) { + cursor += 1; + } + if (cursor >= text.length) { + return null; + } + return isEscapeFinalByte(text.charCodeAt(cursor)) ? cursor + 1 : start + 1; +} + +// One code point, treating a valid surrogate pair as a single unit. +function plainTextUnitLength(text: string, index: number): number { + const codePoint = text.charCodeAt(index); + if (codePoint < 0xd800 || codePoint > 0xdbff) { + return 1; + } + const low = text.charCodeAt(index + 1); + return low >= 0xdc00 && low <= 0xdfff ? 2 : 1; +} + +// Returns the index right after the single atomic unit starting at `index`: +// one whole escape sequence (7-bit ESC-prefixed or 8-bit C1 form) or one +// plain code point. A sequence that starts before the end of `text` but +// doesn't finish still counts as one unit extending to the end — safe to +// keep, since xterm.js buffers a partial sequence across separate write() +// calls the same way Manager.ts's pendingHistoryControlSequence defers a +// partial chunk. What must never happen is a cut landing *inside* a +// sequence, after its ESC/introducer has already been dropped. +function advanceOneUnit(text: string, index: number): number { + const codePoint = text.charCodeAt(index); + + if (codePoint === 0x1b) { + const next = text.charCodeAt(index + 1); + + if (next === 0x5b) { + // CSI: ESC [ params... final + let cursor = index + 2; + while (cursor < text.length && !isCsiFinalByte(text.charCodeAt(cursor))) { + cursor += 1; + } + return cursor < text.length ? cursor + 1 : text.length; + } + + if (next === 0x5d || next === 0x50 || next === 0x5e || next === 0x5f) { + // OSC / DCS / PM / APC: ESC ] | P | ^ | _ ... ST + return findStringTerminatorEnd(text, index + 2) ?? text.length; + } + + return findBareEscapeSequenceEnd(text, index + 1) ?? text.length; + } + + if (codePoint === 0x9b) { + // 8-bit CSI + let cursor = index + 1; + while (cursor < text.length && !isCsiFinalByte(text.charCodeAt(cursor))) { + cursor += 1; + } + return cursor < text.length ? cursor + 1 : text.length; + } + + if (codePoint === 0x9d || codePoint === 0x90 || codePoint === 0x9e || codePoint === 0x9f) { + // 8-bit OSC / DCS / PM / APC + return findStringTerminatorEnd(text, index + 1) ?? text.length; + } + + return index + plainTextUnitLength(text, index); +} + +// Walks `text` from its true start (always safe — nothing precedes it) and +// returns the smallest unit boundary >= idealStart. Scanning from a known- +// safe anchor, rather than pattern-matching on text.slice(idealStart) in +// isolation, is what makes this unambiguous: a leading digit at idealStart +// could be plain text or an orphaned CSI parameter, and there's no way to +// tell which from the suffix alone. +function nearestSafeBoundaryAtOrAfter(text: string, idealStart: number): number { + if (idealStart <= 0) { + return 0; + } + if (idealStart >= text.length) { + return text.length; + } + let index = 0; + while (index < idealStart) { + index = advanceOneUnit(text, index); + } + return index; +} + function trimBufferToBytes(buffer: string, maxBufferBytes: number): string { if (maxBufferBytes <= 0) { return ""; @@ -76,16 +216,21 @@ function trimBufferToBytes(buffer: string, maxBufferBytes: number): string { return buffer; } - let start = encoded.byteLength - maxBufferBytes; - while (start < encoded.length) { - const byte = encoded[start]; + let byteStart = encoded.byteLength - maxBufferBytes; + while (byteStart < encoded.length) { + const byte = encoded[byteStart]; if (byte === undefined || (byte & 0b1100_0000) !== 0b1000_0000) { break; } - start += 1; + byteStart += 1; } - return textDecoder.decode(encoded.subarray(start)); + // byteStart now sits on a UTF-8 lead-byte boundary, so decoding the + // dropped prefix on its own is exact and gives the equivalent code-unit + // offset into `buffer` for the escape-boundary scan below. + const droppedLength = textDecoder.decode(encoded.subarray(0, byteStart)).length; + + return buffer.slice(nearestSafeBoundaryAtOrAfter(buffer, droppedLength)); } export function terminalBufferStateFromSnapshot( diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 67cc0b28044f..63fbcda3038d 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -158,7 +158,8 @@ export type DesktopUpdateStatus = export type DesktopRuntimeArch = "arm64" | "x64" | "other"; export type DesktopTheme = "light" | "dark" | "system"; export type DesktopUpdateChannel = "latest" | "nightly"; -export type DesktopAppStageLabel = "Alpha" | "Dev" | "Nightly"; +// "" means the stable/GA channel: no parenthetical suffix on the product name. +export type DesktopAppStageLabel = "" | "Dev" | "Nightly"; export const DesktopUpdateStatusSchema = Schema.Literals([ "disabled", @@ -173,7 +174,7 @@ export const DesktopUpdateStatusSchema = Schema.Literals([ export const DesktopRuntimeArchSchema = Schema.Literals(["arm64", "x64", "other"]); export const DesktopThemeSchema = Schema.Literals(["light", "dark", "system"]); export const DesktopUpdateChannelSchema = Schema.Literals(["latest", "nightly"]); -export const DesktopAppStageLabelSchema = Schema.Literals(["Alpha", "Dev", "Nightly"]); +export const DesktopAppStageLabelSchema = Schema.Literals(["", "Dev", "Nightly"]); export interface DesktopAppBranding { baseName: string; diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index c91b16eecdd1..16bf6a1dc8a1 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -92,7 +92,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { }); it("switches desktop packaging product names to nightly for nightly builds", () => { - assert.equal(resolveDesktopProductName("0.0.17"), "Marcode (Alpha)"); + assert.equal(resolveDesktopProductName("0.0.17"), "Marcode"); assert.equal(resolveDesktopProductName("0.0.17-nightly.20260413.42"), "Marcode (Nightly)"); });