diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs index 081ef903a066..929afeeabe9c 100644 --- a/apps/desktop/scripts/electron-launcher.mjs +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -20,7 +20,7 @@ export const APP_BUNDLE_ID = isDevelopment ? `com.t3tools.t3code.dev.${devBundleIdSuffix || "local"}` : "com.t3tools.t3code"; const APP_PROTOCOL_SCHEMES = isDevelopment ? ["t3code-dev"] : ["t3code"]; -const LAUNCHER_VERSION = 12; +const LAUNCHER_VERSION = 14; const defaultIconPath = NodePath.join(desktopDir, "resources", "icon.icns"); const developmentMacIconPngPath = NodePath.join( repoRoot, @@ -220,11 +220,12 @@ function ensureDevelopmentIconIcns(runtimeDir) { } } -function patchMainBundleInfoPlist(appBundlePath, iconPath) { +function patchMainBundleInfoPlist(appBundlePath, iconPath, executableName) { const infoPlistPath = NodePath.join(appBundlePath, "Contents", "Info.plist"); setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME); setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME); setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID); + setPlistString(infoPlistPath, "CFBundleExecutable", executableName); setPlistString(infoPlistPath, "CFBundleIconFile", "icon.icns"); setPlistJson(infoPlistPath, "CFBundleURLTypes", [ { @@ -277,11 +278,25 @@ function readJson(path) { } } +export function resolveMacLauncherPaths(appBundlePath, displayName = APP_DISPLAY_NAME) { + const executableDir = NodePath.join(appBundlePath, "Contents", "MacOS"); + const launcherExecutableName = `${displayName} Launcher`; + return { + launcherExecutableName, + launcherBinaryPath: NodePath.join(executableDir, launcherExecutableName), + runtimeElectronBinaryPath: NodePath.join(executableDir, "Electron"), + }; +} + function buildMacLauncher(electronBinaryPath) { const sourceAppBundlePath = NodePath.resolve(NodePath.dirname(electronBinaryPath), "../.."); const runtimeDir = NodePath.join(desktopDir, ".electron-runtime"); const targetAppBundlePath = NodePath.join(runtimeDir, `${APP_DISPLAY_NAME}.app`); - const targetBinaryPath = NodePath.join(targetAppBundlePath, "Contents", "MacOS", "Electron"); + const developmentPaths = resolveMacLauncherPaths(targetAppBundlePath); + const runtimeElectronBinaryPath = developmentPaths.runtimeElectronBinaryPath; + const launcherBinaryPath = isDevelopment + ? developmentPaths.launcherBinaryPath + : runtimeElectronBinaryPath; const iconPath = isDevelopment ? ensureDevelopmentIconIcns(runtimeDir) : defaultIconPath; const metadataPath = NodePath.join(runtimeDir, "metadata.json"); @@ -298,7 +313,8 @@ function buildMacLauncher(electronBinaryPath) { const currentMetadata = readJson(metadataPath); if ( - NodeFS.existsSync(targetBinaryPath) && + NodeFS.existsSync(launcherBinaryPath) && + (!isDevelopment || NodeFS.existsSync(runtimeElectronBinaryPath)) && currentMetadata && JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) ) { @@ -306,10 +322,10 @@ function buildMacLauncher(electronBinaryPath) { // The launcher also handles protocol activations outside the dev runner, // so refresh its fallback environment on every launch. Never let a value // captured by an older parent app override the live dev-runner environment. - writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath); + writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath); } registerMacLauncherBundle(targetAppBundlePath); - return targetBinaryPath; + return launcherBinaryPath; } NodeFS.rmSync(targetAppBundlePath, { recursive: true, force: true }); @@ -321,15 +337,24 @@ function buildMacLauncher(electronBinaryPath) { recursive: true, verbatimSymlinks: true, }); - patchMainBundleInfoPlist(targetAppBundlePath, iconPath); + patchMainBundleInfoPlist( + targetAppBundlePath, + iconPath, + isDevelopment ? developmentPaths.launcherExecutableName : "Electron", + ); patchHelperBundleInfoPlists(targetAppBundlePath); if (isDevelopment) { - writeDevelopmentLauncherScript(targetBinaryPath, electronBinaryPath); + // Keep Electron's native executable inside the branded bundle. Launching the + // node_modules copy makes macOS associate the process (and Dock label) with + // Electron.app even though this bundle's Info.plist has the T3 Code name. + // Its conventional executable name also keeps Electron's default-app runtime + // in development mode instead of making app.isPackaged report true. + writeDevelopmentLauncherScript(launcherBinaryPath, runtimeElectronBinaryPath); } NodeFS.writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); registerMacLauncherBundle(targetAppBundlePath); - return targetBinaryPath; + return launcherBinaryPath; } function isLinuxSetuidSandboxConfigured(electronBinaryPath) { diff --git a/apps/desktop/scripts/electron-launcher.test.mjs b/apps/desktop/scripts/electron-launcher.test.mjs index 36e9429e4446..1c82167ea217 100644 --- a/apps/desktop/scripts/electron-launcher.test.mjs +++ b/apps/desktop/scripts/electron-launcher.test.mjs @@ -1,6 +1,10 @@ import { assert, describe, it } from "vite-plus/test"; -import { makeDevelopmentLauncherScript, resolveElectronBinaryPath } from "./electron-launcher.mjs"; +import { + makeDevelopmentLauncherScript, + resolveElectronBinaryPath, + resolveMacLauncherPaths, +} from "./electron-launcher.mjs"; describe("electron development launcher", () => { it("uses captured values only as fallbacks for a live runner environment", () => { @@ -45,4 +49,33 @@ describe("electron development launcher", () => { ); assert.deepEqual(calls, ["ensure", "require:electron"]); }); + + it("keeps the native Electron executable name inside the branded macOS bundle", () => { + const paths = resolveMacLauncherPaths( + "/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app", + "T3 Code (Dev)", + ); + + assert.equal(paths.launcherExecutableName, "T3 Code (Dev) Launcher"); + assert.equal( + paths.launcherBinaryPath, + "/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app/Contents/MacOS/T3 Code (Dev) Launcher", + ); + assert.equal( + paths.runtimeElectronBinaryPath, + "/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app/Contents/MacOS/Electron", + ); + + const script = makeDevelopmentLauncherScript({ + electronBinaryPath: paths.runtimeElectronBinaryPath, + mainEntryPath: "/repo/apps/desktop/dist-electron/main.cjs", + desktopRoot: "/repo/apps/desktop", + environment: {}, + }); + assert.include( + script, + "exec '/repo/apps/desktop/.electron-runtime/T3 Code (Dev).app/Contents/MacOS/Electron'", + ); + assert.notInclude(script, "node_modules/electron"); + }); }); diff --git a/apps/desktop/src/electron/ElectronUpdater.test.ts b/apps/desktop/src/electron/ElectronUpdater.test.ts index 8fcc34f41c24..c2acc9ce120a 100644 --- a/apps/desktop/src/electron/ElectronUpdater.test.ts +++ b/apps/desktop/src/electron/ElectronUpdater.test.ts @@ -10,6 +10,7 @@ const { autoUpdaterMock } = vi.hoisted(() => ({ autoInstallOnAppQuit: true, channel: "latest", disableDifferentialDownload: false, + fullChangelog: false, checkForUpdates: vi.fn(() => Promise.resolve(null)), downloadUpdate: vi.fn(() => Promise.resolve([])), on: vi.fn(), @@ -33,6 +34,7 @@ describe("ElectronUpdater", () => { autoUpdaterMock.autoInstallOnAppQuit = true; autoUpdaterMock.channel = "latest"; autoUpdaterMock.disableDifferentialDownload = false; + autoUpdaterMock.fullChangelog = false; autoUpdaterMock.checkForUpdates.mockClear(); autoUpdaterMock.checkForUpdates.mockImplementation(() => Promise.resolve(null)); autoUpdaterMock.downloadUpdate.mockClear(); @@ -98,6 +100,18 @@ describe("ElectronUpdater", () => { }).pipe(Effect.provide(ElectronUpdater.layer)), ); + it.effect("sets full changelog mode", () => + Effect.gen(function* () { + const updater = yield* ElectronUpdater.ElectronUpdater; + + yield* updater.setFullChangelog(true); + assert.equal(autoUpdaterMock.fullChangelog, true); + + yield* updater.setFullChangelog(false); + assert.equal(autoUpdaterMock.fullChangelog, false); + }).pipe(Effect.provide(ElectronUpdater.layer)), + ); + it.effect("preserves quit-and-install flags and the execution-time channel", () => Effect.gen(function* () { const cause = new Error("quit and install failed"); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 435fbd002289..4157d29a9df8 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -66,6 +66,7 @@ export class ElectronUpdater extends Context.Service< readonly setAllowPrerelease: (value: boolean) => Effect.Effect; readonly allowDowngrade: Effect.Effect; readonly setAllowDowngrade: (value: boolean) => Effect.Effect; + readonly setFullChangelog: (value: boolean) => Effect.Effect; readonly setDisableDifferentialDownload: (value: boolean) => Effect.Effect; readonly checkForUpdates: Effect.Effect; readonly downloadUpdate: Effect.Effect; @@ -112,6 +113,11 @@ export const make = ElectronUpdater.of({ autoUpdater.allowDowngrade = value; return Effect.void; }), + setFullChangelog: (value) => + Effect.suspend(() => { + autoUpdater.fullChangelog = value; + return Effect.void; + }), setDisableDifferentialDownload: (value) => Effect.suspend(() => { autoUpdater.disableDifferentialDownload = value; diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 696bd755506b..5ae92bbee963 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -38,6 +38,7 @@ const flushCallbacks = Effect.yieldNow; function makeHarness(options: UpdatesHarnessOptions = {}) { let checkCount = 0; let allowDowngrade = false; + let fullChangelog = false; const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; const listeners = new Map void>>(); const sentStates: DesktopUpdateState[] = []; @@ -73,6 +74,10 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { Effect.sync(() => { allowDowngrade = value; }), + setFullChangelog: (value) => + Effect.sync(() => { + fullChangelog = value; + }), setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, checkForUpdates: Effect.sync(() => { checkCount += 1; @@ -186,6 +191,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { layer, checkCount: () => checkCount, feedUrls: () => feedUrls, + fullChangelog: () => fullChangelog, listenerCount: () => Array.from(listeners.values()).reduce( (total, eventListeners) => total + eventListeners.size, @@ -287,6 +293,49 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("enables nightly full changelog release notes and broadcasts summaries", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + yield* updates.setChannel("nightly"); + assert.equal(harness.fullChangelog(), true); + + harness.emit("update-available", { + version: "1.2.4-nightly.20260709.766", + releaseNotes: [ + { + version: "1.2.4-nightly.20260709.766", + note: `

What's Changed

Full Changelog

`, + }, + { + version: "1.2.4-nightly.20260709.765", + note: "- [codex] Upgrade Clerk stack by @juliusmarminge in #3821", + }, + ], + }); + yield* flushCallbacks; + + const state = yield* updates.getState; + assert.equal(state.status, "available"); + assert.deepEqual(state.releaseNotes, [ + { + version: "1.2.4-nightly.20260709.766", + items: ["feat(client): persist offline environment data by @juliusmarminge in #3795"], + }, + { + version: "1.2.4-nightly.20260709.765", + items: ["[codex] Upgrade Clerk stack by @juliusmarminge in #3821"], + }, + ]); + assert.deepEqual(harness.sentStates.at(-1)?.releaseNotes, state.releaseNotes); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("keeps raw updater event failures out of update state", () => { const harness = makeHarness(); const cause = new Error( diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index aabb0830b0f8..7357907e1783 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -27,6 +27,7 @@ import * as ElectronUpdater from "../electron/ElectronUpdater.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as IpcChannels from "../ipc/channels.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; +import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts"; import { createInitialDesktopUpdateState, @@ -49,6 +50,10 @@ type AppUpdateYmlConfig = typeof AppUpdateYmlConfig.Type; const UpdateInfo = Schema.Struct({ version: Schema.String, + // Left unvalidated on purpose: a malformed release-notes payload must never + // fail the decode and block the update state transition. The shape is + // validated defensively in normalizeDesktopUpdateReleaseNotes. + releaseNotes: Schema.optional(Schema.Unknown), }); const DownloadProgressInfo = Schema.Struct({ @@ -330,10 +335,12 @@ export const make = Effect.gen(function* () { yield* electronUpdater.setChannel(channel); yield* electronUpdater.setAllowPrerelease(allowsPrerelease); yield* electronUpdater.setAllowDowngrade(allowsPrerelease); + yield* electronUpdater.setFullChangelog(allowsPrerelease); yield* logUpdaterInfo("using update channel", { channel, allowPrerelease: allowsPrerelease, allowDowngrade: allowsPrerelease, + fullChangelog: allowsPrerelease, }); }); @@ -567,11 +574,15 @@ export const make = Effect.gen(function* () { } const checkedAt = yield* currentIsoTimestamp; + const releaseNotes = normalizeDesktopUpdateReleaseNotes(info.releaseNotes, info.version); yield* setState( - reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt), + reduceDesktopUpdateStateOnUpdateAvailable(state, info.version, checkedAt, releaseNotes), ); yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); - yield* logUpdaterInfo("update available", { version: info.version }); + yield* logUpdaterInfo("update available", { + version: info.version, + releaseNoteGroups: releaseNotes.length, + }); }), ), Effect.catchCause((cause) => { diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts new file mode 100644 index 000000000000..9d6bbaea6bcb --- /dev/null +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { normalizeDesktopUpdateReleaseNotes } from "./releaseNotes.ts"; + +describe("normalizeDesktopUpdateReleaseNotes", () => { + it("splits a plain string note into items under the fallback version", () => { + const notes = normalizeDesktopUpdateReleaseNotes( + "## What's changed\n- First fix\n- Second fix", + "1.2.3", + ); + expect(notes).toEqual([{ version: "1.2.3", items: ["First fix", "Second fix"] }]); + }); + + it("keeps per-version groups and drops empty ones", () => { + const notes = normalizeDesktopUpdateReleaseNotes( + [ + { version: "1.2.3", note: "- Newer change" }, + { version: "1.2.2", note: "Full changelog: https://example.com/compare/x...y" }, + { version: "1.2.1", note: "- Older change" }, + ], + "1.2.3", + ); + expect(notes).toEqual([ + { version: "1.2.3", items: ["Newer change"] }, + { version: "1.2.1", items: ["Older change"] }, + ]); + }); + + it("decodes valid HTML entities", () => { + const notes = normalizeDesktopUpdateReleaseNotes("- Fix & polish 😀", "1.0.0"); + expect(notes).toEqual([{ version: "1.0.0", items: ["Fix & polish 😀"] }]); + }); + + it("ignores malformed entries instead of throwing", () => { + const notes = normalizeDesktopUpdateReleaseNotes( + [ + { version: "1.2.3", note: "- Valid change" }, + { version: 42, note: "- Bad version type" }, + { version: "1.2.1", note: { html: "

object note

" } }, + "not an object", + null, + ], + "1.2.3", + ); + expect(notes).toEqual([{ version: "1.2.3", items: ["Valid change"] }]); + }); + + it("returns non-empty groups even when preceded by many boilerplate-only groups", () => { + const boilerplate = Array.from({ length: 7 }, (_, index) => ({ + version: `1.3.${9 - index}`, + note: "Full changelog: https://example.com/compare/x...y", + })); + const notes = normalizeDesktopUpdateReleaseNotes( + [...boilerplate, { version: "1.3.2", note: "- Older but real change" }], + "1.3.9", + ); + expect(notes).toEqual([{ version: "1.3.2", items: ["Older but real change"] }]); + }); + + it("does not throw on out-of-range numeric entities and keeps the literal", () => { + expect(() => + normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"), + ).not.toThrow(); + const notes = normalizeDesktopUpdateReleaseNotes("- Broken entity �", "1.0.0"); + expect(notes).toEqual([{ version: "1.0.0", items: ["Broken entity �"] }]); + }); +}); diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts new file mode 100644 index 000000000000..69857c92b3f1 --- /dev/null +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -0,0 +1,125 @@ +import type { DesktopUpdateReleaseNote } from "@t3tools/contracts"; + +interface ElectronReleaseNoteInfo { + readonly version: string; + readonly note: string | null | undefined; +} + +function isElectronReleaseNoteInfo(value: unknown): value is ElectronReleaseNoteInfo { + if (typeof value !== "object" || value === null) return false; + const candidate = value as { readonly version?: unknown; readonly note?: unknown }; + return ( + typeof candidate.version === "string" && + (typeof candidate.note === "string" || candidate.note === null || candidate.note === undefined) + ); +} + +const MAX_RELEASE_NOTE_GROUPS = 6; +const MAX_RELEASE_NOTE_ITEMS_PER_GROUP = 8; +const MAX_RELEASE_NOTE_ITEM_LENGTH = 220; + +const HTML_ENTITY_REPLACEMENTS: Readonly> = { + amp: "&", + apos: "'", + gt: ">", + lt: "<", + nbsp: " ", + quot: '"', +}; + +function decodeCodePoint(codePoint: number, entity: string): string { + // String.fromCodePoint throws RangeError outside the valid Unicode range, and + // Number.isFinite alone lets oversized values (e.g. �) through. + if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { + return `&${entity};`; + } + return String.fromCodePoint(codePoint); +} + +function decodeHtmlEntity(entity: string): string { + const named = HTML_ENTITY_REPLACEMENTS[entity]; + if (named) return named; + if (entity.startsWith("#x")) { + return decodeCodePoint(Number.parseInt(entity.slice(2), 16), entity); + } + if (entity.startsWith("#")) { + return decodeCodePoint(Number.parseInt(entity.slice(1), 10), entity); + } + return `&${entity};`; +} + +function decodeHtmlEntities(input: string): string { + return input.replace(/&([a-zA-Z]+|#\d+|#x[0-9a-fA-F]+);/g, (_, entity: string) => + decodeHtmlEntity(entity), + ); +} + +function stripMarkup(input: string): string { + return decodeHtmlEntities( + input + .replace(//gi, "\n") + .replace(/]*>/gi, "\n- ") + .replace(/<\/(?:p|div|li|h[1-6]|ul|ol|blockquote)>/gi, "\n") + .replace(/<[^>]*>/g, "") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1"), + ); +} + +function truncateReleaseNoteItem(item: string): string { + if (item.length <= MAX_RELEASE_NOTE_ITEM_LENGTH) return item; + return `${item.slice(0, MAX_RELEASE_NOTE_ITEM_LENGTH - 3).trimEnd()}...`; +} + +function isIgnoredReleaseNoteLine(line: string): boolean { + const normalized = line + .toLowerCase() + .replace(/[*_`#]/g, "") + .trim(); + return ( + normalized === "" || + normalized === "what's changed" || + normalized === "whats changed" || + normalized === "full changelog" || + normalized === "new contributors" || + normalized.startsWith("compare: ") || + normalized.includes("/compare/") + ); +} + +function extractReleaseNoteItems(note: string | null | undefined): ReadonlyArray { + if (!note) return []; + + const items: string[] = []; + for (const rawLine of stripMarkup(note).split("\n")) { + const item = rawLine + .trim() + .replace(/^[-*]\s+/, "") + .replace(/^\d+[.)]\s+/, "") + .replace(/\s+/g, " "); + if (isIgnoredReleaseNoteLine(item)) continue; + items.push(truncateReleaseNoteItem(item)); + if (items.length >= MAX_RELEASE_NOTE_ITEMS_PER_GROUP) break; + } + return items; +} + +export function normalizeDesktopUpdateReleaseNotes( + releaseNotes: unknown, + fallbackVersion: string, +): ReadonlyArray { + const rawNotes = + typeof releaseNotes === "string" + ? [{ version: fallbackVersion, note: releaseNotes }] + : Array.isArray(releaseNotes) + ? releaseNotes.filter(isElectronReleaseNoteInfo) + : []; + + return rawNotes + .map((entry) => ({ + version: entry.version, + items: extractReleaseNoteItems(entry.note), + })) + .filter((entry) => entry.items.length > 0) + .slice(0, MAX_RELEASE_NOTE_GROUPS); +} diff --git a/apps/desktop/src/updates/updateMachine.test.ts b/apps/desktop/src/updates/updateMachine.test.ts index 4b2a87c7ce0c..040411f76f4f 100644 --- a/apps/desktop/src/updates/updateMachine.test.ts +++ b/apps/desktop/src/updates/updateMachine.test.ts @@ -118,6 +118,12 @@ describe("updateMachine", () => { }); it("tracks available, download start, and progress cleanly", () => { + const releaseNotes = [ + { + version: "1.1.0", + items: ["feat: add update release notes"], + }, + ]; const available = reduceDesktopUpdateStateOnUpdateAvailable( { ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"), @@ -126,15 +132,33 @@ describe("updateMachine", () => { }, "1.1.0", "2026-03-04T00:00:00.000Z", + releaseNotes, ); const downloading = reduceDesktopUpdateStateOnDownloadStart(available); const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5); expect(available.status).toBe("available"); expect(available.channel).toBe("latest"); + expect(available.releaseNotes).toBe(releaseNotes); + expect(downloading.releaseNotes).toBe(releaseNotes); expect(downloading.status).toBe("downloading"); expect(downloading.downloadPercent).toBe(0); expect(progress.downloadPercent).toBe(55.5); expect(progress.errorContext).toBeNull(); }); + + it("clears release notes when checking again", () => { + const state = reduceDesktopUpdateStateOnCheckStart( + { + ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "nightly"), + enabled: true, + status: "available", + availableVersion: "1.1.0-nightly.1", + releaseNotes: [{ version: "1.1.0-nightly.1", items: ["feat: old note"] }], + }, + "2026-03-04T00:00:00.000Z", + ); + + expect(state.releaseNotes).toEqual([]); + }); }); diff --git a/apps/desktop/src/updates/updateMachine.ts b/apps/desktop/src/updates/updateMachine.ts index b5037225774b..fef51bbb8ab2 100644 --- a/apps/desktop/src/updates/updateMachine.ts +++ b/apps/desktop/src/updates/updateMachine.ts @@ -1,6 +1,7 @@ import type { DesktopRuntimeInfo, DesktopUpdateChannel, + DesktopUpdateReleaseNote, DesktopUpdateState, } from "@t3tools/contracts"; @@ -29,6 +30,7 @@ export function createInitialDesktopUpdateState( runningUnderArm64Translation: runtimeInfo.runningUnderArm64Translation, availableVersion: null, downloadedVersion: null, + releaseNotes: [], downloadPercent: null, checkedAt: null, message: null, @@ -45,6 +47,7 @@ export function reduceDesktopUpdateStateOnCheckStart( ...state, status: "checking", checkedAt, + releaseNotes: [], message: null, downloadPercent: null, errorContext: null, @@ -72,12 +75,14 @@ export function reduceDesktopUpdateStateOnUpdateAvailable( state: DesktopUpdateState, version: string, checkedAt: string, + releaseNotes: ReadonlyArray = [], ): DesktopUpdateState { return { ...state, status: "available", availableVersion: version, downloadedVersion: null, + releaseNotes, downloadPercent: null, checkedAt, message: null, @@ -95,6 +100,7 @@ export function reduceDesktopUpdateStateOnNoUpdate( status: "up-to-date", availableVersion: null, downloadedVersion: null, + releaseNotes: [], downloadPercent: null, checkedAt, message: null, diff --git a/apps/marketing/public/harnesses/cursor_light.svg b/apps/marketing/public/harnesses/cursor_light.svg index e61e0be3bfdd..089d4676370b 100644 --- a/apps/marketing/public/harnesses/cursor_light.svg +++ b/apps/marketing/public/harnesses/cursor_light.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/apps/marketing/src/lib/site.ts b/apps/marketing/src/lib/site.ts index 5ff5958c588f..92e491a077a7 100644 --- a/apps/marketing/src/lib/site.ts +++ b/apps/marketing/src/lib/site.ts @@ -1,6 +1,6 @@ export const GITHUB_REPOSITORY_URL = "https://github.com/pingdotgg/t3code"; export const MARKETING_STATS = { - githubStars: "12k+", + githubStars: "14k+", users: "100,000", } as const; diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index fd7a93884159..d1c29ffcfc6e 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -1,5 +1,6 @@ import type { ExpoConfig } from "expo/config"; +import { BRAND_ASSET_PATHS } from "../../scripts/lib/brand-assets.ts"; import { loadRepoEnv } from "../../scripts/lib/public-config.ts"; type AppVariant = "development" | "preview" | "production"; @@ -15,6 +16,8 @@ const isIosPersonalTeamBuild = repoEnv.T3CODE_IOS_PERSONAL_TEAM === "1"; const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID?.trim(); const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/; +const fromRepoRoot = (relativePath: string) => `../../${relativePath}`; + if ( isIosPersonalTeamBuild && (!personalTeamBundleIdentifier || @@ -26,20 +29,31 @@ if ( } const DEVELOPMENT_ASSETS = { - appIcon: "./assets/splash-icon-dev.png", - iosIcon: "./assets/icon-composer-dev.icon", - splashIcon: "./assets/splash-icon-dev.png", - androidAdaptiveForeground: "./assets/android-icon-dev-foreground.png", + appIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng), + iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIconComposerProject), + splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.developmentIosIconPng), + androidAdaptiveForeground: fromRepoRoot(BRAND_ASSET_PATHS.developmentUniversalIconPng), androidAdaptiveBackgroundColor: "#00639B", androidMonochromeIcon: "./assets/android-icon-mark.png", androidNotificationIcon: "./assets/android-notification-icon.png", androidNotificationColor: "#00639B", } as const; +const PREVIEW_ASSETS = { + appIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng), + iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIconComposerProject), + splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.nightlyIosIconPng), + androidAdaptiveForeground: fromRepoRoot(BRAND_ASSET_PATHS.nightlyLinuxIconPng), + androidAdaptiveBackgroundColor: "#111533", + androidMonochromeIcon: "./assets/android-icon-mark.png", + androidNotificationIcon: "./assets/android-notification-icon.png", + androidNotificationColor: "#7565C7", +} as const; + const RELEASE_ASSETS = { - appIcon: "./assets/splash-icon-prod.png", - iosIcon: "./assets/icon-composer-prod.icon", - splashIcon: "./assets/splash-icon-prod.png", + appIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng), + iosIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIconComposerProject), + splashIcon: fromRepoRoot(BRAND_ASSET_PATHS.productionIosIconPng), androidAdaptiveForeground: "./assets/android-icon-mark.png", androidAdaptiveBackgroundColor: "#000000", androidMonochromeIcon: "./assets/android-icon-mark.png", @@ -62,7 +76,7 @@ const VARIANT_CONFIG = { iosBundleIdentifier: "com.t3tools.t3code.preview", androidPackage: "com.t3tools.t3code.preview", relyingParty: "clerk.t3.codes", - assets: RELEASE_ASSETS, + assets: PREVIEW_ASSETS, }, production: { appName: "T3 Code", diff --git a/apps/mobile/assets/android-icon-dev-foreground.png b/apps/mobile/assets/android-icon-dev-foreground.png deleted file mode 100644 index b33d7978d720..000000000000 Binary files a/apps/mobile/assets/android-icon-dev-foreground.png and /dev/null differ diff --git a/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg b/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg deleted file mode 100644 index d98c41a4e3e8..000000000000 Binary files a/apps/mobile/assets/icon-composer-dev.icon/Assets/Texturelabs_Paper_381XL.jpg and /dev/null differ diff --git a/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png b/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png deleted file mode 100644 index de5a82d8c49e..000000000000 Binary files a/apps/mobile/assets/icon-composer-dev.icon/Assets/gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png and /dev/null differ diff --git a/apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg b/apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg deleted file mode 100644 index b12706fdfc23..000000000000 --- a/apps/mobile/assets/icon-composer-prod.icon/Assets/T3.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/apps/mobile/assets/splash-icon-dev.png b/apps/mobile/assets/splash-icon-dev.png deleted file mode 100644 index b33d6f337b0b..000000000000 Binary files a/apps/mobile/assets/splash-icon-dev.png and /dev/null differ diff --git a/apps/mobile/assets/splash-icon-prod.png b/apps/mobile/assets/splash-icon-prod.png deleted file mode 100644 index 2ff311ce786f..000000000000 Binary files a/apps/mobile/assets/splash-icon-prod.png and /dev/null differ diff --git a/apps/mobile/src/components/BrandMark.tsx b/apps/mobile/src/components/BrandMark.tsx index 4d1ac01b1ff4..c8f1d4385517 100644 --- a/apps/mobile/src/components/BrandMark.tsx +++ b/apps/mobile/src/components/BrandMark.tsx @@ -1,13 +1,23 @@ -import { Image, View } from "react-native"; +import Constants from "expo-constants"; +import { Image } from "expo-image"; +import { View } from "react-native"; import { AppText as Text } from "./AppText"; -const BRAND_MARK_SOURCE = require("../../../../assets/dev/blueprint-ios-1024.png"); +const appVariant = Constants.expoConfig?.extra?.appVariant; +const BRAND_MARK_SOURCE = + appVariant === "development" + ? require("../../../../assets/dev/blueprint-ios-1024.png") + : 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"; export function BrandMark(props: { readonly compact?: boolean; readonly stageLabel?: string }) { const compact = props.compact ?? false; const iconSize = compact ? 32 : 44; - const stageLabel = props.stageLabel ?? "Alpha"; + const stageLabel = props.stageLabel ?? DEFAULT_STAGE_LABEL; return ( diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index b46a4ce1ba3c..35ffd1a4756f 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -1,3 +1,5 @@ +import type { ProviderInteractionMode } from "@t3tools/contracts"; + const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = ` ## T3 Code collaborative browser @@ -145,3 +147,26 @@ The \`request_user_input\` tool is unavailable in Default mode. If you call it w In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. ${T3_CODE_BROWSER_TOOL_INSTRUCTIONS} `; + +export interface CodexRuntimeInfo { + readonly model: string; + readonly reasoningEffort: string; +} + +// Values come from trusted config, but keep the block single-line regardless. +function toSingleLine(value: string): string { + return value.replaceAll(/\s+/g, " ").trim(); +} + +export function buildCodexDeveloperInstructions( + interactionMode: ProviderInteractionMode, + runtime: CodexRuntimeInfo, +): string { + const base = + interactionMode === "plan" + ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS + : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS; + return `${base} + +In case you're asked: you are running in T3 Code through the Codex harness, as ${toSingleLine(runtime.model)} with ${toSingleLine(runtime.reasoningEffort)} reasoning effort. No need to mention this otherwise.`; +} diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index f2b04b3a2820..ee9cddf949cf 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -119,6 +119,7 @@ export const ClaudeDriver: ProviderDriver = { Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const path = yield* Path.Path; + const { cwd } = yield* ServerConfig; const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; @@ -154,11 +155,11 @@ export const ClaudeDriver: ProviderDriver = { capacity: 1, timeToLive: CAPABILITIES_PROBE_TTL, lookup: () => - probeClaudeCapabilities(effectiveConfig, processEnv).pipe( + probeClaudeCapabilities(effectiveConfig, processEnv, cwd).pipe( Effect.provideService(Path.Path, path), ), }); - const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig); + const capabilitiesCacheKey = yield* makeClaudeCapabilitiesCacheKey(effectiveConfig, cwd); const checkProvider = checkClaudeProviderStatus( effectiveConfig, diff --git a/apps/server/src/provider/Drivers/ClaudeHome.test.ts b/apps/server/src/provider/Drivers/ClaudeHome.test.ts index 1839e850af1b..d30666b736ea 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.test.ts @@ -41,11 +41,20 @@ it.layer(NodeServices.layer)("ClaudeHome", (it) => { `claude:home:${configDir}`, ); expect(yield* makeClaudeCapabilitiesCacheKey({ binaryPath: "claude", homePath })).toBe( - `claude\0${configDir}`, + `claude\0${configDir}\0`, ); }), ); + it.effect("separates capability probes by cwd", () => + Effect.gen(function* () { + const config = { binaryPath: "claude", homePath: "" }; + const first = yield* makeClaudeCapabilitiesCacheKey(config, "/repo-a"); + const second = yield* makeClaudeCapabilitiesCacheKey(config, "/repo-b"); + expect(first).not.toBe(second); + }), + ); + it.effect("treats paths that already end with .claude as the config dir", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index b42c26157ce0..d839d0b4a629 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -72,8 +72,9 @@ export const makeClaudeContinuationGroupKey = Effect.fn("makeClaudeContinuationG export const makeClaudeCapabilitiesCacheKey = Effect.fn("makeClaudeCapabilitiesCacheKey")( function* ( config: Pick, + cwd?: string, ): Effect.fn.Return { const configDir = yield* resolveClaudeConfigDir(config); - return `${config.binaryPath}\0${configDir}`; + return `${config.binaryPath}\0${configDir}\0${cwd ?? ""}`; }, ); diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index d855d1a45151..4eb32c20c472 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -84,6 +84,7 @@ export const GrokDriver: ProviderDriver = { defaultConfig: (): GrokSettings => decodeGrokSettings({}), create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; @@ -114,6 +115,7 @@ export const GrokDriver: ProviderDriver = { const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe( Effect.map(stampIdentity), + Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 8a98f38816e8..2e35731c7671 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -644,6 +644,7 @@ function waitForAbortSignal(signal: AbortSignal): Promise { const probeClaudeCapabilities = ( claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, + cwd?: string, ) => { const abort = new AbortController(); return Effect.gen(function* () { @@ -663,6 +664,7 @@ const probeClaudeCapabilities = ( settingSources: ["user", "project", "local"], allowedTools: [], env: claudeEnvironment, + ...(cwd ? { cwd } : {}), stderr: () => {}, }, }); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 8aeacd870cc4..1527072dae7a 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -4,11 +4,12 @@ import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { describe } from "vite-plus/test"; -import { ThreadId } from "@t3tools/contracts"; +import { DEFAULT_MODEL, ThreadId } from "@t3tools/contracts"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; import { + buildCodexDeveloperInstructions, CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, } from "../CodexDeveloperInstructions.ts"; @@ -118,7 +119,10 @@ describe("buildTurnStartParams", () => { settings: { model: "gpt-5.3-codex", reasoning_effort: "medium", - developer_instructions: CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, + developer_instructions: buildCodexDeveloperInstructions("plan", { + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }), }, }, }); @@ -163,12 +167,31 @@ describe("buildTurnStartParams", () => { settings: { model: "gpt-5.3-codex", reasoning_effort: "medium", - developer_instructions: CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, + developer_instructions: buildCodexDeveloperInstructions("default", { + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }), }, }, }); }); + it("reports the same fallback model and effort in settings and instructions", () => { + const params = Effect.runSync( + buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: "Go", + interactionMode: "default", + }), + ); + + const settings = params.collaborationMode?.settings; + NodeAssert.equal(settings?.model, DEFAULT_MODEL); + NodeAssert.equal(settings?.reasoning_effort, "medium"); + NodeAssert.ok(settings?.developer_instructions?.includes(`as ${DEFAULT_MODEL} with medium`)); + }); + it("omits collaboration mode when interaction mode is absent", () => { const params = Effect.runSync( buildTurnStartParams({ @@ -194,6 +217,53 @@ describe("buildTurnStartParams", () => { }); }); +describe("buildCodexDeveloperInstructions", () => { + it("appends runtime info after the mode instructions", () => { + const instructions = buildCodexDeveloperInstructions("default", { + model: "gpt-5.3-codex", + reasoningEffort: "high", + }); + + NodeAssert.ok(instructions.startsWith(CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS)); + NodeAssert.match(instructions, /T3 Code/); + NodeAssert.match(instructions, /Codex harness/); + NodeAssert.match(instructions, /as gpt-5\.3-codex with high reasoning effort/); + }); + + it("includes runtime info alongside plan mode instructions", () => { + const instructions = buildCodexDeveloperInstructions("plan", { + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }); + + NodeAssert.ok(instructions.startsWith(CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS)); + NodeAssert.match(instructions, /as gpt-5\.3-codex with medium reasoning effort/); + }); + + it("varies with the model and effort of each turn", () => { + const first = buildCodexDeveloperInstructions("default", { + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }); + const second = buildCodexDeveloperInstructions("default", { + model: "gpt-5.4", + reasoningEffort: "high", + }); + + NodeAssert.notEqual(first, second); + }); + + it("flattens multiline metadata into single-line runtime info", () => { + const instructions = buildCodexDeveloperInstructions("default", { + model: "gpt\n5.3\ncodex", + reasoningEffort: " high\neffort ", + }); + + NodeAssert.match(instructions, /as gpt 5\.3 codex with high effort reasoning effort/); + NodeAssert.doesNotMatch(instructions, /[^<]*\n/); + }); +}); + describe("T3 browser developer instructions", () => { it("prefers the product-native preview tools in both collaboration modes", () => { for (const instructions of [ diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 5825dd6ea33b..4a320fa78a27 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -37,10 +37,7 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { buildCodexInitializeParams } from "./CodexProvider.ts"; import { expandHomePath } from "../../pathExpansion.ts"; -import { - CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, - CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, -} from "../CodexDeveloperInstructions.ts"; +import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; const decodeV2TurnStartResponse = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnStartResponse); const PROVIDER = ProviderDriverKind.make("codex"); @@ -333,15 +330,16 @@ function buildCodexCollaborationMode(input: { return undefined; } const model = normalizeCodexModelSlug(input.model) ?? DEFAULT_MODEL; + const reasoningEffort = input.effort ?? "medium"; return { mode: input.interactionMode, settings: { model, - reasoning_effort: input.effort ?? "medium", - developer_instructions: - input.interactionMode === "plan" - ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS - : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, + reasoning_effort: reasoningEffort, + developer_instructions: buildCodexDeveloperInstructions(input.interactionMode, { + model, + reasoningEffort, + }), }, }; } diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index c22b21801835..d8c288a8292f 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -596,6 +596,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte : {}), ...acpNativeLoggers, }).pipe( + Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(Scope.Scope, sessionScope), Effect.mapError( (cause) => diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index cf5d5ad9c8d8..33f61ad97f6d 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -7,6 +7,7 @@ import { } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -167,7 +168,11 @@ const runGrokVersionCommand = ( export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, -): Effect.fn.Return { +): Effect.fn.Return< + ServerProviderDraft, + never, + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto +> { const checkedAt = DateTime.formatIso(yield* DateTime.now); const fallbackModels = grokModelsFromSettings(grokSettings.customModels); diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index f1a51616c480..547e7bd92c57 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -76,7 +76,7 @@ const makeClaudeConfig = (overrides: Partial): ClaudeSettings => const makeCursorConfig = (overrides: Partial): CursorSettings => ({ enabled: false, - binaryPath: "agent", + binaryPath: "cursor-agent", apiEndpoint: "", customModels: [], ...overrides, diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index dc88eb67e79b..886eff5ef1b8 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1411,7 +1411,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive), Layer.provideMerge( mockCommandSpawnerLayer((command, args) => { - if (command === "agent") { + if (command === "cursor-agent") { cursorSpawned = true; } const joined = args.join(" "); diff --git a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts index 4e9700dab7d6..b1ef0d3e5953 100644 --- a/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts +++ b/apps/server/src/provider/acp/AcpJsonRpcConnection.test.ts @@ -116,6 +116,43 @@ describe("AcpSessionRuntime", () => { ), ); + it.effect("keeps assistant item IDs unique when a provider session restarts", () => { + const collectFirstAssistantItemId = Effect.gen(function* () { + const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; + const started = yield* runtime.start(); + expect(started.sessionId).toBe("mock-session-1"); + + yield* runtime.prompt({ + prompt: [{ type: "text", text: "hi" }], + }); + + const events = Array.from(yield* Stream.runCollect(Stream.take(runtime.getEvents(), 4))); + const assistantStart = events.find((event) => event._tag === "AssistantItemStarted"); + expect(assistantStart?._tag).toBe("AssistantItemStarted"); + return assistantStart?._tag === "AssistantItemStarted" ? assistantStart.itemId : ""; + }).pipe( + Effect.provide( + AcpSessionRuntime.layer({ + spawn: { + command: mockAgentCommand, + args: mockAgentArgs, + }, + cwd: process.cwd(), + clientInfo: { name: "t3-test", version: "0.0.0" }, + authMethodId: "test", + }), + ), + Effect.scoped, + ); + + return Effect.gen(function* () { + const beforeRestart = yield* collectFirstAssistantItemId; + const afterRestart = yield* collectFirstAssistantItemId; + + expect(afterRestart).not.toBe(beforeRestart); + }).pipe(Effect.provide(NodeServices.layer)); + }); + it.effect("drops session updates emitted for a child ACP session", () => Effect.gen(function* () { const runtime = yield* AcpSessionRuntime.AcpSessionRuntime; diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index bd10e46b0cf6..82cad3921a49 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -1,6 +1,7 @@ import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -270,14 +271,24 @@ export const make = ( ): Effect.Effect< AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Scope.Scope + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | Scope.Scope > => Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runtimeScope = yield* Scope.Scope; const eventQueue = yield* Queue.unbounded(); const modeStateRef = yield* Ref.make(undefined); const toolCallsRef = yield* Ref.make(new Map()); + const assistantItemRuntimeId = yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new EffectAcpErrors.AcpTransportError({ + detail: "Failed to generate an ACP assistant item runtime identifier.", + cause, + }), + ), + ); const assistantSegmentRef = yield* Ref.make({ nextSegmentIndex: 0 }); const configOptionsRef = yield* Ref.make(sessionConfigOptionsFromSetup(undefined)); const startStateRef = yield* Ref.make({ _tag: "NotStarted" }); @@ -393,6 +404,7 @@ export const make = ( modeStateRef, toolCallsRef, assistantSegmentRef, + assistantItemRuntimeId, params: notification, }); }), @@ -817,7 +829,7 @@ export const layer = ( ): Layer.Layer< AcpSessionRuntime, EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner + ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto > => Layer.effect(AcpSessionRuntime, make(options)); function sessionConfigOptionsFromSetup( @@ -849,12 +861,14 @@ const handleSessionUpdate = ({ modeStateRef, toolCallsRef, assistantSegmentRef, + assistantItemRuntimeId, params, }: { readonly queue: Queue.Queue; readonly modeStateRef: Ref.Ref; readonly toolCallsRef: Ref.Ref>; readonly assistantSegmentRef: Ref.Ref; + readonly assistantItemRuntimeId: string; readonly params: EffectAcpSchema.SessionNotification; }): Effect.Effect => Effect.gen(function* () { @@ -902,6 +916,7 @@ const handleSessionUpdate = ({ queue, assistantSegmentRef, sessionId: params.sessionId, + assistantItemRuntimeId, }); yield* Queue.offer(queue, { ...event, @@ -939,17 +954,19 @@ function shouldEmitToolCallUpdate( return previous === undefined || previous.title !== next.title || previous.detail !== next.detail; } -const assistantItemId = (sessionId: string, segmentIndex: number) => - `assistant:${sessionId}:segment:${segmentIndex}`; +const assistantItemId = (sessionId: string, runtimeId: string, segmentIndex: number) => + `assistant:${sessionId}:runtime:${runtimeId}:segment:${segmentIndex}`; const ensureActiveAssistantSegment = ({ queue, assistantSegmentRef, sessionId, + assistantItemRuntimeId, }: { readonly queue: Queue.Queue; readonly assistantSegmentRef: Ref.Ref; readonly sessionId: string; + readonly assistantItemRuntimeId: string; }) => Ref.modify( assistantSegmentRef, @@ -957,7 +974,7 @@ const ensureActiveAssistantSegment = ({ if (current.activeItemId) { return [{ itemId: current.activeItemId }, current] as const; } - const itemId = assistantItemId(sessionId, current.nextSegmentIndex); + const itemId = assistantItemId(sessionId, assistantItemRuntimeId, current.nextSegmentIndex); return [ { itemId, diff --git a/apps/server/src/provider/acp/GrokAcpSupport.ts b/apps/server/src/provider/acp/GrokAcpSupport.ts index 3e6d63a53935..c928b3ed80e0 100644 --- a/apps/server/src/provider/acp/GrokAcpSupport.ts +++ b/apps/server/src/provider/acp/GrokAcpSupport.ts @@ -1,4 +1,5 @@ import { type GrokSettings, ProviderDriverKind } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Scope from "effect/Scope"; @@ -55,7 +56,7 @@ export const makeGrokAcpRuntime = ( ): Effect.Effect< AcpSessionRuntime.AcpSessionRuntime["Service"], EffectAcpErrors.AcpError, - Scope.Scope + Crypto.Crypto | Scope.Scope > => Effect.gen(function* () { const acpContext = yield* Layer.build( diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index f33a9bbce426..641c9b52e56c 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -48,7 +48,7 @@ function lifecycleFor(provider: ProviderDriverKind): ProviderMaintenanceCapabili return makeProviderMaintenanceCapabilities({ provider, packageName: null, - updateExecutable: "agent", + updateExecutable: "cursor-agent", updateArgs: ["update"], updateLockKey: "cursor-agent", }); @@ -226,7 +226,7 @@ describe("providerMaintenanceRunner", () => { const result = yield* updater.updateProvider(CURSOR_DRIVER); assert.deepStrictEqual(calls, [ { - command: "agent", + command: "cursor-agent", args: ["update"], }, ]); diff --git a/apps/server/src/textGeneration/GrokTextGeneration.ts b/apps/server/src/textGeneration/GrokTextGeneration.ts index 1bb582163056..fd367acdf4c4 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.ts @@ -1,3 +1,4 @@ +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; @@ -37,6 +38,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi grokSettings: GrokSettings, environment: NodeJS.ProcessEnv = process.env, ) { + const crypto = yield* Crypto.Crypto; const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runGrokJson = ({ @@ -65,7 +67,7 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi childProcessSpawner: commandSpawner, cwd, clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, - }); + }).pipe(Effect.provideService(Crypto.Crypto, crypto)); yield* runtime.handleSessionUpdate((notification) => { const update = notification.update; diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 455cc9199f64..aefa9ab9f418 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -1,6 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; -import { useEffect, useState, type CSSProperties, type ReactNode } from "react"; -import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { useLocation, useNavigate } from "@tanstack/react-router"; import { isElectron } from "../env"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; @@ -55,6 +55,9 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const navigate = useNavigate(); + const pathname = useLocation({ select: (location) => location.pathname }); + const pathnameRef = useRef(pathname); + pathnameRef.current = pathname; const isMacosDesktop = isElectron && isMacPlatform(navigator.platform); const [isWindowFullscreen, setIsWindowFullscreen] = useState(() => { const getWindowFullscreenState = window.desktopBridge?.getWindowFullscreenState; @@ -92,7 +95,10 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { const unsubscribe = onMenuAction((action) => { if (action === "open-settings") { - void navigate({ to: "/settings" }); + const isSettingsRoute = /^\/settings(\/|$)/.test(pathnameRef.current); + if (!isSettingsRoute) { + void navigate({ to: "/settings" }); + } } }); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c8baa9183d6b..a59c125a4536 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -41,6 +41,7 @@ import { detectComposerTrigger, expandCollapsedComposerCursor, replaceTextRange, + shouldSubmitComposerOnEnter, } from "../../composer-logic"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; import { @@ -1725,7 +1726,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return true; } } - if (key === "Enter" && !event.shiftKey) { + if ( + key === "Enter" && + shouldSubmitComposerOnEnter({ isMobileViewport, shiftKey: event.shiftKey }) + ) { submitComposer(); return true; } diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index d826eca0063a..bd02a91b248e 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -190,7 +190,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard(
{option.label} {option.description && option.description !== option.label ? ( - {option.description} + {option.description} ) : null}
{isSelected ? ( diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index a05bc33a10f2..65083b5b0bfd 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -24,6 +24,7 @@ const baseState: DesktopUpdateState = { runningUnderArm64Translation: false, availableVersion: null, downloadedVersion: null, + releaseNotes: [], downloadPercent: null, checkedAt: null, message: null, diff --git a/apps/web/src/components/preview/PreviewPanelShell.test.ts b/apps/web/src/components/preview/PreviewPanelShell.test.ts new file mode 100644 index 000000000000..43bc5ffa7307 --- /dev/null +++ b/apps/web/src/components/preview/PreviewPanelShell.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { getPreviewPanelMaxWidth } from "./PreviewPanelShell"; + +describe("getPreviewPanelMaxWidth", () => { + it("allows the panel to use 70% of the available chat+preview row without a pixel ceiling", () => { + expect(getPreviewPanelMaxWidth(6_000)).toBe(4_200); + }); + + it("rounds fractional CSS pixels down", () => { + expect(getPreviewPanelMaxWidth(2_001)).toBe(1_400); + }); + + it("leaves chat room when the available row is narrower than the viewport", () => { + // e.g. 6000px viewport minus a 2000px left sidebar → 4000px row + expect(getPreviewPanelMaxWidth(4_000)).toBe(2_800); + }); +}); diff --git a/apps/web/src/components/preview/PreviewPanelShell.tsx b/apps/web/src/components/preview/PreviewPanelShell.tsx index 6de828c1e63e..46b02fade011 100644 --- a/apps/web/src/components/preview/PreviewPanelShell.tsx +++ b/apps/web/src/components/preview/PreviewPanelShell.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useEffect, useState } from "react"; +import { type ReactNode, type RefObject, useEffect, useRef, useState } from "react"; import { isElectron } from "~/env"; import { useResizableWidth } from "~/hooks/useResizableWidth"; @@ -10,12 +10,14 @@ export type PreviewPanelMode = "inline" | "sheet" | "sidebar" | "embedded"; const PREVIEW_PANEL_WIDTH_STORAGE_KEY = "t3code:preview-panel-width"; const PREVIEW_PANEL_MIN_WIDTH = 360; -/** Hard ceiling so a wide monitor can't yield a panel that swallows the chat. */ -const PREVIEW_PANEL_MAX_WIDTH_PX = 1400; -/** Fraction of the viewport allowed; the panel is min(this · vw, MAX_PX). */ +/** Fraction of the available row width allowed, preserving space for chat. */ const PREVIEW_PANEL_MAX_WIDTH_FRACTION = 0.7; const PREVIEW_PANEL_DEFAULT_WIDTH = 540; +export function getPreviewPanelMaxWidth(availableWidth: number): number { + return Math.floor(availableWidth * PREVIEW_PANEL_MAX_WIDTH_FRACTION); +} + /** * Shell for the preview panel. In inline mode the panel is user-resizable * via a drag handle on the left edge; width persists per browser. In @@ -28,7 +30,8 @@ export function PreviewPanelShell(props: { }) { const useDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded"; const isInline = props.mode === "inline"; - const maxWidth = useViewportClampedMaxWidth(); + const panelRef = useRef(null); + const maxWidth = useContainerClampedMaxWidth(panelRef); const { width, handlers } = useResizableWidth({ storageKey: PREVIEW_PANEL_WIDTH_STORAGE_KEY, defaultWidth: PREVIEW_PANEL_DEFAULT_WIDTH, @@ -39,6 +42,7 @@ export function PreviewPanelShell(props: { return (
(typeof window === "undefined" ? 1280 : window.innerWidth)); +function useContainerClampedMaxWidth(panelRef: RefObject): number { + const [availableWidth, setAvailableWidth] = useState(() => + typeof window === "undefined" ? 1280 : window.innerWidth, + ); useEffect(() => { - if (typeof window === "undefined") return; - let frame = 0; - const onResize = () => { - // Coalesce rapid resize events into one rAF tick. - if (frame !== 0) return; - frame = window.requestAnimationFrame(() => { - frame = 0; - setVw(window.innerWidth); - }); - }; - window.addEventListener("resize", onResize); - return () => { - window.removeEventListener("resize", onResize); - if (frame !== 0) window.cancelAnimationFrame(frame); + const panel = panelRef.current; + const container = panel?.parentElement; + if (!container || typeof ResizeObserver === "undefined") return; + + const update = (width: number) => { + if (width > 0) setAvailableWidth(width); }; - }, []); - return Math.min(PREVIEW_PANEL_MAX_WIDTH_PX, Math.floor(vw * PREVIEW_PANEL_MAX_WIDTH_FRACTION)); + update(container.clientWidth); + + const observer = new ResizeObserver((entries) => { + const width = entries[0]?.contentRect.width ?? container.clientWidth; + update(width); + }); + observer.observe(container); + return () => observer.disconnect(); + }, [panelRef]); + return getPreviewPanelMaxWidth(availableWidth); } diff --git a/apps/web/src/components/preview/PreviewUnreachable.tsx b/apps/web/src/components/preview/PreviewUnreachable.tsx index c6ada2cb4914..dac80f070b48 100644 --- a/apps/web/src/components/preview/PreviewUnreachable.tsx +++ b/apps/web/src/components/preview/PreviewUnreachable.tsx @@ -17,7 +17,7 @@ interface Props { export function PreviewUnreachable({ url, code, description, onReload }: Props) { const [showDetails, setShowDetails] = useState(false); const host = safeHost(url) ?? url; - const friendly = describePreviewError(code, description); + const friendly = describePreviewError(description); const errorLabel = description.length > 0 ? description : `ERR_${Math.abs(code) || "FAILED"}`; return ( diff --git a/apps/web/src/components/preview/errorCodeMessages.ts b/apps/web/src/components/preview/errorCodeMessages.ts index 78ee928800b5..76ce7d939230 100644 --- a/apps/web/src/components/preview/errorCodeMessages.ts +++ b/apps/web/src/components/preview/errorCodeMessages.ts @@ -2,11 +2,11 @@ import { PREVIEW_ERROR_CODE_MESSAGES } from "./previewConstants"; /** * Resolve a friendly description for a Chromium / network error. Falls back - * to the description string passed in when the code isn't in our table. + * to the description string passed in when it isn't in our table. */ -export function describePreviewError(code: number, description: string): string { +export function describePreviewError(description: string): string { const friendly = PREVIEW_ERROR_CODE_MESSAGES[description]; if (friendly) return friendly; if (description.length > 0) return description; - return `Network error (${code})`; + return "Network error"; } diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 92d8d3f68272..b1a54feb7188 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -23,7 +23,7 @@ import * as Option from "effect/Option"; import { cn } from "../../lib/utils"; import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; -import { formatRelativeTime } from "../../timestampFormat"; +import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; import { useEnvironmentQuery } from "../../state/query"; import { primaryServerAvailableEditorsAtom, @@ -65,8 +65,7 @@ function formatBytes(value: number): string { function formatRelative(value: DateTime.Utc | null): string { if (!value) return "No trace records"; - const relative = formatRelativeTime(DateTime.formatIso(value)); - return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; + return formatRelativeTimeLabel(DateTime.formatIso(value)); } function formatRelativeNoWrap(value: DateTime.Utc | null): string { @@ -755,12 +754,16 @@ function ProcessResourceHistoryTable({ function DiagnosticsLastChecked({ checkedAt }: { checkedAt: DateTime.Utc | null }) { useRelativeTimeTick(); - const relative = checkedAt ? formatRelativeTime(DateTime.formatIso(checkedAt)) : null; + const relative = getRelativeTimeState(checkedAt ? DateTime.formatIso(checkedAt) : null); - if (!relative) { + if (relative.status === "missing") { return Checking; } + if (relative.status === "invalid") { + return Checked unavailable; + } + return ( {relative.suffix ? ( diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 40017d563143..5b29c5494cc7 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -58,7 +58,7 @@ import { import { usePrimaryEnvironment } from "../../state/environments"; import { useProjects } from "../../state/entities"; import { useArchivedThreadSnapshots } from "../../lib/archivedThreadsState"; -import { formatRelativeTime, formatRelativeTimeLabel } from "../../timestampFormat"; +import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; import { Button } from "../ui/button"; import { DraftInput } from "../ui/draft-input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; @@ -134,12 +134,16 @@ const PROVIDER_SETTINGS = DRIVER_OPTIONS.map((definition) => ({ function ProviderLastChecked({ lastCheckedAt }: { lastCheckedAt: string | null }) { useRelativeTimeTick(); - const lastCheckedRelative = lastCheckedAt ? formatRelativeTime(lastCheckedAt) : null; + const lastCheckedRelative = getRelativeTimeState(lastCheckedAt); - if (!lastCheckedRelative) { + if (lastCheckedRelative.status === "missing") { return null; } + if (lastCheckedRelative.status === "invalid") { + return Checked unavailable; + } + return ( {lastCheckedRelative.suffix ? ( @@ -401,6 +405,10 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.enableAssistantStreaming !== DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming ? ["Assistant output"] : []), + ...(settings.enableProviderUpdateChecks !== + DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks + ? ["Provider update checks"] + : []), ...(Duration.toMillis(settings.automaticGitFetchInterval) !== Duration.toMillis(DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval) ? ["Automatic Git fetch interval"] @@ -434,6 +442,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.diffIgnoreWhitespace, settings.automaticGitFetchInterval, settings.enableAssistantStreaming, + settings.enableProviderUpdateChecks, settings.sidebarThreadPreviewCount, settings.timestampFormat, settings.wordWrap, @@ -459,6 +468,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, + enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, automaticGitFetchInterval: DEFAULT_UNIFIED_SETTINGS.automaticGitFetchInterval, defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, newWorktreesStartFromOrigin: DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index c3ac56d10928..fc15862fa46d 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -15,8 +15,48 @@ import { shouldToastDesktopUpdateActionResult, } from "../desktopUpdate.logic"; import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; +import { Separator } from "../ui/separator"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +function SidebarUpdateReleaseNotesTooltip({ + state, + tooltip, +}: { + readonly state: NonNullable>; + readonly tooltip: string; +}) { + if (state.channel !== "nightly" || state.releaseNotes.length === 0) { + return <>{tooltip}; + } + + return ( +
+
+
{tooltip}
+
+
+ {state.releaseNotes.map((releaseNote, index) => ( +
+ {index > 0 && } +
+

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

+
    + {releaseNote.items.map((item, itemIndex) => ( +
  • + {item} +
  • + ))} +
+
+
+ ))} +
+
+ ); +} + export function SidebarUpdatePill() { const state = useDesktopUpdateState(); const [dismissed, setDismissed] = useState(false); @@ -151,7 +191,21 @@ export function SidebarUpdatePill() { } /> - {tooltip} + 0 + ? "max-w-none text-balance" + : undefined + } + side="top" + > + {state ? ( + + ) : ( + tooltip + )} + {action === "download" && ( diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 19b7f43f8c8d..99ac6bba7163 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -8,9 +8,24 @@ import { isCollapsedCursorAdjacentToInlineToken, parseStandaloneComposerSlashCommand, replaceTextRange, + shouldSubmitComposerOnEnter, } from "./composer-logic"; import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "./lib/terminalContext"; +describe("shouldSubmitComposerOnEnter", () => { + it("submits plain Enter on desktop", () => { + expect(shouldSubmitComposerOnEnter({ isMobileViewport: false, shiftKey: false })).toBe(true); + }); + + it("inserts a newline for plain Enter on mobile", () => { + expect(shouldSubmitComposerOnEnter({ isMobileViewport: true, shiftKey: false })).toBe(false); + }); + + it("inserts a newline for Shift+Enter", () => { + expect(shouldSubmitComposerOnEnter({ isMobileViewport: false, shiftKey: true })).toBe(false); + }); +}); + describe("detectComposerTrigger", () => { it("detects @path trigger at cursor", () => { const text = "Please check @src/com"; diff --git a/apps/web/src/composer-logic.ts b/apps/web/src/composer-logic.ts index 46b2c964ae87..2d1d3aed3b1e 100644 --- a/apps/web/src/composer-logic.ts +++ b/apps/web/src/composer-logic.ts @@ -11,6 +11,13 @@ export interface ComposerTrigger { rangeEnd: number; } +export function shouldSubmitComposerOnEnter(input: { + isMobileViewport: boolean; + shiftKey: boolean; +}): boolean { + return !input.isMobileViewport && !input.shiftKey; +} + const isInlineTokenSegment = ( segment: | { type: "text"; text: string } diff --git a/apps/web/src/contextMenuFallback.test.ts b/apps/web/src/contextMenuFallback.test.ts index 56b4596b9f02..29596e72a9ff 100644 --- a/apps/web/src/contextMenuFallback.test.ts +++ b/apps/web/src/contextMenuFallback.test.ts @@ -196,6 +196,23 @@ describe("showContextMenuFallback", () => { await expect(selectionPromise).resolves.toBe("rename"); }); + it("ignores a click from the gesture that opened the menu", async () => { + let enablePointerSelection: ((time: number) => void) | undefined; + vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => { + enablePointerSelection = callback; + return 0; + }); + + const selectionPromise = showContextMenuFallback([{ id: "rename", label: "Rename" }]); + const renameButton = findButton("Rename"); + + renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + enablePointerSelection?.(0); + renameButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + await expect(selectionPromise).resolves.toBe("rename"); + }); + it("opens nested submenus and resolves the clicked leaf id", async () => { const selectionPromise = showContextMenuFallback([ { diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index a301e0c92c8b..9b3bb94dbcef 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -272,7 +272,9 @@ export function showContextMenuFallback( button.addEventListener("mouseenter", () => { closeMenusFromLevel(level + 1); }); - button.addEventListener("click", () => cleanup(item.id)); + button.addEventListener("click", () => { + if (canDismissFromPointer) cleanup(item.id); + }); } } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 433f94a71691..f3f32c46436d 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -6,6 +6,7 @@ :root { --app-scrollbar-width: 6px; + --desktop-window-right-resize-inset: 0px; --workspace-topbar-height: 52px; --workspace-controls-top: 0px; --workspace-controls-left: calc(env(safe-area-inset-left) + 0.75rem); @@ -387,6 +388,11 @@ body { overflow-y: hidden; overscroll-behavior-y: none; padding-top: max(env(safe-area-inset-top), 0px); + padding-right: var(--desktop-window-right-resize-inset); +} + +.electron-windows { + --desktop-window-right-resize-inset: 6px; } /* App-chrome grain. Baked into each surface's own background (behind diff --git a/apps/web/src/lib/windowControlsOverlay.ts b/apps/web/src/lib/windowControlsOverlay.ts index 8f9ae786b0e0..42f9f13c7cda 100644 --- a/apps/web/src/lib/windowControlsOverlay.ts +++ b/apps/web/src/lib/windowControlsOverlay.ts @@ -1,4 +1,8 @@ +import { isWindowsPlatform } from "./utils"; + const WCO_CLASS_NAME = "wco"; +const ELECTRON_CLASS_NAME = "electron"; +const ELECTRON_WINDOWS_CLASS_NAME = "electron-windows"; interface WindowControlsOverlayLike { readonly visible: boolean; @@ -38,3 +42,25 @@ export function syncDocumentWindowControlsOverlayClass(): () => void { overlay.removeEventListener("geometrychange", update); }; } + +export function getElectronPlatformClassNames( + platform: string, +): + | readonly [typeof ELECTRON_CLASS_NAME] + | readonly [typeof ELECTRON_CLASS_NAME, typeof ELECTRON_WINDOWS_CLASS_NAME] { + return isWindowsPlatform(platform) + ? [ELECTRON_CLASS_NAME, ELECTRON_WINDOWS_CLASS_NAME] + : [ELECTRON_CLASS_NAME]; +} + +export function syncDocumentElectronPlatformClasses(platform: string): () => void { + if (typeof document === "undefined") { + return () => {}; + } + + const classNames = getElectronPlatformClassNames(platform); + document.documentElement.classList.add(...classNames); + return () => { + document.documentElement.classList.remove(...classNames); + }; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 5db89c94724f..f37da2e3b834 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -19,7 +19,10 @@ import { APP_DISPLAY_NAME } from "./branding"; import { getAppSettingsSnapshot } from "./appSettings"; import { applyAccentColorToDocument } from "./accentColor"; import { applyThemeConfigToDocument } from "./themeConfig"; -import { syncDocumentWindowControlsOverlayClass } from "./lib/windowControlsOverlay"; +import { + syncDocumentElectronPlatformClasses, + syncDocumentWindowControlsOverlayClass, +} from "./lib/windowControlsOverlay"; import { AppRoot } from "./AppRoot"; // Electron loads the app from a file-backed shell, so hash history avoids path resolution issues. @@ -28,6 +31,7 @@ const history = isElectron ? createHashHistory() : createBrowserHistory(); const router = getRouter(history); if (isElectron) { + syncDocumentElectronPlatformClasses(navigator.platform); syncDocumentWindowControlsOverlayClass(); } diff --git a/apps/web/src/state/desktopUpdate.test.ts b/apps/web/src/state/desktopUpdate.test.ts index a2bcbd19a33f..0c05b2d45395 100644 --- a/apps/web/src/state/desktopUpdate.test.ts +++ b/apps/web/src/state/desktopUpdate.test.ts @@ -15,6 +15,7 @@ const baseState: DesktopUpdateState = { runningUnderArm64Translation: false, availableVersion: null, downloadedVersion: null, + releaseNotes: [], downloadPercent: null, checkedAt: null, message: null, diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index c68d5b7ee465..c2fe4b62714f 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -3,7 +3,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { formatElapsedDurationLabel, formatExpiresInLabel, + formatRelativeTime, + formatRelativeTimeLabel, + formatRelativeTimeUntil, formatRelativeTimeUntilLabel, + formatShortTimestamp, + formatTimestamp, + getRelativeTimeState, getTimestampFormatOptions, } from "./timestampFormat"; @@ -90,6 +96,60 @@ describe("formatExpiresInLabel", () => { }); }); +describe("invalid timestamp inputs", () => { + it("returns an empty timestamp instead of throwing", () => { + expect(() => formatTimestamp("not-a-date", "12-hour")).not.toThrow(); + expect(formatTimestamp("not-a-date", "12-hour")).toBe(""); + }); + + it("returns an empty short timestamp instead of throwing", () => { + expect(() => formatShortTimestamp("not-a-date", "12-hour")).not.toThrow(); + expect(formatShortTimestamp("not-a-date", "12-hour")).toBe(""); + }); + + it("returns an empty relative time label instead of a NaN label", () => { + expect(formatRelativeTime("not-a-date")).toBeNull(); + expect(formatRelativeTimeLabel("not-a-date")).toBe(""); + }); + + it("distinguishes missing and invalid relative time state", () => { + expect(getRelativeTimeState(null)).toEqual({ status: "missing" }); + expect(getRelativeTimeState("not-a-date")).toEqual({ status: "invalid" }); + }); + + it("returns an empty elapsed duration instead of a NaN label", () => { + expect(formatElapsedDurationLabel("not-a-date")).toBe(""); + }); + + it("returns an empty relative time until label instead of a NaN label", () => { + expect(formatRelativeTimeUntil("not-a-date")).toBeNull(); + expect(formatRelativeTimeUntilLabel("not-a-date")).toBe(""); + }); + + it("returns an empty expires-in label instead of a NaN label", () => { + expect(formatExpiresInLabel("not-a-date")).toBe(""); + }); +}); + +describe("getRelativeTimeState", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-07T12:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns relative parts for valid timestamps", () => { + expect(getRelativeTimeState("2026-04-07T11:45:00.000Z")).toEqual({ + status: "relative", + value: "15m", + suffix: "ago", + }); + }); +}); + describe("formatElapsedDurationLabel", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index bcf8dc9c1f20..31cc41798ebe 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -40,8 +40,15 @@ function getTimestampFormatter( return formatter; } +function parseTimestampDate(isoDate: string): Date | null { + const date = new Date(isoDate); + return Number.isNaN(date.getTime()) ? null : date; +} + export function formatTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { - return getTimestampFormatter(timestampFormat, true).format(new Date(isoDate)); + const date = parseTimestampDate(isoDate); + if (!date) return ""; + return getTimestampFormatter(timestampFormat, true).format(date); } const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" }); @@ -69,8 +76,8 @@ export function formatChatTimestampTooltip( isoDate: string, timestampFormat: TimestampFormat, ): string { - const date = new Date(isoDate); - if (Number.isNaN(date.getTime())) return ""; + const date = parseTimestampDate(isoDate); + if (!date) return ""; const time = formatShortTimestamp(isoDate, timestampFormat); const day = date.getDate(); const month = monthNameFormatter.format(date); @@ -79,7 +86,9 @@ export function formatChatTimestampTooltip( } export function formatShortTimestamp(isoDate: string, timestampFormat: TimestampFormat): string { - return getTimestampFormatter(timestampFormat, false).format(new Date(isoDate)); + const date = parseTimestampDate(isoDate); + if (!date) return ""; + return getTimestampFormatter(timestampFormat, false).format(date); } /** @@ -87,8 +96,16 @@ export function formatShortTimestamp(isoDate: string, timestampFormat: Timestamp * Returns `{ value: "20s", suffix: "ago" }` or `{ value: "just now", suffix: null }` * so callers can style the numeric portion independently. */ -export function formatRelativeTime(isoDate: string): { value: string; suffix: string | null } { - const diffMs = Date.now() - new Date(isoDate).getTime(); +type RelativeTimeParts = { value: string; suffix: string | null }; +export type RelativeTimeState = + | { status: "missing" } + | { status: "invalid" } + | { status: "relative"; value: string; suffix: string | null }; + +export function formatRelativeTime(isoDate: string): RelativeTimeParts | null { + const date = parseTimestampDate(isoDate); + if (!date) return null; + const diffMs = Date.now() - date.getTime(); if (diffMs < 0) return { value: "just now", suffix: null }; const seconds = Math.floor(diffMs / 1000); if (seconds < 60) return { value: "just now", suffix: null }; @@ -100,9 +117,17 @@ export function formatRelativeTime(isoDate: string): { value: string; suffix: st return { value: `${days}d`, suffix: "ago" }; } -export function formatRelativeTimeLabel(isoDate: string): string { - const { value, suffix } = formatRelativeTime(isoDate); - return suffix ? `${value} ${suffix}` : value; +export function formatRelativeTimeLabel(isoDate: string) { + const relative = formatRelativeTime(isoDate); + if (!relative) return ""; + return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; +} + +export function getRelativeTimeState(isoDate: string | null): RelativeTimeState { + if (!isoDate) return { status: "missing" }; + const relative = formatRelativeTime(isoDate); + if (!relative) return { status: "invalid" }; + return { status: "relative", ...relative }; } /** @@ -110,7 +135,9 @@ export function formatRelativeTimeLabel(isoDate: string): string { * Useful for labels like "Connected for 3m". */ export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date.now()): string { - const diffMs = nowMs - new Date(isoDate).getTime(); + const date = parseTimestampDate(isoDate); + if (!date) return ""; + const diffMs = nowMs - date.getTime(); if (diffMs <= 0) return "just now"; const seconds = Math.floor(diffMs / 1000); @@ -130,8 +157,10 @@ export function formatElapsedDurationLabel(isoDate: string, nowMs: number = Date /** * Relative time until an ISO instant (e.g. expiry). Mirrors {@link formatRelativeTime} but for future times. */ -export function formatRelativeTimeUntil(isoDate: string): { value: string; suffix: string | null } { - const diffMs = new Date(isoDate).getTime() - Date.now(); +export function formatRelativeTimeUntil(isoDate: string): RelativeTimeParts | null { + const date = parseTimestampDate(isoDate); + if (!date) return null; + const diffMs = date.getTime() - Date.now(); if (diffMs <= 0) return { value: "Expired", suffix: null }; const seconds = Math.floor(diffMs / 1000); if (seconds < 5) return { value: "Soon", suffix: null }; @@ -146,6 +175,7 @@ export function formatRelativeTimeUntil(isoDate: string): { value: string; suffi export function formatRelativeTimeUntilLabel(isoDate: string): string { const relative = formatRelativeTimeUntil(isoDate); + if (!relative) return ""; return relative.suffix ? `${relative.value} ${relative.suffix}` : relative.value; } @@ -154,7 +184,9 @@ export function formatRelativeTimeUntilLabel(isoDate: string): string { * Pass `nowMs` when a parent tick drives re-renders so the diff matches that snapshot. */ export function formatExpiresInLabel(isoDate: string, nowMs: number = Date.now()): string { - const diffMs = new Date(isoDate).getTime() - nowMs; + const date = parseTimestampDate(isoDate); + if (!date) return ""; + const diffMs = date.getTime() - nowMs; if (diffMs <= 0) return "Expired"; const totalSeconds = Math.floor(diffMs / 1000); diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 000000000000..8a7e3c5f20a8 --- /dev/null +++ b/assets/README.md @@ -0,0 +1,50 @@ +# Brand icons + +The three Icon Composer projects are the source of truth for full application icons: + +- `dev/app-icon.icon` +- `nightly/app-icon.icon` +- `prod/app-icon.icon` + +Each project uses `text.svg` for the T3 mark and `background.svg` when the background is a vector layer. Additional layers use semantic names that describe their role and placement. + +Run `vp run icons:export` from the repository root to regenerate the tracked iOS, Linux, Windows, and web assets. Run `vp run icons:check` to verify that those generated assets match their sources without changing files. + +Exporting requires Icon Composer 2 or newer on macOS. The script selects the newest compatible exporter from Xcode or a standalone Icon Composer installation and pins design generation 26. Set `ICON_COMPOSER_TOOL` to the full path of `Icon Composer.app/Contents/Executables/ictool` to override automatic discovery. + +## macOS exports + +Icon Composer's command-line exporter does not expose the `macOS pre-Tahoe` preset. A plain command-line `macOS` export is full bleed and is not suitable for the desktop app, so the export script intentionally leaves the tracked macOS PNGs unchanged and prints a reminder after every run. + +After changing an Icon Composer project, open it in Icon Composer and export the macOS PNG with exactly these settings: + +- Platform: `macOS pre-Tahoe` +- Appearance: `Default` +- Size: `1024pt` +- Scale: `1×` + +Save the three exports to: + +- `dev/app-icon.icon` -> `dev/blueprint-macos-1024.png` +- `nightly/app-icon.icon` -> `nightly/nightly-macos-1024.png` +- `prod/app-icon.icon` -> `prod/black-macos-1024.png` + +The result must be a 1024×1024 PNG with the classic macOS safe area: the opaque icon body is 824×824, inset 100 pixels on every side, with only the native Icon Composer shadow extending into the surrounding transparent canvas. + +To have Codex perform the native exports, paste this prompt into a task opened at the repository root: + +```text +Use [@Computer](plugin://computer-use@openai-bundled) and the Icon Composer app to export the three macOS app icons in this repository. + +For each project below, use Platform: macOS pre-Tahoe, Appearance: Default, Size: 1024pt, and Scale: 1×, then save the PNG to the exact destination: + +- assets/dev/app-icon.icon -> assets/dev/blueprint-macos-1024.png +- assets/nightly/app-icon.icon -> assets/nightly/nightly-macos-1024.png +- assets/prod/app-icon.icon -> assets/prod/black-macos-1024.png + +Do not resize, composite, or otherwise post-process the exported PNGs. + +Verify every result is 1024×1024 and has the classic macOS safe area: an 824×824 opaque body inset 100px on every side, with only Icon Composer's native shadow extending beyond it. +``` + +Do not edit the generated PNG or ICO files directly. diff --git a/assets/dev/app-icon.icon/Assets/annotations.svg b/assets/dev/app-icon.icon/Assets/annotations.svg new file mode 100644 index 000000000000..dffde33559ea --- /dev/null +++ b/assets/dev/app-icon.icon/Assets/annotations.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/dev/app-icon.icon/Assets/background.svg b/assets/dev/app-icon.icon/Assets/background.svg new file mode 100644 index 000000000000..db5c319d607d --- /dev/null +++ b/assets/dev/app-icon.icon/Assets/background.svg @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/dev/app-icon.icon/Assets/text.svg b/assets/dev/app-icon.icon/Assets/text.svg new file mode 100644 index 000000000000..0b1825f2ef47 --- /dev/null +++ b/assets/dev/app-icon.icon/Assets/text.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/mobile/assets/icon-composer-dev.icon/icon.json b/assets/dev/app-icon.icon/icon.json similarity index 53% rename from apps/mobile/assets/icon-composer-dev.icon/icon.json rename to assets/dev/app-icon.icon/icon.json index fd3fb6819a91..ff7d43d78112 100644 --- a/apps/mobile/assets/icon-composer-dev.icon/icon.json +++ b/assets/dev/app-icon.icon/icon.json @@ -6,35 +6,40 @@ { "layers": [ { - "hidden": false, - "image-name": "gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr.png", - "name": "gpt-image-1.5-jd70szmrd03p36z4zv48ycsbax81egvr", + "glass": true, + "image-name": "text.svg", + "name": "Text", "position": { - "scale": 1.1, + "scale": 8.5, "translation-in-points": [0, 0] } }, { - "image-name": "T3.svg", - "name": "T3", + "glass": false, + "image-name": "annotations.svg", + "name": "Annotations", "position": { - "scale": 10, + "scale": 8.5, "translation-in-points": [0, 0] } }, { - "hidden": true, - "image-name": "Texturelabs_Paper_381XL.jpg", - "name": "Texturelabs_Paper_381XL" + "glass": true, + "image-name": "background.svg", + "name": "Background", + "position": { + "scale": 8.1, + "translation-in-points": [0, 0] + } } ], "shadow": { "kind": "neutral", - "opacity": 0.5 + "opacity": 0.6 }, "translucency": { "enabled": true, - "value": 0.5 + "value": 0.2 } } ], diff --git a/assets/dev/blueprint-icon-composer.icon/Assets/T3.svg b/assets/dev/blueprint-icon-composer.icon/Assets/T3.svg deleted file mode 100644 index b12706fdfc23..000000000000 --- a/assets/dev/blueprint-icon-composer.icon/Assets/T3.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg b/assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg deleted file mode 100644 index d98c41a4e3e8..000000000000 Binary files a/assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg and /dev/null differ diff --git a/assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png b/assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png deleted file mode 100644 index fa7700d01c73..000000000000 Binary files a/assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png and /dev/null differ diff --git a/assets/dev/blueprint-icon-composer.icon/icon.json b/assets/dev/blueprint-icon-composer.icon/icon.json deleted file mode 100644 index 1c15123dab4f..000000000000 --- a/assets/dev/blueprint-icon-composer.icon/icon.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "fill": { - "solid": "display-p3:0.00000,0.00000,0.00000,1.00000" - }, - "groups": [ - { - "layers": [ - { - "blend-mode": "normal", - "fill": "automatic", - "glass": true, - "hidden": false, - "image-name": "gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png", - "name": "gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871", - "position": { - "scale": 1.05, - "translation-in-points": [0, 0] - } - }, - { - "image-name": "T3.svg", - "name": "T3", - "position": { - "scale": 10, - "translation-in-points": [0, 0] - } - }, - { - "hidden": false, - "image-name": "Texturelabs_Paper_381XL.jpg", - "name": "Texturelabs_Paper_381XL" - } - ], - "shadow": { - "kind": "neutral", - "opacity": 0.5 - }, - "translucency": { - "enabled": true, - "value": 0.5 - } - } - ], - "supported-platforms": { - "squares": ["iOS", "macOS"] - } -} diff --git a/assets/dev/blueprint-ios-1024.png b/assets/dev/blueprint-ios-1024.png index 51b764ac72ae..a53c8d19aa83 100644 Binary files a/assets/dev/blueprint-ios-1024.png and b/assets/dev/blueprint-ios-1024.png differ diff --git a/assets/dev/blueprint-macos-1024.png b/assets/dev/blueprint-macos-1024.png index 7c165fdb851b..68a567058be1 100644 Binary files a/assets/dev/blueprint-macos-1024.png and b/assets/dev/blueprint-macos-1024.png differ diff --git a/assets/dev/blueprint-universal-1024.png b/assets/dev/blueprint-universal-1024.png index 51b764ac72ae..a53c8d19aa83 100644 Binary files a/assets/dev/blueprint-universal-1024.png and b/assets/dev/blueprint-universal-1024.png differ diff --git a/assets/dev/blueprint-web-apple-touch-180.png b/assets/dev/blueprint-web-apple-touch-180.png index 4a135d22fdbc..3eed25ea6b78 100644 Binary files a/assets/dev/blueprint-web-apple-touch-180.png and b/assets/dev/blueprint-web-apple-touch-180.png differ diff --git a/assets/dev/blueprint-web-favicon-16x16.png b/assets/dev/blueprint-web-favicon-16x16.png index 56e0e837131e..a3431b8c6dfe 100644 Binary files a/assets/dev/blueprint-web-favicon-16x16.png and b/assets/dev/blueprint-web-favicon-16x16.png differ diff --git a/assets/dev/blueprint-web-favicon-32x32.png b/assets/dev/blueprint-web-favicon-32x32.png index 9e9f31ea9893..862f7629971f 100644 Binary files a/assets/dev/blueprint-web-favicon-32x32.png and b/assets/dev/blueprint-web-favicon-32x32.png differ diff --git a/assets/dev/blueprint-web-favicon.ico b/assets/dev/blueprint-web-favicon.ico index e0ee3e1408f0..750da22602ee 100644 Binary files a/assets/dev/blueprint-web-favicon.ico and b/assets/dev/blueprint-web-favicon.ico differ diff --git a/assets/dev/blueprint-windows.ico b/assets/dev/blueprint-windows.ico index e0ee3e1408f0..750da22602ee 100644 Binary files a/assets/dev/blueprint-windows.ico and b/assets/dev/blueprint-windows.ico differ diff --git a/assets/nightly/app-icon.icon/Assets/background.svg b/assets/nightly/app-icon.icon/Assets/background.svg new file mode 100644 index 000000000000..1ceca91c0236 --- /dev/null +++ b/assets/nightly/app-icon.icon/Assets/background.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/nightly/app-icon.icon/Assets/cloud-lower-left.svg b/assets/nightly/app-icon.icon/Assets/cloud-lower-left.svg new file mode 100644 index 000000000000..d43478860f46 --- /dev/null +++ b/assets/nightly/app-icon.icon/Assets/cloud-lower-left.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/nightly/app-icon.icon/Assets/cloud-upper-right.svg b/assets/nightly/app-icon.icon/Assets/cloud-upper-right.svg new file mode 100644 index 000000000000..7b094441db15 --- /dev/null +++ b/assets/nightly/app-icon.icon/Assets/cloud-upper-right.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/nightly/app-icon.icon/Assets/text.svg b/assets/nightly/app-icon.icon/Assets/text.svg new file mode 100644 index 000000000000..0b1825f2ef47 --- /dev/null +++ b/assets/nightly/app-icon.icon/Assets/text.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/nightly/app-icon.icon/icon.json b/assets/nightly/app-icon.icon/icon.json new file mode 100644 index 000000000000..eb48d26b4a52 --- /dev/null +++ b/assets/nightly/app-icon.icon/icon.json @@ -0,0 +1,94 @@ +{ + "fill": { + "automatic-gradient": "display-p3:0.00000,0.00000,0.00000,1.00000" + }, + "groups": [ + { + "layers": [ + { + "glass": true, + "image-name": "text.svg", + "name": "Text", + "position": { + "scale": 8.5, + "translation-in-points": [0, 0] + } + }, + { + "glass": false, + "image-name": "cloud-upper-right.svg", + "name": "Cloud Upper Right", + "opacity": 1, + "position": { + "scale": 15, + "translation-in-points": [387.9605131881942, -134.30064713259117] + } + }, + { + "glass": false, + "image-name": "cloud-lower-left.svg", + "name": "Cloud Lower Left", + "position": { + "scale": 25, + "translation-in-points": [-309.63750000000005, 268.66077693836917] + } + }, + { + "glass": true, + "hidden": false, + "image-name": "background.svg", + "name": "Background", + "position": { + "scale": 8.1, + "translation-in-points": [0, -2.171875] + } + } + ], + "shadow": { + "kind": "neutral", + "opacity": 0.6 + }, + "translucency": { + "enabled": true, + "value": 0.2 + } + }, + { + "layers": [], + "shadow": { + "kind": "neutral", + "opacity": 0.6 + }, + "translucency": { + "enabled": true, + "value": 0.2 + } + }, + { + "layers": [], + "shadow": { + "kind": "neutral", + "opacity": 0.6 + }, + "translucency": { + "enabled": true, + "value": 0.2 + } + }, + { + "layers": [], + "shadow": { + "kind": "neutral", + "opacity": 0.6 + }, + "translucency": { + "enabled": true, + "value": 0.2 + } + } + ], + "supported-platforms": { + "circles": ["watchOS"], + "squares": "shared" + } +} diff --git a/assets/nightly/blueprint-ios-1024.png b/assets/nightly/blueprint-ios-1024.png deleted file mode 100644 index b33d6f337b0b..000000000000 Binary files a/assets/nightly/blueprint-ios-1024.png and /dev/null differ diff --git a/assets/nightly/blueprint-macos-1024.png b/assets/nightly/blueprint-macos-1024.png deleted file mode 100644 index 8dba03e01fe1..000000000000 Binary files a/assets/nightly/blueprint-macos-1024.png and /dev/null differ diff --git a/assets/nightly/blueprint-universal-1024.png b/assets/nightly/blueprint-universal-1024.png deleted file mode 100644 index b33d6f337b0b..000000000000 Binary files a/assets/nightly/blueprint-universal-1024.png and /dev/null differ diff --git a/assets/nightly/blueprint-web-apple-touch-180.png b/assets/nightly/blueprint-web-apple-touch-180.png deleted file mode 100644 index e0e1b9659b83..000000000000 Binary files a/assets/nightly/blueprint-web-apple-touch-180.png and /dev/null differ diff --git a/assets/nightly/blueprint-web-favicon-16x16.png b/assets/nightly/blueprint-web-favicon-16x16.png deleted file mode 100644 index 673d84599981..000000000000 Binary files a/assets/nightly/blueprint-web-favicon-16x16.png and /dev/null differ diff --git a/assets/nightly/blueprint-web-favicon-32x32.png b/assets/nightly/blueprint-web-favicon-32x32.png deleted file mode 100644 index 25bcc95d4aaa..000000000000 Binary files a/assets/nightly/blueprint-web-favicon-32x32.png and /dev/null differ diff --git a/assets/nightly/blueprint-web-favicon.ico b/assets/nightly/blueprint-web-favicon.ico deleted file mode 100644 index 36975b997827..000000000000 Binary files a/assets/nightly/blueprint-web-favicon.ico and /dev/null differ diff --git a/assets/nightly/blueprint-windows.ico b/assets/nightly/blueprint-windows.ico deleted file mode 100644 index 36975b997827..000000000000 Binary files a/assets/nightly/blueprint-windows.ico and /dev/null differ diff --git a/assets/nightly/nightly-ios-1024.png b/assets/nightly/nightly-ios-1024.png new file mode 100644 index 000000000000..42ce5589e9ca Binary files /dev/null and b/assets/nightly/nightly-ios-1024.png differ diff --git a/assets/nightly/nightly-macos-1024.png b/assets/nightly/nightly-macos-1024.png new file mode 100644 index 000000000000..1beacd9f34ad Binary files /dev/null and b/assets/nightly/nightly-macos-1024.png differ diff --git a/assets/nightly/nightly-universal-1024.png b/assets/nightly/nightly-universal-1024.png new file mode 100644 index 000000000000..42ce5589e9ca Binary files /dev/null and b/assets/nightly/nightly-universal-1024.png differ diff --git a/assets/nightly/nightly-web-apple-touch-180.png b/assets/nightly/nightly-web-apple-touch-180.png new file mode 100644 index 000000000000..f09a169458ab Binary files /dev/null and b/assets/nightly/nightly-web-apple-touch-180.png differ diff --git a/assets/nightly/nightly-web-favicon-16x16.png b/assets/nightly/nightly-web-favicon-16x16.png new file mode 100644 index 000000000000..20208f5f3695 Binary files /dev/null and b/assets/nightly/nightly-web-favicon-16x16.png differ diff --git a/assets/nightly/nightly-web-favicon-32x32.png b/assets/nightly/nightly-web-favicon-32x32.png new file mode 100644 index 000000000000..b9008d518bce Binary files /dev/null and b/assets/nightly/nightly-web-favicon-32x32.png differ diff --git a/assets/nightly/nightly-web-favicon.ico b/assets/nightly/nightly-web-favicon.ico new file mode 100644 index 000000000000..b6a0b43b93d2 Binary files /dev/null and b/assets/nightly/nightly-web-favicon.ico differ diff --git a/assets/nightly/nightly-windows.ico b/assets/nightly/nightly-windows.ico new file mode 100644 index 000000000000..b6a0b43b93d2 Binary files /dev/null and b/assets/nightly/nightly-windows.ico differ diff --git a/apps/mobile/assets/icon-composer-dev.icon/Assets/T3.svg b/assets/prod/app-icon.icon/Assets/text.svg similarity index 100% rename from apps/mobile/assets/icon-composer-dev.icon/Assets/T3.svg rename to assets/prod/app-icon.icon/Assets/text.svg diff --git a/apps/mobile/assets/icon-composer-prod.icon/icon.json b/assets/prod/app-icon.icon/icon.json similarity index 88% rename from apps/mobile/assets/icon-composer-prod.icon/icon.json rename to assets/prod/app-icon.icon/icon.json index 8f7579311f26..8c12c022ef86 100644 --- a/apps/mobile/assets/icon-composer-prod.icon/icon.json +++ b/assets/prod/app-icon.icon/icon.json @@ -6,8 +6,8 @@ { "layers": [ { - "image-name": "T3.svg", - "name": "T3", + "image-name": "text.svg", + "name": "Text", "position": { "scale": 10, "translation-in-points": [0, 0] diff --git a/assets/prod/black-ios-1024.png b/assets/prod/black-ios-1024.png index 073ad811c250..a8a458337ab0 100644 Binary files a/assets/prod/black-ios-1024.png and b/assets/prod/black-ios-1024.png differ diff --git a/assets/prod/black-macos-1024.png b/assets/prod/black-macos-1024.png index 073ad811c250..f5b68b1a6d40 100644 Binary files a/assets/prod/black-macos-1024.png and b/assets/prod/black-macos-1024.png differ diff --git a/assets/prod/black-universal-1024.png b/assets/prod/black-universal-1024.png index 073ad811c250..a8a458337ab0 100644 Binary files a/assets/prod/black-universal-1024.png and b/assets/prod/black-universal-1024.png differ diff --git a/assets/prod/t3-black-web-apple-touch-180.png b/assets/prod/t3-black-web-apple-touch-180.png index 3ed96e897f33..2e30aecbc72b 100644 Binary files a/assets/prod/t3-black-web-apple-touch-180.png and b/assets/prod/t3-black-web-apple-touch-180.png differ diff --git a/assets/prod/t3-black-web-favicon-16x16.png b/assets/prod/t3-black-web-favicon-16x16.png index 44c99d844718..c175f2c87a21 100644 Binary files a/assets/prod/t3-black-web-favicon-16x16.png and b/assets/prod/t3-black-web-favicon-16x16.png differ diff --git a/assets/prod/t3-black-web-favicon-32x32.png b/assets/prod/t3-black-web-favicon-32x32.png index c0ed3eddf734..fe6fb545b859 100644 Binary files a/assets/prod/t3-black-web-favicon-32x32.png and b/assets/prod/t3-black-web-favicon-32x32.png differ diff --git a/assets/prod/t3-black-web-favicon.ico b/assets/prod/t3-black-web-favicon.ico index 947f6d57b92a..a0fe86da92f0 100644 Binary files a/assets/prod/t3-black-web-favicon.ico and b/assets/prod/t3-black-web-favicon.ico differ diff --git a/assets/prod/t3-black-windows.ico b/assets/prod/t3-black-windows.ico index e3ab4ae5e024..a0fe86da92f0 100644 Binary files a/assets/prod/t3-black-windows.ico and b/assets/prod/t3-black-windows.ico differ diff --git a/docs/reference/scripts.md b/docs/reference/scripts.md index d4d2b96869ee..6bdea2666652 100644 --- a/docs/reference/scripts.md +++ b/docs/reference/scripts.md @@ -1,29 +1,29 @@ # Scripts -- `bun run dev` — Starts contracts, server, and web in `turbo watch` mode. -- `bun run dev:server` — Starts just the WebSocket server (uses Bun TypeScript execution). -- `bun run dev:web` — Starts just the Vite dev server for the web app. -- Dev commands default `T3CODE_STATE_DIR` to `~/.t3/dev` to keep dev state isolated from desktop/prod state. +- `vp run dev` — Starts contracts, server, and web in watch mode. +- `vp run dev:server` — Starts just the WebSocket server. The server process runs on Bun (`@effect/platform-bun` + `BunPtyAdapter`), but task running uses `vp run`. +- `vp run dev:web` — Starts just the Vite dev server for the web app. +- Dev commands default `T3CODE_HOME` to `~/.t3` — the same shared home the desktop/production app uses. Override with `--home-dir` (see below) to keep dev state separate. - Override server CLI-equivalent flags from root dev commands with `--`, for example: - `bun run dev -- --base-dir ~/.t3-2` -- `bun run start` — Runs the production server (serves built web app as static files). -- `bun run build` — Builds contracts, web app, and server through Turbo. -- `bun run typecheck` — Strict TypeScript checks for all packages. -- `bun run test` — Runs workspace tests. -- `bun run dist:desktop:artifact -- --platform --target --arch ` — Builds a desktop artifact for a specific platform/target/arch. -- `bun run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`. -- `bun run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`. -- `bun run dist:desktop:linux` — Builds a Linux AppImage into `./release`. -- `bun run dist:desktop:win` — Builds a Windows NSIS installer into `./release`. + `vp run dev -- --home-dir ~/.t3-2` +- `vp run start` — Runs the production server (serves built web app as static files). +- `vp run build` — Builds contracts, web app, and server. +- `vp run typecheck` — Strict TypeScript checks for all packages. +- `vp run test` — Runs workspace tests. +- `vp run dist:desktop:artifact -- --platform --target --arch ` — Builds a desktop artifact for a specific platform/target/arch. +- `vp run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`. +- `vp run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`. +- `vp run dist:desktop:linux` — Builds a Linux AppImage into `./release`. +- `vp run dist:desktop:win` — Builds a Windows NSIS installer into `./release`. ## Desktop `.dmg` packaging notes - Default build is unsigned/not notarized for local sharing. -- The DMG build uses `assets/macos-icon-1024.png` as the production app icon source. +- The DMG build uses `assets/prod/black-macos-1024.png` as the production app icon source. - Desktop production windows load the bundled UI from `t3code://app/index.html` (not a `127.0.0.1` document URL). - Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an auth token for WebSocket/API traffic. - Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch. -- To keep staging files for debugging package contents, run: `bun run dist:desktop:dmg -- --keep-stage` +- To keep staging files for debugging package contents, run: `vp run dist:desktop:dmg -- --keep-stage` - To allow code-signing/notarization when configured in CI/secrets, add: `--signed`. - Signed macOS builds also require `T3CODE_APPLE_TEAM_ID` and `T3CODE_MACOS_PROVISIONING_PROFILE`. The passkey RP domain is derived from @@ -38,8 +38,8 @@ Set `T3CODE_DEV_INSTANCE` to any value to deterministically shift all dev ports together. -- Default ports: server `3773`, web `5733` +- Default ports: server `13773`, web `5733` - Shifted ports: `base + offset` (offset is hashed from `T3CODE_DEV_INSTANCE`) -- Example: `T3CODE_DEV_INSTANCE=branch-a bun run dev:desktop` +- Example: `T3CODE_DEV_INSTANCE=branch-a vp run dev:desktop` If you want full control instead of hashing, set `T3CODE_PORT_OFFSET` to a numeric offset. diff --git a/package.json b/package.json index 4464d49f159a..600c3083a8db 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,8 @@ "start:marketing": "vp run --filter @t3tools/marketing preview", "start:mock-update-server": "node scripts/mock-update-server.ts", "screenshots:mobile": "node scripts/mobile-showcase.ts", + "icons:export": "node scripts/export-brand-icons.ts", + "icons:check": "node scripts/export-brand-icons.ts --check", "build": "vp run --filter './apps/*' --filter './packages/*' --filter './oxlint-plugin-t3code' --filter './scripts' build", "sync:upstream-prs": "node scripts/sync-upstream-pr-tracks.mjs", "build:marketing": "vp run --filter @t3tools/marketing build", diff --git a/packages/client-runtime/src/state/threadSort.test.ts b/packages/client-runtime/src/state/threadSort.test.ts new file mode 100644 index 000000000000..dd6f8c3a295e --- /dev/null +++ b/packages/client-runtime/src/state/threadSort.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { sortThreads, type ThreadSortInput } from "./threadSort.ts"; + +type TestThread = { readonly id: string } & ThreadSortInput; + +function makeThread(overrides: Partial = {}): TestThread { + return { + id: "thread-1", + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:00:00.000Z", + messages: [], + latestUserMessageAt: null, + ...overrides, + }; +} + +describe("sortThreads", () => { + it("falls back to updatedAt and createdAt when latestUserMessageAt is invalid and there are no messages", () => { + const sorted = sortThreads( + [ + makeThread({ + id: "thread-1", + latestUserMessageAt: "not-a-date", + createdAt: "2026-03-09T10:00:00.000Z", + updatedAt: "2026-03-09T10:05:00.000Z", + }), + makeThread({ + id: "thread-2", + latestUserMessageAt: "still-not-a-date", + createdAt: "invalid-created-at", + updatedAt: "invalid-updated-at", + }), + makeThread({ + id: "thread-3", + latestUserMessageAt: "invalid-latest-user-message-at", + createdAt: "2026-03-09T10:06:00.000Z", + updatedAt: "invalid-updated-at", + }), + ], + "updated_at", + ); + + expect(sorted.map((thread) => thread.id)).toEqual(["thread-3", "thread-1", "thread-2"]); + }); + + it("falls back to the latest valid user message when latestUserMessageAt is invalid", () => { + const sorted = sortThreads( + [ + makeThread({ + id: "thread-1", + latestUserMessageAt: "invalid-latest-user-message-at", + updatedAt: "2026-03-09T10:00:00.000Z", + messages: [ + { role: "user", createdAt: "2026-03-09T10:05:00.000Z" }, + { role: "assistant", createdAt: "2026-03-09T10:30:00.000Z" }, + { role: "user", createdAt: "2026-03-09T10:20:00.000Z" }, + ], + }), + makeThread({ + id: "thread-2", + createdAt: "2026-03-09T10:15:00.000Z", + updatedAt: "2026-03-09T10:15:00.000Z", + }), + ], + "updated_at", + ); + + expect(sorted.map((thread) => thread.id)).toEqual(["thread-1", "thread-2"]); + }); +}); diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index 4da184962e61..aed63cd442d7 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -32,7 +32,10 @@ function getFirstSortableTimestamp(...values: Array): function getLatestUserMessageTimestamp(thread: ThreadSortInput): number { if (thread.latestUserMessageAt) { - return toSortableTimestamp(thread.latestUserMessageAt) ?? Number.NEGATIVE_INFINITY; + const latestUserMessageTimestamp = toSortableTimestamp(thread.latestUserMessageAt); + if (latestUserMessageTimestamp !== null) { + return latestUserMessageTimestamp; + } } let latestUserMessageTimestamp: number | null = null; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 42e32e27121c..a0a857a6f35d 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -213,6 +213,7 @@ export interface DesktopUpdateState { runningUnderArm64Translation: boolean; availableVersion: string | null; downloadedVersion: string | null; + releaseNotes: ReadonlyArray; downloadPercent: number | null; checkedAt: string | null; message: string | null; @@ -220,6 +221,16 @@ export interface DesktopUpdateState { canRetry: boolean; } +export interface DesktopUpdateReleaseNote { + version: string; + items: ReadonlyArray; +} + +export const DesktopUpdateReleaseNoteSchema = Schema.Struct({ + version: Schema.String, + items: Schema.Array(Schema.String), +}); + export const DesktopUpdateStateSchema = Schema.Struct({ enabled: Schema.Boolean, status: DesktopUpdateStatusSchema, @@ -230,6 +241,7 @@ export const DesktopUpdateStateSchema = Schema.Struct({ runningUnderArm64Translation: Schema.Boolean, availableVersion: Schema.NullOr(Schema.String), downloadedVersion: Schema.NullOr(Schema.String), + releaseNotes: Schema.Array(DesktopUpdateReleaseNoteSchema), downloadPercent: Schema.NullOr(Schema.Number), checkedAt: Schema.NullOr(Schema.String), message: Schema.NullOr(Schema.String), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index c1bb0423f4bb..9a1d10563d5a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -252,7 +252,7 @@ export const CursorSettings = makeProviderSettingsSchema( Schema.withDecodingDefault(Effect.succeed(false)), Schema.annotateKey({ providerSettingsForm: { hidden: true } }), ), - binaryPath: makeBinaryPathSetting("agent").pipe( + binaryPath: makeBinaryPathSetting("cursor-agent").pipe( Schema.annotateKey({ title: "Legacy binary path", description: "Legacy Cursor ACP setting. Cursor now runs through @cursor/sdk.", diff --git a/packages/shared/src/remote.test.ts b/packages/shared/src/remote.test.ts index 24e787570099..5b7a9973652a 100644 --- a/packages/shared/src/remote.test.ts +++ b/packages/shared/src/remote.test.ts @@ -59,6 +59,70 @@ describe("remote", () => { }); }); + it("treats a protocol-relative host as https", () => { + expect( + resolveRemotePairingTarget({ + host: "//remote.example.com", + pairingCode: "pairing-token", + }), + ).toEqual({ + credential: "pairing-token", + httpBaseUrl: "https://remote.example.com/", + wsBaseUrl: "wss://remote.example.com/", + }); + }); + + it("preserves the port when normalizing a protocol-relative host", () => { + expect( + resolveRemotePairingTarget({ + host: "//remote.example.com:3000", + pairingCode: "pairing-token", + }), + ).toEqual({ + credential: "pairing-token", + httpBaseUrl: "https://remote.example.com:3000/", + wsBaseUrl: "wss://remote.example.com:3000/", + }); + }); + + it("normalizes a protocol-relative host from a hosted pairing link", () => { + expect( + resolveRemotePairingTarget({ + pairingUrl: "https://app.t3.codes/pair?host=%2F%2Fremote.example.com#token=pairing-token", + }), + ).toEqual({ + credential: "pairing-token", + httpBaseUrl: "https://remote.example.com/", + wsBaseUrl: "wss://remote.example.com/", + }); + }); + + it("collapses extra leading slashes instead of producing an empty host", () => { + expect( + resolveRemotePairingTarget({ + host: "///example.com", + pairingCode: "pairing-token", + }), + ).toEqual({ + credential: "pairing-token", + httpBaseUrl: "https://example.com/", + wsBaseUrl: "wss://example.com/", + }); + }); + + it("does not double-prepend https when the host already carries a scheme", () => { + expect( + resolveRemotePairingTarget({ + host: "//https://example.com", + pairingCode: "pairing-token", + }), + ).toEqual({ + credential: "pairing-token", + httpBaseUrl: "https://example.com/", + wsBaseUrl: "wss://example.com/", + }); + }); + it("preserves host ports when normalizing a bare host input", () => { expect( resolveRemotePairingTarget({ diff --git a/packages/shared/src/remote.ts b/packages/shared/src/remote.ts index 7347dbc74a17..0adfc6fe90a6 100644 --- a/packages/shared/src/remote.ts +++ b/packages/shared/src/remote.ts @@ -81,10 +81,10 @@ const normalizeRemoteBaseUrl = ( throw new RemoteBackendUrlMissingError(); } - const normalizedInput = - /^[a-zA-Z][a-zA-Z\d+-]*:\/\//.test(trimmed) || trimmed.startsWith("//") - ? trimmed - : `https://${trimmed}`; + const withoutLeadingSlashes = trimmed.replace(/^\/+/, ""); + const normalizedInput = /^[a-zA-Z][a-zA-Z\d+-]*:\/\//.test(withoutLeadingSlashes) + ? withoutLeadingSlashes + : `https://${withoutLeadingSlashes}`; let url: URL; try { url = new URL(normalizedInput); diff --git a/scripts/export-brand-icons.ts b/scripts/export-brand-icons.ts new file mode 100644 index 000000000000..c24169dc2e13 --- /dev/null +++ b/scripts/export-brand-icons.ts @@ -0,0 +1,800 @@ +#!/usr/bin/env node + +import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; +import * as Console from "effect/Console"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { Command, Flag } from "effect/unstable/cli"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; +import { encodePngIco, readPngDimensions, WINDOWS_ICON_SIZES } from "./lib/icon-export.ts"; + +const DESIGN_GENERATION = 26; +const ICON_COMPOSER_EXECUTABLE_PARTS = [ + "Contents", + "Applications", + "Icon Composer.app", + "Contents", + "Executables", + "ictool", +] as const; +const STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS = [ + "Icon Composer.app", + "Contents", + "Executables", + "ictool", +] as const; +const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); + +const IconComposerVersion = Schema.Struct({ + "bundle-version": Schema.NonEmptyString, + "short-bundle-version": Schema.NonEmptyString, +}); +const decodeIconComposerVersion = Schema.decodeUnknownEffect( + Schema.fromJsonString(IconComposerVersion), +); + +type IconPlatform = "iOS"; + +interface VariantOutputs { + readonly ios: string; + readonly macos: string; + readonly universal: string; + readonly appleTouch: string; + readonly favicon16: string; + readonly favicon32: string; + readonly faviconIco: string; + readonly windowsIco: string; +} + +interface IconVariant { + readonly label: string; + readonly source: string; + readonly outputs: VariantOutputs; +} + +interface IconComposerTool { + readonly path: string; + readonly version: string; + readonly bundleVersion: string; + readonly supportsDesignGeneration: boolean; +} + +interface CommandResult { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number; +} + +export class IconExportFileSystemError extends Schema.TaggedErrorClass()( + "IconExportFileSystemError", + { + operation: Schema.Literals([ + "resolve-repository-root", + "check-path", + "read-directory", + "read-file", + "make-directory", + "make-temp-directory", + "make-temp-file", + "write-file", + "rename-file", + ]), + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Icon export file-system operation '${this.operation}' failed for ${this.path}.`; + } +} + +export class IconExportProcessError extends Schema.TaggedErrorClass()( + "IconExportProcessError", + { + operation: Schema.Literals(["spawn", "collect-stdout", "collect-stderr", "wait-for-exit"]), + command: Schema.String, + argumentCount: NonNegativeInt, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Icon export process operation '${this.operation}' failed for ${this.command}.`; + } +} + +export class IconExportCommandFailedError extends Schema.TaggedErrorClass()( + "IconExportCommandFailedError", + { + command: Schema.String, + argumentCount: NonNegativeInt, + exitCode: Schema.Int, + sourcePath: Schema.String, + size: Schema.Int, + stdout: Schema.optional(Schema.String), + stderr: Schema.optional(Schema.String), + }, +) { + override get message(): string { + return `Icon Composer failed to export ${this.sourcePath} at ${this.size}x${this.size}.`; + } +} + +export class IconExportToolResolutionError extends Schema.TaggedErrorClass()( + "IconExportToolResolutionError", + { + reason: Schema.Literals(["configured-invalid", "configured-outdated", "not-found"]), + designGeneration: Schema.Int, + toolPath: Schema.optional(Schema.String), + version: Schema.optional(Schema.String), + }, +) { + override get message(): string { + switch (this.reason) { + case "configured-invalid": + return `ICON_COMPOSER_TOOL does not point to Icon Composer's export-capable ictool: ${this.toolPath}`; + case "configured-outdated": + return `ICON_COMPOSER_TOOL points to Icon Composer ${this.version}, but version 2 or newer is required for design generation ${this.designGeneration}.`; + case "not-found": + return `Could not find an Icon Composer 2.x exporter compatible with design generation ${this.designGeneration}. Install a compatible Icon Composer/Xcode or set ICON_COMPOSER_TOOL to Icon Composer.app/Contents/Executables/ictool.`; + } + } +} + +export class IconExportSourceMissingError extends Schema.TaggedErrorClass()( + "IconExportSourceMissingError", + { + sourcePath: Schema.String, + }, +) { + override get message(): string { + return `Missing Icon Composer source project: ${this.sourcePath}`; + } +} + +export class IconExportRenditionError extends Schema.TaggedErrorClass()( + "IconExportRenditionError", + { + sourcePath: Schema.String, + outputPath: Schema.String, + expectedSize: Schema.Int, + actualWidth: Schema.optional(Schema.Int), + actualHeight: Schema.optional(Schema.Int), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + const actual = + this.actualWidth === undefined || this.actualHeight === undefined + ? "an invalid PNG" + : `${this.actualWidth}x${this.actualHeight}`; + return `Icon Composer produced ${actual}; expected ${this.expectedSize}x${this.expectedSize} for ${this.sourcePath}.`; + } +} + +export class IconExportEncodingError extends Schema.TaggedErrorClass()( + "IconExportEncodingError", + { + variant: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to encode ICO renditions for the ${this.variant} icon.`; + } +} + +export class IconExportAssetsStaleError extends Schema.TaggedErrorClass()( + "IconExportAssetsStaleError", + { + paths: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return `Generated icon assets are stale:\n${this.paths.map((path) => `- ${path}`).join("\n")}`; + } +} + +const ICON_VARIANTS = [ + { + label: "development", + source: BRAND_ASSET_PATHS.developmentIconComposerProject, + outputs: { + ios: BRAND_ASSET_PATHS.developmentIosIconPng, + macos: BRAND_ASSET_PATHS.developmentDesktopIconPng, + universal: BRAND_ASSET_PATHS.developmentUniversalIconPng, + appleTouch: BRAND_ASSET_PATHS.developmentWebAppleTouchIconPng, + favicon16: BRAND_ASSET_PATHS.developmentWebFavicon16Png, + favicon32: BRAND_ASSET_PATHS.developmentWebFavicon32Png, + faviconIco: BRAND_ASSET_PATHS.developmentWebFaviconIco, + windowsIco: BRAND_ASSET_PATHS.developmentWindowsIconIco, + }, + }, + { + label: "preview", + source: BRAND_ASSET_PATHS.nightlyIconComposerProject, + outputs: { + ios: BRAND_ASSET_PATHS.nightlyIosIconPng, + macos: BRAND_ASSET_PATHS.nightlyMacIconPng, + universal: BRAND_ASSET_PATHS.nightlyLinuxIconPng, + appleTouch: BRAND_ASSET_PATHS.nightlyWebAppleTouchIconPng, + favicon16: BRAND_ASSET_PATHS.nightlyWebFavicon16Png, + favicon32: BRAND_ASSET_PATHS.nightlyWebFavicon32Png, + faviconIco: BRAND_ASSET_PATHS.nightlyWebFaviconIco, + windowsIco: BRAND_ASSET_PATHS.nightlyWindowsIconIco, + }, + }, + { + label: "production", + source: BRAND_ASSET_PATHS.productionIconComposerProject, + outputs: { + ios: BRAND_ASSET_PATHS.productionIosIconPng, + macos: BRAND_ASSET_PATHS.productionMacIconPng, + universal: BRAND_ASSET_PATHS.productionLinuxIconPng, + appleTouch: BRAND_ASSET_PATHS.productionWebAppleTouchIconPng, + favicon16: BRAND_ASSET_PATHS.productionWebFavicon16Png, + favicon32: BRAND_ASSET_PATHS.productionWebFavicon32Png, + faviconIco: BRAND_ASSET_PATHS.productionWebFaviconIco, + windowsIco: BRAND_ASSET_PATHS.productionWindowsIconIco, + }, + }, +] as const satisfies ReadonlyArray; + +const MACOS_EXPORT_CODEX_PROMPT = [ + "Use [@Computer](plugin://computer-use@openai-bundled) and the Icon Composer app to export the three macOS app icons in this repository.", + "For each project below, use Platform: macOS pre-Tahoe, Appearance: Default, Size: 1024pt, and Scale: 1×, then save the PNG to the exact destination:", + ...ICON_VARIANTS.map((variant) => `- ${variant.source} -> ${variant.outputs.macos}`), + "Do not resize, composite, or otherwise post-process the exported PNGs.", + "Verify every result is 1024×1024 and has the classic macOS safe area: an 824×824 opaque body inset 100px on every side, with only Icon Composer's native shadow extending beyond it.", +]; + +const RepositoryRoot = Effect.service(Path.Path).pipe( + Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "resolve-repository-root", + path: new URL("..", import.meta.url).pathname, + cause, + }), + ), +); + +const collectStreamAsString = (stream: Stream.Stream) => + stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (acc, chunk) => acc + chunk, + ), + ); + +const runCommand = Effect.fn("iconExport.runCommand")(function* ( + command: string, + args: ReadonlyArray, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const child = yield* spawner.spawn(ChildProcess.make(command, args)).pipe( + Effect.mapError( + (cause) => + new IconExportProcessError({ + operation: "spawn", + command, + argumentCount: args.length, + cause, + }), + ), + ); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout).pipe( + Effect.mapError( + (cause) => + new IconExportProcessError({ + operation: "collect-stdout", + command, + argumentCount: args.length, + cause, + }), + ), + ), + collectStreamAsString(child.stderr).pipe( + Effect.mapError( + (cause) => + new IconExportProcessError({ + operation: "collect-stderr", + command, + argumentCount: args.length, + cause, + }), + ), + ), + child.exitCode.pipe( + Effect.map(Number), + Effect.mapError( + (cause) => + new IconExportProcessError({ + operation: "wait-for-exit", + command, + argumentCount: args.length, + cause, + }), + ), + ), + ], + { concurrency: "unbounded" }, + ); + + return { stdout, stderr, exitCode } satisfies CommandResult; +}); + +const iconComposerToolFromDeveloperDirectory = (developerDirectory: string, path: Path.Path) => + path.resolve(developerDirectory, "..", ...ICON_COMPOSER_EXECUTABLE_PARTS.slice(1)); + +const readSelectedDeveloperDirectory = Effect.fn("iconExport.readSelectedDeveloperDirectory")( + function* () { + const result = yield* runCommand("xcode-select", ["-p"]).pipe(Effect.option); + return Option.flatMap(result, (output) => { + const developerDirectory = output.stdout.trim(); + return output.exitCode === 0 && developerDirectory.length > 0 + ? Option.some(developerDirectory) + : Option.none(); + }); + }, +); + +const findXcodeAppCandidates = Effect.fn("iconExport.findXcodeAppCandidates")(function* ( + directory: string, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const entries = yield* fs.readDirectory(directory).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "read-directory", + path: directory, + cause, + }), + ), + Effect.orElseSucceed(() => []), + ); + return entries + .filter((entry) => /^Xcode.*\.app$/.test(entry)) + .map((entry) => path.join(directory, entry, ...ICON_COMPOSER_EXECUTABLE_PARTS)); +}); + +const probeIconComposerTool = Effect.fn("iconExport.probeIconComposerTool")(function* ( + candidate: string, +) { + const fs = yield* FileSystem.FileSystem; + const exists = yield* fs.exists(candidate).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "check-path", + path: candidate, + cause, + }), + ), + Effect.orElseSucceed(() => false), + ); + if (!exists) return Option.none(); + + const result = yield* runCommand(candidate, ["--version"]).pipe(Effect.option); + if (Option.isNone(result) || result.value.exitCode !== 0) { + return Option.none(); + } + + const version = yield* decodeIconComposerVersion(result.value.stdout).pipe(Effect.option); + if (Option.isNone(version)) return Option.none(); + + const bundleVersion = version.value["bundle-version"]; + const shortVersion = version.value["short-bundle-version"]; + return Option.some({ + path: candidate, + version: `${shortVersion} (${bundleVersion})`, + bundleVersion, + supportsDesignGeneration: Number.parseInt(shortVersion, 10) >= 2, + }); +}); + +const resolveIconComposerTool = Effect.fn("iconExport.resolveIconComposerTool")(function* () { + const path = yield* Path.Path; + const environment = yield* HostProcessEnvironment; + const configuredTool = environment.ICON_COMPOSER_TOOL?.trim(); + if (configuredTool) { + const tool = yield* probeIconComposerTool(configuredTool); + if (Option.isNone(tool)) { + return yield* new IconExportToolResolutionError({ + reason: "configured-invalid", + designGeneration: DESIGN_GENERATION, + toolPath: configuredTool, + }); + } + if (!tool.value.supportsDesignGeneration) { + return yield* new IconExportToolResolutionError({ + reason: "configured-outdated", + designGeneration: DESIGN_GENERATION, + toolPath: configuredTool, + version: tool.value.version, + }); + } + return tool.value; + } + + const selectedDeveloperDirectory = yield* readSelectedDeveloperDirectory(); + const configuredDeveloperDirectory = environment.DEVELOPER_DIR?.trim(); + const homeDirectory = environment.HOME?.trim(); + const searchDirectories = [ + "/Applications", + ...(homeDirectory ? [path.join(homeDirectory, "Downloads")] : []), + ]; + const xcodeCandidates = yield* Effect.forEach(searchDirectories, findXcodeAppCandidates, { + concurrency: "unbounded", + }); + const candidates = new Set([ + ...(configuredDeveloperDirectory + ? [iconComposerToolFromDeveloperDirectory(configuredDeveloperDirectory, path)] + : []), + ...Option.match(selectedDeveloperDirectory, { + onNone: () => [], + onSome: (developerDirectory) => [ + iconComposerToolFromDeveloperDirectory(developerDirectory, path), + ], + }), + path.join("/Applications", ...STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS), + ...(homeDirectory + ? [path.join(homeDirectory, "Applications", ...STANDALONE_ICON_COMPOSER_EXECUTABLE_PARTS)] + : []), + ...xcodeCandidates.flat(), + ]); + const probed = yield* Effect.forEach([...candidates], probeIconComposerTool, { + concurrency: "unbounded", + }); + const compatibleTools = probed + .filter(Option.isSome) + .map((tool) => tool.value) + .filter((tool) => tool.supportsDesignGeneration) + .sort((left, right) => + right.bundleVersion.localeCompare(left.bundleVersion, undefined, { numeric: true }), + ); + const newestTool = compatibleTools[0]; + if (newestTool) return newestTool; + + return yield* new IconExportToolResolutionError({ + reason: "not-found", + designGeneration: DESIGN_GENERATION, + }); +}); + +const renderIcon = Effect.fn("iconExport.renderIcon")(function* ( + toolPath: string, + sourcePath: string, + outputPath: string, + platform: IconPlatform, + size: number, +) { + const fs = yield* FileSystem.FileSystem; + const args = [ + sourcePath, + "--export-image", + "--output-file", + outputPath, + "--platform", + platform, + "--rendition", + "Default", + "--width", + String(size), + "--height", + String(size), + "--scale", + "1", + "--design-generation", + String(DESIGN_GENERATION), + ]; + const result = yield* runCommand(toolPath, args); + if (result.exitCode !== 0) { + return yield* new IconExportCommandFailedError({ + command: toolPath, + argumentCount: args.length, + exitCode: result.exitCode, + sourcePath, + size, + ...(result.stdout.trim() ? { stdout: result.stdout.trim() } : {}), + ...(result.stderr.trim() ? { stderr: result.stderr.trim() } : {}), + }); + } + + const contents = yield* fs.readFile(outputPath).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "read-file", + path: outputPath, + cause, + }), + ), + ); + const buffer = Buffer.from(contents); + const dimensions = yield* Effect.try({ + try: () => readPngDimensions(buffer), + catch: (cause) => + new IconExportRenditionError({ + sourcePath, + outputPath, + expectedSize: size, + cause, + }), + }); + if (dimensions.width !== size || dimensions.height !== size) { + return yield* new IconExportRenditionError({ + sourcePath, + outputPath, + expectedSize: size, + actualWidth: dimensions.width, + actualHeight: dimensions.height, + }); + } + return buffer; +}); + +const renderVariant = Effect.fn("iconExport.renderVariant")(function* ( + toolPath: string, + repositoryRoot: string, + temporaryDirectory: string, + variant: IconVariant, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourcePath = path.join(repositoryRoot, variant.source); + const sourceExists = yield* fs.exists(sourcePath).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "check-path", + path: sourcePath, + cause, + }), + ), + ); + if (!sourceExists) { + return yield* new IconExportSourceMissingError({ sourcePath: variant.source }); + } + + const renditionCache = new Map(); + const render = Effect.fn("iconExport.renderVariant.rendition")(function* ( + platform: IconPlatform, + size: number, + ) { + const cacheKey = `${platform}-${size}`; + const cached = renditionCache.get(cacheKey); + if (cached) return cached; + + const outputPath = path.join(temporaryDirectory, `${variant.label}-${platform}-${size}.png`); + const contents = yield* renderIcon(toolPath, sourcePath, outputPath, platform, size); + renditionCache.set(cacheKey, contents); + return contents; + }); + + const ios = yield* render("iOS", 1024); + const icoRenditions = yield* Effect.forEach( + WINDOWS_ICON_SIZES, + (size) => render("iOS", size).pipe(Effect.map((contents) => ({ size, contents }))), + { concurrency: 1 }, + ); + const ico = yield* Effect.try({ + try: () => encodePngIco(icoRenditions), + catch: (cause) => new IconExportEncodingError({ variant: variant.label, cause }), + }); + + return new Map([ + [variant.outputs.ios, ios], + [variant.outputs.universal, ios], + [variant.outputs.appleTouch, yield* render("iOS", 180)], + [variant.outputs.favicon16, yield* render("iOS", 16)], + [variant.outputs.favicon32, yield* render("iOS", 32)], + [variant.outputs.faviconIco, ico], + [variant.outputs.windowsIco, ico], + ]); +}); + +const logManualMacOsExportInstructions = Effect.fn("iconExport.logManualMacOsExportInstructions")( + function* () { + yield* Console.warn( + [ + "macOS icons require Icon Composer's GUI-only pre-Tahoe preset and were not changed.", + "Export each source with Platform: macOS pre-Tahoe, Appearance: Default, Size: 1024pt, Scale: 1×:", + ...ICON_VARIANTS.map((variant) => `- ${variant.source} -> ${variant.outputs.macos}`), + "See assets/README.md for the complete workflow.", + "", + "Copy/paste this prompt into Codex to perform the native exports:", + "---", + ...MACOS_EXPORT_CODEX_PROMPT, + "---", + ].join("\n"), + ); + }, +); + +const writeAtomically = Effect.fn("iconExport.writeAtomically")(function* ( + repositoryRoot: string, + relativePath: string, + contents: Buffer, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const targetPath = path.join(repositoryRoot, relativePath); + const targetDirectory = path.dirname(targetPath); + yield* fs.makeDirectory(targetDirectory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "make-directory", + path: targetDirectory, + cause, + }), + ), + ); + const temporaryPath = yield* fs + .makeTempFileScoped({ + directory: targetDirectory, + prefix: ".t3-icon-export-", + suffix: ".tmp", + }) + .pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "make-temp-file", + path: targetDirectory, + cause, + }), + ), + ); + yield* fs.writeFile(temporaryPath, contents).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "write-file", + path: temporaryPath, + cause, + }), + ), + ); + yield* fs.rename(temporaryPath, targetPath).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "rename-file", + path: targetPath, + cause, + }), + ), + ); +}); + +const isCurrent = Effect.fn("iconExport.isCurrent")(function* ( + repositoryRoot: string, + relativePath: string, + expected: Buffer, +) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const targetPath = path.join(repositoryRoot, relativePath); + const exists = yield* fs.exists(targetPath).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "check-path", + path: targetPath, + cause, + }), + ), + ); + if (!exists) return false; + + const actual = yield* fs.readFile(targetPath).pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "read-file", + path: targetPath, + cause, + }), + ), + ); + return Buffer.from(actual).equals(expected); +}); + +export const exportBrandIcons = Effect.fn("exportBrandIcons")(function* (checkOnly: boolean) { + const fs = yield* FileSystem.FileSystem; + const repositoryRoot = yield* RepositoryRoot; + const tool = yield* resolveIconComposerTool(); + const temporaryDirectory = yield* fs + .makeTempDirectoryScoped({ + prefix: "t3-icon-export-", + }) + .pipe( + Effect.mapError( + (cause) => + new IconExportFileSystemError({ + operation: "make-temp-directory", + path: "system temporary directory", + cause, + }), + ), + ); + yield* Console.log( + `Exporting icons with Icon Composer ${tool.version}, design generation ${DESIGN_GENERATION}.`, + ); + + const generated = new Map(); + for (const variant of ICON_VARIANTS) { + yield* Console.log(`Rendering ${variant.label} from ${variant.source}...`); + const variantAssets = yield* renderVariant( + tool.path, + repositoryRoot, + temporaryDirectory, + variant, + ); + for (const [relativePath, contents] of variantAssets) { + generated.set(relativePath, contents); + } + } + + if (checkOnly) { + const stale = yield* Effect.filter( + [...generated.entries()], + ([relativePath, contents]) => + isCurrent(repositoryRoot, relativePath, contents).pipe(Effect.map((current) => !current)), + { concurrency: "unbounded" }, + ); + if (stale.length > 0) { + return yield* new IconExportAssetsStaleError({ + paths: stale.map(([relativePath]) => relativePath), + }); + } + yield* Console.log(`All ${generated.size} generated icon assets are current.`); + yield* logManualMacOsExportInstructions(); + return; + } + + yield* Effect.forEach( + generated, + ([relativePath, contents]) => writeAtomically(repositoryRoot, relativePath, contents), + { concurrency: 1, discard: true }, + ); + yield* Console.log(`Updated ${generated.size} generated icon assets.`); + yield* logManualMacOsExportInstructions(); +}); + +export const exportBrandIconsCommand = Command.make( + "export-brand-icons", + { + check: Flag.boolean("check").pipe( + Flag.withDescription("Verify generated icon assets without modifying files."), + Flag.withDefault(false), + ), + }, + ({ check }) => exportBrandIcons(check).pipe(Effect.scoped), +).pipe( + Command.withDescription( + "Export development, preview, and production assets from Icon Composer projects.", + ), +); + +if (import.meta.main) { + Command.run(exportBrandIconsCommand, { version: "0.0.0" }).pipe( + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, + ); +} diff --git a/scripts/lib/brand-assets.test.ts b/scripts/lib/brand-assets.test.ts index 8e1875e8d274..6ab3c6a3a293 100644 --- a/scripts/lib/brand-assets.test.ts +++ b/scripts/lib/brand-assets.test.ts @@ -55,4 +55,19 @@ describe("brand-assets", () => { expect(resolveWebAssetBrandForChannel("latest")).toBe("production"); expect(resolveWebAssetBrandForChannel("nightly")).toBe("nightly"); }); + + it("keeps development, nightly, and production icon families separate", () => { + expect([ + BRAND_ASSET_PATHS.developmentIconComposerProject, + BRAND_ASSET_PATHS.nightlyIconComposerProject, + BRAND_ASSET_PATHS.productionIconComposerProject, + ]).toEqual([ + "assets/dev/app-icon.icon", + "assets/nightly/app-icon.icon", + "assets/prod/app-icon.icon", + ]); + expect(BRAND_ASSET_PATHS.developmentDesktopIconPng).toMatch(/^assets\/dev\/blueprint-/); + expect(BRAND_ASSET_PATHS.nightlyMacIconPng).toMatch(/^assets\/nightly\/nightly-/); + expect(BRAND_ASSET_PATHS.productionMacIconPng).toMatch(/^assets\/prod\/black-/); + }); }); diff --git a/scripts/lib/brand-assets.ts b/scripts/lib/brand-assets.ts index c326c43ff575..c4de2eb820b5 100644 --- a/scripts/lib/brand-assets.ts +++ b/scripts/lib/brand-assets.ts @@ -1,5 +1,13 @@ export const BRAND_ASSET_PATHS = { + developmentIconComposerProject: "assets/dev/app-icon.icon", + developmentIosIconPng: "assets/dev/blueprint-ios-1024.png", + developmentUniversalIconPng: "assets/dev/blueprint-universal-1024.png", + + // Fork-only: opt-in macOS 26 appearance-aware icon, populated manually via + // Apple Icon Composer. See docs/custom-alpha-workflow.md#macos-26-icon-method. productionMacIconComposer: "assets/prod/icon.icon", + productionIconComposerProject: "assets/prod/app-icon.icon", + productionIosIconPng: "assets/prod/black-ios-1024.png", productionMacIconPng: "assets/prod/black-macos-1024.png", productionLinuxIconPng: "assets/prod/black-universal-1024.png", productionWindowsIconIco: "assets/prod/t3-black-windows.ico", @@ -8,13 +16,15 @@ export const BRAND_ASSET_PATHS = { productionWebFavicon32Png: "assets/prod/t3-black-web-favicon-32x32.png", productionWebAppleTouchIconPng: "assets/prod/t3-black-web-apple-touch-180.png", - nightlyMacIconPng: "assets/nightly/blueprint-macos-1024.png", - nightlyLinuxIconPng: "assets/nightly/blueprint-universal-1024.png", - nightlyWindowsIconIco: "assets/nightly/blueprint-windows.ico", - nightlyWebFaviconIco: "assets/nightly/blueprint-web-favicon.ico", - nightlyWebFavicon16Png: "assets/nightly/blueprint-web-favicon-16x16.png", - nightlyWebFavicon32Png: "assets/nightly/blueprint-web-favicon-32x32.png", - nightlyWebAppleTouchIconPng: "assets/nightly/blueprint-web-apple-touch-180.png", + nightlyIconComposerProject: "assets/nightly/app-icon.icon", + nightlyIosIconPng: "assets/nightly/nightly-ios-1024.png", + nightlyMacIconPng: "assets/nightly/nightly-macos-1024.png", + nightlyLinuxIconPng: "assets/nightly/nightly-universal-1024.png", + nightlyWindowsIconIco: "assets/nightly/nightly-windows.ico", + nightlyWebFaviconIco: "assets/nightly/nightly-web-favicon.ico", + nightlyWebFavicon16Png: "assets/nightly/nightly-web-favicon-16x16.png", + nightlyWebFavicon32Png: "assets/nightly/nightly-web-favicon-32x32.png", + nightlyWebAppleTouchIconPng: "assets/nightly/nightly-web-apple-touch-180.png", developmentDesktopIconPng: "assets/dev/blueprint-macos-1024.png", developmentWindowsIconIco: "assets/dev/blueprint-windows.ico", diff --git a/scripts/lib/icon-export.test.ts b/scripts/lib/icon-export.test.ts new file mode 100644 index 000000000000..3db6f22cbdbc --- /dev/null +++ b/scripts/lib/icon-export.test.ts @@ -0,0 +1,47 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { encodePngIco, readPngDimensions } from "./icon-export.ts"; + +const pngHeader = (width: number, height: number) => { + const contents = Buffer.alloc(24); + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(contents); + contents.write("IHDR", 12, "ascii"); + contents.writeUInt32BE(width, 16); + contents.writeUInt32BE(height, 20); + return contents; +}; + +describe("icon export", () => { + it("reads dimensions from a PNG IHDR chunk", () => { + assert.deepEqual(readPngDimensions(pngHeader(1024, 512)), { width: 1024, height: 512 }); + }); + + it("encodes PNG renditions into an ICO directory", () => { + const small = pngHeader(16, 16); + const large = pngHeader(256, 256); + const ico = encodePngIco([ + { size: 16, contents: small }, + { size: 256, contents: large }, + ]); + + assert.equal(ico.readUInt16LE(2), 1); + assert.equal(ico.readUInt16LE(4), 2); + assert.equal(ico.readUInt8(6), 16); + assert.equal(ico.readUInt8(22), 0); + assert.equal(ico.readUInt32LE(18), 38); + assert.equal(ico.readUInt32LE(34), 38 + small.length); + assert.deepEqual(ico.subarray(38, 38 + small.length), small); + assert.deepEqual(ico.subarray(38 + small.length), large); + }); + + it("rejects duplicate ICO rendition sizes", () => { + assert.throws( + () => + encodePngIco([ + { size: 32, contents: pngHeader(32, 32) }, + { size: 32, contents: pngHeader(32, 32) }, + ]), + /provided more than once/, + ); + }); +}); diff --git a/scripts/lib/icon-export.ts b/scripts/lib/icon-export.ts new file mode 100644 index 000000000000..f48228a94fd3 --- /dev/null +++ b/scripts/lib/icon-export.ts @@ -0,0 +1,72 @@ +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + +export const WINDOWS_ICON_SIZES = [16, 24, 32, 48, 64, 128, 256] as const; + +export interface PngIconImage { + readonly size: number; + readonly contents: Buffer; +} + +export function readPngDimensions(contents: Buffer): { + readonly width: number; + readonly height: number; +} { + if ( + contents.length < 24 || + !contents.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE) || + contents.toString("ascii", 12, 16) !== "IHDR" + ) { + throw new Error("Icon Composer produced an invalid PNG."); + } + + return { + width: contents.readUInt32BE(16), + height: contents.readUInt32BE(20), + }; +} + +/** Encodes PNG renditions directly into a modern, multi-resolution ICO file. */ +export function encodePngIco(images: ReadonlyArray): Buffer { + if (images.length === 0) { + throw new Error("An ICO file requires at least one PNG rendition."); + } + + const seenSizes = new Set(); + for (const image of images) { + if (!Number.isInteger(image.size) || image.size < 1 || image.size > 256) { + throw new Error(`ICO rendition size must be an integer from 1 to 256, got ${image.size}.`); + } + if (seenSizes.has(image.size)) { + throw new Error(`ICO rendition size ${image.size} was provided more than once.`); + } + if (image.contents.length === 0) { + throw new Error(`ICO rendition ${image.size}x${image.size} is empty.`); + } + seenSizes.add(image.size); + } + + const headerSize = 6; + const directoryEntrySize = 16; + const directorySize = directoryEntrySize * images.length; + const header = Buffer.alloc(headerSize + directorySize); + header.writeUInt16LE(0, 0); + header.writeUInt16LE(1, 2); + header.writeUInt16LE(images.length, 4); + + let imageOffset = header.length; + images.forEach((image, index) => { + const entryOffset = headerSize + index * directoryEntrySize; + const encodedSize = image.size === 256 ? 0 : image.size; + header.writeUInt8(encodedSize, entryOffset); + header.writeUInt8(encodedSize, entryOffset + 1); + header.writeUInt8(0, entryOffset + 2); + header.writeUInt8(0, entryOffset + 3); + header.writeUInt16LE(1, entryOffset + 4); + header.writeUInt16LE(32, entryOffset + 6); + header.writeUInt32LE(image.contents.length, entryOffset + 8); + header.writeUInt32LE(imageOffset, entryOffset + 12); + imageOffset += image.contents.length; + }); + + return Buffer.concat([header, ...images.map((image) => image.contents)]); +}