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)"); });