From 2ea51bd31f494ec3214a0c9f9adc3262c71e6530 Mon Sep 17 00:00:00 2001 From: Arham Amin <132888838+arhxam@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:01:31 +0530 Subject: [PATCH 01/28] fix(server): OpenCode model parsing drops models with a slash in the JSON body (#5072) Co-authored-by: Claude Opus 4.8 (1M context) --- .../opencodeRuntime.cliParsers.test.ts | 25 +++++++++++++++++++ apps/server/src/provider/opencodeRuntime.ts | 8 +++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts index 6208f04507e7..46577f377816 100644 --- a/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts +++ b/apps/server/src/provider/opencodeRuntime.cliParsers.test.ts @@ -125,6 +125,31 @@ describe("parseModelsCliOutput", () => { NodeAssert.ok(model.variants); NodeAssert.equal(model.variants!["medium"] !== undefined, true); }); + + it("keeps a model whose JSON body has a slash and no interior whitespace", () => { + // OpenRouter-style: the model id contains a `/` and no string value has a + // space, so the JSON body line itself matches the slug regex. It must still + // be treated as the body of the preceding slug, not a new slug. + const stdout = [ + "openrouter/qwen/qwen3-coder", + JSON.stringify({ + id: "qwen/qwen3-coder", + providerID: "openrouter", + name: "qwen3-coder", + status: "active", + }), + ].join("\n"); + + const result = parseModelsCliOutput(stdout); + NodeAssert.equal(result.providers.size, 1); + NodeAssert.deepEqual([...result.connected], ["openrouter"]); + const provider = result.providers.get("openrouter")!; + NodeAssert.ok(provider); + const model = provider.models["qwen/qwen3-coder"]!; + NodeAssert.ok(model); + NodeAssert.equal(model.id, "qwen/qwen3-coder"); + NodeAssert.equal(model.providerID, "openrouter"); + }); }); describe("parseAgentListCliOutput", () => { diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index d9a07fb8f284..95a6a0045343 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -216,7 +216,13 @@ export function parseModelsCliOutput(stdout: string): { }; for (const line of lines) { - const slugMatch = SLUG_LINE_RE.exec(line); + // A model's JSON body is a single `JSON.stringify` line starting with `{`, + // while a provider/model slug is a bare `provider/model` header. Only the + // latter can be a slug: without this guard a body line with no interior + // whitespace and a `/` in one of its values (e.g. an OpenRouter model whose + // `id` is `vendor/model`) matches SLUG_LINE_RE, so flushModel runs against + // an empty body and the model is silently dropped. + const slugMatch = line.trimStart().startsWith("{") ? null : SLUG_LINE_RE.exec(line); if (slugMatch) { flushModel(); currentSlug = slugMatch[1]!; From 59d0c922e9363b209bcb004444c05e70fa9fdd8b Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 11 Aug 2026 03:32:22 +0000 Subject: [PATCH 02/28] fix(web): persist sidebar shelf collapse state (#5136) Co-authored-by: Illia Panasenko --- apps/web/src/components/Sidebar.tsx | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 18fa2a6708c1..76d678cb35cc 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,5 +1,6 @@ import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; +import * as Schema from "effect/Schema"; import { DndContext, PointerSensor, @@ -100,6 +101,7 @@ import { openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; +import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; @@ -176,6 +178,9 @@ import { // stays behind an explicit Show more. const SETTLED_TAIL_INITIAL_COUNT = 10; const SETTLED_TAIL_PAGE_COUNT = 25; +// Keep the v2 key so existing preferences survive the v2-to-default rename. +const SETTLED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:settled-expanded"; +const SNOOZED_SHELF_EXPANDED_KEY = "t3code:sidebar-v2:snoozed-expanded"; function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; @@ -2044,8 +2049,15 @@ export default function Sidebar() { () => setSettledVisibleCount((count) => count + SETTLED_TAIL_PAGE_COUNT), [], ); - const [settledShelfExpanded, setSettledShelfExpanded] = useState(true); - const toggleSettledShelf = useCallback(() => setSettledShelfExpanded((value) => !value), []); + const [settledShelfExpanded, setSettledShelfExpanded] = useLocalStorage( + SETTLED_SHELF_EXPANDED_KEY, + true, + Schema.Boolean, + ); + const toggleSettledShelf = useCallback( + () => setSettledShelfExpanded((value) => !value), + [setSettledShelfExpanded], + ); const renderedSettledThreads = useMemo(() => { if (settledShelfExpanded) return visibleSettledThreads; if (routeThreadKey === null) return []; @@ -2059,8 +2071,15 @@ export default function Sidebar() { // The snoozed shelf is collapsed by default: out of the way, never gone. // Collapsed threads don't render (and so don't participate in jump // shortcuts or multi-select), matching the settled tail's paging model. - const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useState(false); - const toggleSnoozedShelf = useCallback(() => setSnoozedShelfExpanded((value) => !value), []); + const [snoozedShelfExpanded, setSnoozedShelfExpanded] = useLocalStorage( + SNOOZED_SHELF_EXPANDED_KEY, + false, + Schema.Boolean, + ); + const toggleSnoozedShelf = useCallback( + () => setSnoozedShelfExpanded((value) => !value), + [setSnoozedShelfExpanded], + ); const visibleSnoozedThreads = useMemo(() => { if (snoozedShelfExpanded) return snoozedThreads; // The open thread must never vanish behind the collapsed shelf: a From f9730979c1046e3e69ac89aa86882ebf8b2be9a6 Mon Sep 17 00:00:00 2001 From: Taras Date: Tue, 11 Aug 2026 06:33:08 +0300 Subject: [PATCH 03/28] perf(web): skip base64 for oversized image candidates (#5220) --- apps/web/src/lib/imageCompression.ts | 27 ++++++++++++--------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/apps/web/src/lib/imageCompression.ts b/apps/web/src/lib/imageCompression.ts index 1c57fdad1a55..be45024f38c4 100644 --- a/apps/web/src/lib/imageCompression.ts +++ b/apps/web/src/lib/imageCompression.ts @@ -140,27 +140,28 @@ function createCanvas(width: number, height: number): Canvas2D | null { * and it keeps alpha, so screenshots with transparency survive intact. * Browsers that can't encode it silently fall back to JPEG. */ -async function encodeToDataUrl( +async function encodeCanvas( canvas: OffscreenCanvas | HTMLCanvasElement, quality: number, mimeType: string, -): Promise<{ dataUrl: string; mimeType: string } | null> { + budgetChars: number, +): Promise<{ dataUrl: string | null; mimeType: string } | null> { if (typeof HTMLCanvasElement !== "undefined" && canvas instanceof HTMLCanvasElement) { const dataUrl = canvas.toDataURL(mimeType, quality); // toDataURL silently returns a PNG when the requested type is unsupported. if (!dataUrl.startsWith(`data:${mimeType}`)) return null; - return { dataUrl, mimeType }; + return { dataUrl: dataUrl.length <= budgetChars ? dataUrl : null, mimeType }; } const blob = await (canvas as OffscreenCanvas).convertToBlob({ type: mimeType, quality }); if (blob.type && blob.type !== mimeType) return null; + const dataUrlLength = `data:${mimeType};base64,`.length + 4 * Math.ceil(blob.size / 3); + if (dataUrlLength > budgetChars) return { dataUrl: null, mimeType }; return { dataUrl: await blobToDataUrl(blob, mimeType), mimeType }; } /** * Draws `bitmap` scaled to fit `maxDimension` and encodes it, stepping - * quality down until the data URL fits `budgetChars`. Returns the smallest - * encoding produced, even if it still exceeds the budget, so the caller can - * decide whether to keep or drop it. + * quality down until the data URL fits `budgetChars`. */ async function encodeWithinBudget( bitmap: ImageBitmap, @@ -175,7 +176,7 @@ async function encodeWithinBudget( // Probe WebP once; JPEG (no alpha) needs a white matte, so the fill has to // happen before drawing and depends on which codec we end up using. - const probe = await encodeToDataUrl(target.canvas, QUALITY_STEPS[0], "image/webp"); + const probe = await encodeCanvas(target.canvas, QUALITY_STEPS[0], "image/webp", 0); const mimeType = probe ? "image/webp" : "image/jpeg"; if (mimeType === "image/jpeg") { @@ -184,18 +185,14 @@ async function encodeWithinBudget( } target.context.drawImage(bitmap, 0, 0, width, height); - let smallest: { dataUrl: string; mimeType: string } | null = null; for (const quality of QUALITY_STEPS) { - const encoded = await encodeToDataUrl(target.canvas, quality, mimeType); + const encoded = await encodeCanvas(target.canvas, quality, mimeType, budgetChars); if (!encoded) break; - if (smallest === null || encoded.dataUrl.length < smallest.dataUrl.length) { - smallest = encoded; - } - if (encoded.dataUrl.length <= budgetChars) { - return encoded; + if (encoded.dataUrl !== null) { + return { dataUrl: encoded.dataUrl, mimeType: encoded.mimeType }; } } - return smallest; + return null; } type ReencodeResult = From 1fa315ea93f24e6f72455e234fa84a97de28492b Mon Sep 17 00:00:00 2001 From: bkntr <888122+bkntr@users.noreply.github.com> Date: Tue, 11 Aug 2026 09:03:18 +0530 Subject: [PATCH 04/28] fix(server): skip Linux libc detection on Windows/macOS (#5354) --- .../ResourceMonitorBinary.test.ts | 32 ++++++++++++++++++- .../ResourceMonitorBinary.ts | 4 +-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts index 4c3afa97abfa..243556b6e3b0 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.test.ts @@ -4,7 +4,7 @@ import { HostProcessEnvironment, HostProcessPlatform, } from "@t3tools/shared/hostProcess"; -import { assert, describe, it } from "@effect/vitest"; +import { afterEach, assert, describe, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -12,6 +12,36 @@ import { ServerConfig } from "../config.ts"; import * as ResourceMonitorBinary from "./ResourceMonitorBinary.ts"; describe("ResourceMonitorBinary", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it.effect("skips Linux libc detection on Windows", () => + Effect.gen(function* () { + const getReport = vi.spyOn(process.report, "getReport").mockImplementation(() => { + throw new Error("Linux libc detection must not run on Windows"); + }); + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-resource-monitor-binary-", + }); + const binaryPath = `${baseDir}/t3-resource-monitor.exe`; + yield* fileSystem.writeFileString(binaryPath, "binary"); + + const service = yield* ResourceMonitorBinary.make().pipe( + Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), + Effect.provideService(HostProcessPlatform, "win32"), + Effect.provideService(HostProcessArchitecture, "arm64"), + Effect.provideService(HostProcessEnvironment, { + T3CODE_RESOURCE_MONITOR_PATH: binaryPath, + }), + ); + + assert.equal(yield* service.resolve, binaryPath); + expect(getReport).not.toHaveBeenCalled(); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("resolves an executable override", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts index 1f14df518660..c93bc54a1fba 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts @@ -106,7 +106,7 @@ export function resourceMonitorPlatformKey( export function resourceMonitorRustTarget( platform: NodeJS.Platform, architecture: NodeJS.Architecture, - linuxLibc: ResourceMonitorLinuxLibc, + linuxLibc?: ResourceMonitorLinuxLibc, ): string | undefined { if (platform === "darwin") { return architecture === "arm64" @@ -142,7 +142,7 @@ export const make = Effect.fn("resourceTelemetry.resourceMonitorBinary.make")(fu const platform = yield* HostProcessPlatform; const architecture = yield* HostProcessArchitecture; const environment = yield* HostProcessEnvironment; - const linuxLibc = yield* ResourceMonitorHostLinuxLibc; + const linuxLibc = platform === "linux" ? yield* ResourceMonitorHostLinuxLibc : undefined; const executableName = binaryName(platform); const platformKey = resourceMonitorPlatformKey(platform, architecture); const rustTarget = resourceMonitorRustTarget(platform, architecture, linuxLibc); From c14bcca1035895edca5ebd8d65faea5fe788a825 Mon Sep 17 00:00:00 2001 From: yassiEmp <158713173+yassiEmp@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:33:58 +0200 Subject: [PATCH 05/28] fix(server): advertise 256-color TERM on Windows terminals (#5693) --- .../src/terminal/NodePtyAdapter.test.ts | 32 +++++++++++++++++-- apps/server/src/terminal/NodePtyAdapter.ts | 11 +++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/apps/server/src/terminal/NodePtyAdapter.test.ts b/apps/server/src/terminal/NodePtyAdapter.test.ts index ed87440d4996..7cf6a167ecfa 100644 --- a/apps/server/src/terminal/NodePtyAdapter.test.ts +++ b/apps/server/src/terminal/NodePtyAdapter.test.ts @@ -33,6 +33,7 @@ const testLayer = NodePtyAdapter.layer.pipe( it.effect("spawns through the public adapter with the provided host references", () => Effect.gen(function* () { + spawn.mockClear(); const adapter = yield* PtyAdapter.PtyAdapter; const process = yield* adapter.spawn({ shell: "powershell.exe", @@ -52,8 +53,35 @@ it.effect("spawns through the public adapter with the provided host references", cwd: "C:\\workspace", cols: 120, rows: 40, - env: {}, - name: "xterm-color", + env: { TERM: "xterm-256color" }, + name: "xterm-256color", + }, + ]); + }).pipe(Effect.provide(testLayer)), +); + +it.effect("preserves a caller-provided TERM in the spawn env on win32", () => + Effect.gen(function* () { + spawn.mockClear(); + const adapter = yield* PtyAdapter.PtyAdapter; + yield* adapter.spawn({ + shell: "powershell.exe", + cwd: "C:\\workspace", + cols: 80, + rows: 24, + env: { TERM: "xterm-direct" }, + }); + + assert.equal(spawn.mock.calls.length, 1); + assert.deepEqual(spawn.mock.calls[0], [ + "powershell.exe", + [], + { + cwd: "C:\\workspace", + cols: 80, + rows: 24, + env: { TERM: "xterm-direct" }, + name: "xterm-256color", }, ]); }).pipe(Effect.provide(testLayer)), diff --git a/apps/server/src/terminal/NodePtyAdapter.ts b/apps/server/src/terminal/NodePtyAdapter.ts index ac06e1edfab8..e9c462ab2c20 100644 --- a/apps/server/src/terminal/NodePtyAdapter.ts +++ b/apps/server/src/terminal/NodePtyAdapter.ts @@ -141,14 +141,21 @@ export const make = Effect.fn("NodePtyAdapter.make")(function* ( return PtyAdapter.PtyAdapter.of({ spawn: Effect.fn("NodePtyAdapter.spawn")(function* (input) { yield* ensureNodePtySpawnHelperExecutableCached; + // node-pty only writes `name` into the child's TERM on the Unix path; + // the ConPTY path leaves the environment untouched, so Windows children + // inherit a missing or 16-color TERM unless it is set here. + const env = + platform === "win32" && input.env["TERM"] === undefined + ? { ...input.env, TERM: "xterm-256color" } + : input.env; const ptyProcess = yield* Effect.try({ try: () => nodePty.spawn(input.shell, input.args ?? [], { cwd: input.cwd, cols: input.cols, rows: input.rows, - env: input.env, - name: platform === "win32" ? "xterm-color" : "xterm-256color", + env, + name: "xterm-256color", }), catch: (cause) => new PtyAdapter.PtySpawnError({ From 9c7622dac3d1a385351e6c74354a9e6b9c2037d5 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:34:18 -0400 Subject: [PATCH 06/28] fix(server): handle unborn HEAD in VCS status (#5944) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 17 +++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 38 ++++++++++++-------- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 6e352f013fe5..18e594512ee8 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1001,6 +1001,23 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reports remote status on unborn HEAD without failing", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + const initialBranch = yield* git(cwd, ["symbolic-ref", "--short", "HEAD"]); + + const status = yield* driver.statusDetailsRemote(cwd, { refreshUpstream: false }); + + assert.equal(status.isRepo, true); + assert.equal(status.branch, initialBranch); + assert.equal(status.hasUpstream, false); + assert.equal(status.aheadCount, 0); + assert.equal(status.behindCount, 0); + }), + ); + it.effect("can read cached remote divergence without fetching upstream", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 5b9359adaa84..1489db9b3ff4 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1477,25 +1477,35 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (branchResult === null) { return NON_REPOSITORY_REMOTE_STATUS_DETAILS; } + let branch: string | null; if (branchResult.exitCode !== 0) { if (isNonRepositoryGitStderr(branchResult.stderr)) { return NON_REPOSITORY_REMOTE_STATUS_DETAILS; } - return yield* new GitCommandError({ - ...gitCommandContext({ - operation: "GitVcsDriver.statusDetailsRemote.branch", - cwd, - args: ["rev-parse", "--abbrev-ref", "HEAD"], - }), - detail: "Git branch lookup failed.", - exitCode: branchResult.exitCode, - stdoutLength: branchResult.stdout.length, - stderrLength: branchResult.stderr.length, - }); - } + if (!isUnbornHeadStderr(branchResult.stderr)) { + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.statusDetailsRemote.branch", + cwd, + args: ["rev-parse", "--abbrev-ref", "HEAD"], + }), + detail: "Git branch lookup failed.", + exitCode: branchResult.exitCode, + stdoutLength: branchResult.stdout.length, + stderrLength: branchResult.stderr.length, + }); + } - const branchValue = branchResult.stdout.trim(); - const branch = branchValue.length > 0 && branchValue !== "HEAD" ? branchValue : null; + const branchValue = yield* runGitStdout( + "GitVcsDriver.statusDetailsRemote.unbornBranch", + cwd, + ["symbolic-ref", "--quiet", "--short", "HEAD"], + ); + branch = branchValue.trim() || null; + } else { + const branchValue = branchResult.stdout.trim(); + branch = branchValue.length > 0 && branchValue !== "HEAD" ? branchValue : null; + } const upstream = yield* resolveCurrentUpstream(cwd); const upstreamRef = upstream?.upstreamRef ?? null; let aheadCount = 0; From f5fce74169a5629f701aeb8c4535cab6f7bd3c92 Mon Sep 17 00:00:00 2001 From: Guilherme Vieira <46866023+GuilhermeVieiraDev@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:11:19 +0100 Subject: [PATCH 07/28] fix(pull-requests): route self-hosted GitLab remotes (#6061) --- .../pullRequest/PullRequestService.test.ts | 110 ++++++++++++++++++ .../src/pullRequest/PullRequestService.ts | 82 ++++++++++++- apps/server/src/server.ts | 1 + .../SourceControlProviderRegistry.test.ts | 32 +++++ .../SourceControlProviderRegistry.ts | 11 +- 5 files changed, 229 insertions(+), 7 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index c46808aa2d82..77a7118d961a 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -10,6 +10,7 @@ import type { } from "@t3tools/contracts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import { PullRequestProviderError, type ProviderChangeRequest, @@ -144,11 +145,16 @@ function fakeProvider( function makeService(input: { readonly projects: ReadonlyArray; readonly providers: ReadonlyArray; + readonly resolveHandle?: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]["resolveHandle"]; }) { return PullRequestService.make.pipe( Effect.provide( Layer.mergeAll( Layer.succeed(PullRequestProviderRegistry, fromProviders(input.providers)), + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + resolveHandle: + input.resolveHandle ?? (() => Effect.die("Unexpected provider refinement")), + }), Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ getShellSnapshot: () => Effect.succeed({ @@ -163,6 +169,110 @@ function makeService(input: { ); } +it.effect("refines unknown self-hosted GitLab projects before listing merge requests", () => + Effect.gen(function* () { + let refinementCalls = 0; + const selfHosted = project({ + id: "p1", + title: "self-hosted", + workspaceRoot: "/gitlab", + repository: "group/project", + provider: "unknown", + host: "code.example.test", + }); + const service = yield* makeService({ + projects: [ + selfHosted, + { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/gitlab-worktree" }, + ], + providers: [fakeProvider("gitlab")], + resolveHandle: ({ context }) => { + refinementCalls += 1; + assert.strictEqual(context?.remoteUrl, "https://code.example.test/group/project.git"); + return Effect.succeed({ + context: { ...context!, provider: { ...context!.provider, kind: "gitlab" } }, + provider: undefined as never, + }); + }, + }); + + const result = yield* service.list({ state: "open" }); + + assert.strictEqual(refinementCalls, 1); + assert.strictEqual(result.providers[0]?.host, "code.example.test"); + assert.strictEqual(result.providers[0]?.kind, "gitlab"); + }), +); + +it.effect("derives a legacy repository host after refining its provider", () => + Effect.gen(function* () { + const current = project({ + id: "p1", + title: "legacy self-hosted", + workspaceRoot: "/gitlab", + repository: "group/project", + provider: "unknown", + host: "code.example.test", + }); + const identity = current.repositoryIdentity!; + // Persisted identities from before canonicalKey existed are still accepted at runtime. + const legacy = { + ...current, + repositoryIdentity: { + locator: identity.locator, + provider: identity.provider, + displayName: identity.displayName, + }, + } as unknown as OrchestrationProjectShell; + const service = yield* makeService({ + projects: [legacy], + providers: [fakeProvider("gitlab")], + resolveHandle: ({ context }) => + Effect.succeed({ + context: { ...context!, provider: { ...context!.provider, kind: "gitlab" } }, + provider: undefined as never, + }), + }); + + const result = yield* service.list({ state: "open", host: "gitlab" }); + + assert.strictEqual(result.providers[0]?.host, "gitlab"); + assert.strictEqual(result.providers[0]?.kind, "gitlab"); + }), +); + +it.effect("tries another checkout when provider refinement remains unknown", () => + Effect.gen(function* () { + const asked: string[] = []; + const selfHosted = project({ + id: "p1", + title: "self-hosted", + workspaceRoot: "/gone", + repository: "group/project", + provider: "unknown", + host: "code.example.test", + }); + const service = yield* makeService({ + projects: [selfHosted, { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/healthy" }], + providers: [fakeProvider("gitlab")], + resolveHandle: ({ cwd, context }) => { + asked.push(cwd); + return cwd === "/gone" + ? Effect.succeed({ context: context!, provider: undefined as never }) + : Effect.succeed({ + context: { ...context!, provider: { ...context!.provider, kind: "gitlab" } }, + provider: undefined as never, + }); + }, + }); + + const result = yield* service.list({ state: "open" }); + + assert.deepStrictEqual(asked, ["/gone", "/healthy"]); + assert.strictEqual(result.providers[0]?.kind, "gitlab"); + }), +); + /** A row as a host that reads several repositories at once hands it over. */ function batchedChangeRequest(number: number, repository: string, updatedAt: string) { return { ...changeRequest(number, updatedAt), repository }; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 8652e4b9c9fc..adff1f83729b 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -36,10 +36,13 @@ import { type PullRequestSubmitReviewInput, type PullRequestThreadReplyInput, type PullRequestThreadResolutionInput, + type SourceControlProviderInfo, type SourceControlProviderKind, } from "@t3tools/contracts"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import { type ProviderChangeRequest, type ProviderListCursor, @@ -360,6 +363,65 @@ function repositoryIdentityOf(project: OrchestrationProjectShell): string | null export const make = Effect.gen(function* () { const registry = yield* PullRequestProviderRegistry; const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; + + const refineUnknownProjectKinds = ( + projects: ReadonlyArray, + filter: Pick, + ) => { + type RefinementCandidate = { + readonly project: OrchestrationProjectShell; + readonly provider: SourceControlProviderInfo; + readonly remoteName: string; + readonly remoteUrl: string; + }; + const refinements = new Map(); + for (const project of projects) { + if (filter.projectId !== undefined && project.id !== filter.projectId) continue; + const identity = project.repositoryIdentity; + if (identity?.provider !== "unknown" || repositoryIdentityOf(project) === null) continue; + const host = pullRequestHostOf(identity, "unknown"); + // A legacy identity has no canonical host until its provider is refined, so it must reach + // the refinement before a host filter can decide whether it belongs in the result. + if (filter.host !== undefined && host !== "unknown" && host !== filter.host.toLowerCase()) { + continue; + } + const { remoteName, remoteUrl } = identity.locator; + const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); + if (provider !== null) { + const candidates = refinements.get(provider.baseUrl); + const candidate = { project, provider, remoteName, remoteUrl }; + if (candidates === undefined) refinements.set(provider.baseUrl, [candidate]); + else candidates.push(candidate); + } + } + + return Effect.forEach( + refinements, + ([baseUrl, candidates]) => + Effect.firstSuccessOf( + candidates.map(({ project, provider, remoteName, remoteUrl }) => + Effect.suspend(() => + sourceControlProviders.resolveHandle({ + cwd: project.workspaceRoot, + context: { provider, remoteName, remoteUrl }, + }), + ).pipe( + Effect.flatMap((handle) => { + const kind = handle.context?.provider.kind; + return kind === undefined || kind === "unknown" + ? Effect.fail(undefined) + : Effect.succeed(kind); + }), + ), + ), + ).pipe( + Effect.map((kind) => [baseUrl, kind] as const), + Effect.orElseSucceed(() => [baseUrl, "unknown"] as const), + ), + { concurrency: REPOSITORY_CONCURRENCY }, + ).pipe(Effect.map((resolved) => new Map(resolved))); + }; const listWorkspaceProjects = ( filter: Pick, @@ -373,7 +435,12 @@ export const make = Effect.gen(function* () { cause: error, }), ), - Effect.map((snapshot) => { + Effect.flatMap((snapshot) => + refineUnknownProjectKinds(snapshot.projects, filter).pipe( + Effect.map((refinedKinds) => ({ refinedKinds, snapshot })), + ), + ), + Effect.map(({ refinedKinds, snapshot }) => { const supported: SupportedProject[] = []; const unimplemented = new Map< string, @@ -383,15 +450,18 @@ export const make = Effect.gen(function* () { const seen = new Set(); for (const project of snapshot.projects) { if (filter.projectId !== undefined && project.id !== filter.projectId) continue; - const kind = project.repositoryIdentity?.provider as - | SourceControlProviderKind - | undefined; + const identity = project.repositoryIdentity; + let kind = identity?.provider as SourceControlProviderKind | undefined; const repository = repositoryIdentityOf(project); - if (kind === undefined || repository === null) continue; + if (!identity || kind === undefined || repository === null) continue; // Worktrees of one repository are separate projects; reading the remote once keeps // the page from repeating every change request per local checkout. The host is part // of the key, so the same `owner/repo` on two hosts stays two repositories. - const host = pullRequestHostOf(project.repositoryIdentity, kind); + if (kind === "unknown") { + const provider = detectSourceControlProviderFromRemoteUrl(identity.locator.remoteUrl); + kind = provider === null ? kind : (refinedKinds.get(provider.baseUrl) ?? kind); + } + const host = pullRequestHostOf(identity, kind); if (filter.host !== undefined && host !== filter.host.toLowerCase()) continue; const api = registry.get(kind); // Recorded before the de-duplication below, so the viewer lookup keeps the alternates diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 8d4f8bb61d75..32bcaaa8b96b 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -435,6 +435,7 @@ const commandReadinessLayer = HttpRouter.middleware( const PullRequestServiceLive = PullRequestService.layer.pipe( // One registry entry per supported host; the service only knows the registry. Layer.provide(PullRequestProviderRegistry.layer), + Layer.provide(SourceControlProviderRegistryLayerLive), Layer.provide(VcsProcess.layer), ); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 5c4d27e46f94..54038502bfde 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -203,6 +203,38 @@ self-hosted.example.test }), ); +it.effect("refines the caller-selected remote instead of choosing another configured remote", () => + Effect.gen(function* () { + const registry = yield* makeRegistry({ + remotes: [{ name: "origin", url: "git@github.com:fork/project.git" }], + process: { + run: () => + Effect.succeed( + processOutput(`self-hosted.example.test + ✓ Logged in to self-hosted.example.test as gitlab-user +`), + ), + }, + }); + + const handle = yield* registry.resolveHandle({ + cwd: "/repo", + context: { + provider: { + kind: "unknown", + name: "self-hosted.example.test", + baseUrl: "https://self-hosted.example.test", + }, + remoteName: "upstream", + remoteUrl: "https://self-hosted.example.test/group/project.git", + }, + }); + + assert.strictEqual(handle.context?.provider.kind, "gitlab"); + assert.strictEqual(handle.context?.remoteName, "upstream"); + }), +); + it.effect("routes authenticated self-hosted GitLab remotes on non-standard ports", () => Effect.gen(function* () { const registry = yield* makeRegistry({ diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index fb70d677e435..9fe089a4184c 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -50,6 +50,7 @@ export class SourceControlProviderRegistry extends Context.Service< >; readonly resolveHandle: (input: { readonly cwd: string; + readonly context?: SourceControlProvider.SourceControlProviderContext; }) => Effect.Effect; readonly resolve: (input: { readonly cwd: string; @@ -254,7 +255,15 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit }); const resolveHandle: SourceControlProviderRegistry["Service"]["resolveHandle"] = (input) => - Cache.get(providerContextCache, input.cwd).pipe( + (input.context === undefined + ? Cache.get(providerContextCache, input.cwd) + : refineUnknownRemoteProvider({ + specs: discoverySpecs, + process, + cwd: input.cwd, + context: input.context, + }) + ).pipe( Effect.map((context) => { const kind = context?.provider.kind ?? "unknown"; const provider = providers.get(kind) ?? unsupportedProvider(kind); From 752acbf6549e6dd422ac8d8ed11c0fa02577965b Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:26:53 +0530 Subject: [PATCH 08/28] feat: add ability to create a new thread in the current project with shift+click and show shortcut in tooltip (#5994) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- apps/web/src/components/Sidebar.logic.test.ts | 16 +++++ apps/web/src/components/Sidebar.logic.ts | 11 +++ apps/web/src/components/Sidebar.tsx | 67 +++++++++++++------ 3 files changed, 74 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index bfe4162cd205..94e78c0216e2 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -32,6 +32,7 @@ import { sortThreadsForSidebar, sortProjectsForSidebar, sortScopedProjectsForSidebar, + shouldCreateNewThreadInCurrentProject, THREAD_JUMP_HINT_SHOW_DELAY_MS, } from "./Sidebar.logic"; import { @@ -410,6 +411,21 @@ describe("isTrailingDoubleClick", () => { }); }); +describe("shouldCreateNewThreadInCurrentProject", () => { + it("creates directly on shift+click in a multi-project setup", () => { + expect(shouldCreateNewThreadInCurrentProject(true, 2)).toBe(true); + }); + + it("opens the picker on a plain click in a multi-project setup", () => { + expect(shouldCreateNewThreadInCurrentProject(false, 2)).toBe(false); + }); + + it("creates directly on any click with a single project", () => { + expect(shouldCreateNewThreadInCurrentProject(false, 1)).toBe(true); + expect(shouldCreateNewThreadInCurrentProject(true, 1)).toBe(true); + }); +}); + describe("orderItemsByPreferredIds", () => { it("keeps preferred ids first, skips stale ids, and preserves the relative order of remaining items", () => { const ordered = orderItemsByPreferredIds({ diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index cae26f5d6bde..9b1db5cb4d9d 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -274,6 +274,17 @@ export function isTrailingDoubleClick(detail: number): boolean { return detail > 1; } +// Shift+click on the new thread button creates directly in the current +// project, skipping the command palette's project picker. With a single +// project there is nothing to pick, so a plain click already creates +// immediately and the modifier changes nothing. +export function shouldCreateNewThreadInCurrentProject( + shiftKey: boolean, + projectGroupCount: number, +): boolean { + return shiftKey || projectGroupCount <= 1; +} + export function orderItemsByPreferredIds(input: { items: readonly TItem[]; preferredIds: readonly TId[]; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 76d678cb35cc..41e964a7e1b6 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -131,6 +131,7 @@ import { resolveSettledTimestamp, resolveSidebarThreadStatus, searchSidebarThreadsByTitle, + shouldCreateNewThreadInCurrentProject, resolveWorkingStartedAt, sortLogicalProjectsForSidebar, sortPinnedThreadsForSidebar, @@ -3164,29 +3165,39 @@ export default function Sidebar() { // falling back to the top project) — same resolution the command palette // uses. The command palette already offers a "New thread in..." submenu // for multi-project setups. - const handleNewThreadClick = useCallback(() => { - // One project: nothing to pick, create immediately. - if (projectGroups.length <= 1) { + const handleNewThreadClick = useCallback( + (event?: ReactMouseEvent) => { + // One project: nothing to pick, create immediately. Shift+click creates + // directly in the current project even with several projects, skipping + // the palette picker. + if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) { + if (isMobile) setOpenMobile(false); + void startNewThreadFromContext({ + activeDraftThread: newThreadContext.activeDraftThread, + activeThread: newThreadContext.activeThread ?? undefined, + defaultProjectRef: newThreadContext.defaultProjectRef, + handleNewThread: newThreadContext.handleNewThread, + }); + return; + } if (isMobile) setOpenMobile(false); - void startNewThreadFromContext({ - activeDraftThread: newThreadContext.activeDraftThread, - activeThread: newThreadContext.activeThread ?? undefined, - defaultProjectRef: newThreadContext.defaultProjectRef, - handleNewThread: newThreadContext.handleNewThread, - }); - return; - } - if (isMobile) setOpenMobile(false); - openCommandPalette({ open: "new-thread-in" }); - }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]); + openCommandPalette({ open: "new-thread-in" }); + }, + [isMobile, newThreadContext, projectGroups.length, setOpenMobile], + ); // The button mirrors chat.new: in multi-project setups both route through // the command palette's "New thread in..." picker, and in single-project - // setups both create immediately. chat.newLocal always creates directly, so - // it is only a correct label when chat.new is unbound. + // setups both create immediately. In multi-project setups the label is only + // the picker's shortcut: falling back to chat.newLocal would advertise the + // same shortcut for both the picker and direct create. In single-project + // setups both commands create directly, so chat.newLocal is a valid + // fallback. The second tooltip line (multi-project only) advertises + // shift+click and its keyboard twin chat.newLocal for direct create. const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.new") ?? - shortcutLabelForCommand(keybindings, "chat.newLocal"); + (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); + const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( <> @@ -3264,9 +3275,25 @@ export default function Sidebar() { /> - {newThreadShortcutLabel - ? `New thread (${newThreadShortcutLabel})` - : "New thread"} + {projectGroups.length > 1 ? ( + + + {newThreadShortcutLabel + ? `New thread (${newThreadShortcutLabel})` + : "New thread"} + + + New thread in current project: Shift+click + {newThreadInProjectShortcutLabel + ? ` (${newThreadInProjectShortcutLabel})` + : ""} + + + ) : newThreadShortcutLabel ? ( + `New thread (${newThreadShortcutLabel})` + ) : ( + "New thread" + )} From 65b005f1e4bfccb6a404b3b1e5bfa363d534ac2a Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:32:02 +0530 Subject: [PATCH 09/28] feat(web): add Copy Thread ID to the sidebar and chat header thread context menu (#5574) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- apps/web/src/components/Sidebar.tsx | 24 ++++++++++++++++++- .../components/threadActionMenu.logic.test.ts | 2 +- .../src/components/threadActionMenu.logic.ts | 2 ++ apps/web/src/hooks/useThreadActionMenu.ts | 12 +++++++++- 4 files changed, 37 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 41e964a7e1b6..840e4918bfa7 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -29,7 +29,7 @@ import { scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import type { TimestampFormat } from "@t3tools/contracts/settings"; import { AlarmClockIcon, @@ -1647,6 +1647,24 @@ export default function Sidebar() { ); }, }); + const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({ + onCopy: ({ threadId }) => { + toastManager.add({ + type: "success", + title: "Thread ID copied", + description: threadId, + }); + }, + onError: (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to copy thread ID", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, + }); const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false); const newThreadContext = useHandleNewThread(); const openAddProjectCommandPalette = useCallback( @@ -3037,6 +3055,9 @@ export default function Sidebar() { copyBranchToClipboard(thread.branch, { branch: thread.branch }); } return; + case "copy-thread-id": + copyThreadIdToClipboard(thread.id, { threadId: thread.id }); + return; case "delete": { if (confirmThreadDelete) { const confirmed = await settlePromise(() => @@ -3079,6 +3100,7 @@ export default function Sidebar() { confirmThreadDelete, copyBranchToClipboard, copyPathToClipboard, + copyThreadIdToClipboard, deleteThread, handleMultiSelectContextMenu, markThreadUnread, diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index a450b29f2266..93dc653e7c0a 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -26,7 +26,7 @@ describe("buildThreadActionMenuItems", () => { ...baseState, supports: { settlement: false, snooze: false, pinning: false, titleRegeneration: false }, }), - ).toEqual(["rename", "mark-unread", "copy-path", "delete"]); + ).toEqual(["rename", "mark-unread", "copy-path", "copy-thread-id", "delete"]); }); it("includes branch items only for threads with a branch", () => { diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index 66aaf3debf54..ef4b38dcdacd 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -20,6 +20,7 @@ export type ThreadActionMenuId = | "mark-unread" | "copy-path" | "copy-branch" + | "copy-thread-id" | "delete"; export interface ThreadActionMenuState { @@ -100,6 +101,7 @@ export function buildThreadActionMenuItems( { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy path", icon: "copy" }, ...(state.branch ? [{ id: "copy-branch" as const, label: "Copy branch", icon: "copy" }] : []), + { id: "copy-thread-id", label: "Copy thread ID", icon: "copy" }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, ]; } diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 24efe4ea1963..4eac13fddb30 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -11,7 +11,7 @@ import { effectiveSnoozed, type ChangeRequestStateLike, } from "@t3tools/client-runtime/state/thread-settled"; -import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { useCallback } from "react"; import { resolveSnoozePresets, snoozeWakeDescription } from "../components/Sidebar.snooze"; @@ -95,6 +95,12 @@ export function useThreadActionMenu(input: { }, onError: (error) => failureToast("Failed to copy branch", error), }); + const { copyToClipboard: copyThreadIdToClipboard } = useCopyToClipboard<{ threadId: ThreadId }>({ + onCopy: ({ threadId }) => { + toastManager.add({ type: "success", title: "Thread ID copied", description: threadId }); + }, + onError: (error) => failureToast("Failed to copy thread ID", error), + }); const openMenu = useCallback( (position: { x: number; y: number }) => { @@ -242,6 +248,9 @@ export function useThreadActionMenu(input: { copyBranchToClipboard(thread.branch, { branch: thread.branch }); } return; + case "copy-thread-id": + copyThreadIdToClipboard(thread.id, { threadId: thread.id }); + return; case "delete": { if (confirmThreadDelete) { const confirmed = await settlePromise(() => @@ -279,6 +288,7 @@ export function useThreadActionMenu(input: { confirmThreadDelete, copyBranchToClipboard, copyPathToClipboard, + copyThreadIdToClipboard, deleteThread, handleNewThread, markThreadUnread, From 6676f9c83b6a6dd1e2076eaa68dff48845ad955b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 11 Aug 2026 16:05:03 +0200 Subject: [PATCH 10/28] fix(mobile): stabilize thread composer and interactions (#5986) Co-authored-by: codex Co-authored-by: Thuong Tin Co-authored-by: Kapish14 Co-authored-by: Claude Fable 5 --- apps/mobile/package.json | 2 +- apps/mobile/src/Stack.tsx | 33 +- apps/mobile/src/components/ControlPill.tsx | 27 +- .../archive/ArchivedThreadsRouteScreen.tsx | 5 +- .../cloud/ClerkSettingsSheetDetent.tsx | 44 --- .../cloud/connectOnboardingNavigation.ts | 4 +- .../src/features/home/HomeRouteScreen.tsx | 29 +- .../layout/AdaptiveWorkspaceLayout.tsx | 10 +- .../settings/SettingsAuthRouteScreen.tsx | 33 +- .../SettingsEnvironmentsRouteScreen.tsx | 10 +- .../features/settings/SettingsRouteScreen.tsx | 19 +- .../settings/components/SettingsRow.tsx | 3 +- .../showcase/ShowcaseCaptureCoordinator.tsx | 28 +- .../features/threads/NewTaskDraftScreen.tsx | 27 +- .../features/threads/PendingUserInputCard.tsx | 368 ++++++++++++++---- .../src/features/threads/ThreadComposer.tsx | 107 ++++- .../features/threads/ThreadDetailScreen.tsx | 368 ++++++++++++++++-- .../src/features/threads/ThreadFeed.tsx | 131 +++++-- .../features/threads/ThreadSettingsSheet.tsx | 28 +- .../threads/pendingUserInputLayout.test.ts | 38 ++ .../threads/pendingUserInputLayout.ts | 37 ++ .../threads/thread-feed-live-follow.test.ts | 70 ++++ .../threads/thread-feed-live-follow.ts | 35 ++ .../threads/thread-settings-menu.test.ts | 284 ++++++++++++++ .../features/threads/thread-settings-menu.ts | 202 ++++++++++ .../src/features/updates/app-updates.test.ts | 39 ++ .../src/features/updates/app-updates.ts | 23 +- apps/mobile/src/lib/threadActivity.test.ts | 95 +++++ apps/mobile/src/lib/threadActivity.ts | 76 +++- .../src/state/use-selected-thread-requests.ts | 25 +- apps/web/package.json | 2 +- patches/@clerk__expo@4.2.0.patch | 79 ++++ ...3.3.patch => @legendapp__list@3.3.5.patch} | 184 ++++----- patches/@react-native-menu__menu@2.0.0.patch | 227 ++++++++++- patches/react-native-screens@4.25.2.patch | 229 ++++++++--- pnpm-lock.yaml | 62 +-- pnpm-workspace.yaml | 5 +- 37 files changed, 2489 insertions(+), 499 deletions(-) delete mode 100644 apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx create mode 100644 apps/mobile/src/features/threads/pendingUserInputLayout.test.ts create mode 100644 apps/mobile/src/features/threads/pendingUserInputLayout.ts create mode 100644 apps/mobile/src/features/threads/thread-feed-live-follow.test.ts create mode 100644 apps/mobile/src/features/threads/thread-feed-live-follow.ts create mode 100644 apps/mobile/src/features/threads/thread-settings-menu.test.ts create mode 100644 apps/mobile/src/features/threads/thread-settings-menu.ts create mode 100644 patches/@clerk__expo@4.2.0.patch rename patches/{@legendapp__list@3.3.3.patch => @legendapp__list@3.3.5.patch} (87%) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 8b6834c9714b..de53a37c995b 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -49,7 +49,7 @@ "@expo-google-fonts/dm-sans": "^0.4.2", "@expo/metro-runtime": "~56.0.15", "@expo/ui": "~56.0.18", - "@legendapp/list": "3.3.3", + "@legendapp/list": "catalog:", "@noble/curves": "catalog:", "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index da1be88a8bdb..93bb6165524c 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -18,7 +18,6 @@ import { AppText as Text } from "./components/AppText"; import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; -import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen"; import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation"; import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen"; @@ -134,7 +133,7 @@ const LEGAL_DOCUMENT_HEADER_OPTIONS: AppScreenOptions = { presentation: "fullScreenModal", }; -const SettingsSheetStack = createNativeStackNavigator({ +const SettingsContentStack = createNativeStackNavigator({ initialRouteName: "Settings", screenOptions: { ...GLASS_HEADER_OPTIONS, @@ -198,20 +197,30 @@ const SettingsSheetStack = createNativeStackNavigator({ title: "Usage", }, }), + }, +}); + +// The outer stack never owns visible chrome. Settings routes render inside a +// nested stack whose native header remains mounted, while Clerk owns auth chrome. +// Keeping bar visibility invariant avoids iOS 26's headerless-to-headered jump. +const SettingsSheetStack = createNativeStackNavigator({ + initialRouteName: "SettingsContent", + screenOptions: { + headerShown: false, + }, + screens: { + SettingsContent: createNativeStackScreen({ + screen: SettingsContentStack, + linking: "", + }), SettingsAuth: createNativeStackScreen({ screen: SettingsAuthRouteScreen, linking: "auth", - options: { - title: "Sign in", - }, }), SettingsWaitlist: createNativeStackScreen({ // Keep the old deep link working after the Connect GA launch. screen: SettingsAuthRouteScreen, linking: "waitlist", - options: { - title: "Sign in", - }, }), }, }); @@ -347,11 +356,9 @@ function RootStackLayout(props: { - - - {props.children} - - + + {props.children} + ); } diff --git a/apps/mobile/src/components/ControlPill.tsx b/apps/mobile/src/components/ControlPill.tsx index 587abcc06f5a..abcfc7f7b803 100644 --- a/apps/mobile/src/components/ControlPill.tsx +++ b/apps/mobile/src/components/ControlPill.tsx @@ -6,6 +6,7 @@ import { type ComponentProps, type ReactElement, type ReactNode, + useRef, } from "react"; import { Platform, Pressable, useColorScheme, View } from "react-native"; import { useThemeColor } from "../lib/useThemeColor"; @@ -21,10 +22,31 @@ export function ControlPill(props: { readonly label?: string; readonly accessibilityLabel?: string; readonly onPress?: () => void; + readonly activateOnPressIn?: boolean; readonly variant?: "circle" | "pill" | "primary" | "danger"; readonly disabled?: boolean; + readonly className?: string; }) { const variant = props.variant ?? "circle"; + const activatedOnPressInRef = useRef(false); + + const handlePressIn = () => { + activatedOnPressInRef.current = true; + props.onPress?.(); + }; + const handlePressOut = () => { + // Pressability invokes onPressOut immediately before onPress on release. + // Defer the reset so onPress can identify the same physical gesture. + setTimeout(() => { + activatedOnPressInRef.current = false; + }, 0); + }; + const handlePress = () => { + if (activatedOnPressInRef.current) { + return; + } + props.onPress?.(); + }; const iconColor = useThemeColor("--color-icon"); const iconSubtle = useThemeColor("--color-icon-subtle"); @@ -54,6 +76,7 @@ export function ControlPill(props: { : variant === "danger" ? "bg-danger" : "bg-subtle", + props.className, ); const labelClassName = cn( "text-center text-xs font-t3-bold", @@ -68,7 +91,9 @@ export function ControlPill(props: { diff --git a/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx index c2381ef25805..9ad4790faab1 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsRouteScreen.tsx @@ -5,7 +5,6 @@ import { useFocusEffect } from "@react-navigation/native"; import { useCallback, useMemo, useState } from "react"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; -import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; import { useArchivedThreadListActions } from "../home/useThreadListActions"; import { ArchivedThreadsScreen, @@ -18,7 +17,6 @@ import { } from "./useArchivedThreadSnapshots"; export function ArchivedThreadsRouteScreen() { - const { expand } = useClerkSettingsSheetDetent(); const { savedConnectionsById } = useSavedRemoteConnections(); const [searchQuery, setSearchQuery] = useState(""); const [selectedEnvironmentId, setSelectedEnvironmentId] = useState(null); @@ -70,9 +68,8 @@ export function ArchivedThreadsRouteScreen() { useFocusEffect( useCallback(() => { - expand(); refresh(); - }, [expand, refresh]), + }, [refresh]), ); return ( diff --git a/apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx b/apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx deleted file mode 100644 index 8bd51b8518d8..000000000000 --- a/apps/mobile/src/features/cloud/ClerkSettingsSheetDetent.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { - createContext, - type PropsWithChildren, - useCallback, - useContext, - useMemo, - useState, -} from "react"; - -interface ClerkSettingsSheetDetentValue { - collapse: () => void; - expand: () => void; - isExpanded: boolean; -} - -const ClerkSettingsSheetDetentContext = createContext(null); - -interface ClerkSettingsSheetDetentProviderProps extends PropsWithChildren { - initiallyExpanded: boolean; -} - -export function ClerkSettingsSheetDetentProvider({ - children, - initiallyExpanded, -}: ClerkSettingsSheetDetentProviderProps) { - const [isExpanded, setIsExpanded] = useState(initiallyExpanded); - const collapse = useCallback(() => setIsExpanded(false), []); - const expand = useCallback(() => setIsExpanded(true), []); - const value = useMemo(() => ({ collapse, expand, isExpanded }), [collapse, expand, isExpanded]); - - return ( - {children} - ); -} - -export function useClerkSettingsSheetDetent(): ClerkSettingsSheetDetentValue { - const value = useContext(ClerkSettingsSheetDetentContext); - if (!value) { - throw new Error( - "useClerkSettingsSheetDetent must be used inside ClerkSettingsSheetDetentProvider", - ); - } - return value; -} diff --git a/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts b/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts index f937453e525a..5c75df80cd3b 100644 --- a/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts +++ b/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts @@ -6,8 +6,8 @@ import { appAtomRegistry } from "../../state/atom-registry"; import { clearConnectOnboardingRequest, connectOnboardingRequestAtom } from "./connectOnboarding"; import { isConnectOnboardingOptedOut } from "./connectOnboardingOptOut"; -// Sign-in happens inside the Settings sheet; give its detent/session-state -// transitions a beat to settle before presenting another formSheet on top. +// Sign-in happens inside the Settings sheet; give its session-state transition +// a beat to settle before presenting another formSheet on top. const PRESENT_ONBOARDING_DELAY_MS = 600; /** diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 7760920f7dbd..d67446a61b42 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -134,7 +134,10 @@ export function HomeRouteScreen() { - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }), + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironments" }, + }), })} /> - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironments" }, + }) + } + onOpenSettings={() => + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "Settings" }, + }) } - onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} @@ -161,7 +172,10 @@ export function HomeRouteScreen() { catalogState={catalogState} environments={environments} onAddConnection={() => - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }) + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironmentNew" }, + }) } onArchiveThread={archiveThread} onDeleteThread={confirmDeleteThread} @@ -174,7 +188,12 @@ export function HomeRouteScreen() { onMovePinnedThread={movePinnedThread} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} - onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} + onOpenSettings={() => + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "Settings" }, + }) + } onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} onSelectThread={(thread) => { diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index a93268d0da6d..e00433de0ed9 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -429,13 +429,19 @@ function AdaptiveWorkspaceLayoutContent( ); const handleOpenSettings = useCallback(() => { - navigation.navigate("SettingsSheet", { screen: "Settings" }); + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "Settings" }, + }); }, [navigation]); // Minted here (root stack navigation) so the sidebar pane stays free of // navigation hooks — on iOS it renders inside an independent nav tree. const handleOpenEnvironmentSettings = useCallback(() => { - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }); + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironments" }, + }); }, [navigation]); const handleNewThreadInProject = useCallback( diff --git a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx index e4efdf70c317..5bc10af0a405 100644 --- a/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsAuthRouteScreen.tsx @@ -1,8 +1,7 @@ import { useAuth } from "@clerk/expo"; import { AuthView, UserProfileView } from "@clerk/expo/native"; import { StackActions, useNavigation } from "@react-navigation/native"; -import { NativeStackScreenOptions } from "../../native/StackHeader"; -import { useCallback, useEffect } from "react"; +import { useCallback, useLayoutEffect } from "react"; import { View } from "react-native"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; @@ -10,9 +9,9 @@ import { hasCloudPublicConfig } from "../cloud/publicConfig"; export function SettingsAuthRouteScreen() { const navigation = useNavigation(); - useEffect(() => { + useLayoutEffect(() => { if (!hasCloudPublicConfig()) { - navigation.dispatch(StackActions.replace("Settings")); + navigation.dispatch(StackActions.replace("SettingsContent")); } }, [navigation]); @@ -22,20 +21,20 @@ export function SettingsAuthRouteScreen() { function ConfiguredSettingsAuthRouteScreen() { const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const navigation = useNavigation(); - const handleHostBack = useCallback(() => navigation.goBack(), [navigation]); + const handleHostBack = useCallback( + () => navigation.dispatch(StackActions.popTo("SettingsContent")), + [navigation], + ); return ( - <> - - - {isLoaded ? ( - isSignedIn ? ( - - ) : ( - - ) - ) : null} - - + + {isLoaded ? ( + isSignedIn ? ( + + ) : ( + + ) + ) : null} + ); } diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index 53bbe4806462..aa30242ea72a 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -98,7 +98,10 @@ export function SettingsEnvironmentsRouteScreen() { accessibilityLabel: "Add environment", icon: "plus", onPress: () => - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }), + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironmentNew" }, + }), }, ]} /> @@ -108,7 +111,10 @@ export function SettingsEnvironmentsRouteScreen() { - navigation.navigate("SettingsSheet", { screen: "SettingsEnvironmentNew" }) + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { screen: "SettingsEnvironmentNew" }, + }) } separateBackground tintColor={headerIconColor} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 90e5af199dec..4fb4b1a97a5a 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -2,7 +2,6 @@ import { useAuth, useUser } from "@clerk/expo"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import Constants from "expo-constants"; import * as Notifications from "expo-notifications"; -import * as Updates from "expo-updates"; import { useNavigation } from "@react-navigation/native"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { SymbolView } from "../../components/AppSymbol"; @@ -30,7 +29,6 @@ import { subscribeAgentAwarenessRegistrationStatus, } from "../agent-awareness/remoteRegistration"; import { refreshManagedRelayEnvironments } from "../cloud/managedRelayState"; -import { useClerkSettingsSheetDetent } from "../cloud/ClerkSettingsSheetDetent"; import { hasCloudPublicConfig, resolveRelayClerkTokenOptions } from "../cloud/publicConfig"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; @@ -40,6 +38,7 @@ import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/ import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; import { type AppUpdateCheckState, + isAppUpdateCheckAvailable, registerHiddenUpdateTap, runAppUpdateCheck, } from "../updates/app-updates"; @@ -147,7 +146,6 @@ function ConfiguredSettingsRouteScreen() { const agentAwarenessPushAvailable = supportsAgentAwarenessPush(); const insets = useSafeAreaInsets(); const navigation = useNavigation(); - const { expand: expandClerkSheet } = useClerkSettingsSheetDetent(); const { getToken, isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { user } = useUser(); const { savedConnectionsById } = useSavedRemoteConnections(); @@ -436,14 +434,8 @@ function ConfiguredSettingsRouteScreen() { const openAccount = useCallback(() => { if (!isLoaded) return; - if (!isSignedIn) { - expandClerkSheet(); - navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); - return; - } - expandClerkSheet(); navigation.navigate("SettingsSheet", { screen: "SettingsAuth" }); - }, [expandClerkSheet, isLoaded, isSignedIn, navigation]); + }, [isLoaded, navigation]); return ( @@ -577,6 +569,7 @@ function AppSettingsSection() { const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production"; const variantLabel = variant === "production" ? "" : capitalize(variant); const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version; + const updateCheckAvailable = isAppUpdateCheckAvailable(); const busy = updateState === "checking" || updateState === "downloading" || updateState === "restarting"; @@ -604,13 +597,13 @@ function AppSettingsSection() { }, []); const handleVersionPress = useCallback(() => { - if (!Updates.isEnabled || updateInFlight.current) return; + if (!updateCheckAvailable || updateInFlight.current) return; const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current); hiddenUpdateTapCount.current = tap.nextCount; if (tap.shouldCheck) { void checkForUpdate(); } - }, [checkForUpdate]); + }, [checkForUpdate, updateCheckAvailable]); const statusLabel = updateState === "checking" @@ -646,7 +639,7 @@ function AppSettingsSection() { - {Updates.isEnabled ? ( + {updateCheckAvailable ? ( navigation.navigate("SettingsSheet", { - screen: target, + screen: "SettingsContent", + params: { screen: target }, }) } > diff --git a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx index ffeca9671b75..424822c35eb0 100644 --- a/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx +++ b/apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx @@ -1,6 +1,12 @@ import { useEffect, useRef, useState } from "react"; import { Keyboard, View } from "react-native"; -import { CommonActions, StackActions, useNavigation } from "@react-navigation/native"; +import { + CommonActions, + type NavigationState, + type PartialState, + StackActions, + useNavigation, +} from "@react-navigation/native"; import { AsyncResult } from "effect/unstable/reactivity"; import { useConnectionController } from "../connection/useConnectionController"; @@ -25,6 +31,8 @@ import { retryShowcaseOperation } from "./showcaseRetry"; const SHOWCASE_ENABLED = process.env.EXPO_PUBLIC_SHOWCASE === "1"; const SHOWCASE_THREAD_ID = "remote-command-center"; +type ShowcaseResetRoute = PartialState["routes"][number]; + function sceneFromPathname(pathname: string): ShowcaseScene | null { const routePath = pathname.split(/[?#]/u, 1)[0] ?? pathname; if (routePath === "/settings" || routePath.endsWith("/settings/environments")) { @@ -166,17 +174,21 @@ export function ShowcaseCaptureCoordinator(props: { readonly pathname: string }) navigation.dispatch(StackActions.popToTop()); return; } - const routes: Array<{ - name: string; - params?: Record; - state?: { index: number; routes: Array<{ name: string }> }; - }> = [{ name: "Home" }]; + const routes: ShowcaseResetRoute[] = [{ name: "Home" }]; if (requestedScene === "environments") { routes.push({ name: "SettingsSheet", state: { - index: 1, - routes: [{ name: "Settings" }, { name: "SettingsEnvironments" }], + index: 0, + routes: [ + { + name: "SettingsContent", + state: { + index: 1, + routes: [{ name: "Settings" }, { name: "SettingsEnvironments" }], + }, + }, + ], }, }); } else { diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index bf2dfa8f4d44..1ece23ca0551 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -2,7 +2,11 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { StackActions, useNavigation, usePreventRemove } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Alert, InteractionManager, Platform, View, useColorScheme } from "react-native"; -import { KeyboardAvoidingView, useKeyboardState } from "react-native-keyboard-controller"; +import { + KeyboardAvoidingView, + KeyboardStickyView, + useKeyboardState, +} from "react-native-keyboard-controller"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; import { useFontFamily } from "../../lib/useFontFamily"; @@ -985,14 +989,29 @@ export function NewTaskDraftScreen(props: { // The draft is a thread that doesn't exist yet, so it mirrors the thread // page: in-screen header, empty feed canvas above, and the same floating // composer chrome as ThreadComposer (collapsed pill → expanded card). + // + // Composer positioning mirrors ThreadDetailScreen's floating overlay + // (KeyboardStickyView, absolute bottom overlay) rather than + // KeyboardAvoidingView's automaticOffset+padding: automaticOffset + // resolves the composer's on-screen frame via a native + // viewPositionInWindow measurement, which this app's Android + // edge-to-edge setup (KeyboardProvider's native content-view margin + // handling neutralizes windowSoftInputMode="adjustResize" while active) + // makes unreliable — the composer stayed under the keyboard instead of + // translating above it. KeyboardStickyView sticks directly to the + // animated keyboard height instead, sidestepping that measurement. return ( navigation.goBack()} /> - - + + ) : null} - + {settingsSheet} ); diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index c3c9b4e7ce83..ddb625f9b219 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -1,18 +1,64 @@ -import type { ApprovalRequestId } from "@t3tools/contracts"; -import { Pressable, View } from "react-native"; +import type { ApprovalRequestId, UserInputQuestion } from "@t3tools/contracts"; +import { useCallback, useRef } from "react"; +import { Platform, Pressable, ScrollView, View, type LayoutChangeEvent } from "react-native"; +import Animated, { + Easing, + FadeInUp, + FadeOutDown, + LinearTransition, + useAnimatedStyle, + useSharedValue, + withTiming, + type SharedValue, +} from "react-native-reanimated"; +import { USER_INPUT_TOGGLE_DURATION_MS } from "./pendingUserInputLayout"; + +import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text, AppTextInput as TextInput } from "../../components/AppText"; +import { ControlPill } from "../../components/ControlPill"; import { cn } from "../../lib/cn"; -import type { PendingUserInput, PendingUserInputDraftAnswer } from "../../lib/threadActivity"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { + isPendingUserInputOptionSelected, + type PendingUserInput, + type PendingUserInputDraftAnswer, +} from "../../lib/threadActivity"; export interface PendingUserInputCardProps { readonly pendingUserInput: PendingUserInput; + /** + * Constant while a request is pending (it reserves keyboard space), so the + * keyboard transition is pure translation; changes only on rare discrete + * corrections, which the layout transition smooths. + */ + readonly maxHeight: number; + readonly collapsed: boolean; + readonly onToggleCollapsed: () => void; + /** Renders a stop control on the collapsed bar, which replaces the composer. */ + readonly onStopThread?: () => void; + /** + * 0 collapsed → 1 expanded. Slides the iOS overlay card down behind the + * collapsed bar (inside a clipping window) on the UI thread; the host + * animates it directly from the tap handler so the card and the feed + * inset glide start the same frame. + */ + readonly cardProgress?: SharedValue; + /** + * Receives how far the expanded card extends above the bar footprint + * (written from onLayout with no re-render); the host adds it to the + * thread feed's end inset so the end of the chat stays visible above the + * card. + */ + readonly cardCoverage?: SharedValue; + /** Fires on custom-answer focus/blur; hosts use it to vet stale keyboard state. */ + readonly onInputFocusChange?: (focused: boolean) => void; readonly drafts: Record; - readonly answers: Record | null; + readonly answers: Record> | null; readonly respondingUserInputId: ApprovalRequestId | null; readonly onSelectOption: ( requestId: ApprovalRequestId, - questionId: string, + question: UserInputQuestion, label: string, ) => void; readonly onChangeCustomAnswer: ( @@ -23,74 +69,242 @@ export interface PendingUserInputCardProps { readonly onSubmit: () => Promise; } +/** + * On iOS the collapsed bar is the PERMANENT in-flow footprint — the expanded + * card is an absolutely-positioned overlay rising above it. The overlay's + * measured height (which drives the thread feed's bottom inset) therefore + * never changes on collapse/expand, so the transcript stays perfectly still + * while the card animates over it. + * + * Android cannot use the overlay: it does not hit-test touches outside a + * parent's bounds, which made everything above the bar-sized wrapper + * untouchable. There the expanded card renders in-flow instead (the wrapper + * grows with it, and the host skips the coverage inset since the measured + * overlay already includes the card). + */ +const EXPANDED_CARD_IS_OVERLAY = Platform.OS === "ios"; + +const CARD_LAYOUT_TRANSITION = LinearTransition.duration(200); + export function PendingUserInputCard(props: PendingUserInputCardProps) { - // The surface is opaque on purpose: the card floats over the thread feed - // with no blur behind it, so a translucent background renders the questions - // on top of whatever message happens to sit underneath. - return ( - - - User input needed - - - Fill in the pending answers - - {props.pendingUserInput.questions.map((question) => { - const draft = props.drafts[question.id]; - return ( - - - {question.header} - - - {question.question} - - - {question.options.map((option) => { - const selected = - draft?.selectedOptionLabel === option.label && !draft.customAnswer?.trim().length; - return ( - - props.onSelectOption( - props.pendingUserInput.requestId, - question.id, - option.label, - ) - } - > - { + if (!cardCoverage) { + return; + } + const coverage = Math.max(0, cardHeightRef.current - barHeightRef.current); + if (coverage === cardCoverage.value) { + return; + } + if (cardCoverage.value === 0) { + // First measurement lands while the list is doing its initial + // end-pin (thread opened onto a pending request); animating it from + // zero would move the end anchor out from under that scroll. + cardCoverage.value = coverage; + return; + } + // Animated so a coverage change at rest (discrete max-height + // corrections) glides the feed instead of stepping it; toggle timing is + // owned by the host's progress values. + cardCoverage.value = withTiming(coverage, { + duration: USER_INPUT_TOGGLE_DURATION_MS, + easing: Easing.out(Easing.cubic), + }); + }, [cardCoverage]); + const handleBarLayout = useCallback( + (event: LayoutChangeEvent) => { + barHeightRef.current = event.nativeEvent.layout.height; + notifyCoverage(); + }, + [notifyCoverage], + ); + const handleCardLayout = useCallback( + (event: LayoutChangeEvent) => { + cardHeightRef.current = event.nativeEvent.layout.height; + cardHeight.value = event.nativeEvent.layout.height; + notifyCoverage(); + }, + [cardHeight, notifyCoverage], + ); + const cardProgress = props.cardProgress; + // No opacity: fading an opaque card over the live transcript reads as a + // crossfade (card text, transcript, and bar all half-visible at once). + // Instead the card stays opaque and slides its full height down past the + // clipping window's bottom edge, so the transcript is only revealed where + // the card has physically left. + const cardAnimatedStyle = useAnimatedStyle(() => { + const progress = cardProgress === undefined ? 1 : cardProgress.value; + return { + transform: [{ translateY: (1 - progress) * cardHeight.value }], + }; + }); + + // On iOS the card stays MOUNTED while collapsed (hidden via the animated + // style): expanding animates existing views on the UI thread the same + // frame the host starts the progress timing, instead of paying a React + // mount + layout before anything moves. + const renderCard = EXPANDED_CARD_IS_OVERLAY || !props.collapsed; + const showBar = props.collapsed || EXPANDED_CARD_IS_OVERLAY; + // The bar renders UNDER the card (earlier in JSX), always opaque: while + // expanded the opaque card covers it, and during the collapse slide the + // card's top edge wipes past and reveals it — no opacity handoff, so no + // crossfade frames. + const bar = showBar ? ( + + + + User input needed + + + {questionCount} question{questionCount === 1 ? "" : "s"} + + + + + {props.onStopThread ? ( + + ) : null} + + ) : null; + const card = renderCard ? ( + // The surface is opaque on purpose: the card floats over the thread + // feed with no blur behind it, so a translucent background renders + // the questions on top of whatever message happens to sit underneath. + + + + + User input needed + + + Fill in the pending answers + + + + + + + + {props.pendingUserInput.questions.map((question) => { + const draft = props.drafts[question.id]; + return ( + + + {question.header} + + + {question.question} + + + {question.options.map((option) => { + const selected = isPendingUserInputOptionSelected(draft, option.label); + return ( + + props.onSelectOption( + props.pendingUserInput.requestId, + question, + option.label, + ) + } > - {option.label} - - - ); - })} + + {option.label} + + + ); + })} + + + props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) + } + onFocus={() => props.onInputFocusChange?.(true)} + onBlur={() => props.onInputFocusChange?.(false)} + placeholder="Or type a custom answer" + className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-base text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50" + /> - - props.onChangeCustomAnswer(props.pendingUserInput.requestId, question.id, value) - } - placeholder="Or type a custom answer" - className="min-h-[54px] rounded-2xl border border-neutral-200 bg-white px-3.5 py-3 font-sans text-base text-neutral-950 dark:border-white/8 dark:bg-neutral-950/70 dark:text-neutral-50" - /> - - ); - })} + ); + })} + Submit answers + + ) : null; + return ( + + {bar} + {EXPANDED_CARD_IS_OVERLAY ? ( + // Clipping window for the collapse slide: same footprint as the + // expanded card, bottom edge on the bar's bottom edge. The sliding + // card exits through the bottom edge instead of drawing over the + // composer area, wiping the bar (and the transcript) into view. + + {card} + + ) : ( + card + )} ); } diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index c846dca287a7..6ce42aeb148d 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -14,6 +14,7 @@ import { serializeComposerFileLink, type ComposerTrigger, } from "@t3tools/shared/composerTrigger"; +import * as Haptics from "expo-haptics"; import type { ReactNode } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { @@ -51,7 +52,7 @@ import { ComposerToolbarScroller, ComposerToolbarTrigger, } from "../../components/ComposerToolbarTrigger"; -import { ControlPill } from "../../components/ControlPill"; +import { ControlPill, ControlPillMenu } from "../../components/ControlPill"; import { ProviderIcon } from "../../components/ProviderIcon"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; @@ -62,9 +63,13 @@ import { normalizeSearchQuery, scoreQueryMatch, } from "@t3tools/shared/searchRanking"; -import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; +import { + applyProviderOptionSelection, + resolveProviderOptionDescriptors, +} from "../../lib/providerOptions"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; +import { buildThreadSettingsMenu } from "./thread-settings-menu"; import { ThreadSettingsSheet, threadSettingsSummaryLabel } from "./ThreadSettingsSheet"; import { useThreadSettingsSheetPresentation } from "./use-thread-settings-sheet-presentation"; @@ -113,6 +118,8 @@ export interface ThreadComposerProps { readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; readonly onReconnectEnvironment: () => void; readonly onExpandedChange?: (expanded: boolean) => void; + /** Fires on editor focus/blur; hosts use it to vet stale keyboard state. */ + readonly onEditorFocusChange?: (focused: boolean) => void; } /** @@ -307,13 +314,16 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } }, [inputRef]); + const onEditorFocusChange = props.onEditorFocusChange; const handleFocus = useCallback(() => { setIsFocused(true); - }, []); + onEditorFocusChange?.(true); + }, [onEditorFocusChange]); const handleBlur = useCallback(() => { setIsFocused(false); - }, []); + onEditorFocusChange?.(false); + }, [onEditorFocusChange]); const showStopAction = props.selectedThread.session?.status === "running" || props.selectedThread.session?.status === "starting"; @@ -623,6 +633,61 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer interactionMode: currentInteractionMode, }); + // iOS gets a native menu on the trigger pill: the everyday adjustments + // apply without resigning the keyboard, while "All Settings…" (and the + // Android trigger) still route through the sheet, which must dismiss it. + const settingsMenu = useMemo( + () => + Platform.OS === "ios" + ? buildThreadSettingsMenu({ + providerGroups: threadProviderGroups, + selectedModel: currentModelSelection, + optionDescriptors: providerOptionDescriptors, + runtimeMode: currentRuntimeMode, + }) + : null, + [threadProviderGroups, currentModelSelection, providerOptionDescriptors, currentRuntimeMode], + ); + + const onUpdateModelSelection = props.onUpdateModelSelection; + const onUpdateRuntimeMode = props.onUpdateRuntimeMode; + const handleSettingsMenuAction = useCallback( + (eventId: string) => { + const event = settingsMenu?.events.get(eventId); + if (!event) { + return; + } + switch (event.type) { + case "select-model": + void Haptics.selectionAsync(); + onUpdateModelSelection(event.option.selection); + return; + case "set-option": { + const options = applyProviderOptionSelection(providerOptionDescriptors, { + id: event.optionId, + value: event.value, + }); + if (options) { + void Haptics.selectionAsync(); + onUpdateModelSelection({ ...currentModelSelection, options }); + } + return; + } + case "set-runtime": + void Haptics.selectionAsync(); + onUpdateRuntimeMode(event.mode); + return; + } + }, + [ + currentModelSelection, + onUpdateModelSelection, + onUpdateRuntimeMode, + providerOptionDescriptors, + settingsMenu, + ], + ); + return ( void props.onPickDraftImages()} showChevron={false} /> - - } - label={settingsSummaryLabel} - maxWidth={320} - onPress={settingsSheetPresentation.open} - /> + {settingsMenu ? ( + handleSettingsMenuAction(nativeEvent.event)} + > + + } + label={settingsSummaryLabel} + maxWidth={320} + /> + + ) : ( + + } + label={settingsSummaryLabel} + maxWidth={320} + onPress={settingsSheetPresentation.open} + /> + )} {showStopAction ? ( ; - readonly activePendingUserInputAnswers: Record | null; + readonly activePendingUserInputAnswers: Record> | null; readonly respondingUserInputId: ApprovalRequestId | null; readonly draftMessage: string; readonly draftAttachments: ReadonlyArray; @@ -93,7 +126,7 @@ export interface ThreadDetailScreenProps { ) => Promise; readonly onSelectUserInputOption: ( requestId: ApprovalRequestId, - questionId: string, + question: UserInputQuestion, label: string, ) => void; readonly onChangeUserInputCustomAnswer: ( @@ -175,8 +208,48 @@ function useStreamingHaptics(threadId: ThreadId, feed: ReadonlyArray state.isVisible); + const liveKeyboardHeight = useKeyboardState((state) => state.height); + // Android can swallow the IME hide callbacks when the app is backgrounded + // mid keyboard-hide (the reported repro: send — which blurs and starts the + // hide — then Home within a second). The keyboard library's height AND + // visibility then stay frozen open, so gating the sticky translation on + // visibility alone still strands the composer after resume. Quarantine the + // translation on every Android resume instead; any sign of a live keyboard + // stream — an owned input gaining focus, or any visibility/height movement — + // lifts it. A healthy resume sees no visual difference (the translation is + // already zero while the keyboard is closed). + const [keyboardStateSuspect, setKeyboardStateSuspect] = useState(false); + useEffect(() => { + if (Platform.OS !== "android") { + return; + } + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") { + setKeyboardStateSuspect(true); + } + }); + return () => { + subscription.remove(); + }; + }, []); + useEffect(() => { + setKeyboardStateSuspect(false); + }, [isKeyboardVisible, liveKeyboardHeight]); + const handleOwnedInputFocusChange = useCallback((focused: boolean) => { + if (focused) { + setKeyboardStateSuspect(false); + } + }, []); + const windowHeight = useWindowDimensions().height; + const navigationHeaderHeight = useContext(HeaderHeightContext) || insets.top + 44; const agentLabel = `${props.selectedThread.modelSelection.instanceId} agent`; const selectedThreadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); const composerEditorRef = useRef(null); @@ -187,11 +260,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const lastScrolledAnchorMessageIdRef = useRef(null); const [composerExpanded, setComposerExpanded] = useState(false); const [anchorMessageId, setAnchorMessageId] = useState(null); - // Key the safe-area padding on keyboard visibility, not focus: on Android - // the back gesture closes the keyboard while the editor stays focused, and - // a focus-keyed inset would leave the toolbar under the gesture bar. - const isKeyboardVisible = useKeyboardState((state) => state.isVisible); - const composerBottomInset = isKeyboardVisible ? 0 : Math.max(insets.bottom, 12); + const [endFollowEnabled, setEndFollowEnabled] = useState(true); + // Android keys the safe-area padding on keyboard visibility (#5988): the + // back gesture closes the keyboard while the editor stays focused, and a + // focus-keyed inset would leave the toolbar under the gesture bar. iOS must + // NOT use visibility — it only flips on keyboardDidHide, after the hide + // animation, so the composer would ride down flush to the screen edge and + // then snap up into the inset. On iOS blur precedes the hide, so the + // focus-keyed inset is already in place while the composer rides down. + const composerBottomInset = (Platform.OS === "android" ? isKeyboardVisible : composerExpanded) + ? 0 + : Math.max(insets.bottom, 12); const contentPresentationKind = props.contentPresentation.kind; // The raw sync status enters "synchronizing" on every full fetch, cached or // not. Whether messages are already on screen decides the pill label: no @@ -212,6 +291,41 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const selectedThreadFeed = props.selectedThreadFeed; const composerChrome = composerExpanded ? COMPOSER_EXPANDED_CHROME : COMPOSER_COLLAPSED_CHROME; const composerOverlapHeight = composerChrome + composerBottomInset; + // While a user-input request is pending, the questionnaire owns the + // composer slot outright: expanded it is the full card, collapsed it is a + // composer-style bar in the same place (with its own stop control). The + // composer never mounts into the transition, which keeps the collapse and + // keyboard animations coherent. Collapse state is keyed by request id so a + // new request re-expands automatically. + const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] = + useState(null); + const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null; + const userInputCollapsed = + activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId; + // The card's height RESERVES keyboard space at all times instead of + // tracking the keyboard: transforms (the sticky translation) apply + // same-frame on the UI thread while layout props lag a Yoga pass behind, + // so any height that follows the keyboard flashes the card over the nav + // header on the way up. With a constant height the keyboard transition is + // pure translation — frame-perfect by construction — and the resting card + // stays compact over the transcript. Before the first open the reserve is + // an estimate; once a real height is known the card corrects once, + // discretely. + const [lastKnownKeyboardHeight, setLastKnownKeyboardHeight] = useState(0); + useEffect(() => { + if (liveKeyboardHeight > 0 && liveKeyboardHeight !== lastKnownKeyboardHeight) { + setLastKnownKeyboardHeight(liveKeyboardHeight); + } + }, [lastKnownKeyboardHeight, liveKeyboardHeight]); + const pendingUserInputMaxHeight = derivePendingUserInputMaxHeight({ + windowHeight, + keyboardHeight: + lastKnownKeyboardHeight > 0 ? lastKnownKeyboardHeight : ESTIMATED_KEYBOARD_HEIGHT, + navigationHeaderHeight, + // The questionnaire owns the composer slot, so only the composer's + // bottom inset still overlaps. + composerOverlapHeight: composerBottomInset, + }); const estimatedOverlayHeight = composerOverlapHeight; // The overlay's measured height includes the home-indicator inset (the // composer pads it), but contentInsetAdjustmentBehavior="automatic" makes @@ -228,7 +342,103 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread Math.max(0, estimatedOverlayHeight - nativeInsetOvercount), -nativeInsetOvercount, ); + // The expanded questionnaire is an absolute overlay on iOS, so it never + // changes the measured overlay height (that constancy is what keeps the + // feed from snapping on collapse/expand). The toggle choreography runs on + // SHARED VALUES set directly in the tap handler — one JS hop, then the + // card's rise/sink and the feed's end-inset glide animate in lockstep on + // the UI thread, keyboard-style, instead of waiting on React mount + + // onLayout + state round trips. Coverage (how far the card extends above + // the bar) is measured straight into a shared value by the card's + // onLayout, with no re-render. + const userInputCardProgress = useSharedValue(1); + const userInputInsetProgress = useSharedValue(1); + const userInputCardCoverage = useSharedValue(0); + // Android renders the expanded card in-flow (it cannot hit-test the iOS + // overlay outside the bar's bounds), so its measured overlay height already + // includes the card — the coverage extra is iOS-only. + const userInputCoverageApplies = Platform.OS === "ios" && activeUserInputRequestId !== null; + const combinedContentInsetEndAdjustment = useSharedValue( + Math.max(0, estimatedOverlayHeight - nativeInsetOvercount), + ); + useAnimatedReaction( + () => + contentInsetEndAdjustment.value + + (userInputCoverageApplies ? userInputInsetProgress.value * userInputCardCoverage.value : 0), + (value) => { + combinedContentInsetEndAdjustment.value = value; + }, + [userInputCoverageApplies], + ); const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({ listRef }); + const endFollowEnabledRef = useRef(true); + endFollowEnabledRef.current = endFollowEnabled; + const userInputRepinTimerRef = useRef | null>(null); + // The list's own corrections for these inset changes drift on short + // content (and the error compounds across toggles), so deterministically + // re-pin the end once a toggle settles: a no-op when the resting position + // is already right, corrective when it is not. Follow state is re-checked + // inside the callback — the user may grab the list during the settle + // window, and yanking them back would override a live gesture. + const scheduleUserInputRepin = useCallback( + (delayMs: number) => { + if (userInputRepinTimerRef.current !== null) { + clearTimeout(userInputRepinTimerRef.current); + } + userInputRepinTimerRef.current = setTimeout(() => { + userInputRepinTimerRef.current = null; + if (!endFollowEnabledRef.current) { + return; + } + void scrollMessageToEnd({ animated: false, closeKeyboard: false }).catch(() => { + freeze.set(false); + }); + }, delayMs); + }, + [freeze, scrollMessageToEnd], + ); + useEffect( + () => () => { + if (userInputRepinTimerRef.current !== null) { + clearTimeout(userInputRepinTimerRef.current); + } + }, + [], + ); + const handleToggleUserInputCollapsed = useCallback(() => { + if (activeUserInputRequestId === null) { + return; + } + if (userInputCollapsed) { + // Expanding: card and feed glide start NOW, on the UI thread. + userInputCardProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING); + userInputInsetProgress.value = withTiming(1, USER_INPUT_TOGGLE_TIMING); + setCollapsedUserInputRequestId(null); + scheduleUserInputRepin(USER_INPUT_TOGGLE_DURATION_MS + 50); + } else { + // Collapsing hides the custom-answer inputs; release the keyboard with + // them instead of leaving it up over a dead responder. + Keyboard.dismiss(); + userInputCardProgress.value = withTiming(0, USER_INPUT_TOGGLE_TIMING); + // Instant: the sinking card still covers the strip being revealed, and + // animating the inset downward is what drifted the short-content end + // anchor. + userInputInsetProgress.value = 0; + setCollapsedUserInputRequestId(activeUserInputRequestId); + scheduleUserInputRepin(60); + } + }, [ + activeUserInputRequestId, + scheduleUserInputRepin, + userInputCardProgress, + userInputCollapsed, + userInputInsetProgress, + ]); + useEffect(() => { + // A new request always arrives expanded. + userInputCardProgress.value = 1; + userInputInsetProgress.value = 1; + }, [activeUserInputRequestId, userInputCardProgress, userInputInsetProgress]); const showContent = props.showContent ?? true; const layoutVariant = props.layoutVariant ?? "compact"; const isSplitLayout = layoutVariant === "split"; @@ -249,6 +459,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread useEffect(() => { setAnchorMessageId(null); lastScrolledAnchorMessageIdRef.current = null; + setEndFollowEnabled(true); freeze.set(false); }, [freeze, selectedThreadKey]); @@ -320,6 +531,16 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread composerEditorRef.current?.blur(); }, []); + const handleScrollToEnd = useCallback(() => { + void Haptics.selectionAsync(); + void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => { + freeze.set(false); + }); + }, [freeze, scrollMessageToEnd]); + + const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled; + const isDarkMode = useColorScheme() === "dark"; + const handleFeedTouchStart = useCallback((event: GestureResponderEvent) => { feedTouchStartRef.current = { pageX: event.nativeEvent.pageX, @@ -373,13 +594,14 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread listRef={listRef} freeze={freeze} anchorMessageId={anchorMessageId} - contentInsetEndAdjustment={contentInsetEndAdjustment} + contentInsetEndAdjustment={combinedContentInsetEndAdjustment} contentTopInset={0} contentBottomInset={estimatedOverlayHeight} contentMaxWidth={contentMaxWidth} layoutVariant={layoutVariant} usesAutomaticContentInsets={props.usesAutomaticContentInsets} onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} + onEndFollowEnabledChange={setEndFollowEnabled} skills={selectedProviderSkills} loadEarlier={props.loadEarlier ?? null} /> @@ -391,6 +613,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {/* Floating composer — sticks to keyboard via KeyboardStickyView */} {showContent ? ( @@ -398,10 +624,60 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread list's bottom inset, so any padding above the pill/composer pushes the resting content floor up by the same amount. */} + {showScrollToEndButton ? ( + + {isLiquidGlassSupported ? ( + + + + ) : ( + + )} + + ) : null} {props.activePendingApproval || props.activePendingUserInput ? ( @@ -415,6 +691,13 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread {props.activePendingUserInput ? ( - + {/* Hidden (not unmounted) while a user-input request owns the + composer slot, so composer drafts and editor state survive. */} + + + ) : null} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 7933e4ca6014..0bd57799fe91 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -48,6 +48,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { FadeIn, FadeInUp, type SharedValue } from "react-native-reanimated"; import { useThemeColor } from "../../lib/useThemeColor"; import { useFontFamily } from "../../lib/useFontFamily"; +import { scopedThreadKey } from "../../lib/scopedEntities"; import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; import { @@ -89,6 +90,10 @@ import { type ThreadFeedLatestTurn, } from "../../lib/threadActivity"; import type { ThreadContentPresentation } from "./threadContentPresentation"; +import { + resolveThreadFeedLiveFollow, + type ThreadFeedLiveFollowEvent, +} from "./thread-feed-live-follow"; import { collapsedWorkLogHeight, ThreadWorkGroupToggle, @@ -149,6 +154,7 @@ export interface ThreadFeedProps { readonly layoutVariant?: LayoutVariant; readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; + readonly onEndFollowEnabledChange?: (enabled: boolean) => void; readonly skills?: ReadonlyArray; /** Non-null when older turns exist beyond the loaded window. */ readonly loadEarlier?: { @@ -1324,6 +1330,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const disclosureAnchorKeyRef = useRef(null); const headerMaterialVisibleRef = useRef(false); const previousLatestTurnRef = useRef(props.latestTurn); + const userScrollSettleTimerRef = useRef | null>(null); const { width: windowWidth } = useWindowDimensions(); const { appearance } = useAppearancePreferences(); const [viewportWidth, setViewportWidth] = useState(() => @@ -1342,13 +1349,23 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // momentum; only motion inside a session can break follow, so MVCP // compensations and programmatic scrolls never strand a follower. const userScrollSessionRef = useRef(false); - const setEndFollow = useCallback((enabled: boolean) => { - if (endFollowEnabledRef.current === enabled) { - return; - } - endFollowEnabledRef.current = enabled; - setEndFollowEnabled(enabled); - }, []); + const setEndFollow = useCallback( + (enabled: boolean) => { + if (endFollowEnabledRef.current === enabled) { + return; + } + endFollowEnabledRef.current = enabled; + setEndFollowEnabled(enabled); + props.onEndFollowEnabledChange?.(enabled); + }, + [props.onEndFollowEnabledChange], + ); + const transitionEndFollow = useCallback( + (event: ThreadFeedLiveFollowEvent) => { + setEndFollow(resolveThreadFeedLiveFollow(endFollowEnabledRef.current, event)); + }, + [setEndFollow], + ); const [interactionState, setInteractionState] = useState<{ readonly copiedRowId: string | null; readonly expandedWorkGroups: Record; @@ -1460,40 +1477,72 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // UIKit's adjustedContentInset, so topContentInset is 0 here). Add the // header height back or the material toggles a full header too late. reportHeaderMaterialVisibility(event.nativeEvent.contentOffset.y + anchorTopInset > 6); - // Latch bookkeeping. LegendList recomputes its inset-aware end distance - // before invoking this handler, so getState() is current. Returning to - // the end re-arms follow no matter who scrolled (the user, or our own - // scroll-to-end); moving away breaks it only during a user-initiated - // scroll session, so MVCP compensations and programmatic repositioning - // can never strand a follower. + // LegendList recomputes its inset-aware end distance before invoking + // this handler, so getState() is current. Only the actual end re-arms + // follow: its broader maintain-scroll threshold is large enough for a + // streaming chunk to pull a user back before their upward drag escapes. + // A live user-scroll session still wins even if the first scroll event + // remains inside LegendList's at-end tolerance. const listState = props.listRef.current?.getState(); if (listState) { - if (listState.isWithinMaintainScrollAtEndThreshold) { - setEndFollow(true); - } else if (userScrollSessionRef.current) { - setEndFollow(false); - } + transitionEndFollow({ + type: "scroll", + isAtEnd: listState.isAtEnd, + userScrollSessionActive: userScrollSessionRef.current, + }); } }, - [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, setEndFollow], + [reportHeaderMaterialVisibility, anchorTopInset, props.listRef, transitionEndFollow], ); + const clearUserScrollSettle = useCallback(() => { + if (userScrollSettleTimerRef.current !== null) { + clearTimeout(userScrollSettleTimerRef.current); + userScrollSettleTimerRef.current = null; + } + }, []); const handleScrollBeginDrag = useCallback(() => { + clearUserScrollSettle(); userScrollSessionRef.current = true; - }, []); - // The session must survive past finger-lift so momentum that carries the - // user away from the end still breaks follow; a drag released with no - // momentum ends its session at the release itself, otherwise at momentum - // end. Leaving a session open would let a later animated maintain-scroll - // read as user motion and break follow spuriously. - const handleScrollEndDrag = useCallback((event: NativeSyntheticEvent) => { - const velocity = event.nativeEvent.velocity?.y ?? 0; - if (Math.abs(velocity) < 0.05) { + // Pause before the first scroll event. Otherwise a stream update can run + // maintainScrollAtEnd between touch-down and the drag leaving its threshold. + transitionEndFollow({ type: "user-scroll-begin" }); + }, [clearUserScrollSettle, transitionEndFollow]); + const finishUserScroll = useCallback( + (releaseIsAtEnd?: boolean) => { + clearUserScrollSettle(); + const userScrollSessionActive = userScrollSessionRef.current; userScrollSessionRef.current = false; + transitionEndFollow({ + type: "user-scroll-end", + // With no momentum, preserve the finger-release position. Streaming + // growth during the native momentum-detection window must not turn a + // release at the live edge into an opt-out from follow. + isAtEnd: releaseIsAtEnd ?? props.listRef.current?.getState().isAtEnd ?? false, + userScrollSessionActive, + }); + }, + [clearUserScrollSettle, props.listRef, transitionEndFollow], + ); + // Finger-lift velocity is not a reliable momentum signal: a gentle fling + // can report zero and still decelerate. Give native momentum a short window + // to announce itself; if it does, onMomentumScrollBegin cancels this fallback + // and the session survives until the settled momentum-end position. This + // mirrors the native-event handoff used by the home thread list's scroll gate. + const handleScrollEndDrag = useCallback(() => { + clearUserScrollSettle(); + const releaseIsAtEnd = props.listRef.current?.getState().isAtEnd ?? false; + userScrollSettleTimerRef.current = setTimeout(() => finishUserScroll(releaseIsAtEnd), 160); + }, [clearUserScrollSettle, finishUserScroll, props.listRef]); + const handleMomentumScrollBegin = useCallback(() => { + if (userScrollSessionRef.current) { + clearUserScrollSettle(); } - }, []); + }, [clearUserScrollSettle]); const handleMomentumScrollEnd = useCallback(() => { - userScrollSessionRef.current = false; - }, []); + finishUserScroll(); + }, [finishUserScroll]); + + useEffect(() => clearUserScrollSettle, [clearUserScrollSettle]); const handleViewportLayout = useCallback((event: LayoutChangeEvent) => { const nextWidth = Math.round(event.nativeEvent.layout.width); @@ -1502,23 +1551,30 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { setViewportHeight((current) => (Math.abs(current - nextHeight) > 1 ? nextHeight : current)); }, []); + // Thread identity is env-scoped: two environments can hold the same + // ThreadId, and keying resets (or the list mount) on the bare id would + // carry stale scroll/follow state across an environment switch. + const feedThreadKey = scopedThreadKey(props.environmentId, props.threadId); + useEffect(() => { reportHeaderMaterialVisibility(false); - }, [props.threadId, reportHeaderMaterialVisibility]); + }, [feedThreadKey, reportHeaderMaterialVisibility]); // A thread switch opens pinned to the end; a send explicitly returns to the // live edge (ThreadDetailScreen scrolls the new message into place). Both // re-arm follow regardless of where the user had scrolled before. useEffect(() => { + clearUserScrollSettle(); userScrollSessionRef.current = false; - setEndFollow(true); - }, [props.threadId, setEndFollow]); + transitionEndFollow({ type: "reset" }); + }, [clearUserScrollSettle, feedThreadKey, transitionEndFollow]); useEffect(() => { if (props.anchorMessageId !== null) { + clearUserScrollSettle(); userScrollSessionRef.current = false; - setEndFollow(true); + transitionEndFollow({ type: "reset" }); } - }, [props.anchorMessageId, setEndFollow]); + }, [clearUserScrollSettle, props.anchorMessageId, transitionEndFollow]); const expandedWorkGroupIds = useMemo(() => { const ids = new Set(); @@ -1554,7 +1610,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { // initial scroll-to-end computes with a zero end inset and rests one // composer-height short of the end. Layout effect: it must land before the // list's first positioning tick or the one-shot initial scroll misses it. - const listMountKey = `${props.threadId}:${props.feed.length === 0 ? "empty" : "filled"}`; + const listMountKey = `${feedThreadKey}:${props.feed.length === 0 ? "empty" : "filled"}`; useLayoutEffect(() => { const bottom = props.contentInsetEndAdjustment.value; if (bottom > 0) { @@ -1921,6 +1977,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onScroll={handleScroll} onScrollBeginDrag={handleScrollBeginDrag} onScrollEndDrag={handleScrollEndDrag} + onMomentumScrollBegin={handleMomentumScrollBegin} onMomentumScrollEnd={handleMomentumScrollEnd} scrollEventThrottle={16} ListHeaderComponent={ diff --git a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx index 9c27e6f01c5a..f87a41e0eefa 100644 --- a/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx +++ b/apps/mobile/src/features/threads/ThreadSettingsSheet.tsx @@ -30,6 +30,7 @@ import { cn } from "../../lib/cn"; import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; import { applyProviderOptionSelection, providerOptionValueLabels } from "../../lib/providerOptions"; import { useThemeColor } from "../../lib/useThemeColor"; +import { RUNTIME_MODE_CHOICES, selectableChoices } from "./thread-settings-menu"; import { pendingModelAfterPress } from "./thread-settings-sheet-state"; import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet-presentation"; @@ -40,26 +41,6 @@ import type { ThreadSettingsSheetCloseReason } from "./use-thread-settings-sheet */ const PRIMARY_PROVIDER_DRIVERS: ReadonlySet = new Set(["claudeAgent", "codex"]); -/** - * Desktop-oriented effort keywords that don't belong in the phone picker. - * Prompt-injected values (ultrathink and friends) are filtered from the - * descriptor metadata; ultracode is a real option but a workflow trigger, not - * a reasoning level. A value set elsewhere still displays, it just isn't - * offered. - */ -const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet = new Set(["ultracode"]); - -const RUNTIME_MODE_CHOICES: ReadonlyArray<{ - readonly mode: RuntimeMode; - readonly label: string; - readonly shortLabel: string; -}> = [ - { mode: "approval-required", label: "Approve actions", shortLabel: "Approve" }, - { mode: "auto-accept-edits", label: "Auto-accept edits", shortLabel: "Edits" }, - { mode: "auto", label: "Auto", shortLabel: "Auto" }, - { mode: "full-access", label: "Full access", shortLabel: "Full" }, -]; - /** * Compact "Fable 5 · Max · Auto" style summary for the composer trigger pill, * covering model, provider options, runtime mode, and plan mode in one label. @@ -79,13 +60,6 @@ export function threadSettingsSummaryLabel(input: { ].join(" · "); } -function selectableChoices(descriptor: Extract) { - const injected = new Set(descriptor.promptInjectedValues ?? []); - return descriptor.options.filter( - (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id), - ); -} - function ModelRow(props: { readonly option: ModelOption; readonly selected: boolean; diff --git a/apps/mobile/src/features/threads/pendingUserInputLayout.test.ts b/apps/mobile/src/features/threads/pendingUserInputLayout.test.ts new file mode 100644 index 000000000000..8dd15ccc8d87 --- /dev/null +++ b/apps/mobile/src/features/threads/pendingUserInputLayout.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { derivePendingUserInputMaxHeight } from "./pendingUserInputLayout"; + +describe("derivePendingUserInputMaxHeight", () => { + it("caps a tall portrait viewport", () => { + expect( + derivePendingUserInputMaxHeight({ + windowHeight: 932, + keyboardHeight: 0, + navigationHeaderHeight: 103, + composerOverlapHeight: 94, + }), + ).toBe(560); + }); + + it("subtracts the keyboard while editing a custom answer", () => { + expect( + derivePendingUserInputMaxHeight({ + windowHeight: 932, + keyboardHeight: 336, + navigationHeaderHeight: 103, + composerOverlapHeight: 94, + }), + ).toBe(387); + }); + + it("keeps the fixed action area usable in a short keyboard-open viewport", () => { + expect( + derivePendingUserInputMaxHeight({ + windowHeight: 375, + keyboardHeight: 240, + navigationHeaderHeight: 44, + composerOverlapHeight: 94, + }), + ).toBe(160); + }); +}); diff --git a/apps/mobile/src/features/threads/pendingUserInputLayout.ts b/apps/mobile/src/features/threads/pendingUserInputLayout.ts new file mode 100644 index 000000000000..56924617e56f --- /dev/null +++ b/apps/mobile/src/features/threads/pendingUserInputLayout.ts @@ -0,0 +1,37 @@ +const PENDING_USER_INPUT_MAX_HEIGHT = 560; +const PENDING_USER_INPUT_MIN_HEIGHT = 160; +const PENDING_USER_INPUT_VERTICAL_GAP = 12; + +/** + * Reserve for a portrait iPhone keyboard with the QuickType bar until a real + * height has been observed. Overestimating only costs card height; an + * underestimate would let the card overshoot on the first keyboard open. + */ +export const ESTIMATED_KEYBOARD_HEIGHT = 336; + +/** + * One clock for the questionnaire expand/collapse choreography: the card's + * enter/exit and the feed-inset glide must share it or they visibly drift. + * Sized for the near-full-height slide (the card travels its own height), + * in the same class as the iOS keyboard's ~250ms. + */ +export const USER_INPUT_TOGGLE_DURATION_MS = 220; + +export function derivePendingUserInputMaxHeight(input: { + readonly windowHeight: number; + readonly keyboardHeight: number; + readonly navigationHeaderHeight: number; + readonly composerOverlapHeight: number; +}): number { + const availableHeight = + input.windowHeight - + Math.max(0, input.keyboardHeight) - + Math.max(0, input.navigationHeaderHeight) - + Math.max(0, input.composerOverlapHeight) - + PENDING_USER_INPUT_VERTICAL_GAP; + + return Math.min( + PENDING_USER_INPUT_MAX_HEIGHT, + Math.max(PENDING_USER_INPUT_MIN_HEIGHT, availableHeight), + ); +} diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts new file mode 100644 index 000000000000..8cc68cb3c525 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveThreadFeedLiveFollow } from "./thread-feed-live-follow"; + +describe("resolveThreadFeedLiveFollow", () => { + it("pauses immediately when the user starts scrolling", () => { + expect(resolveThreadFeedLiveFollow(true, { type: "user-scroll-begin" })).toBe(false); + }); + + it("stays paused away from the actual end", () => { + expect( + resolveThreadFeedLiveFollow(false, { + type: "scroll", + isAtEnd: false, + userScrollSessionActive: true, + }), + ).toBe(false); + }); + + it("does not mistake programmatic layout compensation for a user scroll", () => { + expect( + resolveThreadFeedLiveFollow(true, { + type: "scroll", + isAtEnd: false, + userScrollSessionActive: false, + }), + ).toBe(true); + }); + + it("does not re-arm at the end while a user scroll session is active", () => { + expect( + resolveThreadFeedLiveFollow(false, { + type: "scroll", + isAtEnd: true, + userScrollSessionActive: true, + }), + ).toBe(false); + }); + + it("re-arms at the actual end only after the user scroll session ends", () => { + expect( + resolveThreadFeedLiveFollow(false, { + type: "user-scroll-end", + isAtEnd: true, + userScrollSessionActive: true, + }), + ).toBe(true); + expect( + resolveThreadFeedLiveFollow(false, { + type: "user-scroll-end", + isAtEnd: false, + userScrollSessionActive: true, + }), + ).toBe(false); + }); + + it("ignores momentum-end events from programmatic scrolling", () => { + expect( + resolveThreadFeedLiveFollow(true, { + type: "user-scroll-end", + isAtEnd: false, + userScrollSessionActive: false, + }), + ).toBe(true); + }); + + it("re-arms after an explicit reset", () => { + expect(resolveThreadFeedLiveFollow(false, { type: "reset" })).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-feed-live-follow.ts b/apps/mobile/src/features/threads/thread-feed-live-follow.ts new file mode 100644 index 000000000000..babe18f0c1cb --- /dev/null +++ b/apps/mobile/src/features/threads/thread-feed-live-follow.ts @@ -0,0 +1,35 @@ +export type ThreadFeedLiveFollowEvent = + | { readonly type: "reset" } + | { readonly type: "user-scroll-begin" } + | { + readonly type: "user-scroll-end"; + readonly isAtEnd: boolean; + readonly userScrollSessionActive: boolean; + } + | { + readonly type: "scroll"; + readonly isAtEnd: boolean; + readonly userScrollSessionActive: boolean; + }; + +export function resolveThreadFeedLiveFollow( + current: boolean, + event: ThreadFeedLiveFollowEvent, +): boolean { + switch (event.type) { + case "reset": + return true; + case "user-scroll-begin": + return false; + case "user-scroll-end": + return event.userScrollSessionActive ? event.isAtEnd : current; + case "scroll": + if (event.userScrollSessionActive) { + return false; + } + if (event.isAtEnd) { + return true; + } + return current; + } +} diff --git a/apps/mobile/src/features/threads/thread-settings-menu.test.ts b/apps/mobile/src/features/threads/thread-settings-menu.test.ts new file mode 100644 index 000000000000..078be2df11bd --- /dev/null +++ b/apps/mobile/src/features/threads/thread-settings-menu.test.ts @@ -0,0 +1,284 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { ProviderInstanceId, type ProviderOptionDescriptor } from "@t3tools/contracts"; + +import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; +import { buildThreadSettingsMenu, type ThreadSettingsMenuEvent } from "./thread-settings-menu"; + +function modelOption( + model: string, + overrides: Partial> = {}, +): ModelOption { + const providerKey = overrides.providerKey ?? "codex"; + return { + key: `${providerKey}:${model}`, + label: model, + subtitle: providerKey, + providerKey, + providerLabel: providerKey === "codex" ? "Codex" : "Claude", + providerDriver: providerKey === "codex" ? "codex" : "claudeAgent", + isDefault: overrides.isDefault ?? false, + isLegacy: overrides.isLegacy ?? false, + capabilities: null, + selection: { + instanceId: ProviderInstanceId.make(providerKey), + model, + options: [], + }, + }; +} + +function group(models: ReadonlyArray): ProviderGroup { + const first = models[0]; + if (!first) { + throw new Error("group requires at least one model"); + } + return { + providerKey: first.providerKey, + providerLabel: first.providerLabel, + models, + }; +} + +const effortDescriptor: ProviderOptionDescriptor = { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium", isDefault: true }, + { id: "high", label: "High" }, + { id: "ultrathink", label: "Ultrathink" }, + { id: "ultracode", label: "Ultracode" }, + ], + currentValue: "high", + promptInjectedValues: ["ultrathink"], +}; + +const fastModeDescriptor: ProviderOptionDescriptor = { + id: "fastMode", + label: "Fast mode", + type: "boolean", + currentValue: false, +}; + +function baseInput() { + const models = [ + modelOption("gpt-current", { isDefault: true }), + modelOption("gpt-next"), + modelOption("gpt-old", { isLegacy: true }), + ]; + return { + providerGroups: [group(models)], + selectedModel: models[0]?.selection ?? null, + optionDescriptors: [effortDescriptor, fastModeDescriptor], + runtimeMode: "auto", + } as const; +} + +function eventFor(menu: ReturnType, id: string | undefined) { + return id === undefined ? undefined : menu.events.get(id); +} + +describe("buildThreadSettingsMenu", () => { + it("orders the top level as model, options, runtime", () => { + const menu = buildThreadSettingsMenu(baseInput()); + + expect(menu.actions.map((action) => action.title)).toEqual([ + "Model", + "Reasoning", + "Fast mode", + "Runtime", + ]); + }); + + it("summarizes the current choice on each submenu row", () => { + const menu = buildThreadSettingsMenu(baseInput()); + + expect(menu.actions.find((action) => action.title === "Model")?.subtitle).toBe("gpt-current"); + expect(menu.actions.find((action) => action.title === "Reasoning")?.subtitle).toBe("High"); + expect(menu.actions.find((action) => action.title === "Runtime")?.subtitle).toBe("Auto"); + }); + + it("checkmarks the selected model and resolves selection events", () => { + const menu = buildThreadSettingsMenu(baseInput()); + + const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; + const current = modelItems.find((action) => action.title === "gpt-current"); + expect(current?.state).toBe("on"); + expect(current?.subtitle).toBe("Default"); + expect(modelItems.find((action) => action.title === "gpt-next")?.state).toBe("off"); + + const event = eventFor(menu, modelItems.find((action) => action.title === "gpt-next")?.id); + expect(event?.type).toBe("select-model"); + expect(event?.type === "select-model" ? event.option.selection.model : null).toBe("gpt-next"); + }); + + it("folds unselected legacy models behind a nested submenu", () => { + const menu = buildThreadSettingsMenu(baseInput()); + + const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; + expect(modelItems.map((action) => action.title)).toEqual([ + "gpt-current", + "gpt-next", + "Legacy Models", + ]); + expect( + modelItems + .find((action) => action.title === "Legacy Models") + ?.subactions?.map((action) => action.title), + ).toEqual(["gpt-old"]); + }); + + it("keeps a selected legacy model in the main list", () => { + const input = baseInput(); + const legacy = input.providerGroups[0]?.models.find((model) => model.isLegacy); + const menu = buildThreadSettingsMenu({ + ...input, + selectedModel: legacy?.selection ?? null, + }); + + const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; + expect(modelItems.map((action) => action.title)).toEqual([ + "gpt-current", + "gpt-next", + "gpt-old", + ]); + expect(modelItems.find((action) => action.title === "gpt-old")?.state).toBe("on"); + }); + + it("hides prompt-injected and workflow-trigger efforts but still summarizes them", () => { + const menu = buildThreadSettingsMenu({ + ...baseInput(), + optionDescriptors: [{ ...effortDescriptor, currentValue: "ultracode" }], + }); + + const reasoning = menu.actions.find((action) => action.title === "Reasoning"); + expect(reasoning?.subactions?.map((action) => action.title)).toEqual(["Low", "Medium", "High"]); + // The hidden value stays visible as the current summary; it just can't be + // picked from the phone. + expect(reasoning?.subtitle).toBe("Ultracode"); + expect(reasoning?.subactions?.every((action) => action.state === "off")).toBe(true); + }); + + it("resolves select-option and runtime events with checkmarked current values", () => { + const menu = buildThreadSettingsMenu(baseInput()); + + const reasoningItems = + menu.actions.find((action) => action.title === "Reasoning")?.subactions ?? []; + expect(reasoningItems.find((action) => action.title === "High")?.state).toBe("on"); + expect(eventFor(menu, reasoningItems.find((action) => action.title === "Low")?.id)).toEqual({ + type: "set-option", + optionId: "effort", + value: "low", + }); + + const runtimeItems = + menu.actions.find((action) => action.title === "Runtime")?.subactions ?? []; + expect(runtimeItems.find((action) => action.title === "Auto")?.state).toBe("on"); + expect( + eventFor(menu, runtimeItems.find((action) => action.title === "Full access")?.id), + ).toEqual({ type: "set-runtime", mode: "full-access" }); + }); + + it("toggles boolean options with the inverted current value", () => { + const menu = buildThreadSettingsMenu(baseInput()); + + const fastMode = menu.actions.find((action) => action.title === "Fast mode"); + expect(fastMode?.state).toBe("off"); + expect(fastMode?.subactions).toBeUndefined(); + expect(eventFor(menu, fastMode?.id)).toEqual({ + type: "set-option", + optionId: "fastMode", + value: true, + }); + + const enabled = buildThreadSettingsMenu({ + ...baseInput(), + optionDescriptors: [{ ...fastModeDescriptor, currentValue: true }], + }); + const enabledRow = enabled.actions.find((action) => action.title === "Fast mode"); + expect(enabledRow?.state).toBe("on"); + expect(eventFor(enabled, enabledRow?.id)).toEqual({ + type: "set-option", + optionId: "fastMode", + value: false, + }); + }); + + it("keeps the menu presented only for top-level toggles", () => { + const menu = buildThreadSettingsMenu(baseInput()); + + // Root-level boolean toggles refresh in place with clean chrome, so they + // keep the menu presented. + expect( + menu.actions.find((action) => action.title === "Fast mode")?.attributes?.keepsMenuPresented, + ).toBe(true); + + // Picks inside nested submenus close the menu: staying presented leaves + // the submenu on screen with an expanded-submenu header, and the + // bottom-anchored collapse back out drops by the levels' height delta. + const expected = undefined; + const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; + const reasoningItems = + menu.actions.find((action) => action.title === "Reasoning")?.subactions ?? []; + const runtimeItems = + menu.actions.find((action) => action.title === "Runtime")?.subactions ?? []; + const nestedPicks = [...modelItems, ...reasoningItems, ...runtimeItems].filter( + (action) => action.subactions === undefined, + ); + expect(nestedPicks.length).toBeGreaterThan(0); + expect(nestedPicks.every((action) => action.attributes?.keepsMenuPresented === expected)).toBe( + true, + ); + }); + + it("sections models by provider only when multiple groups are offered", () => { + const codexModels = [modelOption("gpt-current", { isDefault: true })]; + const claudeModels = [modelOption("fable-5", { providerKey: "claude" })]; + const menu = buildThreadSettingsMenu({ + providerGroups: [group(codexModels), group(claudeModels)], + selectedModel: codexModels[0]?.selection ?? null, + optionDescriptors: [], + runtimeMode: "auto", + }); + + const modelItems = menu.actions.find((action) => action.title === "Model")?.subactions ?? []; + expect( + modelItems.map((action) => ({ title: action.title, inline: action.displayInline ?? false })), + ).toEqual([ + { title: "Codex", inline: true }, + { title: "Claude", inline: true }, + ]); + const claudeSection = modelItems.find((action) => action.title === "Claude"); + expect(claudeSection?.subactions?.map((action) => action.title)).toEqual(["fable-5"]); + }); + + const eventTypes = (menu: ReturnType) => { + const types = new Set(); + for (const event of menu.events.values()) { + types.add(event.type); + } + return types; + }; + + it("registers an event for every leaf action id", () => { + const menu = buildThreadSettingsMenu(baseInput()); + const leafIds: string[] = []; + const collect = (items: ReadonlyArray<{ id?: string; subactions?: unknown[] }>) => { + for (const item of items) { + if (Array.isArray(item.subactions) && item.subactions.length > 0) { + collect(item.subactions as ReadonlyArray<{ id?: string; subactions?: unknown[] }>); + } else if (item.id !== undefined) { + leafIds.push(item.id); + } + } + }; + collect(menu.actions); + + for (const id of leafIds) { + expect(menu.events.get(id), `missing event for ${id}`).toBeDefined(); + } + expect(eventTypes(menu)).toEqual(new Set(["select-model", "set-option", "set-runtime"])); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-settings-menu.ts b/apps/mobile/src/features/threads/thread-settings-menu.ts new file mode 100644 index 000000000000..31b1c021c46f --- /dev/null +++ b/apps/mobile/src/features/threads/thread-settings-menu.ts @@ -0,0 +1,202 @@ +import type { MenuAction } from "@react-native-menu/menu"; +import type { ModelSelection, ProviderOptionDescriptor, RuntimeMode } from "@t3tools/contracts"; +import { + getProviderOptionCurrentLabel, + getProviderOptionCurrentValue, +} from "@t3tools/shared/model"; + +import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; + +/** + * Desktop-oriented effort keywords that don't belong in the phone picker. + * Prompt-injected values (ultrathink and friends) are filtered from the + * descriptor metadata; ultracode is a real option but a workflow trigger, not + * a reasoning level. A value set elsewhere still displays, it just isn't + * offered. + */ +export const HIDDEN_EFFORT_OPTION_IDS: ReadonlySet = new Set(["ultracode"]); + +export const RUNTIME_MODE_CHOICES: ReadonlyArray<{ + readonly mode: RuntimeMode; + readonly label: string; + readonly shortLabel: string; +}> = [ + { mode: "approval-required", label: "Approve actions", shortLabel: "Approve" }, + { mode: "auto-accept-edits", label: "Auto-accept edits", shortLabel: "Edits" }, + { mode: "auto", label: "Auto", shortLabel: "Auto" }, + { mode: "full-access", label: "Full access", shortLabel: "Full" }, +]; + +export function selectableChoices( + descriptor: Extract, +) { + const injected = new Set(descriptor.promptInjectedValues ?? []); + return descriptor.options.filter( + (option) => !injected.has(option.id) && !HIDDEN_EFFORT_OPTION_IDS.has(option.id), + ); +} + +export type ThreadSettingsMenuEvent = + | { readonly type: "select-model"; readonly option: ModelOption } + | { readonly type: "set-option"; readonly optionId: string; readonly value: string | boolean } + | { readonly type: "set-runtime"; readonly mode: RuntimeMode }; + +export type ThreadSettingsMenu = { + readonly actions: MenuAction[]; + /** Menu action id → the change it applies, for the onPressAction dispatch. */ + readonly events: ReadonlyMap; +}; + +/** + * Native menu replacement for the thread settings sheet (model, select and + * boolean provider options, runtime mode). The menu presents from the + * composer pill without resigning the keyboard, so adjusting settings never + * bounces focus. A thread is bound to one harness, so the menu covers the + * sheet's full surface for existing threads; the sheet remains the Android + * and new-task-draft surface. + * + * Selections apply immediately — the sheet's stage-then-Save flow only exists + * because the sheet batches a model change with its option edits. + */ +export function buildThreadSettingsMenu(input: { + readonly providerGroups: ReadonlyArray; + readonly selectedModel: ModelSelection | null; + readonly optionDescriptors: ReadonlyArray; + readonly runtimeMode: RuntimeMode; +}): ThreadSettingsMenu { + const events = new Map(); + const actions: MenuAction[] = []; + + const isSelected = (option: ModelOption) => + option.selection.instanceId === input.selectedModel?.instanceId && + option.selection.model === input.selectedModel.model; + + // Only top-level leaves (boolean toggles) keep the menu presented (iOS + // 16+): the root refreshes in place with clean chrome. Picks inside nested + // submenus close the menu — keeping the submenu presented renders an + // expanded-submenu header with no way to pop back to the root, and the + // bottom-anchored collapse back out travels the levels' height difference. + const keepPresented = { keepsMenuPresented: true } as const; + + const modelAction = (option: ModelOption, id: string): MenuAction => { + events.set(id, { type: "select-model", option }); + return { + id, + title: option.label, + ...(option.isDefault ? { subtitle: "Default" } : {}), + state: isSelected(option) ? "on" : "off", + }; + }; + + const modelItems: MenuAction[] = []; + const legacyItems: MenuAction[] = []; + let selectedModelLabel: string | undefined; + input.providerGroups.forEach((group, groupIndex) => { + const groupItems: MenuAction[] = []; + group.models.forEach((option, modelIndex) => { + if (isSelected(option)) { + selectedModelLabel = option.label; + } + const id = `model:${groupIndex}:${modelIndex}`; + // A highlighted legacy model stays in the main list (mirroring the + // sheet) so the checkmark isn't hidden behind the Legacy fold. + if (option.isLegacy && !isSelected(option)) { + legacyItems.push(modelAction(option, id)); + } else { + groupItems.push(modelAction(option, id)); + } + }); + if (groupItems.length === 0) { + return; + } + // A thread is bound to one harness, so provider sections only appear for + // multi-group callers (the new-task draft, if it ever adopts the menu). + if (input.providerGroups.length > 1) { + modelItems.push({ + id: `model-group:${groupIndex}`, + title: group.providerLabel, + displayInline: true, + subactions: groupItems, + }); + } else { + modelItems.push(...groupItems); + } + }); + if (legacyItems.length > 0) { + modelItems.push({ + id: "legacy-models", + title: "Legacy Models", + subactions: legacyItems, + }); + } + if (modelItems.length > 0) { + actions.push({ + id: "model", + title: "Model", + ...(selectedModelLabel === undefined + ? input.selectedModel + ? { subtitle: input.selectedModel.model } + : {} + : { subtitle: selectedModelLabel }), + subactions: modelItems, + }); + } + + for (const descriptor of input.optionDescriptors) { + if (descriptor.type === "boolean") { + const id = `option:${descriptor.id}`; + events.set(id, { + type: "set-option", + optionId: descriptor.id, + value: !(descriptor.currentValue ?? false), + }); + actions.push({ + id, + title: descriptor.label, + state: descriptor.currentValue ? "on" : "off", + attributes: keepPresented, + }); + continue; + } + const currentValue = getProviderOptionCurrentValue(descriptor); + const choices = selectableChoices(descriptor).map((choice): MenuAction => { + const id = `option:${descriptor.id}:${choice.id}`; + events.set(id, { type: "set-option", optionId: descriptor.id, value: choice.id }); + return { + id, + title: choice.label, + state: choice.id === currentValue ? "on" : "off", + }; + }); + if (choices.length === 0) { + continue; + } + const currentLabel = getProviderOptionCurrentLabel(descriptor); + actions.push({ + id: `option:${descriptor.id}`, + title: descriptor.label, + ...(currentLabel === undefined ? {} : { subtitle: currentLabel }), + subactions: choices, + }); + } + + const runtimeLabel = RUNTIME_MODE_CHOICES.find( + (choice) => choice.mode === input.runtimeMode, + )?.label; + actions.push({ + id: "runtime", + title: "Runtime", + ...(runtimeLabel === undefined ? {} : { subtitle: runtimeLabel }), + subactions: RUNTIME_MODE_CHOICES.map((choice): MenuAction => { + const id = `runtime:${choice.mode}`; + events.set(id, { type: "set-runtime", mode: choice.mode }); + return { + id, + title: choice.label, + state: choice.mode === input.runtimeMode ? "on" : "off", + }; + }), + }); + + return { actions, events }; +} diff --git a/apps/mobile/src/features/updates/app-updates.test.ts b/apps/mobile/src/features/updates/app-updates.test.ts index 474c99668cdb..4926ae65ca3a 100644 --- a/apps/mobile/src/features/updates/app-updates.test.ts +++ b/apps/mobile/src/features/updates/app-updates.test.ts @@ -32,6 +32,19 @@ function makeUpdateClient(overrides: Partial = {}): AppUpdateCl } describe("runAppUpdateCheck", () => { + it("does nothing while running from the Metro development server", async () => { + vi.stubGlobal("__DEV__", true); + const client = makeUpdateClient(); + + try { + await runAppUpdateCheck({ client }); + } finally { + vi.unstubAllGlobals(); + } + + expect(client.checkForUpdateAsync).not.toHaveBeenCalled(); + }); + it("downloads and restarts when a new update is available", async () => { const client = makeUpdateClient({ checkForUpdateAsync: vi.fn(async () => ({ @@ -100,6 +113,32 @@ describe("runAppUpdateCheck", () => { reportError.mockRestore(); }); + it.each(["ERR_NOT_AVAILABLE_IN_DEV_CLIENT", "ERR_UPDATES_DISABLED"])( + "treats Expo's %s failure as an unavailable update check", + async (code) => { + const reportError = vi.spyOn(console, "error").mockImplementation(() => {}); + const error = Object.assign(new Error("Updates are unavailable"), { code }); + const client = makeUpdateClient({ + checkForUpdateAsync: vi.fn(async () => { + throw error; + }), + }); + const failures: string[] = []; + const states: AppUpdateCheckState[] = []; + + await runAppUpdateCheck({ + client, + onFailure: (message) => failures.push(message), + onStateChange: (state) => states.push(state), + }); + + expect(reportError).not.toHaveBeenCalled(); + expect(failures).toEqual([]); + expect(states).toEqual(["checking", "idle"]); + reportError.mockRestore(); + }, + ); + it("coalesces overlapping launch and manual checks", async () => { let resolveCheck!: (result: { readonly isAvailable: boolean; diff --git a/apps/mobile/src/features/updates/app-updates.ts b/apps/mobile/src/features/updates/app-updates.ts index ab896b53c074..66525d022925 100644 --- a/apps/mobile/src/features/updates/app-updates.ts +++ b/apps/mobile/src/features/updates/app-updates.ts @@ -48,8 +48,17 @@ interface Deferred { } const HIDDEN_UPDATE_TAP_COUNT = 5; +const UPDATE_CHECK_UNAVAILABLE_ERROR_CODES = new Set([ + "ERR_NOT_AVAILABLE_IN_DEV_CLIENT", + "ERR_UPDATES_DISABLED", +]); let appUpdateCheckInFlight: AppUpdateCheckInFlight | undefined; +/** Expo's development launcher reports updates as enabled even though its OTA APIs reject. */ +export function isAppUpdateCheckAvailable(client: Pick = Updates) { + return client.isEnabled && !(typeof __DEV__ !== "undefined" && __DEV__); +} + /** * Keeps the manual update affordance discoverable only to someone deliberately * tapping the version row five times. @@ -73,7 +82,7 @@ export function registerHiddenUpdateTap(count: number): { export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Promise { const client = options.client ?? Updates; - if (!client.isEnabled) return; + if (!isAppUpdateCheckAvailable(client)) return; if (appUpdateCheckInFlight) { await observeAppUpdateCheck(appUpdateCheckInFlight, options); @@ -207,19 +216,27 @@ function reportUpdateFailure( fallback: string, onFailure: AppUpdateCheckOptions["onFailure"], ): void { - reportAtomCommandResult(result, { label: "app update check" }); if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; const error = squashAtomCommandFailure(result); + if (isAppUpdateUnavailableError(error)) return; + + reportAtomCommandResult(result, { label: "app update check" }); onFailure?.(error instanceof Error ? error.message : fallback); } +function isAppUpdateUnavailableError(error: unknown): boolean { + if (typeof error !== "object" || error === null || !("code" in error)) return false; + const code = error.code; + return typeof code === "string" && UPDATE_CHECK_UNAVAILABLE_ERROR_CODES.has(code); +} + export function createAppUpdateLaunchCheck( client: AppUpdateClient = Updates, ): () => Promise | undefined { let started = false; return () => { - if (started || !client.isEnabled) return undefined; + if (started || !isAppUpdateCheckAvailable(client)) return undefined; started = true; return runAppUpdateCheck({ client }); }; diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index ae9a93e9fc36..e1d46fd858e9 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -12,12 +12,107 @@ import { } from "@t3tools/contracts"; import { + buildPendingUserInputAnswers, buildThreadFeed, deriveThreadFeedPresentation, + isPendingUserInputOptionSelected, + setPendingUserInputCustomAnswer, + togglePendingUserInputOptionSelection, type ThreadFeedActivity, type ThreadFeedEntry, } from "./threadActivity"; +const singleSelectQuestion = { + id: "runtime", + header: "Runtime", + question: "Which runtime should be used?", + options: [ + { label: "Go", description: "One binary" }, + { label: "Node.js", description: "Reuse TypeScript" }, + ], + multiSelect: false, +} as const; + +const multiSelectQuestion = { + id: "scope", + header: "Scope", + question: "Which data should be collected?", + options: [ + { label: "Orders", description: "Receipts" }, + { label: "Listings", description: "Inventory" }, + ], + multiSelect: true, +} as const; + +describe("pending user input answers", () => { + it("replaces single-select options and toggles multi-select options", () => { + expect( + togglePendingUserInputOptionSelection( + singleSelectQuestion, + { selectedOptionLabels: ["Go"] }, + "Node.js", + ), + ).toEqual({ customAnswer: "", selectedOptionLabels: ["Node.js"] }); + + const orders = togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Orders"); + const ordersAndListings = togglePendingUserInputOptionSelection( + multiSelectQuestion, + orders, + "Listings", + ); + expect(ordersAndListings).toEqual({ + customAnswer: "", + selectedOptionLabels: ["Orders", "Listings"], + }); + expect( + togglePendingUserInputOptionSelection(multiSelectQuestion, ordersAndListings, "Orders"), + ).toEqual({ customAnswer: "", selectedOptionLabels: ["Listings"] }); + + const paddedOrders = togglePendingUserInputOptionSelection( + multiSelectQuestion, + undefined, + " Orders ", + ); + expect(paddedOrders).toEqual({ customAnswer: "", selectedOptionLabels: ["Orders"] }); + expect( + togglePendingUserInputOptionSelection(multiSelectQuestion, paddedOrders, " Orders "), + ).toEqual({ customAnswer: "" }); + }); + + it("builds array answers for multi-select questions", () => { + expect( + buildPendingUserInputAnswers([singleSelectQuestion, multiSelectQuestion], { + runtime: { selectedOptionLabels: ["Go"] }, + scope: { selectedOptionLabels: ["Orders", "Listings"] }, + }), + ).toEqual({ + runtime: "Go", + scope: ["Orders", "Listings"], + }); + }); + + it("clears selected options while a custom answer is active", () => { + expect( + setPendingUserInputCustomAnswer( + { selectedOptionLabels: ["Orders", "Listings"] }, + "Orders first", + ), + ).toEqual({ customAnswer: "Orders first" }); + }); + + it("matches selected chips against normalized option labels", () => { + expect( + isPendingUserInputOptionSelected({ selectedOptionLabels: ["Orders"] }, " Orders "), + ).toBe(true); + expect( + isPendingUserInputOptionSelected( + { selectedOptionLabels: ["Orders"], customAnswer: "Orders first" }, + " Orders ", + ), + ).toBe(false); + }); +}); + function makeActivity( input: Partial & Pick, diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index cd8e8cad2122..fbcb2e1c7e2a 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -26,7 +26,7 @@ export interface PendingUserInput { } export interface PendingUserInputDraftAnswer { - readonly selectedOptionLabel?: string; + readonly selectedOptionLabels?: ReadonlyArray; readonly customAnswer?: string; } @@ -227,14 +227,32 @@ function normalizeDraftAnswer(value: string | undefined): string | null { return trimmed.length > 0 ? trimmed : null; } +function normalizeSelectedOptionLabels( + value: ReadonlyArray | undefined, +): ReadonlyArray { + if (!Array.isArray(value)) { + return []; + } + + return Array.from( + new Set(value.map((entry) => entry.trim()).filter((entry) => entry.length > 0)), + ); +} + function resolvePendingUserInputAnswer( + question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, -): string | null { +): string | ReadonlyArray | null { const customAnswer = normalizeDraftAnswer(draft?.customAnswer); if (customAnswer) { return customAnswer; } - return normalizeDraftAnswer(draft?.selectedOptionLabel); + + const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); + if (question.multiSelect) { + return selectedOptionLabels.length > 0 ? selectedOptionLabels : null; + } + return selectedOptionLabels[0] ?? null; } /** Codex children settle via task.updated (idle/failed/interrupted), never @@ -1428,22 +1446,62 @@ export function setPendingUserInputCustomAnswer( draft: PendingUserInputDraftAnswer | undefined, customAnswer: string, ): PendingUserInputDraftAnswer { - const selectedOptionLabel = - customAnswer.trim().length > 0 ? undefined : draft?.selectedOptionLabel; + const selectedOptionLabels = + customAnswer.trim().length > 0 + ? undefined + : normalizeSelectedOptionLabels(draft?.selectedOptionLabels); return { customAnswer, - ...(selectedOptionLabel ? { selectedOptionLabel } : {}), + ...(selectedOptionLabels && selectedOptionLabels.length > 0 ? { selectedOptionLabels } : {}), + }; +} + +export function isPendingUserInputOptionSelected( + draft: PendingUserInputDraftAnswer | undefined, + optionLabel: string, +): boolean { + if (normalizeDraftAnswer(draft?.customAnswer)) { + return false; + } + + return normalizeSelectedOptionLabels(draft?.selectedOptionLabels).includes(optionLabel.trim()); +} + +export function togglePendingUserInputOptionSelection( + question: UserInputQuestion, + draft: PendingUserInputDraftAnswer | undefined, + optionLabel: string, +): PendingUserInputDraftAnswer { + const normalizedOptionLabel = optionLabel.trim(); + + if (question.multiSelect) { + const selectedOptionLabels = normalizeSelectedOptionLabels(draft?.selectedOptionLabels); + const nextSelectedOptionLabels = selectedOptionLabels.includes(normalizedOptionLabel) + ? selectedOptionLabels.filter((label) => label !== normalizedOptionLabel) + : [...selectedOptionLabels, normalizedOptionLabel]; + + return { + customAnswer: "", + ...(nextSelectedOptionLabels.length > 0 + ? { selectedOptionLabels: nextSelectedOptionLabels } + : {}), + }; + } + + return { + customAnswer: "", + selectedOptionLabels: [normalizedOptionLabel], }; } export function buildPendingUserInputAnswers( questions: ReadonlyArray, draftAnswers: Record, -): Record | null { - const answers: Record = {}; +): Record> | null { + const answers: Record> = {}; for (const question of questions) { - const answer = resolvePendingUserInputAnswer(draftAnswers[question.id]); + const answer = resolvePendingUserInputAnswer(question, draftAnswers[question.id]); if (!answer) { return null; } diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index 82ff42f247a2..30b3a0704f8e 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -1,7 +1,11 @@ import { useAtomValue } from "@effect/atom-react"; import { useCallback, useMemo, useState } from "react"; -import { ApprovalRequestId, type ProviderApprovalDecision } from "@t3tools/contracts"; +import { + ApprovalRequestId, + type ProviderApprovalDecision, + type UserInputQuestion, +} from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { threadEnvironment } from "../state/threads"; @@ -12,6 +16,7 @@ import { derivePendingUserInputs, setPendingUserInputCustomAnswer, sortThreadActivities, + togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../lib/threadActivity"; import { appAtomRegistry } from "./atom-registry"; @@ -23,15 +28,21 @@ const userInputDraftsByRequestKeyAtom = Atom.make< Record> >({}).pipe(Atom.keepAlive, Atom.withLabel("mobile:user-input-drafts")); -function setUserInputDraftOption(requestKey: string, questionId: string, label: string): void { +function setUserInputDraftOption( + requestKey: string, + question: UserInputQuestion, + label: string, +): void { const current = appAtomRegistry.get(userInputDraftsByRequestKeyAtom); appAtomRegistry.set(userInputDraftsByRequestKeyAtom, { ...current, [requestKey]: { ...current[requestKey], - [questionId]: { - selectedOptionLabel: label, - }, + [question.id]: togglePendingUserInputOptionSelection( + question, + current[requestKey]?.[question.id], + label, + ), }, }); } @@ -97,13 +108,13 @@ export function useSelectedThreadRequests() { : null; const onSelectUserInputOption = useCallback( - (requestId: ApprovalRequestId, questionId: string, label: string) => { + (requestId: ApprovalRequestId, question: UserInputQuestion, label: string) => { if (!selectedThreadShell) { return; } const requestKey = scopedRequestKey(selectedThreadShell.environmentId, requestId); - setUserInputDraftOption(requestKey, questionId, label); + setUserInputDraftOption(requestKey, question, label); }, [selectedThreadShell], ); diff --git a/apps/web/package.json b/apps/web/package.json index dfec330a1079..68f6847b0164 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -21,7 +21,7 @@ "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", "@formkit/auto-animate": "^0.9.0", - "@legendapp/list": "3.3.3", + "@legendapp/list": "catalog:", "@lexical/react": "^0.41.0", "@pierre/diffs": "catalog:", "@pierre/trees": "1.0.0-beta.4", diff --git a/patches/@clerk__expo@4.2.0.patch b/patches/@clerk__expo@4.2.0.patch new file mode 100644 index 000000000000..2d4a9287c114 --- /dev/null +++ b/patches/@clerk__expo@4.2.0.patch @@ -0,0 +1,79 @@ +diff --git a/ios/ClerkAuthNativeView.swift b/ios/ClerkAuthNativeView.swift +index e76a8be1b1c8faa64ec6dfa83764764094133aff..17b36ad1319765e2b6db0551d32e07d7140e482f 100644 +--- a/ios/ClerkAuthNativeView.swift ++++ b/ios/ClerkAuthNativeView.swift +@@ -108,7 +108,12 @@ public class ClerkAuthNativeView: ClerkNativeViewHost { + + override func makeHostedController() -> UIViewController? { + let hostBackAction: (() -> Void)? = currentHostBackButton +- ? { [weak self] in self?.onHostBack([:]) } ++ ? { [weak self] in ++ guard let self else { return } ++ if !self.popEnclosingNavigationRoute() { ++ self.onHostBack([:]) ++ } ++ } + : nil + + return ClerkNativeBridge.shared.makeAuthViewController( +diff --git a/ios/ClerkNativeViewHost.swift b/ios/ClerkNativeViewHost.swift +index 0d91f0e749f121595c17bc803663df6ac90e4163..8f8a89df97a54168b8b45d0e9c197ca3953b7028 100644 +--- a/ios/ClerkNativeViewHost.swift ++++ b/ios/ClerkNativeViewHost.swift +@@ -5,6 +5,7 @@ public class ClerkNativeViewHost: ExpoView { + private lazy var hostingCoordinator = ClerkNativeHostingCoordinator(containerView: self) + private var hasInitialized: Bool = false + private var configuredObserver: NSObjectProtocol? ++ private var isPoppingHostRoute = false + + public required init(appContext: AppContext? = nil) { + super.init(appContext: appContext) +@@ -58,6 +59,30 @@ public class ClerkNativeViewHost: ExpoView { + + func hostedViewDidDetachFromWindow() {} + ++ /// Pops the React Navigation route that contains this view without waiting for ++ /// the JavaScript event loop. React Native Screens reports the native dismissal ++ /// back to React Navigation so its state remains synchronized. ++ func popEnclosingNavigationRoute() -> Bool { ++ guard !isPoppingHostRoute else { return true } ++ ++ var responder: UIResponder? = self ++ ++ while let nextResponder = responder?.next { ++ if let viewController = nextResponder as? UIViewController, ++ let navigationController = viewController.navigationController, ++ navigationController.viewControllers.count > 1 { ++ isPoppingHostRoute = true ++ if navigationController.popViewController(animated: true) != nil { ++ return true ++ } ++ isPoppingHostRoute = false ++ } ++ responder = nextResponder ++ } ++ ++ return false ++ } ++ + private func addConfiguredObserver() { + guard configuredObserver == nil else { return } + +diff --git a/ios/ClerkUserProfileNativeView.swift b/ios/ClerkUserProfileNativeView.swift +index 12d6248b1dc4b4779b252c9954c2f89145907310..d838283b7adcf49fec2a63bff53251b9bee31bf8 100644 +--- a/ios/ClerkUserProfileNativeView.swift ++++ b/ios/ClerkUserProfileNativeView.swift +@@ -44,7 +44,12 @@ public class ClerkUserProfileNativeView: ClerkNativeViewHost { + + override func makeHostedController() -> UIViewController? { + let hostBackAction: (() -> Void)? = currentHostBackButton +- ? { [weak self] in self?.onHostBack([:]) } ++ ? { [weak self] in ++ guard let self else { return } ++ if !self.popEnclosingNavigationRoute() { ++ self.onHostBack([:]) ++ } ++ } + : nil + + return ClerkNativeBridge.shared.makeUserProfileViewController( diff --git a/patches/@legendapp__list@3.3.3.patch b/patches/@legendapp__list@3.3.5.patch similarity index 87% rename from patches/@legendapp__list@3.3.3.patch rename to patches/@legendapp__list@3.3.5.patch index 4fa135d5aa08..60a5954d8f28 100644 --- a/patches/@legendapp__list@3.3.3.patch +++ b/patches/@legendapp__list@3.3.5.patch @@ -1,8 +1,8 @@ diff --git a/keyboard.d.ts b/keyboard.d.ts -index 7bc3bb8..75ec120 100644 +index 367945cdfa8a8c260b7a127657a75c016c9ab46f..95263268a47d3f1f57bbc5d528bcc77674f9121f 100644 --- a/keyboard.d.ts +++ b/keyboard.d.ts -@@ -277,7 +277,7 @@ type KeyboardChatComposerInsetListRef = { +@@ -279,7 +279,7 @@ type KeyboardChatComposerInsetListRef = { type KeyboardChatComposerRef = { current: Pick | null; }; @@ -11,7 +11,7 @@ index 7bc3bb8..75ec120 100644 contentInsetEndAdjustment: SharedValue; onComposerLayout: (event: LayoutChangeEvent) => void; }; -@@ -286,8 +286,10 @@ declare function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }: UseKeyb +@@ -288,8 +288,10 @@ declare function useKeyboardScrollToEnd({ freeze: freezeProp, listRef }: UseKeyb scrollMessageToEnd: ({ animated, closeKeyboard }: ScrollMessageToEndOptions) => Promise; }; declare const KeyboardAwareLegendList: (props: Omit, "anchoredEndSpace" | "contentInsetEndAdjustment" | "renderScrollComponent"> & KeyboardChatScrollViewPropsUnique & { @@ -23,7 +23,7 @@ index 7bc3bb8..75ec120 100644 } & React.RefAttributes) => React.ReactElement | null; diff --git a/keyboard.js b/keyboard.js -index 736286a..8218172 100644 +index 6645bcbb1f77c36c432035eaf8b2d6d924fc8bda..74c1f568c485d0f907a0d3ea4ad600ccb8d3be62 100644 --- a/keyboard.js +++ b/keyboard.js @@ -33,19 +33,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !reactNativeKeyboardController. @@ -62,8 +62,8 @@ index 736286a..8218172 100644 freeze, keyboardLiftBehavior, keyboardOffset, -@@ -109,11 +111,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( - includeInEndInset: true, +@@ -108,11 +110,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + ...anchoredEndSpace, onSizeChanged: (size) => { var _a; - blankSpace.value = size; @@ -80,7 +80,7 @@ index 736286a..8218172 100644 const onContentInsetChange = React.useCallback((insets) => { var _a; (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); -@@ -124,6 +130,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -123,6 +129,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( reactNativeKeyboardController.KeyboardChatScrollView, { ...scrollProps, @@ -88,7 +88,7 @@ index 736286a..8218172 100644 applyWorkaroundForContentInsetHitTestBug, blankSpace, extraContentPadding: contentInsetEndAdjustment, -@@ -135,6 +142,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -134,6 +141,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( ); }, [ @@ -97,15 +97,15 @@ index 736286a..8218172 100644 blankSpace, contentInsetEndAdjustment, @@ -149,6 +157,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( - AnimatedLegendListInternal, { anchoredEndSpace: anchoredEndSpaceWithBlankSpace, + anchoredEndSpaceOwnerInternal: "scroll", + contentInsetEndAdjustment: contentInsetEndStaticAdjustment, ref: combinedRef, renderScrollComponent: memoList, ...rest diff --git a/keyboard.mjs b/keyboard.mjs -index c1dd270..cb0d142 100644 +index 87b38b9607c2eba6b407c2acfd520633bdb0e7c2..62206d657d5616893985fd2c228e57af7eba744b 100644 --- a/keyboard.mjs +++ b/keyboard.mjs @@ -12,19 +12,19 @@ if (typeof __DEV__ !== "undefined" && __DEV__ && !KeyboardChatScrollView) { @@ -144,8 +144,8 @@ index c1dd270..cb0d142 100644 freeze, keyboardLiftBehavior, keyboardOffset, -@@ -88,11 +90,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( - includeInEndInset: true, +@@ -87,11 +89,15 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( + ...anchoredEndSpace, onSizeChanged: (size) => { var _a; - blankSpace.value = size; @@ -162,7 +162,7 @@ index c1dd270..cb0d142 100644 const onContentInsetChange = useCallback((insets) => { var _a; (_a = refLegendList.current) == null ? void 0 : _a.reportContentInset(insets); -@@ -103,6 +109,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -102,6 +108,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( KeyboardChatScrollView, { ...scrollProps, @@ -170,7 +170,7 @@ index c1dd270..cb0d142 100644 applyWorkaroundForContentInsetHitTestBug, blankSpace, extraContentPadding: contentInsetEndAdjustment, -@@ -114,6 +121,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( +@@ -113,6 +120,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( ); }, [ @@ -179,15 +179,15 @@ index c1dd270..cb0d142 100644 blankSpace, contentInsetEndAdjustment, @@ -128,6 +136,7 @@ var KeyboardAwareLegendList = typedForwardRef(function KeyboardAwareLegendList2( - AnimatedLegendListInternal, { anchoredEndSpace: anchoredEndSpaceWithBlankSpace, + anchoredEndSpaceOwnerInternal: "scroll", + contentInsetEndAdjustment: contentInsetEndStaticAdjustment, ref: combinedRef, renderScrollComponent: memoList, ...rest diff --git a/react-native.d.ts b/react-native.d.ts -index 8204015..cdeaab7 100644 +index ce1fe00001c9e5aee6c6ea8bb2d4757d4586d002..3ccf6f16067152dfcb0c143371e2ec6aba6636e5 100644 --- a/react-native.d.ts +++ b/react-native.d.ts @@ -293,6 +293,12 @@ interface LegendListSpecificProps { @@ -204,10 +204,10 @@ index 8204015..cdeaab7 100644 * Number of columns to render items in. * @default 1 diff --git a/react-native.js b/react-native.js -index 229f09a..2a1ceb6 100644 +index b3c5a306b293f797a8b338adfca3060c0f6db22b..89077be4d6833cfaabf9d6d6205d9551505e32b4 100644 --- a/react-native.js +++ b/react-native.js -@@ -930,7 +930,7 @@ function setInitialRenderState(ctx, { +@@ -954,7 +954,7 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -216,7 +216,7 @@ index 229f09a..2a1ceb6 100644 if (isReadyToRender && !peek$(ctx, "readyToRender")) { set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal", "ready"); -@@ -1259,18 +1259,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +@@ -1304,18 +1304,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { } // src/core/clampScrollOffset.ts @@ -242,7 +242,7 @@ index 229f09a..2a1ceb6 100644 return clampedOffset; } -@@ -1406,10 +1411,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1451,10 +1456,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -255,19 +255,19 @@ index 229f09a..2a1ceb6 100644 x: ctx.state.props.horizontal ? offset : 0, y: ctx.state.props.horizontal ? 0 : offset }); -@@ -1456,7 +1461,10 @@ function checkFinishedScrollFallback(ctx) { - }); - scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); - } else if (shouldRetryUnalignedEndScroll) { -- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); -+ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; -+ if (!isActivelyAnimatingToEnd) { -+ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); -+ } - scheduleFallbackCheck(100); - } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { - finishScrollTo(ctx); -@@ -1517,9 +1525,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1503,7 +1508,10 @@ function checkFinishedScrollFallback(ctx) { + ); + scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); + } else if (shouldRetryUnalignedEndScroll) { +- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); ++ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; ++ if (!isActivelyAnimatingToEnd) { ++ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); ++ } + scheduleFallbackCheck(100); + } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { + finishScrollTo(ctx); +@@ -1566,9 +1574,18 @@ function doMaintainScrollAtEnd(ctx) { } if (shouldMaintainScrollAtEnd) { state.pendingMaintainScrollAtEnd = false; @@ -287,7 +287,7 @@ index 229f09a..2a1ceb6 100644 } if (!state.maintainingScrollAtEnd) { const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; -@@ -1539,9 +1556,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1591,9 +1608,18 @@ function doMaintainScrollAtEnd(ctx) { y: 0 }); } else { @@ -309,7 +309,7 @@ index 229f09a..2a1ceb6 100644 } setTimeout( () => { -@@ -1571,6 +1597,10 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1624,6 +1650,10 @@ function doMaintainScrollAtEnd(ctx) { function requestAdjust(ctx, positionDiff, dataChanged) { const state = ctx.state; if (Math.abs(positionDiff) > 0.1) { @@ -320,7 +320,7 @@ index 229f09a..2a1ceb6 100644 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; const doit = () => { if (needsScrollWorkaround) { -@@ -1674,7 +1704,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { +@@ -1728,7 +1758,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { return 0; } @@ -331,7 +331,7 @@ index 229f09a..2a1ceb6 100644 const clampDelta = maxScroll - state.scroll; if (unresolvedAmount < 0) { return Math.max(unresolvedAmount, Math.min(0, clampDelta)); -@@ -1736,7 +1768,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { +@@ -1790,7 +1822,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); return true; } @@ -340,7 +340,7 @@ index 229f09a..2a1ceb6 100644 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; if (isAtExpectedNativeClamp) { -@@ -1869,7 +1901,7 @@ function prepareMVCP(ctx, dataChanged) { +@@ -1923,7 +1955,7 @@ function prepareMVCP(ctx, dataChanged) { if (diff > 0) { diff = Math.max(0, totalSize - state.scroll - state.scrollLength); } else { @@ -349,7 +349,7 @@ index 229f09a..2a1ceb6 100644 state.scroll = maxScroll; state.scrollPending = maxScroll; diff = 0; -@@ -2274,8 +2306,121 @@ function scrollToIndex(ctx, { +@@ -2320,8 +2352,121 @@ function scrollToIndex(ctx, { } // src/core/initialScroll.ts @@ -471,7 +471,7 @@ index 229f09a..2a1ceb6 100644 const requestedIndex = target.index; const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; const itemSize = getItemSizeAtIndex(ctx, index); -@@ -2704,7 +2849,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { +@@ -2747,7 +2892,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { return; } if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { @@ -482,9 +482,9 @@ index 229f09a..2a1ceb6 100644 if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { -@@ -4637,7 +4784,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { - } - contentBelowAnchor += footerSize + stylePaddingBottom; +@@ -4672,7 +4819,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { + contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); + contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); isReady = !hasUnknownTailSize; - nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); + const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); @@ -492,20 +492,20 @@ index 229f09a..2a1ceb6 100644 } else if (anchorIndex >= 0) { isReady = false; } -@@ -4655,6 +4803,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { - updateScroll(ctx, state.scroll, true); +@@ -4692,6 +4840,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { + updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); + } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { + set$(ctx, "anchoredEndSpaceSize", nextSize); + (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); + if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { -+ updateScroll(ctx, state.scroll, true); ++ updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); + } } return nextSize; } -@@ -6960,6 +7114,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7075,6 +7229,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -513,7 +513,7 @@ index 229f09a..2a1ceb6 100644 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -6990,6 +7145,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7105,6 +7260,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onLayout: onLayoutProp, onLoad, onMomentumScrollEnd, @@ -521,7 +521,7 @@ index 229f09a..2a1ceb6 100644 onRefresh, onScroll: onScrollProp, onScrollBeginDrag, -@@ -7076,7 +7232,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7200,7 +7356,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const combinedRef = useCombinedRef(refScroller, refScrollView); const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); const stickyHeaderIndices = stickyHeaderIndicesProp; @@ -530,7 +530,7 @@ index 229f09a..2a1ceb6 100644 const previousContentInsetEndAdjustmentRef = React2.useRef(contentInsetEndAdjustmentResolved); const alwaysRenderIndices = React2.useMemo(() => { const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); -@@ -7215,6 +7371,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7341,6 +7497,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -538,7 +538,7 @@ index 229f09a..2a1ceb6 100644 data: dataProp, dataKey, dataVersion, -@@ -7303,6 +7460,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7423,6 +7580,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -552,7 +552,7 @@ index 229f09a..2a1ceb6 100644 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); React2.useLayoutEffect(() => { -@@ -7526,6 +7690,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7651,6 +7815,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onScroll: (event) => onScroll(ctx, event), onScrollBeginDrag: (event) => { var _a4, _b2; @@ -560,7 +560,7 @@ index 229f09a..2a1ceb6 100644 prepareReachedEdgeForNextUserScroll(ctx); (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); }, -@@ -7555,6 +7720,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7681,6 +7846,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onLayout, onLayoutFooter, onMomentumScrollEnd: fns.onMomentumScrollEnd, @@ -569,10 +569,10 @@ index 229f09a..2a1ceb6 100644 recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2__namespace.cloneElement(refreshControlElement, { diff --git a/react-native.mjs b/react-native.mjs -index c2e0f38..5313086 100644 +index 40e87cda8c9bc79a889e5542f29af429a24b24d4..2d556eecb0bafe2f54084c809f82144c7a5dce7f 100644 --- a/react-native.mjs +++ b/react-native.mjs -@@ -909,7 +909,7 @@ function setInitialRenderState(ctx, { +@@ -933,7 +933,7 @@ function setInitialRenderState(ctx, { if (didInitialScroll) { state.didFinishInitialScroll = true; } @@ -581,7 +581,7 @@ index c2e0f38..5313086 100644 if (isReadyToRender && !peek$(ctx, "readyToRender")) { set$(ctx, "readyToRender", true); setAdaptiveRender(ctx, "normal", "ready"); -@@ -1238,18 +1238,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { +@@ -1283,18 +1283,23 @@ function calculateOffsetWithOffsetPosition(ctx, offsetParam, params) { } // src/core/clampScrollOffset.ts @@ -607,7 +607,7 @@ index c2e0f38..5313086 100644 return clampedOffset; } -@@ -1385,10 +1390,10 @@ function checkFinishedScrollFrame(ctx) { +@@ -1430,10 +1435,10 @@ function checkFinishedScrollFrame(ctx) { finishScrollTo(ctx); } } @@ -620,19 +620,19 @@ index c2e0f38..5313086 100644 x: ctx.state.props.horizontal ? offset : 0, y: ctx.state.props.horizontal ? 0 : offset }); -@@ -1435,7 +1440,10 @@ function checkFinishedScrollFallback(ctx) { - }); - scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); - } else if (shouldRetryUnalignedEndScroll) { -- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); -+ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; -+ if (!isActivelyAnimatingToEnd) { -+ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); -+ } - scheduleFallbackCheck(100); - } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { - finishScrollTo(ctx); -@@ -1496,9 +1504,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1482,7 +1487,10 @@ function checkFinishedScrollFallback(ctx) { + ); + scheduleFallbackCheck(SILENT_INITIAL_SCROLL_RETRY_DELAY_MS); + } else if (shouldRetryUnalignedEndScroll) { +- scrollToFallbackOffset(ctx, completionState.clampedTargetOffset); ++ const isActivelyAnimatingToEnd = !!isStillScrollingTo.animated && Date.now() - state.scrollTime < 100; ++ if (!isActivelyAnimatingToEnd) { ++ scrollToFallbackOffset(ctx, completionState.clampedTargetOffset, !!isStillScrollingTo.animated); ++ } + scheduleFallbackCheck(100); + } else if (shouldFinishZeroTarget || shouldFinishAfterObservedScroll || canFinishInitialScrollWithoutNativeProgress || canFinishAfterSilentNativeDispatch || numChecks > maxChecks) { + finishScrollTo(ctx); +@@ -1545,9 +1553,18 @@ function doMaintainScrollAtEnd(ctx) { } if (shouldMaintainScrollAtEnd) { state.pendingMaintainScrollAtEnd = false; @@ -652,7 +652,7 @@ index c2e0f38..5313086 100644 } if (!state.maintainingScrollAtEnd) { const pendingState = maintainScrollAtEnd.animated ? "pending-animated" : "pending-instant"; -@@ -1518,9 +1535,18 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1570,9 +1587,18 @@ function doMaintainScrollAtEnd(ctx) { y: 0 }); } else { @@ -674,7 +674,7 @@ index c2e0f38..5313086 100644 } setTimeout( () => { -@@ -1550,6 +1576,10 @@ function doMaintainScrollAtEnd(ctx) { +@@ -1603,6 +1629,10 @@ function doMaintainScrollAtEnd(ctx) { function requestAdjust(ctx, positionDiff, dataChanged) { const state = ctx.state; if (Math.abs(positionDiff) > 0.1) { @@ -685,7 +685,7 @@ index c2e0f38..5313086 100644 const needsScrollWorkaround = Platform.OS === "android" && !IsNewArchitecture && dataChanged && state.scroll <= positionDiff; const doit = () => { if (needsScrollWorkaround) { -@@ -1653,7 +1683,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { +@@ -1707,7 +1737,9 @@ function getPredictedNativeClamp(state, unresolvedAmount, totalSize) { if (Math.abs(unresolvedAmount) <= MVCP_POSITION_EPSILON) { return 0; } @@ -696,7 +696,7 @@ index c2e0f38..5313086 100644 const clampDelta = maxScroll - state.scroll; if (unresolvedAmount < 0) { return Math.max(unresolvedAmount, Math.min(0, clampDelta)); -@@ -1715,7 +1747,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { +@@ -1769,7 +1801,7 @@ function resolvePendingNativeMVCPAdjust(ctx, newScroll) { settlePendingNativeMVCPAdjust(ctx, remainingAfterManual, nativeDelta); return true; } @@ -705,7 +705,7 @@ index c2e0f38..5313086 100644 const distanceToClamp = Math.abs(newScroll - expectedNativeClampScroll); const isAtExpectedNativeClamp = distanceToClamp <= NATIVE_END_CLAMP_EPSILON; if (isAtExpectedNativeClamp) { -@@ -1848,7 +1880,7 @@ function prepareMVCP(ctx, dataChanged) { +@@ -1902,7 +1934,7 @@ function prepareMVCP(ctx, dataChanged) { if (diff > 0) { diff = Math.max(0, totalSize - state.scroll - state.scrollLength); } else { @@ -714,7 +714,7 @@ index c2e0f38..5313086 100644 state.scroll = maxScroll; state.scrollPending = maxScroll; diff = 0; -@@ -2253,8 +2285,121 @@ function scrollToIndex(ctx, { +@@ -2299,8 +2331,121 @@ function scrollToIndex(ctx, { } // src/core/initialScroll.ts @@ -836,7 +836,7 @@ index c2e0f38..5313086 100644 const requestedIndex = target.index; const index = requestedIndex !== void 0 ? clampScrollIndex(requestedIndex, ctx.state.props.data.length) : void 0; const itemSize = getItemSizeAtIndex(ctx, index); -@@ -2683,7 +2828,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { +@@ -2726,7 +2871,9 @@ function clearFinishedBootstrapInitialScrollTargetIfMovedAway(ctx) { return; } if (didFinishedInitialScrollMoveAwayFromTarget(ctx, initialScroll)) { @@ -847,9 +847,9 @@ index c2e0f38..5313086 100644 if (!shouldKeepEndTargetAlive) { if (shouldPreserveInitialScrollForFooterLayout(initialScroll)) { clearPendingInitialScrollFooterLayout(ctx, { -@@ -4616,7 +4763,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { - } - contentBelowAnchor += footerSize + stylePaddingBottom; +@@ -4651,7 +4798,8 @@ function maybeUpdateAnchoredEndSpace(ctx) { + contentBelowAnchor = Math.max(0, contentBelowAnchor - ctx.scrollAxisGap); + contentBelowAnchor += (ctx.values.get("footerSize") || 0) + getStylePaddingEnd(state.props); isReady = !hasUnknownTailSize; - nextSize = hasUnknownTailSize ? previousSize || 0 : Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); + const knownSizeBound = Math.max(0, state.scrollLength - contentBelowAnchor - anchorOffset); @@ -857,20 +857,20 @@ index c2e0f38..5313086 100644 } else if (anchorIndex >= 0) { isReady = false; } -@@ -4634,6 +4782,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { - updateScroll(ctx, state.scroll, true); +@@ -4671,6 +4819,12 @@ function maybeUpdateAnchoredEndSpace(ctx) { + updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); } (_b = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onReady) == null ? void 0 : _b.call(anchoredEndSpace, { anchorIndex: nextAnchorIndex, anchorKey: nextAnchorKey, size: nextSize }); + } else if (!isReady && didSizeChange && nextSize < (previousSize || 0)) { + set$(ctx, "anchoredEndSpaceSize", nextSize); + (_a3 = anchoredEndSpace == null ? void 0 : anchoredEndSpace.onSizeChanged) == null ? void 0 : _a3.call(anchoredEndSpace, nextSize); + if (anchoredEndSpace == null ? void 0 : anchoredEndSpace.includeInEndInset) { -+ updateScroll(ctx, state.scroll, true); ++ updateScroll(ctx, state.scroll, true, { markHasScrolled: false }); + } } return nextSize; } -@@ -6939,6 +7093,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7054,6 +7208,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded dataVersion, drawDistance = 250, contentInsetEndAdjustment, @@ -878,7 +878,7 @@ index c2e0f38..5313086 100644 estimatedItemSize = 100, estimatedListSize, extraData, -@@ -7055,7 +7210,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7179,7 +7334,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded const combinedRef = useCombinedRef(refScroller, refScrollView); const keyExtractor = keyExtractorProp != null ? keyExtractorProp : ((_item, index) => index.toString()); const stickyHeaderIndices = stickyHeaderIndicesProp; @@ -887,7 +887,7 @@ index c2e0f38..5313086 100644 const previousContentInsetEndAdjustmentRef = useRef(contentInsetEndAdjustmentResolved); const alwaysRenderIndices = useMemo(() => { const indices = getAlwaysRenderIndices(alwaysRender, dataProp, keyExtractor, anchoredEndSpace == null ? void 0 : anchoredEndSpace.anchorIndex); -@@ -7194,6 +7349,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7320,6 +7475,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded contentContainerAlignItems: contentContainerStyle.alignItems, contentInset, contentInsetEndAdjustment: contentInsetEndAdjustmentResolved, @@ -895,7 +895,7 @@ index c2e0f38..5313086 100644 data: dataProp, dataKey, dataVersion, -@@ -7282,6 +7438,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7402,6 +7558,13 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded return void 0; } const resolvedOffset = (_a4 = initialScroll.contentOffset) != null ? _a4 : resolveInitialScrollOffset(ctx, initialScroll); @@ -909,7 +909,7 @@ index c2e0f38..5313086 100644 return usesBootstrapInitialScroll && ((_b2 = state.initialScrollSession) == null ? void 0 : _b2.kind) === "bootstrap" && Platform.OS === "web" ? void 0 : resolvedOffset; }, [usesBootstrapInitialScroll]); useLayoutEffect(() => { -@@ -7505,6 +7668,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7630,6 +7793,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onScroll: (event) => onScroll(ctx, event), onScrollBeginDrag: (event) => { var _a4, _b2; @@ -917,7 +917,7 @@ index c2e0f38..5313086 100644 prepareReachedEdgeForNextUserScroll(ctx); (_b2 = (_a4 = state.props).onScrollBeginDrag) == null ? void 0 : _b2.call(_a4, event); }, -@@ -7534,6 +7698,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded +@@ -7660,6 +7824,7 @@ var LegendListInner = typedForwardRef(function LegendListInner2(props, forwarded onLayout, onLayoutFooter, onMomentumScrollEnd: fns.onMomentumScrollEnd, @@ -926,7 +926,7 @@ index c2e0f38..5313086 100644 recycleItems, refreshControl: refreshControlElement ? stylePaddingTopState > 0 ? React2.cloneElement(refreshControlElement, { diff --git a/reanimated.d.ts b/reanimated.d.ts -index 940da28..28dccbe 100644 +index e5043320700b12f34f4c0babbc341f85ca8135c1..2ce63830a28636b21937a0741fd0e613dae950fe 100644 --- a/reanimated.d.ts +++ b/reanimated.d.ts @@ -294,6 +294,12 @@ interface LegendListSpecificProps { @@ -943,7 +943,7 @@ index 940da28..28dccbe 100644 * Number of columns to render items in. * @default 1 diff --git a/reanimated.js b/reanimated.js -index f1265fa..16dcef0 100644 +index f1265fad74189591b5aae86cf2e3a31f9c0fdb02..16dcef04a6591d500c724635df272274123bad2d 100644 --- a/reanimated.js +++ b/reanimated.js @@ -116,7 +116,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( @@ -978,7 +978,7 @@ index f1265fa..16dcef0 100644 style: viewStyle, ...rest diff --git a/reanimated.mjs b/reanimated.mjs -index 29a00d5..9c25ec5 100644 +index 29a00d5dc084fd408a0e0a0a4d64e856d0ed1525..9c25ec5dd79fb137c073adc24643ad8a2996bf56 100644 --- a/reanimated.mjs +++ b/reanimated.mjs @@ -92,7 +92,7 @@ var ReanimatedPositionView = typedMemo(function ReanimatedPositionViewComponent( diff --git a/patches/@react-native-menu__menu@2.0.0.patch b/patches/@react-native-menu__menu@2.0.0.patch index f03ef60bb5b7..8794cf208eee 100644 --- a/patches/@react-native-menu__menu@2.0.0.patch +++ b/patches/@react-native-menu__menu@2.0.0.patch @@ -1,9 +1,117 @@ +diff --git a/ios/NewArch/MenuView.mm b/ios/NewArch/MenuView.mm +index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..066b01259a3c8ad9532688606be3d29a94a8f7d4 100644 +--- a/ios/NewArch/MenuView.mm ++++ b/ios/NewArch/MenuView.mm +@@ -105,6 +105,27 @@ - (void)onOpenMenu { + NSMutableArray *subactionsArray = [NSMutableArray arrayWithCapacity:actions.size()]; + if (action.subactions.size() > 0) { + for (const MenuViewActionsSubactionsStruct &subaction : action.subactions) { ++ NSMutableArray *subSubactionsArray = ++ [NSMutableArray arrayWithCapacity:subaction.subactions.size()]; ++ for (const MenuViewActionsSubactionsSubactionsStruct &subSubaction : subaction.subactions) { ++ NSDictionary *subSubactionDict = @{ ++ @"id": [NSString stringWithUTF8String:subSubaction.id.c_str()], ++ @"title": [NSString stringWithUTF8String:subSubaction.title.c_str()], ++ @"titleColor": @(subSubaction.titleColor), ++ @"subtitle": [NSString stringWithUTF8String:subSubaction.subtitle.c_str()], ++ @"state": [NSString stringWithUTF8String:subSubaction.state.c_str()], ++ @"image": [NSString stringWithUTF8String:subSubaction.image.c_str()], ++ @"imageColor": @(subSubaction.imageColor), ++ @"displayInline": @(subSubaction.displayInline), ++ @"attributes": @{ ++ @"destructive": @(subSubaction.attributes.destructive), ++ @"disabled": @(subSubaction.attributes.disabled), ++ @"hidden": @(subSubaction.attributes.hidden), ++ @"keepsMenuPresented": @(subSubaction.attributes.keepsMenuPresented), ++ }, ++ }; ++ [subSubactionsArray addObject:subSubactionDict]; ++ } + NSDictionary *subactionDict = @{ + @"id": [NSString stringWithUTF8String:subaction.id.c_str()], + @"title": [NSString stringWithUTF8String:subaction.title.c_str()], +@@ -118,7 +139,9 @@ - (void)onOpenMenu { + @"destructive": @(subaction.attributes.destructive), + @"disabled": @(subaction.attributes.disabled), + @"hidden": @(subaction.attributes.hidden), ++ @"keepsMenuPresented": @(subaction.attributes.keepsMenuPresented), + }, ++ @"subactions": subSubactionsArray, + }; + [subactionsArray addObject:subactionDict]; + } +@@ -138,6 +161,7 @@ - (void)onOpenMenu { + @"destructive": @(action.attributes.destructive), + @"disabled": @(action.attributes.disabled), + @"hidden": @(action.attributes.hidden), ++ @"keepsMenuPresented": @(action.attributes.keepsMenuPresented), + }, + @"subactions": subactionsArray, + }; diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift -index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e71e734d70 100644 +index 5c4e0da4292b15d3a27b5ea1555f11452a470815..db134864676ed83dbcd895d7a1bde38e8037a005 100644 --- a/ios/Shared/MenuViewImplementation.swift +++ b/ios/Shared/MenuViewImplementation.swift -@@ -88,6 +88,41 @@ public class MenuViewImplementation: UIButton { +@@ -59,18 +59,43 @@ public class MenuViewImplementation: UIButton { + self.setup() + } + ++ // Presentation is tracked from the two delegate methods the class already ++ // overrode. Overriding willDisplayMenuFor as well (even for bookkeeping) ++ // shadows UIButton's own implementation and degrades the button-anchored ++ // presentation into generic context-menu chrome — an empty header row with ++ // a dismiss chevron appears above the actions. + public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { ++ // Flush updates deferred by the presented-guard before the action ++ // provider snapshots self.menu (covers a stuck flag from an ++ // interaction that never ended cleanly). ++ if pendingMenu != nil { ++ pendingMenu = nil ++ isMenuPresented = false ++ self.setup() ++ } ++ isMenuPresented = true + sendMenuOpen() + return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in + guard let self = self else { return nil } + return self.menu + } + } +- ++ + public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) { + sendMenuClose() ++ isMenuPresented = false ++ if pendingMenu != nil { ++ // Re-run the full assignment now that presentation is over, so ++ // any props deferred by the presented-guard (menu contents, press ++ // mode) land on the button. ++ pendingMenu = nil ++ self.setup() ++ } + } ++ private var isMenuPresented = false ++ private var pendingMenu: UIMenu? ++ + func setup () { + let menu = UIMenu(title: _title, + identifier: nil, +@@ -86,8 +111,98 @@ public class MenuViewImplementation: UIButton { + } + } + ++ if isMenuPresented { ++ // An action fired with keepsMenuPresented leaves the menu on ++ // screen, and reassigning self.menu while it is presented makes ++ // UIKit dismiss it — the update triggered by a selection would ++ // defeat the keep-presented attribute. Update the visible copy in ++ // place instead and defer the button assignment to dismissal. ++ pendingMenu = menu ++ self.refreshPresentedMenu(menu) ++ return ++ } ++ self.menu = menu self.showsMenuAsPrimaryAction = !shouldOpenOnLongPress + // In long-press mode the button must not intercept touches: as a @@ -17,6 +125,50 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e7 + self.updateLongPressInteraction() + } + ++ private func refreshPresentedMenu(_ menu: UIMenu) { ++ // Tap mode presents through the button's built-in interaction, ++ // long-press mode through the superview-hosted one; cover both (plus ++ // the interaction added in init) and dedupe by identity. ++ var candidates = self.interactions.compactMap { $0 as? UIContextMenuInteraction } ++ if let builtin = self.contextMenuInteraction { ++ candidates.append(builtin) ++ } ++ if let host = longPressInteraction { ++ candidates.append(host) ++ } ++ var visited: Set = [] ++ for interaction in candidates where visited.insert(ObjectIdentifier(interaction)).inserted { ++ // The block receives whichever menu level is currently on screen — ++ // the navigated submenu when the user picked inside one, not the ++ // root. Swap in the matching node from the rebuilt tree (stable ++ // identifiers from the JS action ids) so that level updates in ++ // place; returning an unrelated menu instead makes UIKit render it ++ // as navigation into a foreign menu, with a stale or blank ++ // expanded-submenu header row above the actions. The root carries ++ // an auto-generated identifier that never matches, so it falls ++ // through to a children-only replacement. ++ interaction.updateVisibleMenu { [weak self] visibleMenu in ++ guard let self = self else { return visibleMenu } ++ if let replacement = self.menuMatching(visibleMenu.identifier, in: menu) { ++ return replacement ++ } ++ return visibleMenu.replacingChildren(menu.children) ++ } ++ } ++ } ++ ++ private func menuMatching(_ identifier: UIMenu.Identifier, in menu: UIMenu) -> UIMenu? { ++ if menu.identifier == identifier { ++ return menu ++ } ++ for element in menu.children { ++ if let submenu = element as? UIMenu, let match = menuMatching(identifier, in: submenu) { ++ return match ++ } ++ } ++ return nil ++ } ++ + private var longPressInteraction: UIContextMenuInteraction? + + public override func didMoveToSuperview() { @@ -44,3 +196,74 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e7 } public override func reactSetFrame(_ frame: CGRect) { +diff --git a/ios/Shared/RCTMenuItem.swift b/ios/Shared/RCTMenuItem.swift +index bb6bb2b7ad56135089f267587c974b166760539d..949b3f7ec49af7ba26d966a8923a51f619443d5a 100644 +--- a/ios/Shared/RCTMenuItem.swift ++++ b/ios/Shared/RCTMenuItem.swift +@@ -103,10 +103,18 @@ class RCTMenuAction { + subMenuActions.append(subaction.createUIMenuElement(handler)) + } + var menu: UIMenu; ++ // Stable identifiers let updateVisibleMenu match submenu nodes in ++ // place, so a refresh while the menu is presented keeps the user's ++ // current submenu level instead of popping back to the root. ++ // Inline sections don't navigate, so they stay stock. ++ let menuIdentifier = identifier.map { UIMenu.Identifier(rawValue: $0.rawValue) } + if self.displayInline { + menu = UIMenu(title: title, image: image, options: .displayInline, children: subMenuActions) + } else { +- menu = UIMenu(title: title, image: image, children: subMenuActions) ++ menu = UIMenu(title: title, image: image, identifier: menuIdentifier, children: subMenuActions) ++ } ++ if #available(iOS 15.0, *) { ++ menu.subtitle = subtitle + } + + if #available(iOS 16.0, *) { +diff --git a/src/NativeModuleSpecs/UIMenuNativeComponent.ts b/src/NativeModuleSpecs/UIMenuNativeComponent.ts +index e6509355275596c451f9d082223cc16bfe504e1a..c783cdaf73f5635cf9835824ca85fba3b46453b4 100644 +--- a/src/NativeModuleSpecs/UIMenuNativeComponent.ts ++++ b/src/NativeModuleSpecs/UIMenuNativeComponent.ts +@@ -13,6 +13,22 @@ import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNati + types here, to avoid issues while `pod install` takes place. + */ + ++type SubSubAction = { ++ id?: string; ++ title: string; ++ titleColor?: Int32; ++ subtitle?: string; ++ state?: string; ++ image?: string; ++ imageColor?: Int32; ++ displayInline?: boolean; ++ attributes?: { ++ destructive?: boolean; ++ disabled?: boolean; ++ hidden?: boolean; ++ keepsMenuPresented?: boolean; ++ }; ++}; + type SubAction = { + id?: string; + title: string; +@@ -26,7 +42,11 @@ type SubAction = { + destructive?: boolean; + disabled?: boolean; + hidden?: boolean; ++ keepsMenuPresented?: boolean; + }; ++ // One extra nesting level (menu → submenu → nested submenu leaves); the ++ // codegen structs can't recurse, so depth is capped explicitly. ++ subactions?: Array; + }; + type MenuAction = { + id?: string; +@@ -41,6 +61,7 @@ type MenuAction = { + destructive?: boolean; + disabled?: boolean; + hidden?: boolean; ++ keepsMenuPresented?: boolean; + }; + subactions?: Array; + }; diff --git a/patches/react-native-screens@4.25.2.patch b/patches/react-native-screens@4.25.2.patch index 7bd9fb744e9d..605366ff19a7 100644 --- a/patches/react-native-screens@4.25.2.patch +++ b/patches/react-native-screens@4.25.2.patch @@ -140,11 +140,24 @@ index 919b984edc9f91ee9ac26faf257d8a721e26457c..5bb0cd6736ed6bc51db57e2a9326f758 NS_ASSUME_NONNULL_END diff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mm -index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d652cbb83 100644 +index 5970e3e3a624b9498b8dedfc16831df03a274d0c..5ebff085788d813f1139eb6f9129fb2199dcbd58 100644 --- a/ios/RNSScreenStackHeaderConfig.mm +++ b/ios/RNSScreenStackHeaderConfig.mm -@@ -30,6 +30,20 @@ +@@ -25,11 +25,33 @@ + #import "RNSSearchBar.h" + #import "UINavigationBar+RNSUtility.h" + ++#import ++ + namespace react = facebook::react; + static const NSNumber *const DEFAULT_TITLE_FONT_SIZE = @17; ++ ++// Keys for the last-applied JS bar button configs, associated with the ++// navigation item so unrelated header updates (title, subtitle, tint) don't ++// recreate the native buttons they configure. ++static char RNSAppliedHeaderBarButtonConfigsKey; ++static char RNSAppliedToolbarConfigsKey; static const NSNumber *const DEFAULT_TITLE_LARGE_FONT_SIZE = @34; +static NSInteger navigationItemStyleFromCppEquivalent( @@ -164,7 +177,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d @interface RCTImageLoader (Private) - (id)imageCache; @end -@@ -47,6 +61,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string +@@ -47,6 +69,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string @end @interface RNSScreenStackHeaderConfig () @@ -174,7 +187,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d @end @implementation RNSScreenStackHeaderConfig { -@@ -81,6 +98,7 @@ - (void)initProps +@@ -81,6 +106,7 @@ - (void)initProps self.hidden = YES; _reactSubviews = [NSMutableArray new]; _backTitleVisible = YES; @@ -182,7 +195,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d _blurEffect = RNSBlurEffectStyleNone; } -@@ -496,6 +514,10 @@ + (void)updateViewController:(UIViewController *)vc +@@ -496,6 +522,10 @@ + (void)updateViewController:(UIViewController *)vc if (shouldHide) { navitem.title = config.title; @@ -193,7 +206,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items. [navctr setNavigationBarHidden:YES animated:animated]; -@@ -512,11 +534,19 @@ + (void)updateViewController:(UIViewController *)vc +@@ -512,11 +542,19 @@ + (void)updateViewController:(UIViewController *)vc } navitem.largeTitleDisplayMode = config.largeTitle ? UINavigationItemLargeTitleDisplayModeAlways : UINavigationItemLargeTitleDisplayModeNever; @@ -213,7 +226,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d // appearance does not apply to the tvOS so we need to use lagacy customization #if TARGET_OS_TV -@@ -637,10 +667,286 @@ + (void)updateViewController:(UIViewController *)vc +@@ -637,10 +675,384 @@ + (void)updateViewController:(UIViewController *)vc // This assignment should be done after `navitem.titleView = ...` assignment (iOS 16.0 bug). // See: https://github.com/software-mansion/react-native-screens/issues/1570 (comments) navitem.title = config.title; @@ -221,32 +234,62 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d - withCurrentItems:navitem.leftBarButtonItems]; - navitem.rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems - withCurrentItems:navitem.rightBarButtonItems]; -+ NSArray *leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems -+ withCurrentItems:navitem.leftBarButtonItems -+ navigationItem:navitem]; -+ NSArray *rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems -+ withCurrentItems:navitem.rightBarButtonItems -+ navigationItem:navitem]; -+ NSArray *centerBarButtonItems = [config barButtonItemsFromConfigs:config.headerCenterBarButtonItems -+ withCurrentItems:@[] ++ NSArray *headerLeftConfigs = config.headerLeftBarButtonItems ?: @[]; ++ NSArray *headerRightConfigs = config.headerRightBarButtonItems ?: @[]; ++ NSArray *headerCenterConfigs = config.headerCenterBarButtonItems ?: @[]; ++ NSArray *subviewLeftItems = navitem.leftBarButtonItems ?: @[]; ++ NSArray *subviewRightItems = navitem.rightBarButtonItems ?: @[]; ++ // The key includes the config instance's identity: cached items capture ++ // this config's event emitter in their press handlers, so a remounted ++ // header-config view with value-equal configs must still rebuild — reusing ++ // the old items would dispatch presses into the dead config's emitter. ++ NSArray *headerItemsKey = ++ @[ @((uintptr_t)config), headerLeftConfigs, headerRightConfigs, headerCenterConfigs ]; ++ // Rebuilding bar button items creates brand-new native buttons (glass ++ // UIButton custom views on iOS 26). Replacing them while UIKit animates an ++ // existing one (menu capsule morph, push/pop glass transitions) strands the ++ // animation overlay — a stuck expanded capsule or an unmasked square back ++ // button. When the JS configs are unchanged, keep the already-applied items. ++ // Subview-backed items are re-derived every pass, so their presence forces ++ // the rebuild path. ++ BOOL reuseHeaderBarButtonItems = subviewLeftItems.count == 0 && subviewRightItems.count == 0 && ++ [objc_getAssociatedObject(navitem, &RNSAppliedHeaderBarButtonConfigsKey) isEqual:headerItemsKey]; ++ if (!reuseHeaderBarButtonItems) { ++ NSArray *leftBarButtonItems = [config barButtonItemsFromConfigs:config.headerLeftBarButtonItems ++ withCurrentItems:navitem.leftBarButtonItems + navigationItem:navitem]; ++ NSArray *rightBarButtonItems = [config barButtonItemsFromConfigs:config.headerRightBarButtonItems ++ withCurrentItems:navitem.rightBarButtonItems ++ navigationItem:navitem]; ++ NSArray *centerBarButtonItems = [config barButtonItemsFromConfigs:config.headerCenterBarButtonItems ++ withCurrentItems:@[] ++ navigationItem:navitem]; +#if !TARGET_OS_TV -+ if (@available(iOS 16.0, *)) { -+ navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:leftBarButtonItems]; -+ navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:rightBarButtonItems]; -+ if (@available(iOS 26.0, *)) { -+ navitem.centerItemGroups = [config barButtonItemGroupsFromItems:centerBarButtonItems]; ++ if (@available(iOS 16.0, *)) { ++ navitem.leadingItemGroups = [config barButtonItemGroupsFromItems:leftBarButtonItems]; ++ navitem.trailingItemGroups = [config barButtonItemGroupsFromItems:rightBarButtonItems]; ++ if (@available(iOS 26.0, *)) { ++ navitem.centerItemGroups = [config barButtonItemGroupsFromItems:centerBarButtonItems]; ++ } ++ navitem.leftBarButtonItems = nil; ++ navitem.rightBarButtonItems = nil; ++ } else { ++ navitem.leftBarButtonItems = leftBarButtonItems; ++ navitem.rightBarButtonItems = rightBarButtonItems; + } -+ navitem.leftBarButtonItems = nil; -+ navitem.rightBarButtonItems = nil; -+ } else { ++#else + navitem.leftBarButtonItems = leftBarButtonItems; + navitem.rightBarButtonItems = rightBarButtonItems; -+ } -+#else -+ navitem.leftBarButtonItems = leftBarButtonItems; -+ navitem.rightBarButtonItems = rightBarButtonItems; +#endif ++ // Only dict-driven items can be reused: with subview-backed items in the ++ // mix the applied state depends on view identity, so clear the key to ++ // force a rebuild on the next pass. ++ objc_setAssociatedObject( ++ navitem, ++ &RNSAppliedHeaderBarButtonConfigsKey, ++ subviewLeftItems.count == 0 && subviewRightItems.count == 0 ? headerItemsKey : nil, ++ OBJC_ASSOCIATION_RETAIN_NONATOMIC); ++ } + NSDictionary *mailSearchToolbarConfig = nil; + for (NSDictionary *toolbarConfig in config.headerToolbarItems) { + if (toolbarConfig[@"mailSearchToolbar"]) { @@ -269,9 +312,24 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d + if (existingMailSearchToolbar == nil) { + existingMailSearchToolbar = [vc.view viewWithTag:RNSMailSearchToolbarViewTag]; + } -+ [existingMailSearchToolbar removeFromSuperview]; ++ // Reuse the live toolbar when nothing it was built from changed: rebuilding ++ // replaces the fallback UISearchTextField with a fresh empty one, dropping ++ // the user's in-progress search text and first responder on every unrelated ++ // header update. Keyed on the config instance (the button/search blocks ++ // capture its event emitter), the toolbar config values, and the host width ++ // (the width constraint constant is resolved from it at build time). ++ static char RNSAppliedMailSearchToolbarConfigKey; ++ NSArray *mailSearchToolbarKey = mailSearchToolbarConfig != nil ++ ? @[ @((uintptr_t)config), mailSearchToolbarConfig, @(chromeHostView.bounds.size.width) ] ++ : nil; ++ BOOL reuseMailSearchToolbar = existingMailSearchToolbar != nil && mailSearchToolbarKey != nil && ++ [objc_getAssociatedObject(existingMailSearchToolbar, &RNSAppliedMailSearchToolbarConfigKey) ++ isEqual:mailSearchToolbarKey]; ++ if (!reuseMailSearchToolbar) { ++ [existingMailSearchToolbar removeFromSuperview]; ++ } + -+ if (mailSearchToolbarConfig != nil) { ++ if (mailSearchToolbarConfig != nil && !reuseMailSearchToolbar) { +#if RNS_IPHONE_OS_VERSION_AVAILABLE(26_0) + if (@available(iOS 26.0, *)) { + CGFloat horizontalInset = 18.0; @@ -304,21 +362,24 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d + + UIView *toolbarHost = [[UIView alloc] init]; + toolbarHost.tag = RNSMailSearchToolbarViewTag; ++ objc_setAssociatedObject( ++ toolbarHost, ++ &RNSAppliedMailSearchToolbarConfigKey, ++ mailSearchToolbarKey, ++ OBJC_ASSOCIATION_RETAIN_NONATOMIC); + toolbarHost.translatesAutoresizingMaskIntoConstraints = NO; + [chromeHostView addSubview:toolbarHost]; -+ // Keyboard avoidance is best-effort: on the iOS 27 beta the keyboard -+ // layout guide no longer rests at the bottom safe-area edge while the -+ // keyboard is hidden, which pushed the toolbar offscreen. The required -+ // resting position is the safe area; the keyboard guide only pulls the -+ // toolbar up when it actually tracks a visible keyboard. ++ // The screen stays mounted beneath pushed routes, so its keyboard layout ++ // guide can track a keyboard owned by another screen. Keep the toolbar at ++ // rest unless its own search field is editing. + NSLayoutConstraint *keyboardAvoidConstraint = + [toolbarHost.bottomAnchor constraintEqualToAnchor:keyboardLayoutGuide.topAnchor + constant:-toolbarBottomSpacing]; -+ keyboardAvoidConstraint.priority = UILayoutPriorityDefaultHigh; ++ keyboardAvoidConstraint.priority = UILayoutPriorityDefaultLow; + NSLayoutConstraint *restingBottomConstraint = + [toolbarHost.bottomAnchor constraintEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor + constant:-toolbarBottomSpacing]; -+ restingBottomConstraint.priority = UILayoutPriorityDefaultLow; ++ restingBottomConstraint.priority = UILayoutPriorityDefaultHigh; + [NSLayoutConstraint activateConstraints:@[ + [toolbarHost.centerXAnchor constraintEqualToAnchor:chromeHostView.centerXAnchor], + [toolbarHost.bottomAnchor constraintLessThanOrEqualToAnchor:chromeHostView.safeAreaLayoutGuide.bottomAnchor @@ -330,6 +391,40 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d + ]]; + [chromeHostView bringSubviewToFront:toolbarHost]; + ++ void (^configureKeyboardTracking)(UITextField *) = ^(UITextField *textField) { ++ BOOL isEditing = textField.isFirstResponder; ++ keyboardAvoidConstraint.priority = ++ isEditing ? UILayoutPriorityDefaultHigh : UILayoutPriorityDefaultLow; ++ restingBottomConstraint.priority = ++ isEditing ? UILayoutPriorityDefaultLow : UILayoutPriorityDefaultHigh; ++ ++ __weak NSLayoutConstraint *weakKeyboardAvoidConstraint = keyboardAvoidConstraint; ++ __weak NSLayoutConstraint *weakRestingBottomConstraint = restingBottomConstraint; ++ __weak UIView *weakChromeHostView = chromeHostView; ++ NSString *beginActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-begin"; ++ NSString *endActionIdentifier = @"org.react-native-screens.mail-search-toolbar.keyboard-end"; ++ [textField removeActionForIdentifier:beginActionIdentifier forControlEvents:UIControlEventEditingDidBegin]; ++ [textField removeActionForIdentifier:endActionIdentifier forControlEvents:UIControlEventEditingDidEnd]; ++ [textField addAction:[UIAction actionWithTitle:@"" ++ image:nil ++ identifier:beginActionIdentifier ++ handler:^(__kindof UIAction *_Nonnull action) { ++ weakRestingBottomConstraint.priority = UILayoutPriorityDefaultLow; ++ weakKeyboardAvoidConstraint.priority = UILayoutPriorityDefaultHigh; ++ [weakChromeHostView setNeedsLayout]; ++ }] ++ forControlEvents:UIControlEventEditingDidBegin]; ++ [textField addAction:[UIAction actionWithTitle:@"" ++ image:nil ++ identifier:endActionIdentifier ++ handler:^(__kindof UIAction *_Nonnull action) { ++ weakKeyboardAvoidConstraint.priority = UILayoutPriorityDefaultLow; ++ weakRestingBottomConstraint.priority = UILayoutPriorityDefaultHigh; ++ [weakChromeHostView setNeedsLayout]; ++ }] ++ forControlEvents:UIControlEventEditingDidEnd]; ++ }; ++ + UIGlassEffect *glassEffect = [UIGlassEffect effectWithStyle:UIGlassEffectStyleRegular]; + glassEffect.interactive = YES; + UIVisualEffectView *glassView = [[UIVisualEffectView alloc] initWithEffect:glassEffect]; @@ -411,6 +506,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d + searchBar.searchTextField.adjustsFontForContentSizeCategory = YES; + searchBar.searchTextField.textColor = UIColor.labelColor; + searchBar.searchTextField.tintColor = UIColor.labelColor; ++ configureKeyboardTracking(searchBar.searchTextField); + if (placeholder != nil) { + searchBar.searchTextField.attributedPlaceholder = + [[NSAttributedString alloc] initWithString:placeholder attributes:placeholderAttributes]; @@ -443,6 +539,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d + searchField.adjustsFontForContentSizeCategory = YES; + searchField.textColor = UIColor.labelColor; + searchField.tintColor = UIColor.labelColor; ++ configureKeyboardTracking(searchField); + searchField.translatesAutoresizingMaskIntoConstraints = NO; + [glassView.contentView addSubview:searchField]; + [NSLayoutConstraint activateConstraints:@[ @@ -491,20 +588,34 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d + navigationToolbarConfigs = @[]; + } + -+ NSArray *toolbarItems = [config barButtonItemsFromConfigs:navigationToolbarConfigs -+ withCurrentItems:@[] -+ navigationItem:navitem]; -+ if (toolbarItems.count > 0) { -+ vc.toolbarItems = toolbarItems; -+ [navctr setToolbarHidden:NO animated:animated]; -+ } else { -+ vc.toolbarItems = nil; -+ [navctr setToolbarHidden:YES animated:animated]; ++ NSArray *toolbarConfigsKey = navigationToolbarConfigs ?: @[]; ++ // Same reuse rule as the header item groups above (including the config ++ // identity — cached toolbar items capture this config's event emitter). ++ // The top-view-controller and hidden-state checks scope the skip to ++ // same-screen refreshes, so transitions between screens with different ++ // toolbars still reapply. ++ NSArray *toolbarCacheKey = @[ @((uintptr_t)config), toolbarConfigsKey ]; ++ BOOL reuseToolbarItems = navctr.topViewController == vc && ++ navctr.isToolbarHidden == (toolbarConfigsKey.count == 0) && ++ [objc_getAssociatedObject(navitem, &RNSAppliedToolbarConfigsKey) isEqual:toolbarCacheKey]; ++ if (!reuseToolbarItems) { ++ NSArray *toolbarItems = [config barButtonItemsFromConfigs:navigationToolbarConfigs ++ withCurrentItems:@[] ++ navigationItem:navitem]; ++ if (toolbarItems.count > 0) { ++ vc.toolbarItems = toolbarItems; ++ [navctr setToolbarHidden:NO animated:animated]; ++ } else { ++ vc.toolbarItems = nil; ++ [navctr setToolbarHidden:YES animated:animated]; ++ } ++ objc_setAssociatedObject( ++ navitem, &RNSAppliedToolbarConfigsKey, toolbarCacheKey, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items // (setting nav bar visibility should be done after `navitem.*BarButtonItems`). -@@ -773,6 +1079,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -773,6 +1185,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * - (NSArray *)barButtonItemsFromConfigs:(NSArray *> *)dicts withCurrentItems:(NSArray *)currentItems @@ -512,7 +623,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d { if (dicts.count == 0) { return currentItems; -@@ -781,7 +1088,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -781,7 +1194,197 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * [items addObjectsFromArray:currentItems]; for (NSUInteger i = 0; i < dicts.count; i++) { NSDictionary *dict = dicts[i]; @@ -700,7 +811,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d + } + } +#endif -+ if (index != nil && index.integerValue < items.count) { ++ if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) { + [items insertObject:item atIndex:index.integerValue]; + } else { + [items addObject:item]; @@ -711,7 +822,14 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d RNSBarButtonItem *item = [[RNSBarButtonItem alloc] initWithConfig:dict action:^(NSString *buttonId) { auto eventEmitter = std::static_pointer_cast( -@@ -809,11 +1306,15 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * +@@ -803,19 +1406,23 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * + } + imageLoader:_imageLoader]; + NSNumber *index = dict[@"index"]; +- if (index.integerValue < items.count) { ++ if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) { + [items insertObject:item atIndex:index.integerValue]; + } else { [items addObject:item]; } } else if (dict[@"spacing"]) { @@ -729,9 +847,12 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d + item.width = [spacingValue doubleValue]; + } NSNumber *index = dict[@"index"]; - if (index.integerValue < items.count) { +- if (index.integerValue < items.count) { ++ if (index != nil && index.integerValue >= 0 && index.integerValue < items.count) { [items insertObject:item atIndex:index.integerValue]; -@@ -825,6 +1326,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * + } else { + [items addObject:item]; +@@ -825,6 +1432,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * return items; } @@ -779,7 +900,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d RNS_IGNORE_SUPER_CALL_BEGIN - (void)insertReactSubview:(RNSScreenStackHeaderSubview *)subview atIndex:(NSInteger)atIndex { -@@ -1013,6 +1555,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1013,6 +1661,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: } _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title); @@ -788,7 +909,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d if (newScreenProps.titleFontFamily != oldScreenProps.titleFontFamily) { _titleFontFamily = RCTNSStringFromStringNilIfEmpty(newScreenProps.titleFontFamily); } -@@ -1038,6 +1582,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1038,6 +1688,7 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: _disableBackButtonMenu = newScreenProps.disableBackButtonMenu; _backButtonDisplayMode = [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode]; @@ -796,7 +917,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) { _userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle]; -@@ -1084,6 +1629,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: +@@ -1084,6 +1735,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: _headerRightBarButtonItems = array; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b9999931837..8796302bab4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ catalogs: '@effect/tsgo': specifier: 0.13.2 version: 0.13.2 + '@legendapp/list': + specifier: 3.3.5 + version: 3.3.5 '@noble/curves': specifier: 1.9.1 version: 1.9.1 @@ -69,12 +72,13 @@ overrides: packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE= patchedDependencies: + '@clerk/expo@4.2.0': 72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1 '@effect/vitest@4.0.0-beta.103': a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b '@expo/metro-config@56.0.14': 8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46 '@ff-labs/fff-node@0.9.4': 2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8 - '@legendapp/list@3.3.3': d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09 + '@legendapp/list@3.3.5': 6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa - '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae + '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045 '@react-native/gradle-plugin@0.85.3': c1b594a16e682d621b6a960926f8ce13fc92edfb397884cfe7fcb0518996a784 '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6 @@ -82,7 +86,7 @@ patchedDependencies: react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 - react-native-screens@4.25.2: 47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8 + react-native-screens@4.25.2: 25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e importers: @@ -197,7 +201,7 @@ importers: version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@clerk/expo': specifier: 4.2.0 - version: 4.2.0(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + version: 4.2.0(patch_hash=72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1)(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) '@effect/atom-react': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.3)(scheduler@0.27.0) @@ -211,8 +215,8 @@ importers: specifier: ~56.0.18 version: 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) '@legendapp/list': - specifier: 3.3.3 - version: 3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + specifier: 'catalog:' + version: 3.3.5(patch_hash=6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -224,7 +228,7 @@ importers: version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-native-menu/menu': specifier: ^2.0.0 - version: 2.0.0(patch_hash=5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 2.0.0(patch_hash=c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/elements': specifier: 2.9.26 version: 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) @@ -233,7 +237,7 @@ importers: version: 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/native-stack': specifier: 7.17.6 - version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(a49e8e72dc3ef754b9d26038db8e6d3f) + version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(0f4ac5b153e229af40627cf59223263d) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -398,7 +402,7 @@ importers: version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-screens: specifier: 4.25.2 - version: 4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-shiki-engine: specifier: ^0.3.12 version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -537,8 +541,8 @@ importers: specifier: ^0.9.0 version: 0.9.0 '@legendapp/list': - specifier: 3.3.3 - version: 3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + specifier: 'catalog:' + version: 3.3.5(patch_hash=6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@lexical/react': specifier: ^0.41.0 version: 0.41.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(yjs@13.6.31) @@ -2950,8 +2954,8 @@ packages: resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==} engines: {node: '>=12'} - '@legendapp/list@3.3.3': - resolution: {integrity: sha512-p3g4xG6f//s4XQKhuus2189GCQgOHEIbJXHePqeDxj+6UQQQyij4YBjyArNSCgqoP0c03sxDPSOuCFB128Ql6g==} + '@legendapp/list@3.3.5': + resolution: {integrity: sha512-XTsLYtpg41SVb5uLBYA+YcDSA3w0tgoPq/W8ZggQ2tx+3lrC/rf+ehTP9KYHea9oFaZIuePAgzACs5/auVMJlQ==} peerDependencies: react: '*' react-dom: '*' @@ -11584,7 +11588,7 @@ snapshots: electron-store: 8.2.0 react-dom: 19.2.6(react@19.2.6) - '@clerk/expo@4.2.0(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)': + '@clerk/expo@4.2.0(patch_hash=72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1)(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)': dependencies: '@clerk/clerk-js': 6.25.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@clerk/react': 6.12.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -12253,7 +12257,7 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.11(014c98b83770a9a763d36edd2815d6d7) + expo-router: 56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' @@ -12329,7 +12333,7 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.11(e081a134f3c85dd26e314f8c96e5476f) + expo-router: 56.2.11(db5c693a26481047569df6781f34db9f) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' @@ -12669,7 +12673,7 @@ snapshots: react: 19.2.3 optionalDependencies: '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-router: 56.2.11(014c98b83770a9a763d36edd2815d6d7) + expo-router: 56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8) react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - supports-color @@ -12684,7 +12688,7 @@ snapshots: react: 19.2.6 optionalDependencies: '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - expo-router: 56.2.11(e081a134f3c85dd26e314f8c96e5476f) + expo-router: 56.2.11(db5c693a26481047569df6781f34db9f) react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: - supports-color @@ -12987,7 +12991,7 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@legendapp/list@3.3.5(patch_hash=6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) @@ -12995,7 +12999,7 @@ snapshots: react-dom: 19.2.3(react@19.2.3) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@legendapp/list@3.3.3(patch_hash=d162d67b73933ab077d00627cdf52b18ea88b28c4fae4e8340a854df25026f09)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@legendapp/list@3.3.5(patch_hash=6befc76c7f590a0b0915b531386ce7e3bbb364612868e1f04e4ac84f60a39ab5)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: react: 19.2.6 use-sync-external-store: 1.6.0(react@19.2.6) @@ -14091,7 +14095,7 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optional: true - '@react-native-menu/menu@2.0.0(patch_hash=5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native-menu/menu@2.0.0(patch_hash=c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) @@ -14269,7 +14273,7 @@ snapshots: optionalDependencies: '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(a49e8e72dc3ef754b9d26038db8e6d3f)': + '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(0f4ac5b153e229af40627cf59223263d)': dependencies: '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) '@react-navigation/native': 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -14277,7 +14281,7 @@ snapshots: react: 19.2.3 react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) sf-symbols-typescript: 2.2.0 warn-once: 0.1.1 transitivePeerDependencies: @@ -17056,7 +17060,7 @@ snapshots: - supports-color - typescript - expo-router@56.2.11(014c98b83770a9a763d36edd2815d6d7): + expo-router@56.2.11(c60e26523d4e8ab19ca3d2f562bb6cb8): dependencies: '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -17087,7 +17091,7 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5) react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) server-only: 0.0.1 sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 @@ -17107,7 +17111,7 @@ snapshots: - supports-color optional: true - expo-router@56.2.11(e081a134f3c85dd26e314f8c96e5476f): + expo-router@56.2.11(db5c693a26481047569df6781f34db9f): dependencies: '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.6(react@19.2.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) @@ -17138,7 +17142,7 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) react-native-drawer-layout: 4.2.4(de9b2f2dc96a3557fdc0df187a8417ee) react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) - react-native-screens: 4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) + react-native-screens: 4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6) server-only: 0.0.1 sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 @@ -19953,14 +19957,14 @@ snapshots: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6) optional: true - react-native-screens@4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-screens@4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-freeze: 1.0.4(react@19.2.3) react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-screens@4.25.2(patch_hash=47a07b0849ebf1ae454be3cb9fde7700c763584afa403d381e11e06e36187de8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): + react-native-screens@4.25.2(patch_hash=25ba61e9bbb54a203ff374b9ca8ce6761ea4970742c748e1bcf8d2500bf3f92e)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6): dependencies: react: 19.2.6 react-freeze: 1.0.4(react@19.2.6) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 27d86fd17849..f6b90756046c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -39,6 +39,7 @@ catalog: "@effect/sql-sqlite-bun": 4.0.0-beta.103 "@effect/tsgo": 0.13.2 "@effect/vitest": 4.0.0-beta.103 + "@legendapp/list": 3.3.5 "@noble/curves": 1.9.1 "@noble/hashes": 1.8.0 "@pierre/diffs": 1.3.0-beta.10 @@ -74,6 +75,7 @@ minimumReleaseAgeExclude: - "@effect/vitest@4.0.0-beta.103" - alchemy@2.0.0-beta.65 - effect@4.0.0-beta.103 + - "@legendapp/list@3.3.5" overrides: "@clerk/backend": "catalog:" @@ -122,10 +124,11 @@ packageExtensions: vite: "catalog:" patchedDependencies: + "@clerk/expo@4.2.0": patches/@clerk__expo@4.2.0.patch "@effect/vitest@4.0.0-beta.103": patches/@effect__vitest@4.0.0-beta.103.patch "@expo/metro-config@56.0.14": patches/@expo%2Fmetro-config@56.0.14.patch "@ff-labs/fff-node@0.9.4": patches/@ff-labs__fff-node@0.9.4.patch - "@legendapp/list@3.3.3": patches/@legendapp__list@3.3.3.patch + "@legendapp/list@3.3.5": patches/@legendapp__list@3.3.5.patch "@pierre/diffs@1.3.0-beta.10": patches/@pierre%2Fdiffs@1.3.0-beta.10.patch "@react-native-menu/menu@2.0.0": patches/@react-native-menu__menu@2.0.0.patch "@react-native/gradle-plugin@0.85.3": patches/@react-native__gradle-plugin@0.85.3.patch From c842c6f5b90122ad88b1d2295df6a99e94954571 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 11 Aug 2026 16:47:13 +0200 Subject: [PATCH 11/28] Add hourly past-24-hour usage view (#6170) --- .../src/features/usage/UsageRouteScreen.tsx | 94 ++++++++++--- apps/mobile/src/state/usage.ts | 12 +- apps/server/src/usage/UsageService.ts | 32 ++++- .../server/src/usage/usageAggregation.test.ts | 74 +++++++++- apps/server/src/usage/usageAggregation.ts | Bin 6019 -> 7388 bytes apps/web/src/components/usage/UsagePage.tsx | 131 +++++++++++++----- .../usage/UsageProviderChart.test.ts | 25 ++++ .../components/usage/UsageProviderChart.tsx | 105 +++++++++----- apps/web/src/state/usage.ts | 12 +- docs/README.md | 1 + docs/user/usage.md | 10 ++ packages/contracts/src/usage.ts | 19 ++- packages/shared/src/usageFormat.test.ts | 57 ++++++++ packages/shared/src/usageFormat.ts | 120 +++++++++++++++- packages/shared/src/usageMerge.test.ts | 26 ++++ packages/shared/src/usageMerge.ts | 45 ++++++ 16 files changed, 668 insertions(+), 95 deletions(-) create mode 100644 docs/user/usage.md create mode 100644 packages/shared/src/usageFormat.test.ts diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 54a9ac7bc21d..817e6d7f9543 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -1,9 +1,11 @@ import { useNavigation } from "@react-navigation/native"; -import type { MergedUsage } from "@t3tools/shared/usageMerge"; +import type { DailyTotals, MergedUsage } from "@t3tools/shared/usageMerge"; import { enumerateDays, + enumerateHourStarts, formatCount, formatDayShort, + formatHourShort, formatPercent, formatTokens, formatUsd, @@ -23,6 +25,7 @@ import type { UsageChartMetric } from "./usageChartData"; import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; const WINDOW_OPTIONS = [ + { days: 1, label: "Past 24h" }, { days: 7, label: "7 days" }, { days: 30, label: "30 days" }, { days: 90, label: "90 days" }, @@ -33,23 +36,62 @@ const CHART_HEIGHT = 180; export function UsageRouteScreen() { const navigation = useNavigation(); const insets = useSafeAreaInsets(); - const [windowDays, setWindowDays] = useState(30); + const [windowSelection, setWindowSelection] = useState(() => ({ + days: 30, + window: makeWindow(30), + })); const [metric, setMetric] = useState("cost"); - - // Recomputed only when the window length changes, so a re-render does not - // shift the range and refetch every environment. - const window = useMemo(() => makeWindow(windowDays), [windowDays]); + const { days: windowDays, window } = windowSelection; + const isPast24Hours = windowDays === 1; const { merged, environments, isPending, isPartial, refresh } = useUsage(window); const days = useMemo( () => enumerateDays(window.sinceDay, window.untilDay), [window.sinceDay, window.untilDay], ); + const chartDays = useMemo( + () => + isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined + ? enumerateHourStarts(window.sinceTime, window.untilTime) + : days, + [days, isPast24Hours, window.sinceTime, window.untilTime], + ); + const chartTotals = useMemo( + (): readonly DailyTotals[] => + isPast24Hours + ? merged.hourly.map((hour) => ({ + day: hour.hourStart, + costUsd: hour.costUsd, + totalTokens: hour.totalTokens, + byProvider: hour.byProvider, + })) + : merged.daily, + [isPast24Hours, merged.daily, merged.hourly], + ); // The pull spinner tracks re-scans of environments that have answered // before. The initial scan renders its own placeholder, and an unreachable // environment stays pending forever — neither may pin the spinner on. const refreshing = environments.some((entry) => entry.isPending && entry.summary !== null); + const selectWindow = (days: number) => { + setWindowSelection({ + days, + window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), + }); + }; + const refreshWindow = () => { + const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); + if ( + nextWindow.sinceDay === window.sinceDay && + nextWindow.untilDay === window.untilDay && + nextWindow.sinceTime === window.sinceTime && + nextWindow.untilTime === window.untilTime + ) { + refresh(); + } else { + setWindowSelection({ days: windowDays, window: nextWindow }); + } + }; return ( @@ -65,12 +107,12 @@ export function UsageRouteScreen() { className="flex-1" contentContainerClassName="gap-6 px-5 pt-4" contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }} - refreshControl={} + refreshControl={} > ({ value: option.days, label: option.label }))} selected={windowDays} - onSelect={setWindowDays} + onSelect={selectWindow} /> @@ -87,14 +129,17 @@ export function UsageRouteScreen() { <> - + )} @@ -142,14 +187,17 @@ function SegmentedControl(props: { function ChartCard(props: { readonly merged: MergedUsage; readonly days: readonly string[]; + readonly daily: readonly DailyTotals[]; readonly metric: UsageChartMetric; readonly onMetricChange: (metric: UsageChartMetric) => void; readonly sinceDay: string; readonly untilDay: string; + readonly isPast24Hours: boolean; + readonly timeZone: string; }) { const { merged, metric } = props; const colors = useProviderColors(); - const hasActivity = merged.daily.some((day) => day.totalTokens > 0); + const hasActivity = props.daily.some((period) => period.totalTokens > 0); return ( @@ -173,7 +221,7 @@ function ChartCard(props: { {hasActivity ? ( @@ -184,7 +232,11 @@ function ChartCard(props: { )} - {formatDayShort(props.sinceDay)} + + {props.isPast24Hours + ? formatHourShort(props.days[0] ?? "", props.timeZone) + : formatDayShort(props.sinceDay)} + {merged.providers.map((provider) => ( @@ -198,7 +250,11 @@ function ChartCard(props: { ))} - {formatDayShort(props.untilDay)} + + {props.isPast24Hours + ? formatHourShort(props.days[props.days.length - 1] ?? "", props.timeZone) + : formatDayShort(props.untilDay)} + ); @@ -292,10 +348,12 @@ function ProviderSection(props: { ); } -function TotalsSection(props: { readonly merged: MergedUsage }) { +function TotalsSection(props: { readonly merged: MergedUsage; readonly isPast24Hours: boolean }) { const { merged } = props; - const activeDays = merged.daily.filter((day) => day.totalTokens > 0).length; - const dailyAverage = activeDays === 0 ? 0 : merged.totalTokens / activeDays; + const activePeriods = (props.isPast24Hours ? merged.hourly : merged.daily).filter( + (period) => period.totalTokens > 0, + ).length; + const periodAverage = activePeriods === 0 ? 0 : merged.totalTokens / activePeriods; const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens; const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; @@ -305,7 +363,7 @@ function TotalsSection(props: { readonly merged: MergedUsage }) { MAX_HOURLY_WINDOW_MS) { + return yield* new UsageReadError({ + reason: "invalidWindow", + detail: "Hourly usage window must be greater than zero and at most 24 hours", + }); + } + hourlyWindow = { sinceTimeMs, untilTimeMs }; + } + const startedAtMs = yield* Clock.currentTimeMillis; yield* ensureRates(); yield* ensureScanCacheLoaded; @@ -312,12 +337,15 @@ export const make = Effect.gen(function* () { detail: `sinceDay '${input.sinceDay}' is not a valid date`, }); } - const windowStartMs = DateTime.toEpochMillis(windowStart.value) - MTIME_SLACK_MS; + const windowStartMs = + (hourlyWindow?.sinceTimeMs ?? DateTime.toEpochMillis(windowStart.value)) - MTIME_SLACK_MS; const aggregator = new UsageAggregator({ timeZone: input.timeZone, sinceDay: input.sinceDay, untilDay: input.untilDay, + resolution: input.resolution ?? "day", + ...hourlyWindow, rates, }); diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 9117e216f129..8da4e920ac06 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -36,11 +36,24 @@ function record(overrides: Partial = {}): UsageRecord { }; } -function aggregate(records: readonly UsageRecord[], timeZone = "UTC") { +function aggregate( + records: readonly UsageRecord[], + timeZone = "UTC", + resolution: "day" | "hour" = "day", +) { + const hourlyBounds = + resolution === "hour" + ? { + sinceTimeMs: Date.parse("2026-08-06T04:37:00.000Z"), + untilTimeMs: Date.parse("2026-08-07T04:37:00.000Z"), + } + : {}; const aggregator = new UsageAggregator({ timeZone, sinceDay: "2026-08-01", untilDay: "2026-08-31", + resolution, + ...hourlyBounds, rates, }); for (const item of records) aggregator.add(item); @@ -48,6 +61,19 @@ function aggregate(records: readonly UsageRecord[], timeZone = "UTC") { } describe("UsageAggregator", () => { + it("requires exact bounds for hourly aggregation", () => { + expect( + () => + new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + resolution: "hour", + rates, + }), + ).toThrow("requires exact time bounds"); + }); + it("keeps only the first record for a repeated dedupe key", () => { const result = aggregate([ record({ dedupeKey: "msg_1:" }), @@ -76,6 +102,52 @@ describe("UsageAggregator", () => { expect(losAngeles.buckets[0]?.day).toBe("2026-08-06"); }); + it("splits an hourly request into fixed buckets anchored to its exact start", () => { + const result = aggregate( + [ + record({ timestampMs: Date.parse("2026-08-07T02:40:13.944Z") }), + record({ timestampMs: Date.parse("2026-08-07T03:40:13.944Z") }), + ], + "America/Los_Angeles", + "hour", + ); + + expect(result.buckets.map((bucket) => [bucket.day, bucket.hourStart])).toEqual([ + ["2026-08-06", "2026-08-07T02:37:00.000Z"], + ["2026-08-06", "2026-08-07T03:37:00.000Z"], + ]); + }); + + it("uses an inclusive start and exclusive end for rolling windows", () => { + const result = aggregate( + [ + record({ timestampMs: Date.parse("2026-08-06T04:36:59.999Z") }), + record({ timestampMs: Date.parse("2026-08-06T04:37:00.000Z") }), + record({ timestampMs: Date.parse("2026-08-07T04:36:59.999Z") }), + record({ timestampMs: Date.parse("2026-08-07T04:37:00.000Z") }), + ], + "UTC", + "hour", + ); + + expect(result.outOfWindow).toBe(2); + expect(result.buckets.map((bucket) => bucket.hourStart)).toEqual([ + "2026-08-06T04:37:00.000Z", + "2026-08-07T03:37:00.000Z", + ]); + }); + + it("keeps daily payloads collapsed when hourly resolution is not requested", () => { + const result = aggregate([ + record({ timestampMs: Date.parse("2026-08-07T04:05:13.944Z") }), + record({ timestampMs: Date.parse("2026-08-07T05:05:13.944Z") }), + ]); + + expect(result.buckets).toHaveLength(1); + expect(result.buckets[0]?.hourStart).toBeUndefined(); + expect(result.buckets[0]?.records).toBe(2); + }); + it("prices against the rate table", () => { const result = aggregate([record()]); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 4f04a318c529c8498a200c3f21a5caed2330ee3e..e100be76e9797e6f3c6d19ec4dd9d6959d27a461 100644 GIT binary patch delta 1412 zcmah}O=}ZT6eX!f8jVPB<;Lq+%1oP1B7)G=n1vSWA`}{fCX=Z)E?j-0TqBVX(K~I(XS=LJjU!U?cpv zQ>j#T=2vpZnHd1Z$RM^g=0D>2dKFp@>ftowPF?~!rrp6$%>iyW9BI3QHl_;z*Jh^m zAHd7!@yU=-2Byt0?V=6_|4(jp{^8QEY*R?I#?MUKAfqZwQd&~Aq*!_@m5Oh{gDGca zS-z3~!3COKP{~mE;^?VHsMTtqh^mSP6E06YGE}M?U|rk5UDL(}Os5d%lgclG;+(ub z0P9mS0vnHDmr_DiWiJp5t}q!;uSanY34&bLYuDr?0iU1_1IA5;HsPfmMH3c_$BVvM z-*oibvTwY}u@Zog|14-zH6bjiDn)q!Bo!%AI*gHCNWlYaIXEe`l!x$NpjSBG`S#`C znVInRZ05IkH$)7quSX!2;trvPEuM z2=_l09{XE`^5)rm#q=!j^1&u|LuqPn&-&Ud!H9k)9sbh1HsFkq)x(Loyg5iq-aM!2H|w@7aI2_r$6I delta 123 zcmca(*{nYyQm~*XzbrE)wMa)HH$NpcM>An#<#xu+j!bWvH~X_?GHzDp_{}uAp8M41 zGrX@ECod8(oy;O=J2^?fO))#QQo&XsL8Uq+v9gvyrMf6JIlm}H4`yI3!{!S@F)Wj9 X#5EWhCTEG;Z@w$e#kY9|u_7CI}p diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index b550d673f6e0..5e9034bb2af0 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -2,13 +2,18 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { CheckIcon, RefreshCwIcon, XIcon } from "lucide-react"; import { useMemo, useState } from "react"; +import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; + import { isElectron } from "../../env"; import { cn } from "../../lib/utils"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; import { enumerateDays, + enumerateHourStarts, formatCount, + formatDateTimeShort, formatDayShort, + formatHourShort, formatPercent, formatTokens, formatUsd, @@ -22,19 +27,21 @@ import { UsageChartLegend, UsageProviderChart, type UsageChartMetric } from "./U import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; const WINDOW_OPTIONS = [ + { days: 1, label: "Past 24h" }, { days: 7, label: "7 days" }, { days: 30, label: "30 days" }, { days: 90, label: "90 days" }, ] as const; export function UsagePage() { - const [windowDays, setWindowDays] = useState(30); + const [windowSelection, setWindowSelection] = useState(() => ({ + days: 30, + window: makeWindow(30), + })); const [metric, setMetric] = useState("cost"); - const [breakdown, setBreakdown] = useState<"model" | "day">("model"); - - // Recomputed only when the window length changes, so a re-render does not - // shift the range and refetch every environment. - const window = useMemo(() => makeWindow(windowDays), [windowDays]); + const [breakdown, setBreakdown] = useState<"model" | "time">("model"); + const { days: windowDays, window } = windowSelection; + const isPast24Hours = windowDays === 1; const { merged, environments, isPending, isPartial, refresh } = useUsage(window); // Hold the content until every environment is terminal. Rendering merged @@ -46,7 +53,17 @@ export function UsagePage() { () => enumerateDays(window.sinceDay, window.untilDay), [window.sinceDay, window.untilDay], ); - const recentDays = useMemo(() => merged.daily.toReversed().slice(0, 8), [merged.daily]); + const hours = useMemo( + () => + window.sinceTime === undefined || window.untilTime === undefined + ? [] + : enumerateHourStarts(window.sinceTime, window.untilTime), + [window.sinceTime, window.untilTime], + ); + const recentPeriods = useMemo( + () => (isPast24Hours ? merged.hourly : merged.daily).toReversed().slice(0, 8), + [isPast24Hours, merged.daily, merged.hourly], + ); // Ranked by whatever the toggle is showing, so the bars always descend. const orderedProviders = useMemo( @@ -57,10 +74,31 @@ export function UsagePage() { [merged.providers, metric], ); - const activeDays = merged.daily.filter((day) => day.totalTokens > 0).length; - const dailyAverage = activeDays === 0 ? 0 : merged.totalTokens / activeDays; + const activePeriods = (isPast24Hours ? merged.hourly : merged.daily).filter( + (period) => period.totalTokens > 0, + ).length; + const periodAverage = activePeriods === 0 ? 0 : merged.totalTokens / activePeriods; const observedInput = merged.uncachedInputTokens + merged.cachedInputTokens; const cachedShare = observedInput === 0 ? 0 : merged.cachedInputTokens / observedInput; + const selectWindow = (days: number) => { + setWindowSelection({ + days, + window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), + }); + }; + const refreshWindow = () => { + const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); + if ( + nextWindow.sinceDay === window.sinceDay && + nextWindow.untilDay === window.untilDay && + nextWindow.sinceTime === window.sinceTime && + nextWindow.untilTime === window.untilTime + ) { + refresh(); + } else { + setWindowSelection({ days: windowDays, window: nextWindow }); + } + }; return ( @@ -95,17 +133,20 @@ export function UsagePage() {

- {formatDayShort(window.sinceDay)} to {formatDayShort(window.untilDay)} + {isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined + ? `${formatDateTimeShort(window.sinceTime, window.timeZone)} to ${formatDateTimeShort(window.untilTime, window.timeZone)}` + : `${formatDayShort(window.sinceDay)} to ${formatDayShort(window.untilDay)}`}

-
+
{WINDOW_OPTIONS.map((option) => (
))}
@@ -325,7 +381,7 @@ export function UsagePage() { - + {PROVIDER_ORDER.map((provider) => ( - {recentDays.length === 0 ? ( + {recentPeriods.length === 0 ? ( ) : ( - recentDays.map((day) => ( - - + recentPeriods.map((period) => ( + + {PROVIDER_ORDER.map((provider) => ( ))} )) @@ -513,7 +576,7 @@ const SKELETON_BAR_HEIGHTS = [34, 58, 41, 72, 22, 12, 49, 63, 80, 38, 55, 26, 44 * chart and metrics strip. No shimmer; blocks fill in exactly once when the * last device answers. */ -function UsageSkeleton() { +function UsageSkeleton({ resolution }: { readonly resolution: "day" | "hour" }) { return ( <>
@@ -542,7 +605,9 @@ function UsageSkeleton() {
-

Daily cost

+

+ {resolution === "hour" ? "Hourly" : "Daily"} cost +

{/* Mirrors the chart's h-56 body and w-14 axis gutter to avoid a relayout when the real chart swaps in. */}
diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index a36cd1e88330..1c91ab1b42ef 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -95,3 +95,28 @@ describe("buildDayColumns", () => { } }); }); + +describe("hourly chart columns", () => { + it("zero-fills inactive hours and preserves hourly provider values", () => { + const byHour = new Map([ + [ + "2026-08-11T09:37:00.000Z", + { + day: "2026-08-11", + hourStart: "2026-08-11T09:37:00.000Z", + costUsd: 4, + totalTokens: 40, + byProvider: new Map([["codex" as const, { costUsd: 4, totalTokens: 40 }]]), + }, + ], + ]); + + expect( + buildDayColumns( + ["2026-08-11T08:37:00.000Z", "2026-08-11T09:37:00.000Z", "2026-08-11T10:37:00.000Z"], + byHour, + "cost", + ).map((column) => column.total), + ).toEqual([0, 4, 0]); + }); +}); diff --git a/apps/web/src/components/usage/UsageProviderChart.tsx b/apps/web/src/components/usage/UsageProviderChart.tsx index 8f31d348eb04..f41945bfe286 100644 --- a/apps/web/src/components/usage/UsageProviderChart.tsx +++ b/apps/web/src/components/usage/UsageProviderChart.tsx @@ -1,8 +1,14 @@ import type { UsageProviderKind } from "@t3tools/contracts"; import { useCallback, useMemo, useRef, useState } from "react"; -import type { DailyTotals } from "@t3tools/shared/usageMerge"; -import { formatDayShort, formatTokens, formatUsd } from "@t3tools/shared/usageFormat"; +import type { DailyTotals, HourlyTotals } from "@t3tools/shared/usageMerge"; +import { + formatDayShort, + formatHourShort, + formatRelativeHourShort, + formatTokens, + formatUsd, +} from "@t3tools/shared/usageFormat"; import { PROVIDER_COLOR, PROVIDER_LABEL, PROVIDER_MARK, PROVIDER_ORDER } from "./usageProviders"; const VIEW_WIDTH = 960; @@ -15,7 +21,12 @@ export type UsageChartMetric = "tokens" | "cost"; interface UsageProviderChartProps { readonly days: readonly string[]; readonly daily: readonly DailyTotals[]; + readonly hours: readonly string[]; + readonly hourly: readonly HourlyTotals[]; readonly metric: UsageChartMetric; + readonly referenceTime: string | undefined; + readonly resolution: "day" | "hour"; + readonly timeZone: string; } /** One day's per-provider values, shared by the paths and the hover readout. */ @@ -33,15 +44,30 @@ interface Point { } function valueFor( - daily: DailyTotals | undefined, + totals: DailyTotals | HourlyTotals | undefined, provider: UsageProviderKind, metric: UsageChartMetric, ): number { - const entry = daily?.byProvider.get(provider); + const entry = totals?.byProvider.get(provider); if (entry === undefined) return 0; return metric === "tokens" ? entry.totalTokens : entry.costUsd; } +function buildPeriodColumns( + periods: readonly string[], + byPeriod: ReadonlyMap, + metric: UsageChartMetric, +): readonly DayColumn[] { + return periods.map((period) => { + const entry = byPeriod.get(period); + const bands = PROVIDER_ORDER.map((provider) => ({ + provider, + value: valueFor(entry, provider, metric), + })); + return { bands, total: bands.reduce((sum, band) => sum + band.value, 0) }; + }); +} + /** * Monotone cubic tangents (Fritsch-Carlson). * @@ -167,23 +193,32 @@ export function buildDayColumns( byDay: ReadonlyMap, metric: UsageChartMetric, ): readonly DayColumn[] { - return days.map((day) => { - const entry = byDay.get(day); - const bands = PROVIDER_ORDER.map((provider) => ({ - provider, - value: valueFor(entry, provider, metric), - })); - return { bands, total: bands.reduce((sum, band) => sum + band.value, 0) }; - }); + return buildPeriodColumns(days, byDay, metric); } -export function UsageProviderChart({ days, daily, metric }: UsageProviderChartProps) { - const byDay = useMemo(() => new Map(daily.map((entry) => [entry.day, entry])), [daily]); +export function UsageProviderChart({ + days, + daily, + hours, + hourly, + metric, + referenceTime, + resolution, + timeZone, +}: UsageProviderChartProps) { + const periods = resolution === "hour" ? hours : days; + const byPeriod = useMemo( + () => + resolution === "hour" + ? new Map(hourly.map((entry) => [entry.hourStart, entry])) + : new Map(daily.map((entry) => [entry.day, entry])), + [daily, hourly, resolution], + ); const [hoverIndex, setHoverIndex] = useState(null); const plotRef = useRef(null); const { paths, ticks, stepX, toY, series } = useMemo(() => { - if (days.length === 0) { + if (periods.length === 0) { return { paths: [], ticks: [0] as readonly number[], @@ -193,7 +228,7 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr }; } - const columns = buildDayColumns(days, byDay, metric); + const columns = buildPeriodColumns(periods, byPeriod, metric); // The scale tops out at the largest single provider-day, not the largest // sum: layered series each measure from zero, so a combined peak would @@ -203,7 +238,7 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr 0, ); const { max, ticks: tickValues } = niceScale(peak, TICK_COUNT); - const step = days.length === 1 ? 0 : VIEW_WIDTH / (days.length - 1); + const step = periods.length === 1 ? 0 : VIEW_WIDTH / (periods.length - 1); // Reserve a sliver above the top gridline so the series stroke, which is // drawn at constant screen width, is not shaved off at a peak. const toY = (value: number) => @@ -231,24 +266,30 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr const ordered = [...built].sort((a, b) => b.total - a.total); return { paths: ordered, ticks: tickValues, stepX: step, toY, series: columns }; - }, [byDay, days, metric]); + }, [byPeriod, metric, periods]); const format = metric === "tokens" ? formatTokens : formatUsd; const handleMove = useCallback( (event: React.MouseEvent) => { const bounds = plotRef.current?.getBoundingClientRect(); - if (bounds === undefined || bounds.width === 0 || days.length === 0) return; + if (bounds === undefined || bounds.width === 0 || periods.length === 0) return; const fraction = (event.clientX - bounds.left) / bounds.width; - const index = Math.round(fraction * (days.length - 1)); - setHoverIndex(Math.min(days.length - 1, Math.max(0, index))); + const index = Math.round(fraction * (periods.length - 1)); + setHoverIndex(Math.min(periods.length - 1, Math.max(0, index))); }, - [days.length], + [periods.length], ); - const hoveredDay = hoverIndex === null ? undefined : days[hoverIndex]; + const hoveredPeriod = hoverIndex === null ? undefined : periods[hoverIndex]; const hoveredColumn = hoverIndex === null ? undefined : series[hoverIndex]; - const hoverLeft = days.length <= 1 ? 0 : ((hoverIndex ?? 0) / (days.length - 1)) * 100; + const hoverLeft = periods.length <= 1 ? 0 : ((hoverIndex ?? 0) / (periods.length - 1)) * 100; + const formatPeriod = (period: string) => + resolution === "hour" ? formatHourShort(period, timeZone) : formatDayShort(period); + const formatTooltipPeriod = (period: string) => + resolution === "hour" && referenceTime !== undefined + ? formatRelativeHourShort(period, referenceTime, timeZone) + : formatPeriod(period); return (
@@ -277,7 +318,7 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr viewBox={`0 0 ${VIEW_WIDTH} ${VIEW_HEIGHT}`} preserveAspectRatio="none" role="img" - aria-label={`Daily ${metric === "tokens" ? "processed tokens" : "cost"} by provider`} + aria-label={`${resolution === "hour" ? "Hourly" : "Daily"} ${metric === "tokens" ? "processed tokens" : "cost"} by provider`} > {ticks.map((tick) => { const y = toY(tick); @@ -325,7 +366,7 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr )} - {hoveredDay === undefined ? null : ( + {hoveredPeriod === undefined ? null : (
60 ? "translateX(-100%)" : "translateX(0)", }} > -
{formatDayShort(hoveredDay)}
+
{formatTooltipPeriod(hoveredPeriod)}
{PROVIDER_ORDER.map((provider) => { const Mark = PROVIDER_MARK[provider]; return ( @@ -362,14 +403,16 @@ export function UsageProviderChart({ days, daily, metric }: UsageProviderChartPr
- {days[0] === undefined ? "" : formatDayShort(days[0])} + {periods[0] === undefined ? "" : formatPeriod(periods[0])} - {days[Math.floor(days.length / 2)] === undefined + {periods[Math.floor(periods.length / 2)] === undefined ? "" - : formatDayShort(days[Math.floor(days.length / 2)] ?? "")} + : formatPeriod(periods[Math.floor(periods.length / 2)] ?? "")} - {days[days.length - 1] === undefined ? "" : formatDayShort(days[days.length - 1] ?? "")} + {periods[periods.length - 1] === undefined + ? "" + : formatPeriod(periods[periods.length - 1] ?? "")}
diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index 9d65b6ad6004..ba78a61d8a88 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -78,8 +78,18 @@ export function useUsage(input: UsageSummaryInput): UsageView { sinceDay: input.sinceDay, untilDay: input.untilDay, timeZone: input.timeZone, + resolution: input.resolution, + sinceTime: input.sinceTime, + untilTime: input.untilTime, }), - [input.sinceDay, input.untilDay, input.timeZone], + [ + input.sinceDay, + input.untilDay, + input.timeZone, + input.resolution, + input.sinceTime, + input.untilTime, + ], ); const atom = usageByWindowAtom(windowKey); const environments = useAtomValue(atom); diff --git a/docs/README.md b/docs/README.md index 51277fd73d28..30653e7d5035 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,7 @@ - [Permission modes](./user/permission-modes.md) - [Keyboard shortcuts](./user/keybindings.md) - [Organizing threads](./user/thread-sidebar.md) +- [Review usage](./user/usage.md) - [Customize a project icon](./user/project-settings.md) - [Remote access](./user/remote-access.md) - [Keeping app and server in sync](./user/updating.md) diff --git a/docs/user/usage.md b/docs/user/usage.md new file mode 100644 index 000000000000..72d19ba77f37 --- /dev/null +++ b/docs/user/usage.md @@ -0,0 +1,10 @@ +# Review usage + +The Usage page combines Codex and Claude Code activity from your connected environments. It reads +the providers' local session history and shows API-equivalent token cost, processed tokens, cache +savings, provider shares, and model breakdowns. Subscription billing is separate from the raw token +cost shown here. + +Use **Past 24h** for an hourly chart covering the exact rolling 24-hour period. The **7 days**, +**30 days**, and **90 days** ranges use daily resolution. Cost and token toggles update both the +headline and chart, and refreshing rescans every connected environment. diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 1aa639fe4a00..cde888a6153e 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -7,8 +7,8 @@ * even for turns that were never driven through T3 Code. This mirrors the * approach `ccusage` takes. * - * Environments return pre-aggregated `(day, provider, model)` buckets. Raw - * transcript records never cross the wire. + * Environments return pre-aggregated `(day, hourStart?, provider, model)` + * buckets. Raw transcript records never cross the wire. * * @module usage */ @@ -21,7 +21,7 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; * client renders partial coverage when an environment reports an older version * rather than failing the whole page. */ -export const USAGE_CONTRACT_VERSION = 3 as const; +export const USAGE_CONTRACT_VERSION = 4 as const; export const UsageProviderKind = Schema.Literals(["claude", "codex"]); export type UsageProviderKind = typeof UsageProviderKind.Type; @@ -39,6 +39,9 @@ export const UsageDay = TrimmedNonEmptyString.check(Schema.isPattern(USAGE_DAY_P ); export type UsageDay = typeof UsageDay.Type; +export const UsageResolution = Schema.Literals(["day", "hour"]); +export type UsageResolution = typeof UsageResolution.Type; + /** * Why a bucket's cost is what it is. * @@ -68,7 +71,8 @@ export const UsageTokenTotals = Schema.Struct({ export type UsageTokenTotals = typeof UsageTokenTotals.Type; /** - * One `(day, provider, model)` cell. + * One `(day, hourStart?, provider, model)` cell. `hourStart` is the UTC start + * instant of a rolling bucket and is present only for hourly requests. * * `costUsd` is the raw API-equivalent cost of these tokens. It is not money * spent: subscription plans bill separately. `unpricedRecords` counts records @@ -77,6 +81,7 @@ export type UsageTokenTotals = typeof UsageTokenTotals.Type; */ export const UsageBucket = Schema.Struct({ day: UsageDay, + hourStart: Schema.optional(TrimmedNonEmptyString), provider: UsageProviderKind, model: TrimmedNonEmptyString, totals: UsageTokenTotals, @@ -165,6 +170,12 @@ export const UsageSummaryInput = Schema.Struct({ * any window that crosses a DST boundary. */ timeZone: TrimmedNonEmptyString, + /** Defaults to daily for older clients. */ + resolution: Schema.optional(UsageResolution), + /** Inclusive UTC instant for an hourly rolling window. */ + sinceTime: Schema.optional(TrimmedNonEmptyString), + /** Exclusive UTC instant for an hourly rolling window. */ + untilTime: Schema.optional(TrimmedNonEmptyString), }); export type UsageSummaryInput = typeof UsageSummaryInput.Type; diff --git a/packages/shared/src/usageFormat.test.ts b/packages/shared/src/usageFormat.test.ts new file mode 100644 index 000000000000..cecc07c6e670 --- /dev/null +++ b/packages/shared/src/usageFormat.test.ts @@ -0,0 +1,57 @@ +// @effect-diagnostics globalDate:off -- A fixed instant keeps calendar-window assertions deterministic. +import { describe, expect, it } from "vite-plus/test"; + +import { + enumerateHourStarts, + formatDateTimeShort, + formatHourShort, + formatRelativeHourShort, + makeWindow, +} from "./usageFormat.ts"; + +describe("hourly usage formatting", () => { + it("enumerates 24 fixed buckets across a rolling window", () => { + const hours = enumerateHourStarts("2026-08-10T12:37:00.000Z", "2026-08-11T12:37:00.000Z"); + + expect(hours).toHaveLength(24); + expect(hours[0]).toBe("2026-08-10T12:37:00.000Z"); + expect(hours[23]).toBe("2026-08-11T11:37:00.000Z"); + }); + + it("formats rolling instants in the requested time zone", () => { + expect(formatHourShort("2026-08-11T00:37:00.000Z", "UTC")).toBe("12 AM"); + expect(formatHourShort("2026-08-11T12:37:00.000Z", "UTC")).toBe("12 PM"); + expect(formatDateTimeShort("2026-08-11T17:37:00.000Z", "UTC")).toBe("Aug 11, 5 PM"); + }); + + it("disambiguates repeated hours during a fall-back transition", () => { + expect(formatHourShort("2026-11-01T05:37:00.000Z", "America/New_York")).toBe("1 AM EDT"); + expect(formatHourShort("2026-11-01T06:37:00.000Z", "America/New_York")).toBe("1 AM EST"); + }); + + it("makes hourly tooltip dates relative to the window in its requested time zone", () => { + const windowEnd = "2026-08-11T14:37:00.000Z"; + + expect(formatRelativeHourShort("2026-08-10T17:37:00.000Z", windowEnd, "UTC")).toBe( + "5 PM yesterday", + ); + expect(formatRelativeHourShort("2026-08-11T14:37:00.000Z", windowEnd, "UTC")).toBe( + "2 PM today", + ); + expect( + formatRelativeHourShort( + "2026-08-11T01:37:00.000Z", + "2026-08-11T10:37:00.000Z", + "America/Los_Angeles", + ), + ).toBe("6 PM yesterday"); + }); + + it("builds an exact minute-aligned 24-hour request", () => { + const window = makeWindow(1, new Date("2026-08-11T12:37:42.123Z"), "hour"); + + expect(window.resolution).toBe("hour"); + expect(window.sinceTime).toBe("2026-08-10T12:37:00.000Z"); + expect(window.untilTime).toBe("2026-08-11T12:37:00.000Z"); + }); +}); diff --git a/packages/shared/src/usageFormat.ts b/packages/shared/src/usageFormat.ts index 21e11fee743d..ef2b2bcf21a1 100644 --- a/packages/shared/src/usageFormat.ts +++ b/packages/shared/src/usageFormat.ts @@ -4,7 +4,7 @@ * * @module usageFormat */ -import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; +import { UsageDay, type UsageResolution, type UsageSummaryInput } from "@t3tools/contracts"; const CURRENCY = new Intl.NumberFormat("en-US", { style: "currency", @@ -80,11 +80,105 @@ export function enumerateDays(sinceDay: string, untilDay: string): readonly stri return days; } +const HOUR_MS = 60 * 60 * 1000; + +/** Every fixed-duration bucket start in an hourly rolling window. */ +export function enumerateHourStarts(sinceTime: string, untilTime: string): readonly string[] { + const starts: string[] = []; + const start = Date.parse(sinceTime); + const end = Date.parse(untilTime); + if (Number.isNaN(start) || Number.isNaN(end) || end <= start) return starts; + + for (let cursor = start; cursor < end; cursor += HOUR_MS) { + starts.push(new Date(cursor).toISOString()); + } + return starts; +} + +/** + * A rolling bucket start rendered in the viewer's requested time zone. + * + * Repeated wall-clock hours during a fall-back transition include their short + * zone name so the two distinct buckets remain distinguishable. + */ +export function formatHourShort(hourStart: string, timeZone?: string): string { + const instant = new Date(hourStart); + if (Number.isNaN(instant.getTime())) return hourStart; + const options = timeZone === undefined ? {} : { timeZone }; + const hourFormat = new Intl.DateTimeFormat("en-US", { + ...options, + hour: "numeric", + }); + const wallHourFormat = new Intl.DateTimeFormat("en-CA", { + ...options, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + hourCycle: "h23", + }); + const wallHour = wallHourFormat.format(instant); + const isRepeatedHour = [-HOUR_MS, HOUR_MS].some( + (offset) => wallHourFormat.format(new Date(instant.getTime() + offset)) === wallHour, + ); + + if (!isRepeatedHour) return hourFormat.format(instant); + return new Intl.DateTimeFormat("en-US", { + ...(timeZone === undefined ? {} : { timeZone }), + hour: "numeric", + timeZoneName: "short", + }).format(instant); +} + +/** `2026-08-11T14:37:00Z` to `Aug 11, 2 PM` in the requested zone. */ +export function formatDateTimeShort(instant: string, timeZone?: string): string { + const date = new Date(instant); + if (Number.isNaN(date.getTime())) return instant; + return new Intl.DateTimeFormat("en-US", { + ...(timeZone === undefined ? {} : { timeZone }), + month: "short", + day: "numeric", + hour: "numeric", + }).format(date); +} + +/** An hourly tooltip label relative to the rolling window's end date. */ +export function formatRelativeHourShort( + hourStart: string, + relativeTo: string, + timeZone?: string, +): string { + const instant = new Date(hourStart); + const reference = new Date(relativeTo); + if (Number.isNaN(instant.getTime()) || Number.isNaN(reference.getTime())) { + return formatDateTimeShort(hourStart, timeZone); + } + + const dayFormat = new Intl.DateTimeFormat("en-CA", { + ...(timeZone === undefined ? {} : { timeZone }), + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + const instantDay = Date.parse(`${dayFormat.format(instant)}T00:00:00Z`); + const referenceDay = Date.parse(`${dayFormat.format(reference)}T00:00:00Z`); + const calendarDaysAgo = Math.round((referenceDay - instantDay) / (24 * HOUR_MS)); + const hour = formatHourShort(hourStart, timeZone); + + if (calendarDaysAgo === 0) return `${hour} today`; + if (calendarDaysAgo === 1) return `${hour} yesterday`; + return formatDateTimeShort(hourStart, timeZone); +} + /** * The window the page requests, expressed in the viewer's own time zone so days * line up with what they actually experienced. */ -export function makeWindow(days: number, now = new Date()): UsageSummaryInput { +export function makeWindow( + days: number, + now = new Date(), + resolution: UsageResolution = "day", +): UsageSummaryInput { const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; const format = new Intl.DateTimeFormat("en-CA", { timeZone, @@ -93,9 +187,26 @@ export function makeWindow(days: number, now = new Date()): UsageSummaryInput { day: "2-digit", }); const untilDay = format.format(now); + if (resolution === "hour") { + // Minute-aligned bounds keep labels readable while still representing an + // exact rolling 24-hour duration. Fixed-duration buckets remain correct + // across offset changes and daylight-saving transitions. + const untilTimeMs = Math.floor(now.getTime() / 60_000) * 60_000; + const sinceTimeMs = untilTimeMs - 24 * HOUR_MS; + const sinceTime = new Date(sinceTimeMs); + const untilTime = new Date(untilTimeMs); + return { + sinceDay: UsageDay.make(format.format(sinceTime)), + untilDay: UsageDay.make(format.format(untilTime)), + timeZone, + resolution, + sinceTime: sinceTime.toISOString(), + untilTime: untilTime.toISOString(), + }; + } // Subtracting fixed milliseconds from `now` lands on the wrong calendar day - // around a DST transition. Only "today" needs the zone; the window start is - // pure calendar arithmetic on that day, done in UTC where days are uniform. + // around a DST transition. The window start is pure calendar arithmetic on + // the local end day, done in UTC where days are uniform. const [year = 0, month = 1, dayOfMonth = 1] = untilDay .split("-") .map((part) => Number.parseInt(part, 10)); @@ -104,5 +215,6 @@ export function makeWindow(days: number, now = new Date()): UsageSummaryInput { sinceDay: UsageDay.make(start.toISOString().slice(0, 10)), untilDay: UsageDay.make(untilDay), timeZone, + resolution, }; } diff --git a/packages/shared/src/usageMerge.test.ts b/packages/shared/src/usageMerge.test.ts index 3c377a33f97e..c2fa9e2a86a1 100644 --- a/packages/shared/src/usageMerge.test.ts +++ b/packages/shared/src/usageMerge.test.ts @@ -254,5 +254,31 @@ describe("mergeUsage", () => { const merged = mergeUsage([], USAGE_CONTRACT_VERSION); expect(merged.costUsd).toBe(0); expect(merged.daily).toHaveLength(0); + expect(merged.hourly).toHaveLength(0); + }); + + it("derives hourly totals without losing the daily rollup", () => { + const merged = mergeUsage( + [ + environment( + "env-a", + summary( + [ + bucket({ hourStart: "2026-08-07T09:37:00.000Z", costUsd: 3 }), + bucket({ hourStart: "2026-08-07T10:37:00.000Z", costUsd: 7 }), + ], + [{ provider: "claude", hostId: "mac", homePath: "/a/.claude" }], + ), + ), + ], + USAGE_CONTRACT_VERSION, + ); + + expect(merged.hourly.map((hour) => [hour.hourStart, hour.costUsd])).toEqual([ + ["2026-08-07T09:37:00.000Z", 3], + ["2026-08-07T10:37:00.000Z", 7], + ]); + expect(merged.daily).toHaveLength(1); + expect(merged.daily[0]?.costUsd).toBe(10); }); }); diff --git a/packages/shared/src/usageMerge.ts b/packages/shared/src/usageMerge.ts index fd73c0a31c02..886b214183bc 100644 --- a/packages/shared/src/usageMerge.ts +++ b/packages/shared/src/usageMerge.ts @@ -45,6 +45,14 @@ export interface DailyTotals { readonly byProvider: ReadonlyMap; } +export interface HourlyTotals { + readonly day: string; + readonly hourStart: string; + readonly costUsd: number; + readonly totalTokens: number; + readonly byProvider: ReadonlyMap; +} + export interface CostQuality { readonly providerReportedShare: number; readonly modelPricedShare: number; @@ -65,6 +73,7 @@ export interface MergedUsage { readonly providers: readonly ProviderTotals[]; readonly models: readonly ModelTotals[]; readonly daily: readonly DailyTotals[]; + readonly hourly: readonly HourlyTotals[]; readonly costQuality: CostQuality; /** Environments whose data was dropped as a duplicate of another's. */ readonly duplicateSources: readonly string[]; @@ -168,6 +177,7 @@ const EMPTY_MERGED: MergedUsage = { providers: [], models: [], daily: [], + hourly: [], costQuality: { providerReportedShare: 0, modelPricedShare: 0, @@ -232,6 +242,16 @@ export function mergeUsage( byProvider: Map; } >(); + const hourlyAccumulator = new Map< + string, + { + day: string; + hourStart: string; + costUsd: number; + totalTokens: number; + byProvider: Map; + } + >(); const contributingEnvironments: EnvironmentId[] = []; for (const environment of current) { @@ -290,6 +310,26 @@ export function mergeUsage( dayProvider.totalTokens += tokens; day.byProvider.set(bucket.provider, dayProvider); dailyAccumulator.set(bucket.day, day); + + if (bucket.hourStart !== undefined) { + const hour = hourlyAccumulator.get(bucket.hourStart) ?? { + day: bucket.day, + hourStart: bucket.hourStart, + costUsd: 0, + totalTokens: 0, + byProvider: new Map(), + }; + hour.costUsd += bucket.costUsd; + hour.totalTokens += tokens; + const hourProvider = hour.byProvider.get(bucket.provider) ?? { + costUsd: 0, + totalTokens: 0, + }; + hourProvider.costUsd += bucket.costUsd; + hourProvider.totalTokens += tokens; + hour.byProvider.set(bucket.provider, hourProvider); + hourlyAccumulator.set(bucket.hourStart, hour); + } } } @@ -326,6 +366,10 @@ export function mergeUsage( })) .sort((a, b) => a.day.localeCompare(b.day)); + const hourly: HourlyTotals[] = [...hourlyAccumulator.values()].sort((a, b) => + a.hourStart.localeCompare(b.hourStart), + ); + return { costUsd, uncachedInputTokens, @@ -339,6 +383,7 @@ export function mergeUsage( providers, models, daily, + hourly, costQuality: { providerReportedShare: records === 0 ? 0 : providerReportedRecords / records, unpricedShare: records === 0 ? 0 : unpricedRecords / records, From 3da7f9c5c35d0c1f2a1b4420fbc10016e11ce069 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 11 Aug 2026 17:06:54 +0200 Subject: [PATCH 12/28] fix(mobile): guard App Store release versions (#6177) Co-authored-by: codex --- .github/workflows/mobile-eas-production.yml | 93 ++++++++++++++++++--- apps/mobile/app.config.ts | 2 +- 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/.github/workflows/mobile-eas-production.yml b/.github/workflows/mobile-eas-production.yml index efeaa803ef59..4ad9f4f7672b 100644 --- a/.github/workflows/mobile-eas-production.yml +++ b/.github/workflows/mobile-eas-production.yml @@ -8,12 +8,14 @@ name: Mobile EAS Production # # Every merge to main that touches the mobile app reconciles, per platform: # 1. Store builds: if the latest production build's version differs from -# app.config.ts, cut a new build with --auto-submit (TestFlight + -# Play internal track). Bumping `version` is therefore all it takes to +# app.config.ts, cut a new build and submit it (TestFlight + Play internal +# track). Bumping `version` is therefore all it takes to # start the next release train — the first build of a version enters # external-TestFlight beta review immediately, and later builds of the -# same version auto-approve. Releasing to the App Store stays a manual -# App Store Connect step. +# same version auto-approve until that version is released. After App +# Store approval, Apple closes the release train and `version` must be +# bumped before another iOS build can be submitted. Releasing to the App +# Store stays a manual App Store Connect step. # 2. OTA: publish a production-channel update for each platform where at # least one finished production build matches the current native # fingerprint. Old-version binaries with a matching fingerprint receive @@ -42,6 +44,10 @@ on: - ios - android - all + version: + description: "Optional build version override (blank uses app.config.ts; an override is committed before building)" + required: false + type: string message: description: "OTA update message (mode=update only)" required: false @@ -89,11 +95,21 @@ jobs: echo "EXPO_TOKEN is not available; skipping EAS production job." fi + - id: version_app_token + name: Mint release app token for version override + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' && inputs.version != '' + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.RELEASE_APP_ID }} + private-key: ${{ secrets.RELEASE_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + - name: Checkout if: steps.expo-token.outputs.present == 'true' uses: actions/checkout@v6 with: fetch-depth: 0 + token: ${{ steps.version_app_token.outputs.token || github.token }} # No sparse-checkout here: it makes actions/checkout fetch with # --filter=blob:none, and eas-cli archives the project via # `git clone --depth 1 file://`, which fails (exit 128) @@ -135,6 +151,65 @@ jobs: EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }} run: eas env:pull production --non-interactive + - name: Apply manual version override + if: steps.version_app_token.outcome == 'success' + env: + GH_TOKEN: ${{ steps.version_app_token.outputs.token }} + APP_SLUG: ${{ steps.version_app_token.outputs.app-slug }} + RELEASE_VERSION: ${{ inputs.version }} + run: | + if [ "$GITHUB_REF_TYPE" != "branch" ]; then + echo "Version overrides require dispatching this workflow from a branch; received $GITHUB_REF_TYPE '$GITHUB_REF_NAME'." >&2 + exit 1 + fi + if ! [[ "$RELEASE_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}$ ]]; then + echo "Version override must contain two or three dot-separated integers; received '$RELEASE_VERSION'." >&2 + exit 1 + fi + + node --input-type=module -e ' + import fs from "node:fs"; + const path = "apps/mobile/app.config.ts"; + const source = fs.readFileSync(path, "utf8"); + const next = source.replace( + /^( version: ")[^"]+(".*)$/m, + `$1${process.env.RELEASE_VERSION}$2`, + ); + if (next === source && !source.includes(` version: "${process.env.RELEASE_VERSION}"`)) { + throw new Error("Could not update app version"); + } + fs.writeFileSync(path, next); + ' + vp fmt apps/mobile/app.config.ts + + if git diff --quiet -- apps/mobile/app.config.ts; then + echo "app.config.ts is already at $RELEASE_VERSION; no version commit needed." + exit 0 + fi + + user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)" + git config user.name "${APP_SLUG}[bot]" + git config user.email "${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" + git add apps/mobile/app.config.ts + git commit \ + -m "chore(mobile): bump app version to $RELEASE_VERSION" \ + -m "Co-authored-by: codex " + git push origin "HEAD:refs/heads/${GITHUB_REF_NAME}" + + - name: Summarize manual build version + if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' + working-directory: apps/mobile + run: | + version="$(npx expo config --json --type public | jq -r '.version')" + { + echo "## Manual production build" + echo + echo "- App version: \`$version\`" + echo "- Platform: \`${{ inputs.platform }}\`" + echo + echo "> Apple closes an iOS release train after App Store approval. Before building iOS, confirm \`$version\` is newer than the approved App Store version." + } >> "$GITHUB_STEP_SUMMARY" + - name: Build and submit (manual) if: steps.expo-token.outputs.present == 'true' && github.event_name == 'workflow_dispatch' && inputs.mode == 'build' working-directory: apps/mobile @@ -157,10 +232,8 @@ jobs: # No --status filter on build:list: an in-queue/in-progress build must # count as existing, or every merge during the build window would cut a - # duplicate. Builds started here stay attached to this serialized run so - # the queued run for a later merge cannot overtake them and lose its OTA. - # After an errored build, retry via workflow_dispatch mode=build — pushes - # won't re-trigger it until the app version changes. + # duplicate. After an errored build, retry via workflow_dispatch + # mode=build — pushes won't re-trigger it until the app version changes. - id: store_builds name: Ensure store builds exist for the current app version if: steps.expo-token.outputs.present == 'true' && github.event_name == 'push' @@ -178,8 +251,8 @@ jobs: continue fi echo "$platform: latest production build is $latest, app.config.ts says $version — building" - if eas build --platform "$platform" --profile production --auto-submit --non-interactive; then - echo ":building_construction: $platform: cut production build for $version (auto-submitted)" >> "$GITHUB_STEP_SUMMARY" + if eas build --platform "$platform" --profile production --auto-submit --non-interactive --no-wait; then + echo ":building_construction: $platform: scheduled production build and submission for $version" >> "$GITHUB_STEP_SUMMARY" else failed=1 echo ":x: $platform: production build or submission failed for $version" >> "$GITHUB_STEP_SUMMARY" diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 486ede13abc1..3813a10fa51a 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -161,7 +161,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: "1.0.2", + version: "1.0.3", runtimeVersion: { // Fingerprint (not appVersion) so an OTA only reaches binaries whose native // project — native deps, config plugins, AND patches/ — matches the update. From ac4780f451f98c10d5b518f2bfa3d035b46645df Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 11 Aug 2026 17:21:58 +0200 Subject: [PATCH 13/28] fix(web): restore typography font sizes to defaults (#6172) --- .../settings/SettingsPanels.logic.test.ts | 14 ++++ .../settings/SettingsPanels.logic.ts | 34 ++++++++ .../components/settings/SettingsPanels.tsx | 78 +++++++++++++++---- 3 files changed, 110 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index d0bdb58db2e3..ec4ad4ff5875 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -12,12 +12,26 @@ import { backgroundActivitySharedPolicySettings, buildProviderInstanceUpdatePatch, formatDiagnosticsDescription, + getChangedTypographySettingLabels, hasChangedBackgroundActivitySettings, isProjectGroupingEnabled, projectGroupingModeFromToggle, resolveBackgroundActivityProfileOption, } from "./SettingsPanels.logic"; +describe("typography settings restore", () => { + it("detects family and size changes by font row", () => { + expect(getChangedTypographySettingLabels(DEFAULT_UNIFIED_SETTINGS)).toEqual([]); + expect( + getChangedTypographySettingLabels({ + ...DEFAULT_UNIFIED_SETTINGS, + fontSizeInterface: 18, + fontFamilyCode: "Fira Code", + }), + ).toEqual(["Interface font", "Code font"]); + }); +}); + describe("background activity settings restore", () => { it("detects legacy interval values even when the structured setting is at its default", () => { expect( diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index efb5e12ff33d..39f4f3cdafd6 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -74,6 +74,40 @@ export function hasChangedBackgroundActivitySettings( ); } +type TypographySettings = Pick< + UnifiedSettings, + | "fontFamilySans" + | "fontFamilyComposer" + | "fontFamilyCode" + | "fontFamilyTerminal" + | "fontSizeInterface" + | "fontSizePrompt" + | "fontSizeCode" + | "fontSizeTerminal" +>; + +/** Labels the font rows whose family or size differs from the defaults. */ +export function getChangedTypographySettingLabels(settings: TypographySettings): string[] { + return [ + ...(settings.fontFamilySans !== DEFAULT_UNIFIED_SETTINGS.fontFamilySans || + settings.fontSizeInterface !== DEFAULT_UNIFIED_SETTINGS.fontSizeInterface + ? ["Interface font"] + : []), + ...(settings.fontFamilyComposer !== DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer || + settings.fontSizePrompt !== DEFAULT_UNIFIED_SETTINGS.fontSizePrompt + ? ["Prompt font"] + : []), + ...(settings.fontFamilyCode !== DEFAULT_UNIFIED_SETTINGS.fontFamilyCode || + settings.fontSizeCode !== DEFAULT_UNIFIED_SETTINGS.fontSizeCode + ? ["Code font"] + : []), + ...(settings.fontFamilyTerminal !== DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal || + settings.fontSizeTerminal !== DEFAULT_UNIFIED_SETTINGS.fontSizeTerminal + ? ["Terminal font"] + : []), + ]; +} + export function resolveBackgroundActivityProfileOption( settings: ServerSettings, ): BackgroundActivityProfile | "advanced" { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 6743952ae26e..e4cfbe9ac033 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -122,6 +122,7 @@ import { backgroundActivitySharedPolicySettings, durationToSeconds, formatDiagnosticsDescription, + getChangedTypographySettingLabels, normalizeIntervalSeconds, PROVIDER_HEALTH_INTERVAL_STEP_SECONDS, hasChangedBackgroundActivitySettings, @@ -493,16 +494,7 @@ export function useSettingsRestore(onRestored?: () => void) { ? ["Auto-settle inactive threads"] : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), - ...(settings.fontFamilySans !== DEFAULT_UNIFIED_SETTINGS.fontFamilySans - ? ["Interface font"] - : []), - ...(settings.fontFamilyComposer !== DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer - ? ["Prompt font"] - : []), - ...(settings.fontFamilyCode !== DEFAULT_UNIFIED_SETTINGS.fontFamilyCode ? ["Code font"] : []), - ...(settings.fontFamilyTerminal !== DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal - ? ["Terminal font"] - : []), + ...getChangedTypographySettingLabels(settings), ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] : []), @@ -652,6 +644,10 @@ export function useSettingsRestore(onRestored?: () => void) { fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer, fontFamilyCode: DEFAULT_UNIFIED_SETTINGS.fontFamilyCode, fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal, + fontSizeInterface: DEFAULT_UNIFIED_SETTINGS.fontSizeInterface, + fontSizePrompt: DEFAULT_UNIFIED_SETTINGS.fontSizePrompt, + fontSizeCode: DEFAULT_UNIFIED_SETTINGS.fontSizeCode, + fontSizeTerminal: DEFAULT_UNIFIED_SETTINGS.fontSizeTerminal, }); onRestored?.(); }, [ @@ -1105,13 +1101,21 @@ function InterfaceFontRow({ preview }: { preview?: ReactNode }) { {...searchableSetting("interface-font")} description="Everything outside code blocks and the terminal." defaultFamily={defaults.sans} + defaultValue={DEFAULT_UNIFIED_SETTINGS.fontFamilySans} value={settings.fontFamilySans} onValueChange={(fontFamilySans) => updateSettings({ fontFamilySans })} + onReset={() => + updateSettings({ + fontFamilySans: DEFAULT_UNIFIED_SETTINGS.fontFamilySans, + fontSizeInterface: DEFAULT_UNIFIED_SETTINGS.fontSizeInterface, + }) + } size={{ label: "Interface font size", min: MIN_INTERFACE_FONT_SIZE, max: MAX_INTERFACE_FONT_SIZE, value: settings.fontSizeInterface, + defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizeInterface, onChange: (fontSizeInterface) => updateSettings({ fontSizeInterface }), }} {...(preview !== undefined ? { preview } : {})} @@ -1128,13 +1132,21 @@ function PromptFontRow() { {...searchableSetting("prompt-font")} description="Only the box you write prompts in. Mono works well here." defaultFamily={defaults.interfaceFamily} + defaultValue={DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer} value={settings.fontFamilyComposer} onValueChange={(fontFamilyComposer) => updateSettings({ fontFamilyComposer })} + onReset={() => + updateSettings({ + fontFamilyComposer: DEFAULT_UNIFIED_SETTINGS.fontFamilyComposer, + fontSizePrompt: DEFAULT_UNIFIED_SETTINGS.fontSizePrompt, + }) + } size={{ label: "Prompt font size", min: MIN_PROMPT_FONT_SIZE, max: MAX_PROMPT_FONT_SIZE, value: settings.fontSizePrompt, + defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizePrompt, onChange: (fontSizePrompt) => updateSettings({ fontSizePrompt }), }} preview={} @@ -1160,14 +1172,22 @@ function CodeFontRow({ {...(title !== undefined ? { title } : {})} description={description} defaultFamily={defaults.code} + defaultValue={DEFAULT_UNIFIED_SETTINGS.fontFamilyCode} value={settings.fontFamilyCode} onValueChange={(fontFamilyCode) => updateSettings({ fontFamilyCode })} + onReset={() => + updateSettings({ + fontFamilyCode: DEFAULT_UNIFIED_SETTINGS.fontFamilyCode, + fontSizeCode: DEFAULT_UNIFIED_SETTINGS.fontSizeCode, + }) + } requireMonospace size={{ label: "Code font size", min: MIN_CODE_FONT_SIZE, max: MAX_CODE_FONT_SIZE, value: settings.fontSizeCode, + defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizeCode, onChange: (fontSizeCode) => updateSettings({ fontSizeCode }), }} preview={preview ?? } @@ -1184,14 +1204,22 @@ function TerminalFontRow() { {...searchableSetting("terminal-font")} description="Terminal output, independent from code blocks and diffs." defaultFamily={defaults.code} + defaultValue={DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal} value={settings.fontFamilyTerminal} onValueChange={(fontFamilyTerminal) => updateSettings({ fontFamilyTerminal })} + onReset={() => + updateSettings({ + fontFamilyTerminal: DEFAULT_UNIFIED_SETTINGS.fontFamilyTerminal, + fontSizeTerminal: DEFAULT_UNIFIED_SETTINGS.fontSizeTerminal, + }) + } requireMonospace size={{ label: "Terminal font size", min: MIN_TERMINAL_FONT_SIZE, max: MAX_TERMINAL_FONT_SIZE, value: settings.fontSizeTerminal, + defaultValue: DEFAULT_UNIFIED_SETTINGS.fontSizeTerminal, onChange: (fontSizeTerminal) => updateSettings({ fontSizeTerminal }), }} preview={ @@ -1369,9 +1397,11 @@ function FontFamilySettingsRow({ title, description, defaultFamily, + defaultValue, preview, value, onValueChange, + onReset, requireMonospace = false, size, }: { @@ -1380,11 +1410,21 @@ function FontFamilySettingsRow({ description: string; /** What an unset preference renders as, e.g. "Menlo". */ defaultFamily: string; + /** The persisted family value supplied by the unified settings defaults. */ + defaultValue: string; preview?: ReactNode; value: string; onValueChange: (value: string) => void; + onReset: () => void; requireMonospace?: boolean; - size: { label: string; min: number; max: number; value: number; onChange: (v: number) => void }; + size: { + label: string; + min: number; + max: number; + value: number; + defaultValue: number; + onChange: (v: number) => void; + }; }) { const trimmed = value.trim(); // The fallback input edits a draft; the preference only commits once typing @@ -1431,12 +1471,18 @@ function FontFamilySettingsRow({ // Flag an unknown name only once typing pauses, and never for an empty // field - that is the starting state, not a rejected entry. const draftPending = draftSettled && draftTrimmed.length > 0 && draftTrimmed !== trimmed; + const resetToDefault = () => { + if (commitTimerRef.current !== null) { + window.clearTimeout(commitTimerRef.current); + commitTimerRef.current = null; + } + setDraft(defaultValue); + setDraftSettled(true); + onReset(); + }; const resetAction = - trimmed.length > 0 ? ( - onValueChange("")} - /> + value !== defaultValue || size.value !== size.defaultValue ? ( + ) : null; const fontEnumeration = useFontEnumeration(); // Everyone starts on the plain input; focusing it is the user gesture that From b30a9bc4143910f12d9c990c221fb9bc20cbc293 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 11 Aug 2026 17:46:52 +0200 Subject: [PATCH 14/28] feat(web): make environment artwork theme aware (#6183) Co-authored-by: codex --- apps/web/src/components/AppSidebarLayout.tsx | 10 +- .../components/SidebarStageBackdrop.test.tsx | 33 ++++ .../src/components/SidebarStageBackdrop.tsx | 127 +++++++++---- .../chat/ComposerPrimaryActions.test.ts | 59 +++++- .../chat/ComposerPrimaryActions.tsx | 17 +- .../components/settings/ThemeEditorPanel.tsx | 42 +---- .../src/components/sidebar/SidebarChrome.tsx | 4 +- apps/web/src/hooks/useSettings.ts | 8 +- apps/web/src/index.css | 173 +++++++++++++++++- apps/web/src/themePalette.test.ts | 31 ++-- apps/web/src/themePalette.ts | 30 +-- docs/user/thread-sidebar.md | 7 + 12 files changed, 416 insertions(+), 125 deletions(-) diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index d27cc0379d72..cd6f2c67e1a2 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -19,7 +19,10 @@ import LegacyThreadSidebar from "./LegacySidebar"; import ThreadSidebar from "./Sidebar"; import { SettingsSidebarNav } from "./settings/SettingsSidebarNav"; import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; -import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; +import { + resolveSidebarStageFocusRingOffsetClass, + useSidebarStageBackdropVariant, +} from "./SidebarStageBackdrop"; import { useProjects } from "../state/entities"; import { resolveInitialThreadSidebarWidth, @@ -105,7 +108,10 @@ function SidebarControl() { "pointer-events-auto", isSidebarVisible && stageBackdropVariant && - "[:hover,[data-pressed]]:bg-white/15 focus-visible:ring-white/90 focus-visible:ring-offset-blue-700 [&_svg]:stroke-white/90! [&_svg]:opacity-100! [&_svg]:hover:stroke-white!", + "focus-visible:ring-white/90 [&_svg]:stroke-white/90! [&_svg]:opacity-100! [&_svg]:hover:stroke-white! [:hover,[data-pressed]]:bg-white/15", + isSidebarVisible && + stageBackdropVariant && + resolveSidebarStageFocusRingOffsetClass(stageBackdropVariant), )} aria-label="Toggle main sidebar" /> diff --git a/apps/web/src/components/SidebarStageBackdrop.test.tsx b/apps/web/src/components/SidebarStageBackdrop.test.tsx index c34eec58316d..eca741af8c89 100644 --- a/apps/web/src/components/SidebarStageBackdrop.test.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.test.tsx @@ -4,7 +4,9 @@ import { renderToStaticMarkup } from "react-dom/server"; import { resolveEnvironmentIdentificationPillLabel, resolveSidebarStageBackdropVariant, + resolveSidebarStageFocusRingOffsetClass, StageBackdropArt, + StageBackdropButtonArt, } from "./SidebarStageBackdrop"; describe("SidebarStageBackdrop", () => { @@ -22,6 +24,15 @@ describe("SidebarStageBackdrop", () => { expect(resolveEnvironmentIdentificationPillLabel("Alpha")).toBeNull(); }); + it("matches the focus-ring offset to each artwork palette", () => { + expect(resolveSidebarStageFocusRingOffsetClass("nightly")).toBe( + "focus-visible:ring-offset-(--stage-night-bottom)", + ); + expect(resolveSidebarStageFocusRingOffsetClass("dev")).toBe( + "focus-visible:ring-offset-(--stage-art-bottom)", + ); + }); + it.each(["nightly", "dev"] as const)( "uses unique SVG definition ids when %s artwork is rendered more than once", (variant) => { @@ -37,4 +48,26 @@ describe("SidebarStageBackdrop", () => { expect(new Set(ids).size).toBe(ids.length); }, ); + + it("paints each artwork variant with theme-owned color tokens", () => { + const nightlyMarkup = renderToStaticMarkup(); + const devMarkup = renderToStaticMarkup(); + + expect(nightlyMarkup).toContain("var(--stage-night-bottom)"); + expect(nightlyMarkup).toContain("var(--stage-night-line)"); + expect(devMarkup).toContain("var(--stage-art-bottom)"); + expect(devMarkup).toContain("var(--stage-art-line)"); + expect(nightlyMarkup).not.toMatch(/#[0-9a-f]{3,8}/i); + expect(devMarkup).not.toMatch(/#[0-9a-f]{3,8}/i); + }); + + it.each([ + ["nightly", "96 0 8192 96"], + ["dev", "64 0 8192 96"], + ] as const)("uses the compact %s crop inside the send button", (variant, viewBox) => { + const markup = renderToStaticMarkup(); + + expect(markup).toContain(`viewBox="${viewBox}"`); + expect(markup).toContain(`stage-${variant === "dev" ? "blueprint" : "nightly"}`); + }); }); diff --git a/apps/web/src/components/SidebarStageBackdrop.tsx b/apps/web/src/components/SidebarStageBackdrop.tsx index ee669e94bd47..549b8a06b88a 100644 --- a/apps/web/src/components/SidebarStageBackdrop.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.tsx @@ -23,6 +23,14 @@ export function resolveSidebarStageBackdropVariant( return null; } +export function resolveSidebarStageFocusRingOffsetClass( + variant: SidebarStageBackdropVariant, +): string { + return variant === "nightly" + ? "focus-visible:ring-offset-(--stage-night-bottom)" + : "focus-visible:ring-offset-(--stage-art-bottom)"; +} + export function resolveEnvironmentIdentificationPillLabel( stageLabel: string, ): EnvironmentIdentificationPillLabel | null { @@ -62,6 +70,10 @@ export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVar return variant === "nightly" ? : ; } +export function StageBackdropButtonArt({ variant }: { variant: SidebarStageBackdropVariant }) { + return variant === "nightly" ? : ; +} + const NIGHTLY_STARS: ReadonlyArray<{ cx: number; cy: number; @@ -93,7 +105,7 @@ const NIGHTLY_SPARKLES: ReadonlyArray<{ x: number; y: number }> = [ { x: 246, y: 26 }, ]; -function NightlySkyArt() { +function NightlySkyArt({ compact = false }: { compact?: boolean }) { const idPrefix = useId().replaceAll(":", ""); const skyId = `${idPrefix}-stage-night-sky`; const glowId = `${idPrefix}-stage-night-glow`; @@ -104,10 +116,10 @@ function NightlySkyArt() { return ( @@ -120,9 +132,9 @@ function NightlySkyArt() { gradientUnits="userSpaceOnUse" spreadMethod="reflect" > - - - + + + - - - + + + - - - + + + - + {NIGHTLY_STARS.map((star) => ( ))} - + {NIGHTLY_SPARKLES.map((sparkle) => ( @@ -191,7 +216,7 @@ function NightlySkyArt() { ); } -function DevBlueprintArt() { +function DevBlueprintArt({ compact = false }: { compact?: boolean }) { const idPrefix = useId().replaceAll(":", ""); const paperId = `${idPrefix}-stage-bp-paper`; const glowId = `${idPrefix}-stage-bp-glow`; @@ -205,10 +230,10 @@ function DevBlueprintArt() { return ( @@ -221,9 +246,9 @@ function DevBlueprintArt() { gradientUnits="userSpaceOnUse" spreadMethod="reflect" > - - - + + + - - - + + + - - - + + + - - - + + + - + - + @@ -281,7 +328,12 @@ function DevBlueprintArt() { - + @@ -294,7 +346,12 @@ function DevBlueprintArt() { - + @@ -318,7 +375,7 @@ function DevBlueprintArt() { - + diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts b/apps/web/src/components/chat/ComposerPrimaryActions.test.ts index ba416e9fce30..3dbcd39e9d13 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.ts +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.ts @@ -1,13 +1,18 @@ import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +const stageArtworkState = vi.hoisted(() => ({ + mode: "none" as "artwork" | "none", + variant: null as "nightly" | "dev" | null, +})); vi.mock("~/hooks/useSettings", () => ({ - useEnvironmentIdentificationMode: () => "none", + useEnvironmentIdentificationMode: () => stageArtworkState.mode, })); vi.mock("../SidebarStageBackdrop", () => ({ - StageBackdropButtonArt: () => null, - useSidebarStageBackdropVariant: () => null, + StageBackdropButtonArt: ({ variant }: { variant: string }) => `stage-${variant}`, + useSidebarStageBackdropVariant: (enabled = true) => (enabled ? stageArtworkState.variant : null), })); import { ComposerPrimaryActions, formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions"; @@ -60,6 +65,32 @@ function renderStandaloneStop() { ); } +function renderSendButton() { + return renderToStaticMarkup( + createElement(ComposerPrimaryActions, { + compact: true, + pendingAction: null, + isRunning: false, + showPlanFollowUpPrompt: false, + promptHasText: true, + isSendBusy: false, + sendDisabledReason: null, + isConnecting: false, + isEnvironmentUnavailable: false, + isPreparingWorktree: false, + hasSendableContent: true, + onPreviousPendingQuestion: () => {}, + onInterrupt: () => {}, + onImplementPlanInNewThread: () => {}, + }), + ); +} + +afterEach(() => { + stageArtworkState.mode = "none"; + stageArtworkState.variant = null; +}); + describe("formatPendingPrimaryActionLabel", () => { it("returns 'Submitting...' while responding", () => { expect( @@ -164,4 +195,24 @@ describe("ComposerPrimaryActions", () => { expect(renderStandaloneStop()).toContain("size-8 sm:h-8 sm:w-8"); expect(renderStandaloneStop()).not.toContain("sm:size-7"); }); + + it("renders stage artwork inside the send button when artwork identification is active", () => { + stageArtworkState.mode = "artwork"; + stageArtworkState.variant = "nightly"; + + const markup = renderSendButton(); + + expect(markup).toContain("stage-nightly"); + expect(markup).toContain("bg-transparent text-white"); + expect(markup).not.toContain("bg-message-action text-message-action-foreground"); + }); + + it("keeps the normal send-button fill when artwork identification is inactive", () => { + stageArtworkState.variant = "nightly"; + + const markup = renderSendButton(); + + expect(markup).not.toContain("stage-nightly"); + expect(markup).toContain("bg-message-action text-message-action-foreground"); + }); }); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 52d2556bbf90..d8626496ae7d 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -1,6 +1,8 @@ import { memo, type PointerEventHandler } from "react"; import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react"; +import { useEnvironmentIdentificationMode } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; +import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop"; import { Button } from "../ui/button"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Spinner } from "../ui/spinner"; @@ -73,7 +75,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ const pointerFocusProps = preserveComposerFocusOnPointerDown ? { onPointerDown: preventPointerFocus } : undefined; + const environmentIdentificationMode = useEnvironmentIdentificationMode(); const isSendDisabled = sendDisabledReason !== null; + const stageBackdropVariant = useSidebarStageBackdropVariant( + environmentIdentificationMode === "artwork", + ); const renderStopGenerationButton = (insidePendingAction: boolean) => ( + ) : null} {isConnecting || isSendBusy ? (
); - const renderSidebarArtworkToggle = () => ( - - ); - const renderColorsHeader = () => (
@@ -1124,7 +1087,6 @@ export function ThemeEditorPanel({

) : null} {renderAppearanceButtons()} - {renderSidebarArtworkToggle()}
{renderColorsHeader()} {renderColorFields()} diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index 8ce42cf45df8..e9d3dcbc4362 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -8,6 +8,7 @@ import { usePrimaryEnvironment } from "../../state/environments"; import { resolveEnvironmentIdentificationPillLabel, resolveSidebarStageBackdropVariant, + resolveSidebarStageFocusRingOffsetClass, SidebarStageBackdrop, useEnvironmentStageLabel, } from "../SidebarStageBackdrop"; @@ -52,7 +53,8 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({ className={cn( "relative z-10 md:hidden", backdropVariant && - "[:hover,[data-pressed]]:bg-white/15 focus-visible:ring-white/90 focus-visible:ring-offset-blue-700 [&_svg]:stroke-white/90! [&_svg]:opacity-100! [&_svg]:hover:stroke-white!", + "focus-visible:ring-white/90 [&_svg]:stroke-white/90! [&_svg]:opacity-100! [&_svg]:hover:stroke-white! [:hover,[data-pressed]]:bg-white/15", + backdropVariant && resolveSidebarStageFocusRingOffsetClass(backdropVariant), )} /> diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index bf273879dc43..590949c09d9b 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -31,6 +31,7 @@ import { getThemePreviewSidebarArtwork, resolveThemeHalf, subscribeToThemePreview, + themeAllowsSidebarArtwork, } from "~/themePalette"; import * as Struct from "effect/Struct"; import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; @@ -236,8 +237,8 @@ export function resolveEnvironmentIdentificationMode(input: { }): EnvironmentIdentificationMode { // Avoid briefly rendering the default artwork before a persisted pill/none choice loads. if (!input.settingsHydrated) return "none"; - // Stage artwork has fixed colors that can clash with palette themes. Keep an - // explicit "none", but use the theme-aware pill in place of artwork. + // Artwork palettes are maintained for built-ins only. Keep an explicit + // "none", but use the theme-aware pill for user-controlled palettes. return input.paletteThemeActive && !input.paletteThemeAllowsArtwork && input.mode === "artwork" ? "pill" : input.mode; @@ -258,8 +259,7 @@ export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMod mode, settingsHydrated, paletteThemeActive: previewSidebarArtwork !== null || activeThemeDefinition !== null, - paletteThemeAllowsArtwork: - previewSidebarArtwork ?? activeThemeDefinition?.sidebarArtwork === true, + paletteThemeAllowsArtwork: previewSidebarArtwork ?? themeAllowsSidebarArtwork(activeTheme), }); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 8600d0048546..09743241a82f 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -265,6 +265,39 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil } @layer base { + :root { + /* Dev artwork defaults. Built-in themes override these seven pigments in + the components layer; Nightly derives a darker matching palette. */ + --stage-art-top: #67c2ff; + --stage-art-mid: #347ff8; + --stage-art-bottom: #1538d0; + --stage-art-highlight: #d4f6ff; + --stage-art-secondary: #65c8ff; + --stage-art-tertiary: #7c8bff; + --stage-art-line: #ddf7ff; + --stage-night-top: color-mix(in oklch, var(--stage-art-top) 38%, #32155b); + --stage-night-mid: color-mix(in oklch, var(--stage-art-mid) 38%, #151443); + --stage-night-bottom: color-mix(in oklch, var(--stage-art-bottom) 38%, #07152f); + --stage-night-highlight: var(--stage-art-highlight); + --stage-night-secondary: color-mix( + in oklch, + var(--stage-art-secondary) 55%, + var(--stage-night-mid) + ); + --stage-night-tertiary: color-mix( + in oklch, + var(--stage-art-tertiary) 60%, + var(--stage-night-top) + ); + --stage-night-line: color-mix(in oklch, var(--stage-art-line) 82%, white); + } + + .dark { + --stage-art-top: #3a7ad1; + --stage-art-mid: #2050ae; + --stage-art-bottom: #101f6e; + } + * { @apply border-border outline-ring/50; } @@ -335,16 +368,136 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil ); } - .stage-blueprint { - --stage-bp-top: #67c2ff; - --stage-bp-mid: #347ff8; - --stage-bp-bottom: #1538d0; - } - - .dark .stage-blueprint { - --stage-bp-top: #3a7ad1; - --stage-bp-mid: #2050ae; - --stage-bp-bottom: #101f6e; + /* Each maintainer palette gives the same line art its own material: rose + vellum, forest drafting paper, marine cyanotype, copper, and violet ink. + These colors stay deliberately deep at the top edge so the white stage + wordmark and controls retain contrast in both appearance modes. */ + html[data-theme-id="t3-chat"] { + --stage-art-top: #f28ac1; + --stage-art-mid: #cb3e86; + --stage-art-bottom: #73194f; + --stage-art-highlight: #ffe6f6; + --stage-art-secondary: #ff82bc; + --stage-art-tertiary: #c67bf1; + --stage-art-line: #ffe7f8; + } + + html.dark[data-theme-id="t3-chat"] { + --stage-art-top: #a8467e; + --stage-art-mid: #742253; + --stage-art-bottom: #390e2d; + --stage-art-highlight: #ffd8ee; + --stage-art-secondary: #de639e; + --stage-art-tertiary: #9c64cf; + --stage-art-line: #ffe4f4; + } + + html[data-theme-id="grove"] { + --stage-art-top: #6cc492; + --stage-art-mid: #2d8a5e; + --stage-art-bottom: #174a33; + --stage-art-highlight: #e4fff0; + --stage-art-secondary: #79d8a5; + --stage-art-tertiary: #d5b563; + --stage-art-line: #eafff2; + --stage-night-top: #286548; + --stage-night-mid: #174a35; + --stage-night-bottom: #0a2c21; + --stage-night-highlight: #c4eed5; + --stage-night-secondary: #54a878; + --stage-night-tertiary: #b79b4d; + --stage-night-line: #d7f6e2; + } + + html.dark[data-theme-id="grove"] { + --stage-art-top: #438e65; + --stage-art-mid: #286447; + --stage-art-bottom: #123525; + --stage-art-highlight: #d2fbe2; + --stage-art-secondary: #62bf88; + --stage-art-tertiary: #c99b45; + --stage-art-line: #e0fae9; + --stage-night-top: #24523a; + --stage-night-mid: #113323; + --stage-night-bottom: #071d16; + --stage-night-highlight: #b5dfc4; + --stage-night-secondary: #478d66; + --stage-night-tertiary: #a9853b; + --stage-night-line: #d0ead9; + } + + html[data-theme-id="ocean"] { + --stage-art-top: #77c9e3; + --stage-art-mid: #3384bd; + --stage-art-bottom: #174d7a; + --stage-art-highlight: #e5faff; + --stage-art-secondary: #70c9df; + --stage-art-tertiary: #5cc7be; + --stage-art-line: #e9fbff; + } + + html.dark[data-theme-id="ocean"] { + --stage-art-top: #4288ac; + --stage-art-mid: #285d84; + --stage-art-bottom: #132e49; + --stage-art-highlight: #d9f5ff; + --stage-art-secondary: #61b5d4; + --stage-art-tertiary: #51b8b3; + --stage-art-line: #dff7ff; + } + + html[data-theme-id="ember"] { + --stage-art-top: #f4a26e; + --stage-art-mid: #d66036; + --stage-art-bottom: #7c291e; + --stage-art-highlight: #fff0df; + --stage-art-secondary: #ffad77; + --stage-art-tertiary: #ef6f69; + --stage-art-line: #fff3e7; + --stage-night-top: #8a442c; + --stage-night-mid: #5c271c; + --stage-night-bottom: #32100d; + --stage-night-highlight: #ffd6ba; + --stage-night-secondary: #cb7148; + --stage-night-tertiary: #ad4b45; + --stage-night-line: #ffe0c9; + } + + html.dark[data-theme-id="ember"] { + --stage-art-top: #ba6540; + --stage-art-mid: #803a29; + --stage-art-bottom: #3c1916; + --stage-art-highlight: #ffe1cd; + --stage-art-secondary: #e68156; + --stage-art-tertiary: #d85b58; + --stage-art-line: #ffe8d7; + --stage-night-top: #6a3425; + --stage-night-mid: #3e1b15; + --stage-night-bottom: #1d0d0b; + --stage-night-highlight: #efc4a9; + --stage-night-secondary: #a85e3f; + --stage-night-tertiary: #8f413e; + --stage-night-line: #f2d2bc; + } + + html[data-theme-id="iris"] { + --stage-art-top: #b49ae8; + --stage-art-mid: #7a5ac4; + --stage-art-bottom: #422d80; + --stage-art-highlight: #f2eaff; + --stage-art-secondary: #b79af1; + --stage-art-tertiary: #ec79c7; + --stage-art-line: #f6eeff; + } + + html.dark[data-theme-id="iris"] { + --stage-art-top: #8063c4; + --stage-art-mid: #513990; + --stage-art-bottom: #261c53; + --stage-art-highlight: #e8ddff; + --stage-art-secondary: #9e83dc; + --stage-art-tertiary: #d96ab7; + --stage-art-line: #eee7ff; } .workspace-topbar { diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index 0e47cfdd9dc9..5cae0a8a74b9 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -20,6 +20,7 @@ import { serializeThemeFile, subscribeToThemePreview, subscribeToCustomThemes, + themeAllowsSidebarArtwork, T3_CHAT_THEME, EMBER_THEME, GROVE_THEME, @@ -206,14 +207,8 @@ describe("theme files", () => { }); }); - it("keeps sidebar artwork opt-in through theme files", () => { - const withoutArtwork = parseThemeFile({ - version: THEME_FILE_VERSION, - name: "Plain sidebar", - appearance: "light", - colors: { accent: "#5b6cff" }, - }); - const withArtwork = parseThemeFile({ + it("keeps sidebar artwork disabled for custom theme files", () => { + const theme = parseThemeFile({ version: THEME_FILE_VERSION, name: "Art sidebar", appearance: "light", @@ -221,12 +216,11 @@ describe("theme files", () => { sidebarArtwork: true, }); - expect(withoutArtwork.sidebarArtwork).toBeUndefined(); - expect(withArtwork.sidebarArtwork).toBe(true); - expect(JSON.parse(serializeThemeFile(withArtwork)).sidebarArtwork).toBe(true); + expect(theme.sidebarArtwork).toBeUndefined(); + expect(JSON.parse(serializeThemeFile(theme))).not.toHaveProperty("sidebarArtwork"); }); - it("publishes sidebar artwork changes from the live theme preview", () => { + it("suppresses sidebar artwork during a live custom-theme preview", () => { const listener = vi.fn(); const unsubscribe = subscribeToThemePreview(listener); vi.stubGlobal("document", { @@ -237,8 +231,8 @@ describe("theme files", () => { }, }); - applyThemeColorPreview(T3_CHAT_THEME.colors, "light", true); - expect(getThemePreviewSidebarArtwork()).toBe(true); + applyThemeColorPreview(T3_CHAT_THEME.colors, "light"); + expect(getThemePreviewSidebarArtwork()).toBe(false); expect(listener).toHaveBeenCalledTimes(1); applyThemePalette("system"); @@ -325,6 +319,8 @@ describe("theme files", () => { for (const theme of [T3_CHAT_THEME, GROVE_THEME, OCEAN_THEME, EMBER_THEME, IRIS_THEME]) { expect(getThemeDefinition(theme.id)).toBe(theme); expect(getThemeModes(theme)).toEqual(["light", "dark"]); + expect(theme.sidebarArtwork).toBe(true); + expect(themeAllowsSidebarArtwork(theme.id)).toBe(true); expect(theme.colors.accent).toMatch(/^#[0-9a-f]{6}$/i); expect(theme.variants?.dark?.accent).toMatch(/^#[0-9a-f]{6}$/i); @@ -359,6 +355,7 @@ describe("theme files", () => { ); } } + expect(themeAllowsSidebarArtwork("my-custom-theme")).toBe(false); }); it("rejects a variant that repeats the base appearance", () => { @@ -451,15 +448,17 @@ describe("theme files", () => { expect(updatedTheme).toMatchObject({ id: "aurora", label: "Aurora Night", - sidebarArtwork: true, }); + expect(updatedTheme).not.toHaveProperty("sidebarArtwork"); invalidateCustomThemes(); expect(getCustomThemes()).toEqual([updatedTheme]); expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).toMatchObject({ id: "aurora", label: "Aurora Night", - sidebarArtwork: true, }); + expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).not.toHaveProperty( + "sidebarArtwork", + ); vi.unstubAllGlobals(); invalidateCustomThemes(); diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index d1db01ffeb4e..5b58e879b570 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -96,7 +96,7 @@ export type ThemeDefinition = Readonly<{ appearance: ThemeAppearance; colors: ThemeColors; variants?: ThemeVariants; - /** Allows fixed Dev/Nightly artwork to render over this theme's sidebar. */ + /** Allows Dev/Nightly artwork to render over a maintainer-controlled sidebar. */ sidebarArtwork?: boolean; /** True when the palette was generated by the guided editor from its * canvas and accent; such themes reopen in guided mode. */ @@ -109,7 +109,6 @@ export type ThemeFile = Readonly<{ appearance: ThemeAppearance; colors: ThemeColorOverrides; variants?: ThemeVariantOverrides; - sidebarArtwork?: boolean; managed?: boolean; }>; @@ -223,7 +222,6 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null { appearance: value.appearance, colors, ...(variants ? { variants } : {}), - ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}), ...(value.managed === true ? { managed: true } : {}), }; } @@ -1299,6 +1297,7 @@ export const T3_CHAT_THEME: ThemeDefinition = { variants: { dark: T3_CHAT_DARK_COLORS, }, + sidebarArtwork: true, }; /** Theme-file defaults follow the flagship palette for the requested mode. */ @@ -1341,6 +1340,7 @@ export const GROVE_THEME: ThemeDefinition = { ...themeActionColors("#e3b34e"), }, }, + sidebarArtwork: true, }; export const OCEAN_THEME: ThemeDefinition = { @@ -1357,6 +1357,7 @@ export const OCEAN_THEME: ThemeDefinition = { ...themeActionColors("#5bd0d6"), }, }, + sidebarArtwork: true, }; export const EMBER_THEME: ThemeDefinition = { @@ -1373,6 +1374,7 @@ export const EMBER_THEME: ThemeDefinition = { ...themeActionColors("#f78a7a"), }, }, + sidebarArtwork: true, }; export const IRIS_THEME: ThemeDefinition = { @@ -1389,6 +1391,7 @@ export const IRIS_THEME: ThemeDefinition = { ...themeActionColors("#f099d8"), }, }, + sidebarArtwork: true, }; const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray = [ @@ -1408,6 +1411,15 @@ export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | nu ); } +/** Artwork palettes are reviewed alongside built-ins; user themes always use the pill fallback. */ +export function themeAllowsSidebarArtwork(theme: ThemePreference): boolean { + const themeId = themeIdFromPreference(theme); + return ( + BUILT_IN_THEME_DEFINITIONS.find((definition) => definition.id === themeId)?.sidebarArtwork === + true + ); +} + export function getThemeColorsForMode( theme: ThemeDefinition, mode: ThemeAppearance, @@ -1569,7 +1581,6 @@ export function parseThemeFile(value: unknown): ThemeDefinition { appearance, colors: { ...fallback, ...overrides }, ...(Object.keys(variants).length > 0 ? { variants } : {}), - ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}), ...(value.managed === true ? { managed: true } : {}), }; } @@ -1582,7 +1593,6 @@ export function serializeThemeFile(theme: ThemeDefinition): string { appearance: theme.appearance, colors: theme.colors, ...(theme.variants ? { variants: theme.variants } : {}), - ...(theme.sidebarArtwork ? { sidebarArtwork: true } : {}), ...(theme.managed ? { managed: true } : {}), }; return `${JSON.stringify(file, null, 2)}\n`; @@ -1660,16 +1670,14 @@ export const THEME_PREVIEW_ID = "__preview"; * can be judged against the real interface instead of a miniature. Callers * restore the stored theme (refreshTheme) when the draft goes away. */ -export function applyThemeColorPreview( - colors: ThemeColors, - appearance: ThemeAppearance, - sidebarArtwork = false, -): void { +export function applyThemeColorPreview(colors: ThemeColors, appearance: ThemeAppearance): void { if (typeof document === "undefined") return; const root = document.documentElement; if (!root?.style) return; - setThemePreviewSidebarArtwork(sidebarArtwork); + // Drafts become user-controlled themes when saved, so their preview keeps + // the fixed stage artwork hidden even when it was seeded from a built-in. + setThemePreviewSidebarArtwork(false); root.dataset.themeId = THEME_PREVIEW_ID; root.classList.toggle("dark", appearance === "dark"); for (const [role, value] of Object.entries(colors) as Array<[ThemeColorRole, string]>) { diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 99c180bafdd2..3ff286055d68 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -11,3 +11,10 @@ other connected devices. If reordering is unavailable for one environment, update the T3 Code server running in that environment. Older servers can still pin and unpin threads, but do not understand synced ordering; their pinned threads keep the default newest-first order below the ones you have arranged. + +## Environment artwork + +Dev and Nightly environments can identify themselves with artwork at the top of the sidebar and in +the send button. Choose **Artwork**, **Version pill**, or **None** in Settings under environment +identification. Artwork is recolored to match each built-in theme. Custom themes use the **Version +pill** fallback because their colors are not controlled by T3 Code. From 6befe42eb09633f3adb500ae906a0e1da3f15f64 Mon Sep 17 00:00:00 2001 From: Arham Amin <132888838+arhxam@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:22:23 +0530 Subject: [PATCH 15/28] fix(shared): normalize a bare Windows drive root the same as C:\ / C:/ (#6189) Co-authored-by: Claude Opus 4.8 (1M context) --- packages/shared/src/path.test.ts | 12 ++++++++++++ packages/shared/src/path.ts | 6 +++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/path.test.ts b/packages/shared/src/path.test.ts index 52a07aae7bef..799f225fb6e2 100644 --- a/packages/shared/src/path.test.ts +++ b/packages/shared/src/path.test.ts @@ -4,6 +4,8 @@ import { isUncPath, isWindowsAbsolutePath, isWindowsDrivePath, + normalizeProjectPathForComparison, + normalizeProjectPathForDispatch, } from "./path.ts"; describe("path helpers", () => { @@ -31,4 +33,14 @@ describe("path helpers", () => { expect(isExplicitRelativePath("..\\repo")).toBe(true); expect(isExplicitRelativePath("~/repo")).toBe(false); }); + + it("normalizes a bare Windows drive root the same as one with a trailing separator", () => { + // `C:`, `C:\` and `C:/` all refer to the drive root and must compare equal. + expect(normalizeProjectPathForDispatch("C:")).toBe("C:\\"); + expect(normalizeProjectPathForComparison("C:")).toBe("c:\\"); + expect(normalizeProjectPathForComparison("C:")).toBe(normalizeProjectPathForComparison("C:\\")); + expect(normalizeProjectPathForComparison("C:")).toBe(normalizeProjectPathForComparison("C:/")); + // Non-root drive paths keep their trailing separator trimmed as before. + expect(normalizeProjectPathForDispatch("C:\\repo\\")).toBe("C:\\repo"); + }); }); diff --git a/packages/shared/src/path.ts b/packages/shared/src/path.ts index 66887d3f2ec3..b6758d814aa8 100644 --- a/packages/shared/src/path.ts +++ b/packages/shared/src/path.ts @@ -22,7 +22,11 @@ export function isExplicitRelativePath(value: string): boolean { } function isRootPath(value: string): boolean { - return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]?$/.test(value); + // The drive separator is required: a bare `C:` is not the drive root (it + // means "current directory on C:"), and treating it as already-canonical + // would leave it as `C:` while `C:\` and `C:/` normalize to the drive root, + // so the same location would fail project identity/dedup comparisons. + return value === "/" || value === "\\" || /^[a-zA-Z]:[/\\]$/.test(value); } function trimTrailingPathSeparators(value: string): string { From 220e573b14cea84b6662a7d69047129047433b63 Mon Sep 17 00:00:00 2001 From: Arham Amin <132888838+arhxam@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:44:02 +0530 Subject: [PATCH 16/28] fix(shared): detect Azure DevOps SSH remotes (ssh.dev.azure.com) (#6187) Co-authored-by: Claude Opus 4.8 (1M context) --- packages/shared/src/sourceControl.test.ts | 16 ++++++++++++++++ packages/shared/src/sourceControl.ts | 10 +++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index 368e8387ee60..bfee883dd9f5 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -57,6 +57,22 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { ).toBe("bitbucket"); }); + it("detects Azure DevOps SSH remotes", () => { + // The default Azure DevOps SSH clone URL uses the ssh.dev.azure.com host. + expect( + detectSourceControlProviderFromRemoteUrl("git@ssh.dev.azure.com:v3/org/project/repo")?.kind, + ).toBe("azure-devops"); + expect( + detectSourceControlProviderFromRemoteUrl("ssh://git@ssh.dev.azure.com:22/v3/org/project/repo") + ?.kind, + ).toBe("azure-devops"); + // Legacy visualstudio.com SSH host stays classified too. + expect( + detectSourceControlProviderFromRemoteUrl("git@vs-ssh.visualstudio.com:v3/org/project/repo") + ?.kind, + ).toBe("azure-devops"); + }); + it("preserves ports while classifying by hostname", () => { expect( detectSourceControlProviderFromRemoteUrl("https://gitlab.com:8443/group/repo.git"), diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 15a98dc7355e..a29fe968e44d 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -176,7 +176,15 @@ function isGitLabHost(host: string): boolean { } function isAzureDevOpsHost(host: string): boolean { - return host === "dev.azure.com" || host.endsWith(".visualstudio.com"); + // `ssh.dev.azure.com` is the default Azure DevOps SSH clone host + // (git@ssh.dev.azure.com:v3/org/project/repo), so match any `*.dev.azure.com` + // subdomain, not just the bare `dev.azure.com`. Legacy hosts stay under + // `.visualstudio.com` (including `vs-ssh.visualstudio.com`). + return ( + host === "dev.azure.com" || + host.endsWith(".dev.azure.com") || + host.endsWith(".visualstudio.com") + ); } function isBitbucketHost(host: string): boolean { From 44621c345fe7ce7cfa5d8e5286290d9c8dc20ce5 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:56:24 +0530 Subject: [PATCH 17/28] feat(web): add back buttons for the pull requests and usage pages in the sidebar footer (#6031) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- .../src/components/sidebar/SidebarChrome.tsx | 57 ++++++++++++++++--- 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index e9d3dcbc4362..48479d8372a8 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -1,6 +1,11 @@ -import { ChartNoAxesColumnIcon, GitPullRequestIcon, SettingsIcon } from "lucide-react"; +import { + ArrowLeftIcon, + ChartNoAxesColumnIcon, + GitPullRequestIcon, + SettingsIcon, +} from "lucide-react"; import { memo, useCallback } from "react"; -import { Link, useNavigate } from "@tanstack/react-router"; +import { Link, useCanGoBack, useLocation, useNavigate } from "@tanstack/react-router"; import { useEnvironmentIdentificationMode } from "../../hooks/useSettings"; import { cn } from "../../lib/utils"; @@ -114,6 +119,15 @@ function T3Wordmark() { export const SidebarChromeFooter = memo(function SidebarChromeFooter() { const navigate = useNavigate(); const { isMobile, setOpenMobile } = useSidebar(); + const canGoBack = useCanGoBack(); + const currentFooterPage = useLocation({ + select: (location) => + location.pathname === "/usage" + ? "usage" + : location.pathname === "/pull-requests" + ? "pull-requests" + : null, + }); const primaryEnvironment = usePrimaryEnvironment(); const pullRequestsSupported = primaryEnvironment?.serverConfig?.environment.capabilities.pullRequests === true; @@ -138,12 +152,28 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() { void navigate({ to: "/usage" }); }, [isMobile, navigate, setOpenMobile]); + const handleBackClick = useCallback(() => { + closeMobileSidebar(); + if (canGoBack) { + window.history.back(); + return; + } + void navigate({ to: "/" }); + }, [canGoBack, closeMobileSidebar, navigate]); + return ( - {pullRequestsSupported ? ( + {currentFooterPage === "pull-requests" ? ( + + + + Back + + + ) : pullRequestsSupported ? ( @@ -151,12 +181,21 @@ export const SidebarChromeFooter = memo(function SidebarChromeFooter() { ) : null} - - - - Usage - - + {currentFooterPage === "usage" ? ( + + + + Back + + + ) : ( + + + + Usage + + + )} From 1e355a2a3444d4de0c9cf68cbe31ade370afd598 Mon Sep 17 00:00:00 2001 From: Rodrigo Brechard Date: Tue, 11 Aug 2026 18:27:02 +0200 Subject: [PATCH 18/28] fix(web): render dropdowns above toasts (#6165) Co-authored-by: Rodrigo Brechard Co-authored-by: Claude Opus 5 (1M context) --- apps/web/src/components/ui/autocomplete.tsx | 2 +- apps/web/src/components/ui/combobox.tsx | 2 +- apps/web/src/components/ui/menu.tsx | 2 +- apps/web/src/components/ui/popover.tsx | 2 +- apps/web/src/components/ui/select.tsx | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ui/autocomplete.tsx b/apps/web/src/components/ui/autocomplete.tsx index 2d099762f280..b81701e25925 100644 --- a/apps/web/src/components/ui/autocomplete.tsx +++ b/apps/web/src/components/ui/autocomplete.tsx @@ -97,7 +97,7 @@ function AutocompletePopup({ align={align} alignOffset={alignOffset} anchor={anchor} - className="z-50 select-none" + className="z-[130] select-none" data-slot="autocomplete-positioner" side={side} sideOffset={sideOffset} diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index 528f4bee15f2..324b67e64d90 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -163,7 +163,7 @@ function ComboboxPopup({ align={align} alignOffset={alignOffset} anchor={anchor} - className="z-50 select-none" + className="z-[130] select-none" data-slot="combobox-positioner" side={side} sideOffset={sideOffset} diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index 5c4fc91fe136..9f7cfc8c0670 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -42,7 +42,7 @@ function MenuPopup({ align={align} alignOffset={alignOffset} anchor={anchor} - className="z-[60]" + className="z-[130]" data-slot="menu-positioner" side={side} sideOffset={sideOffset} diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index 04a640f5baf9..e82033cf1096 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -42,7 +42,7 @@ function PopoverPopup({ align={align} alignOffset={alignOffset} anchor={anchor} - className="z-[60] h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-transform data-instant:transition-none" + className="z-[130] h-(--positioner-height) w-(--positioner-width) max-w-(--available-width) transition-transform data-instant:transition-none" data-slot="popover-positioner" side={side} sideOffset={sideOffset} diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index 9f01359b6be8..98cdf339e1c3 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -132,7 +132,7 @@ function SelectPopup({ alignItemWithTrigger={alignItemWithTrigger} alignOffset={alignOffset} anchor={anchor} - className="z-50 select-none" + className="z-[130] select-none" data-slot="select-positioner" side={side} sideOffset={sideOffset} From 57b1052679da56229dba45e3505718e5f5991b32 Mon Sep 17 00:00:00 2001 From: Mina Yacoub <56601613+myacoub91@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:27:55 +0200 Subject: [PATCH 19/28] fix(web): thread error banner dismiss survives reconnect and rerenders (#6123) --- apps/web/src/components/ChatView.tsx | 36 ++++++++-- .../chat/ThreadErrorBanner.test.tsx | 67 ++++++++++++++++++- .../src/components/chat/ThreadErrorBanner.tsx | 29 ++++++++ 3 files changed, 127 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 762dac559f21..32a8e309beb9 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -264,7 +264,13 @@ import { ProviderStatusBanner, shouldShowProviderStatusBanner, } from "./chat/ProviderStatusBanner"; -import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; +import { + dismissThreadErrorBannerForSession, + getThreadErrorBannerKey, + isThreadErrorBannerDismissedForSession, + shouldShowThreadErrorBanner, + ThreadErrorBanner, +} from "./chat/ThreadErrorBanner"; import { resolveThreadPr } from "./ThreadStatusIndicators"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ThreadSyncStatusPill } from "./chat/ThreadSyncStatusPill"; @@ -1493,6 +1499,24 @@ function ChatViewContent(props: ChatViewProps) { const threadError = isServerThread ? (localServerError ?? activeServerThread?.session?.lastError ?? null) : localDraftError; + // Dismissals can only mask the shown error, never clear it: a server thread + // keeps its error in session.lastError, so clearing the local shadow would + // just fall through to the persisted one. Mask the current error until a + // different error arrives, mirroring the provider status banner. + const threadErrorBannerKey = getThreadErrorBannerKey(routeThreadKey, threadError); + const visibleThreadError = shouldShowThreadErrorBanner( + routeThreadKey, + threadError, + isThreadErrorBannerDismissedForSession(threadErrorBannerKey), + ) + ? threadError + : null; + // Dismissing only mutates the session-scoped mask set, which does not + // trigger a render on its own; setThreadError(null) can also bail when the + // local shadow is already empty and the banner is driven purely by + // session.lastError. Bump a tick so the banner hides immediately. Mirrors + // the branch mismatch banner. + const [, setThreadErrorBannerDismissTick] = useState(0); const runtimeMode = composerRuntimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; // Plan mode is legacy (Settings → Beta). With the flag off the effective // mode is forced to "default" — even for threads with a stored plan mode — @@ -2618,7 +2642,7 @@ function ChatViewContent(props: ChatViewProps) { ) ? activeProviderStatus : null; - const hasTimelineTopBanner = Boolean(threadError) || visibleProviderStatus !== null; + const hasTimelineTopBanner = Boolean(visibleThreadError) || visibleProviderStatus !== null; const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; @@ -6146,8 +6170,12 @@ function ChatViewContent(props: ChatViewProps) { setThreadError(activeThread.id, null)} + error={visibleThreadError} + onDismiss={() => { + setThreadError(activeThread.id, null); + dismissThreadErrorBannerForSession(threadErrorBannerKey); + setThreadErrorBannerDismissTick((tick) => tick + 1); + }} /> {/* Main content area with optional plan sidebar */}
diff --git a/apps/web/src/components/chat/ThreadErrorBanner.test.tsx b/apps/web/src/components/chat/ThreadErrorBanner.test.tsx index 73e4c08fc889..a3dbb27515ae 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.test.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.test.tsx @@ -1,9 +1,74 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; -import { ThreadErrorBanner } from "./ThreadErrorBanner"; +import { + dismissThreadErrorBannerForSession, + getThreadErrorBannerKey, + isThreadErrorBannerDismissedForSession, + shouldShowThreadErrorBanner, + ThreadErrorBanner, +} from "./ThreadErrorBanner"; describe("ThreadErrorBanner", () => { + it("stays hidden after its current error is dismissed", () => { + const bannerKey = getThreadErrorBannerKey("env:thread-a", "Aborted"); + dismissThreadErrorBannerForSession(bannerKey); + + expect( + shouldShowThreadErrorBanner( + "env:thread-a", + "Aborted", + isThreadErrorBannerDismissedForSession(bannerKey), + ), + ).toBe(false); + }); + + it("reappears when a new error arrives on the same thread", () => { + dismissThreadErrorBannerForSession(getThreadErrorBannerKey("env:thread-b", "Turn failed")); + const newErrorKey = getThreadErrorBannerKey("env:thread-b", "Provider crashed"); + + expect(isThreadErrorBannerDismissedForSession(newErrorKey)).toBe(false); + expect( + shouldShowThreadErrorBanner( + "env:thread-b", + "Provider crashed", + isThreadErrorBannerDismissedForSession(newErrorKey), + ), + ).toBe(true); + }); + + it("scopes dismissals to the thread that dismissed them", () => { + dismissThreadErrorBannerForSession(getThreadErrorBannerKey("env:thread-c", "Aborted")); + const otherThreadKey = getThreadErrorBannerKey("env:other-thread", "Aborted"); + + expect(isThreadErrorBannerDismissedForSession(otherThreadKey)).toBe(false); + expect( + shouldShowThreadErrorBanner( + "env:other-thread", + "Aborted", + isThreadErrorBannerDismissedForSession(otherThreadKey), + ), + ).toBe(true); + }); + + it("keeps a dismissal across visiting threads with no error", () => { + const bannerKey = getThreadErrorBannerKey("env:thread-d", "Aborted"); + dismissThreadErrorBannerForSession(bannerKey); + + expect(shouldShowThreadErrorBanner("env:thread-d", null, false)).toBe(false); + expect(isThreadErrorBannerDismissedForSession(bannerKey)).toBe(true); + expect( + shouldShowThreadErrorBanner( + "env:thread-d", + "Aborted", + isThreadErrorBannerDismissedForSession(bannerKey), + ), + ).toBe(false); + }); + + it("never shows a null error", () => { + expect(shouldShowThreadErrorBanner("env:thread-e", null, false)).toBe(false); + }); it("aligns the warning and dismiss icons with the first line of a multi-line error", () => { const markup = renderToStaticMarkup( (); + +export function dismissThreadErrorBannerForSession(bannerKey: string | null): void { + if (bannerKey !== null) { + sessionDismissedThreadErrorBannerKeys.add(bannerKey); + } +} + +export function isThreadErrorBannerDismissedForSession(bannerKey: string | null): boolean { + return bannerKey !== null && sessionDismissedThreadErrorBannerKeys.has(bannerKey); +} + export const ThreadErrorBanner = memo(function ThreadErrorBanner({ error, onDismiss, From 35172010b131510d36d0cef54e174926e38a3013 Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Tue, 11 Aug 2026 19:39:59 +0300 Subject: [PATCH 20/28] fix(web): use a clearer pull action icon (#6194) --- apps/web/src/components/GitActionsControl.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index b0df578dde9a..7602e7c5bfb3 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -22,6 +22,7 @@ import { flushSync } from "react-dom"; import { CheckIcon, ChevronDownIcon, + CloudDownloadIcon, CloudUploadIcon, ExternalLinkIcon, GitBranchPlusIcon, @@ -357,7 +358,7 @@ function GitQuickActionIcon({ const iconClassName = "size-3.5"; if (quickAction.kind === "open_pr") return ; if (quickAction.kind === "open_publish") return ; - if (quickAction.kind === "run_pull") return ; + if (quickAction.kind === "run_pull") return ; if (quickAction.kind === "run_action") { if (quickAction.action === "commit") return ; if (quickAction.action === "push" || quickAction.action === "commit_push") { From 083fa4ab24c464ddf01e5b7ab22135d1ebdc120b Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:00:17 +0200 Subject: [PATCH 21/28] feat(web): use OKLCH for theme palettes (#6036) --- apps/web/index.html | 127 ++-- apps/web/package.json | 2 + .../components/settings/ThemeColorPicker.tsx | 40 +- apps/web/src/index.css | 236 +++---- apps/web/src/themeBoot.test.ts | 69 +- apps/web/src/themePalette.test.ts | 333 ++++++++-- apps/web/src/themePalette.ts | 592 +++++++++++------- apps/web/src/vscodeThemeImport.test.ts | 45 +- apps/web/src/vscodeThemeImport.ts | 3 +- pnpm-lock.yaml | 11 + 10 files changed, 978 insertions(+), 480 deletions(-) diff --git a/apps/web/index.html b/apps/web/index.html index 5a4ccce76d6f..8f49fd32c829 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -35,16 +35,16 @@ // app colors before React mounts. const DEFAULT_THEME_PALETTES = { light: { - background: "#fdf7fd", - foreground: "#501854", - accent: "#db2777", - chrome: "#fdf7fd", + background: "oklch(0.982446 0.010114 325.653)", + foreground: "oklch(0.325698 0.116116 325.037)", + accent: "oklch(0.591646 0.217985 0.584)", + chrome: "oklch(0.982446 0.010114 325.653)", }, dark: { - background: "#1f1a24", - foreground: "#f9f8fb", - accent: "#a3004c", - chrome: "#1f1a24", + background: "oklch(0.22813 0.020366 307.469)", + foreground: "oklch(0.980735 0.004092 301.426)", + accent: "oklch(0.460685 0.185347 4.099)", + chrome: "oklch(0.22813 0.020366 307.469)", }, }; // Keep this small boot-time copy in sync with the built-in palettes so @@ -52,72 +52,72 @@ const BUILT_IN_THEME_PALETTES = { "t3-chat": { light: { - background: "#fdf7fd", - foreground: "#501854", - accent: "#db2777", - chrome: "#fdf7fd", + background: "oklch(0.982446 0.010114 325.653)", + foreground: "oklch(0.325698 0.116116 325.037)", + accent: "oklch(0.591646 0.217985 0.584)", + chrome: "oklch(0.982446 0.010114 325.653)", }, dark: { - background: "#1f1a24", - foreground: "#f9f8fb", - accent: "#a3004c", - chrome: "#1f1a24", + background: "oklch(0.22813 0.020366 307.469)", + foreground: "oklch(0.980735 0.004092 301.426)", + accent: "oklch(0.460685 0.185347 4.099)", + chrome: "oklch(0.22813 0.020366 307.469)", }, }, grove: { light: { - background: "#f3f7f4", - foreground: "#241523", - accent: "#1b7d50", - chrome: "#f3f7f4", + background: "oklch(0.972369 0.005497 157.15)", + foreground: "oklch(0.222003 0.03479 328.979)", + accent: "oklch(0.523295 0.112292 158.089)", + chrome: "oklch(0.972369 0.005497 157.15)", }, dark: { - background: "#1b2821", - foreground: "#fffaff", - accent: "#69d69a", - chrome: "#1b2821", + background: "oklch(0.260865 0.02152 162.75)", + foreground: "oklch(0.990339 0.008411 325.64)", + accent: "oklch(0.796228 0.133058 157.319)", + chrome: "oklch(0.260865 0.02152 162.75)", }, }, ocean: { light: { - background: "#f5f7f8", - foreground: "#241523", - accent: "#2672af", - chrome: "#f5f7f8", + background: "oklch(0.974199 0.002856 241.597)", + foreground: "oklch(0.222003 0.03479 328.979)", + accent: "oklch(0.536684 0.120219 247.01)", + chrome: "oklch(0.974199 0.002856 241.597)", }, dark: { - background: "#17212b", - foreground: "#fffaff", - accent: "#70b9ee", - chrome: "#17212b", + background: "oklch(0.242641 0.024125 250.573)", + foreground: "oklch(0.990339 0.008411 325.64)", + accent: "oklch(0.758933 0.105833 241.548)", + chrome: "oklch(0.242641 0.024125 250.573)", }, }, ember: { light: { - background: "#f9f7f5", - foreground: "#241523", - accent: "#ae552a", - chrome: "#f9f7f5", + background: "oklch(0.976527 0.002685 60.725)", + foreground: "oklch(0.222003 0.03479 328.979)", + accent: "oklch(0.552831 0.129438 44.656)", + chrome: "oklch(0.976527 0.002685 60.725)", }, dark: { - background: "#291e1a", - foreground: "#fffaff", - accent: "#f09a64", - chrome: "#291e1a", + background: "oklch(0.245899 0.019144 42.044)", + foreground: "oklch(0.990339 0.008411 325.64)", + accent: "oklch(0.762174 0.124117 52.082)", + chrome: "oklch(0.245899 0.019144 42.044)", }, }, iris: { light: { - background: "#f8f7f9", - foreground: "#241523", - accent: "#7253b9", - chrome: "#f8f7f9", + background: "oklch(0.976531 0.003855 303.226)", + foreground: "oklch(0.222003 0.03479 328.979)", + accent: "oklch(0.525348 0.15373 294.176)", + chrome: "oklch(0.976531 0.003855 303.226)", }, dark: { - background: "#1d1929", - foreground: "#fffaff", - accent: "#9d7df2", - chrome: "#1d1929", + background: "oklch(0.225975 0.031062 293.741)", + foreground: "oklch(0.990339 0.008411 325.64)", + accent: "oklch(0.671712 0.169136 293.929)", + chrome: "oklch(0.225975 0.031062 293.741)", }, }, }; @@ -152,9 +152,26 @@ } }; - const isHexColor = (value) => - typeof value === "string" && - /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value); + // The runtime decodes the same literal CSS formats into OKLCH. Keep + // contextual colors out: they cannot be resolved from stored data. + const SYSTEM_COLOR_KEYWORD = + /^(?:accentcolor(?:text)?|active(?:border|caption|text)|appworkspace|background|button(?:border|face|highlight|shadow|text)|canvas(?:text)?|captiontext|field(?:text)?|graytext|highlight(?:text)?|inactive(?:border|caption(?:text)?)|info(?:background|text)|linktext|mark(?:text)?|menu(?:text)?|scrollbar|selecteditem(?:text)?|threed(?:darkshadow|face|highlight|lightshadow|shadow)|visitedtext|window(?:frame|text)?)$/i; + const isThemeColor = (value) => { + if (typeof value !== "string" || typeof CSS === "undefined") return false; + const color = value.trim(); + if ( + /^(?:currentcolor|inherit|initial|revert|revert-layer|unset)$/i.test(color) || + SYSTEM_COLOR_KEYWORD.test(color) || + /\bfrom\b|(?:var|env|attr|calc|min|max|clamp)\(/i.test(color) + ) { + return false; + } + const isLiteralColor = + /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(color) || + /^[a-z]+$/i.test(color) || + /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\(/i.test(color); + return isLiteralColor && CSS.supports("color", color); + }; const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value); const isThemeId = (value) => @@ -322,13 +339,13 @@ const fallbackSplash = isDark ? SPLASH_COLORS.dark : SPLASH_COLORS.light; const customSplash = customColors ? { - background: isHexColor(customColors.canvas) + background: isThemeColor(customColors.canvas) ? customColors.canvas : (customDefaults?.background ?? fallbackSplash.background), - foreground: isHexColor(customColors.text) + foreground: isThemeColor(customColors.text) ? customColors.text : (customDefaults?.foreground ?? fallbackSplash.foreground), - accent: isHexColor(customColors.accent) + accent: isThemeColor(customColors.accent) ? customColors.accent : (customDefaults?.accent ?? fallbackSplash.accent), } @@ -350,7 +367,7 @@ } document.documentElement.classList.toggle("dark", isDark); const chromeColor = customColors - ? isHexColor(customColors.chrome) + ? isThemeColor(customColors.chrome) ? customColors.chrome : (customDefaults?.chrome ?? fallbackSplash.background) : builtInSplash diff --git a/apps/web/package.json b/apps/web/package.json index 68f6847b0164..0fce19b28630 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -31,6 +31,7 @@ "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", "class-variance-authority": "^0.7.1", + "culori": "^4.0.2", "effect": "catalog:", "jose": "catalog:", "lexical": "^0.41.0", @@ -53,6 +54,7 @@ "@tanstack/router-plugin": "^1.161.0", "@types/babel__core": "^7.20.5", "@types/compression": "^1.8.1", + "@types/culori": "^4.0.1", "@types/react": "~19.2.14", "@types/react-dom": "~19.2.3", "@vercel/config": "^0.3.0", diff --git a/apps/web/src/components/settings/ThemeColorPicker.tsx b/apps/web/src/components/settings/ThemeColorPicker.tsx index 38a72ff5c870..e835019364a2 100644 --- a/apps/web/src/components/settings/ThemeColorPicker.tsx +++ b/apps/web/src/components/settings/ThemeColorPicker.tsx @@ -1,6 +1,6 @@ import type { KeyboardEvent, PointerEvent } from "react"; import { memo, useCallback, useEffect, useRef, useState } from "react"; -import { isThemeColor, type ThemeColorRole } from "../../themePalette"; +import { isThemeColor, themeColorToHex, type ThemeColorRole } from "../../themePalette"; import { cn } from "../../lib/utils"; import { Input } from "../ui/input"; import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; @@ -37,39 +37,18 @@ function clampThemeColor(value: number, min = 0, max = 1) { } /** - * The picker's plane and sliders operate on opaque six-digit hex, but theme - * colors may carry alpha. The suffix is preserved separately and re-attached - * on commit so adjusting hue or brightness cannot change transparency. + * The picker remains an sRGB/hex adapter over the OKLCH palette engine. Alpha + * is preserved separately and re-attached on commit so adjusting hue or + * brightness cannot change transparency. */ function themePickerAlphaSuffix(value: string): string { - const trimmed = value.trim().toLowerCase(); - const alpha = /^#[0-9a-f]{4}$/.test(trimmed) - ? trimmed.slice(4).repeat(2) - : /^#[0-9a-f]{8}$/.test(trimmed) - ? trimmed.slice(7) - : ""; + const normalized = themeColorToHex(value) ?? ""; + const alpha = normalized.length === 9 ? normalized.slice(7) : ""; return alpha === "ff" ? "" : alpha; } function normalizeThemePickerColor(value: string): string { - const trimmed = value.trim(); - if (/^#[0-9a-f]{3}$/i.test(trimmed)) { - return `#${trimmed - .slice(1) - .split("") - .map((character) => `${character}${character}`) - .join("")}`; - } - if (/^#[0-9a-f]{4}$/i.test(trimmed)) { - return `#${trimmed - .slice(1, 4) - .split("") - .map((character) => `${character}${character}`) - .join("")}`; - } - if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed; - if (/^#[0-9a-f]{8}$/i.test(trimmed)) return trimmed.slice(0, 7); - return "#000000"; + return (themeColorToHex(value) ?? "#000000").slice(0, 7); } function themeHexToHsv(hex: string): ThemeColorHsv { @@ -505,6 +484,9 @@ export const ThemeColorField = memo(function ThemeColorField({ const label = customLabel ?? getThemeRoleLabel(role); const isColorValue = isThemeColor(value); const swatchValue = isColorValue ? value : "#000000"; + const editorValue = value.trim().toLowerCase().startsWith("oklch(") + ? (themeColorToHex(value) ?? value) + : value; return (
onSelect?.(role)} size="sm" unstyled - value={value} + value={editorValue} />
diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 09743241a82f..76e77a4f3967 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -268,16 +268,28 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil :root { /* Dev artwork defaults. Built-in themes override these seven pigments in the components layer; Nightly derives a darker matching palette. */ - --stage-art-top: #67c2ff; - --stage-art-mid: #347ff8; - --stage-art-bottom: #1538d0; - --stage-art-highlight: #d4f6ff; - --stage-art-secondary: #65c8ff; - --stage-art-tertiary: #7c8bff; - --stage-art-line: #ddf7ff; - --stage-night-top: color-mix(in oklch, var(--stage-art-top) 38%, #32155b); - --stage-night-mid: color-mix(in oklch, var(--stage-art-mid) 38%, #151443); - --stage-night-bottom: color-mix(in oklch, var(--stage-art-bottom) 38%, #07152f); + --stage-art-top: oklch(0.782169 0.123386 240.226); + --stage-art-mid: oklch(0.616111 0.195824 259.735); + --stage-art-bottom: oklch(0.441553 0.232394 265.474); + --stage-art-highlight: oklch(0.951597 0.037289 215.482); + --stage-art-secondary: oklch(0.794668 0.12136 235.46); + --stage-art-tertiary: oklch(0.678991 0.170261 275.365); + --stage-art-line: oklch(0.959666 0.029238 218.179); + --stage-night-top: color-mix( + in oklch, + var(--stage-art-top) 38%, + oklch(0.283792 0.117327 297.201) + ); + --stage-night-mid: color-mix( + in oklch, + var(--stage-art-mid) 38%, + oklch(0.227147 0.086086 277.99) + ); + --stage-night-bottom: color-mix( + in oklch, + var(--stage-art-bottom) 38%, + oklch(0.200528 0.055699 261.216) + ); --stage-night-highlight: var(--stage-art-highlight); --stage-night-secondary: color-mix( in oklch, @@ -289,13 +301,13 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil var(--stage-art-tertiary) 60%, var(--stage-night-top) ); - --stage-night-line: color-mix(in oklch, var(--stage-art-line) 82%, white); + --stage-night-line: color-mix(in oklch, var(--stage-art-line) 82%, oklch(1 0 0)); } .dark { - --stage-art-top: #3a7ad1; - --stage-art-mid: #2050ae; - --stage-art-bottom: #101f6e; + --stage-art-top: oklch(0.581473 0.149124 256.9); + --stage-art-mid: oklch(0.456509 0.159377 261.945); + --stage-art-bottom: oklch(0.291327 0.136578 267.649); } * { @@ -373,131 +385,131 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil These colors stay deliberately deep at the top edge so the white stage wordmark and controls retain contrast in both appearance modes. */ html[data-theme-id="t3-chat"] { - --stage-art-top: #f28ac1; - --stage-art-mid: #cb3e86; - --stage-art-bottom: #73194f; - --stage-art-highlight: #ffe6f6; - --stage-art-secondary: #ff82bc; - --stage-art-tertiary: #c67bf1; - --stage-art-line: #ffe7f8; + --stage-art-top: oklch(0.760675 0.141361 348.019); + --stage-art-mid: oklch(0.588093 0.188832 353.138); + --stage-art-bottom: oklch(0.383959 0.135096 348.656); + --stage-art-highlight: oklch(0.949689 0.034241 339.171); + --stage-art-secondary: oklch(0.763402 0.163836 352.525); + --stage-art-tertiary: oklch(0.70819 0.180285 311.949); + --stage-art-line: oklch(0.952158 0.034194 336.179); } html.dark[data-theme-id="t3-chat"] { - --stage-art-top: #a8467e; - --stage-art-mid: #742253; - --stage-art-bottom: #390e2d; - --stage-art-highlight: #ffd8ee; - --stage-art-secondary: #de639e; - --stage-art-tertiary: #9c64cf; - --stage-art-line: #ffe4f4; + --stage-art-top: oklch(0.540689 0.143665 347.587); + --stage-art-mid: oklch(0.396586 0.126592 347.6); + --stage-art-bottom: oklch(0.249959 0.079694 340.523); + --stage-art-highlight: oklch(0.921297 0.051708 343.229); + --stage-art-secondary: oklch(0.667398 0.165674 352.549); + --stage-art-tertiary: oklch(0.609315 0.163722 306.315); + --stage-art-line: oklch(0.945349 0.036045 341.433); } html[data-theme-id="grove"] { - --stage-art-top: #6cc492; - --stage-art-mid: #2d8a5e; - --stage-art-bottom: #174a33; - --stage-art-highlight: #e4fff0; - --stage-art-secondary: #79d8a5; - --stage-art-tertiary: #d5b563; - --stage-art-line: #eafff2; - --stage-night-top: #286548; - --stage-night-mid: #174a35; - --stage-night-bottom: #0a2c21; - --stage-night-highlight: #c4eed5; - --stage-night-secondary: #54a878; - --stage-night-tertiary: #b79b4d; - --stage-night-line: #d7f6e2; + --stage-art-top: oklch(0.751465 0.112731 157.313); + --stage-art-mid: oklch(0.567105 0.11099 158.859); + --stage-art-bottom: oklch(0.367918 0.067635 160.558); + --stage-art-highlight: oklch(0.976387 0.034353 161.456); + --stage-art-secondary: oklch(0.80968 0.117071 158.817); + --stage-art-tertiary: oklch(0.784102 0.108047 88.516); + --stage-art-line: oklch(0.981098 0.027812 158.718); + --stage-night-top: oklch(0.45805 0.079616 160.092); + --stage-night-mid: oklch(0.368483 0.065631 162.719); + --stage-night-bottom: oklch(0.264797 0.044397 168.088); + --stage-night-highlight: oklch(0.912563 0.055173 158.974); + --stage-night-secondary: oklch(0.665652 0.109731 156.599); + --stage-night-tertiary: oklch(0.698651 0.103024 89.828); + --stage-night-line: oklch(0.945336 0.041923 157.222); } html.dark[data-theme-id="grove"] { - --stage-art-top: #438e65; - --stage-art-mid: #286447; - --stage-art-bottom: #123525; - --stage-art-highlight: #d2fbe2; - --stage-art-secondary: #62bf88; - --stage-art-tertiary: #c99b45; - --stage-art-line: #e0fae9; - --stage-night-top: #24523a; - --stage-night-mid: #113323; - --stage-night-bottom: #071d16; - --stage-night-highlight: #b5dfc4; - --stage-night-secondary: #478d66; - --stage-night-tertiary: #a9853b; - --stage-night-line: #d0ead9; + --stage-art-top: oklch(0.58719 0.09869 157.426); + --stage-art-mid: oklch(0.454979 0.079031 159.756); + --stage-art-bottom: oklch(0.297856 0.050355 161.167); + --stage-art-highlight: oklch(0.952407 0.053872 158.44); + --stage-art-secondary: oklch(0.732591 0.120606 155.853); + --stage-art-tertiary: oklch(0.716282 0.116547 80.563); + --stage-art-line: oklch(0.961577 0.035285 157.03); + --stage-night-top: oklch(0.398632 0.065534 158.601); + --stage-night-mid: oklch(0.290561 0.049694 160.456); + --stage-night-bottom: oklch(0.210147 0.03173 169.818); + --stage-night-highlight: oklch(0.866303 0.057526 156.796); + --stage-night-secondary: oklch(0.586553 0.093722 157.365); + --stage-night-tertiary: oklch(0.6364 0.101769 82.985); + --stage-night-line: oklch(0.913292 0.035718 156.976); } html[data-theme-id="ocean"] { - --stage-art-top: #77c9e3; - --stage-art-mid: #3384bd; - --stage-art-bottom: #174d7a; - --stage-art-highlight: #e5faff; - --stage-art-secondary: #70c9df; - --stage-art-tertiary: #5cc7be; - --stage-art-line: #e9fbff; + --stage-art-top: oklch(0.792993 0.088072 220.421); + --stage-art-mid: oklch(0.590296 0.116841 243.184); + --stage-art-bottom: oklch(0.409123 0.093718 247.98); + --stage-art-highlight: oklch(0.971217 0.023008 213.615); + --stage-art-secondary: oklch(0.788391 0.090856 215.684); + --stage-art-tertiary: oklch(0.76441 0.099607 187.893); + --stage-art-line: oklch(0.976025 0.019647 212.543); } html.dark[data-theme-id="ocean"] { - --stage-art-top: #4288ac; - --stage-art-mid: #285d84; - --stage-art-bottom: #132e49; - --stage-art-highlight: #d9f5ff; - --stage-art-secondary: #61b5d4; - --stage-art-tertiary: #51b8b3; - --stage-art-line: #dff7ff; + --stage-art-top: oklch(0.59663 0.089167 233.427); + --stage-art-mid: oklch(0.461094 0.084904 243.478); + --stage-art-bottom: oklch(0.294818 0.05947 250.526); + --stage-art-highlight: oklch(0.952907 0.032224 221.27); + --stage-art-secondary: oklch(0.732079 0.09296 224.414); + --stage-art-tertiary: oklch(0.720885 0.095495 190.903); + --stage-art-line: oklch(0.961039 0.027355 219.756); } html[data-theme-id="ember"] { - --stage-art-top: #f4a26e; - --stage-art-mid: #d66036; - --stage-art-bottom: #7c291e; - --stage-art-highlight: #fff0df; - --stage-art-secondary: #ffad77; - --stage-art-tertiary: #ef6f69; - --stage-art-line: #fff3e7; - --stage-night-top: #8a442c; - --stage-night-mid: #5c271c; - --stage-night-bottom: #32100d; - --stage-night-highlight: #ffd6ba; - --stage-night-secondary: #cb7148; - --stage-night-tertiary: #ad4b45; - --stage-night-line: #ffe0c9; + --stage-art-top: oklch(0.782439 0.118806 52.569); + --stage-art-mid: oklch(0.62863 0.15922 39.401); + --stage-art-bottom: oklch(0.404251 0.117664 30.424); + --stage-art-highlight: oklch(0.962469 0.02774 70.95); + --stage-art-secondary: oklch(0.815735 0.11904 53.659); + --stage-art-tertiary: oklch(0.69361 0.159346 24.787); + --stage-art-line: oklch(0.970313 0.02052 67.583); + --stage-night-top: oklch(0.470236 0.102322 39.355); + --stage-night-mid: oklch(0.345367 0.080697 33.217); + --stage-night-bottom: oklch(0.227386 0.056077 27.437); + --stage-night-highlight: oklch(0.903891 0.059281 56.181); + --stage-night-secondary: oklch(0.641705 0.126508 44.376); + --stage-night-tertiary: oklch(0.538694 0.129931 25.865); + --stage-night-line: oklch(0.926348 0.046029 58.73); } html.dark[data-theme-id="ember"] { - --stage-art-top: #ba6540; - --stage-art-mid: #803a29; - --stage-art-bottom: #3c1916; - --stage-art-highlight: #ffe1cd; - --stage-art-secondary: #e68156; - --stage-art-tertiary: #d85b58; - --stage-art-line: #ffe8d7; - --stage-night-top: #6a3425; - --stage-night-mid: #3e1b15; - --stage-night-bottom: #1d0d0b; - --stage-night-highlight: #efc4a9; - --stage-night-secondary: #a85e3f; - --stage-night-tertiary: #8f413e; - --stage-night-line: #f2d2bc; + --stage-art-top: oklch(0.597533 0.120694 43.455); + --stage-art-mid: oklch(0.437763 0.101287 34.86); + --stage-art-bottom: oklch(0.264269 0.055858 26.548); + --stage-art-highlight: oklch(0.929214 0.042638 55.801); + --stage-art-secondary: oklch(0.705592 0.137369 43.176); + --stage-art-tertiary: oklch(0.629583 0.158322 24.088); + --stage-art-line: oklch(0.945058 0.033906 58.824); + --stage-night-top: oklch(0.392352 0.081287 36.444); + --stage-night-mid: oklch(0.271305 0.056352 31.135); + --stage-night-bottom: oklch(0.182126 0.028154 27.774); + --stage-night-highlight: oklch(0.851007 0.061294 53.805); + --stage-night-secondary: oklch(0.560789 0.10645 42.953); + --stage-night-tertiary: oklch(0.476228 0.106656 24.165); + --stage-night-line: oklch(0.884931 0.046607 56.556); } html[data-theme-id="iris"] { - --stage-art-top: #b49ae8; - --stage-art-mid: #7a5ac4; - --stage-art-bottom: #422d80; - --stage-art-highlight: #f2eaff; - --stage-art-secondary: #b79af1; - --stage-art-tertiary: #ec79c7; - --stage-art-line: #f6eeff; + --stage-art-top: oklch(0.738305 0.113454 298.926); + --stage-art-mid: oklch(0.551826 0.159189 294.531); + --stage-art-bottom: oklch(0.37147 0.133317 289.999); + --stage-art-highlight: oklch(0.949472 0.029204 303.081); + --stage-art-secondary: oklch(0.745085 0.125892 298.647); + --stage-art-tertiary: oklch(0.73066 0.167815 340.964); + --stage-art-line: oklch(0.960278 0.024064 306.969); } html.dark[data-theme-id="iris"] { - --stage-art-top: #8063c4; - --stage-art-mid: #513990; - --stage-art-bottom: #261c53; - --stage-art-highlight: #e8ddff; - --stage-art-secondary: #9e83dc; - --stage-art-tertiary: #d96ab7; - --stage-art-line: #eee7ff; + --stage-art-top: oklch(0.57297 0.145973 295.185); + --stage-art-mid: oklch(0.419499 0.13752 292.131); + --stage-art-bottom: oklch(0.274235 0.095798 286.608); + --stage-art-highlight: oklch(0.916698 0.047206 300.224); + --stage-art-secondary: oklch(0.670994 0.13095 296.689); + --stage-art-tertiary: oklch(0.679357 0.165376 340.439); + --stage-art-line: oklch(0.940582 0.032921 299.076); } .workspace-topbar { diff --git a/apps/web/src/themeBoot.test.ts b/apps/web/src/themeBoot.test.ts index d6137e643818..ae82d184e4c6 100644 --- a/apps/web/src/themeBoot.test.ts +++ b/apps/web/src/themeBoot.test.ts @@ -15,6 +15,7 @@ import { OCEAN_THEME, THEME_APPEARANCE_MODE_STORAGE_KEY, THEME_FOLLOW_SYSTEM_STORAGE_KEY, + toCanonicalThemeColor, } from "./themePalette"; const THEME_STORAGE_KEY = "t3code:theme"; @@ -83,7 +84,12 @@ function runBootScript(options: { matchMedia: () => ({ matches: options.prefersDark }), }; - new Function("window", "document", bootScript)(fakeWindow, fakeDocument); + const fakeCss = { + supports: (property: string, value: string) => + property === "color" && toCanonicalThemeColor(value) !== null, + }; + + new Function("window", "document", "CSS", bootScript)(fakeWindow, fakeDocument, fakeCss); return { isDark: classes.has("dark"), @@ -275,6 +281,63 @@ describe("index.html boot script", () => { expect(aurora.metaContent).toBe(DEFAULT_DARK_CHROME); }); + it("accepts exponent-form OKLCH before the runtime mounts", () => { + const colors = { + canvas: "oklch(9.5e-1 1e-2 2.8e2)", + chrome: "oklch(9.4e-1 1e-2 2.8e2)", + text: "oklch(2e-1 0 0 / 9e-1)", + accent: "oklch(6.2e-1 0.2 2.8e2)", + }; + const boot = runBootScript({ + storage: { + [THEME_STORAGE_KEY]: "scientific", + [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([ + { + id: "scientific", + label: "Scientific", + appearance: "light", + colors, + }, + ]), + }, + prefersDark: false, + }); + + expect(boot.bootVariables["--boot-background"]).toBe(colors.canvas); + expect(boot.bootVariables["--boot-foreground"]).toBe(colors.text); + expect(boot.bootVariables["--boot-accent"]).toBe(colors.accent); + expect(boot.backgroundColor).toBe(colors.chrome); + expect(boot.metaContent).toBe(colors.chrome); + }); + + it("accepts legacy CSS color formats before the runtime mounts", () => { + const colors = { + canvas: "rgb(248 251 255)", + chrome: "hsl(210 100% 99%)", + text: "rebeccapurple", + accent: "color(display-p3 0.36 0.42 1)", + }; + const boot = runBootScript({ + storage: { + [THEME_STORAGE_KEY]: "legacy-css", + [CUSTOM_THEMES_STORAGE_KEY]: JSON.stringify([ + { + id: "legacy-css", + label: "Legacy CSS", + appearance: "light", + colors, + }, + ]), + }, + prefersDark: false, + }); + + expect(boot.bootVariables["--boot-background"]).toBe(colors.canvas); + expect(boot.bootVariables["--boot-foreground"]).toBe(colors.text); + expect(boot.bootVariables["--boot-accent"]).toBe(colors.accent); + expect(boot.backgroundColor).toBe(colors.chrome); + }); + // Asserting against the real palette definitions (not literals) turns the // boot script's hand-maintained copy into a CI-enforced contract: any // palette change breaks this test until the copy in index.html is updated. @@ -410,9 +473,9 @@ describe("index.html boot script", () => { }); expect(boot.themeId).toBe("partial"); - expect(boot.bootVariables["--boot-background"]).toBe("#1f1a24"); + expect(boot.bootVariables["--boot-background"]).toBe(getDefaultThemeColors("dark").canvas); expect(boot.bootVariables["--boot-foreground"]).toBe("#fffaff"); - expect(boot.bootVariables["--boot-accent"]).toBe("#a3004c"); + expect(boot.bootVariables["--boot-accent"]).toBe(getDefaultThemeColors("dark").accent); expect(boot.backgroundColor).toBe(DEFAULT_DARK_CHROME); expect(boot.metaContent).toBe(DEFAULT_DARK_CHROME); }); diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index 5cae0a8a74b9..c1965208864e 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -15,6 +15,7 @@ import { canonicalThemePreference, parseThemeFile, parseThemeHalves, + removeCustomTheme, resolveDesktopTheme, resolveThemeAppearance, serializeThemeFile, @@ -31,12 +32,35 @@ import { createManagedThemeColors, createVividThemeColors, getDefaultThemeColors, + themeColorToHex, + toCanonicalThemeColor, THEME_FILE_VERSION, } from "./themePalette"; +function asHex(value: string): string { + const hex = themeColorToHex(value); + if (!hex) throw new Error(`Expected a theme color, received ${value}`); + return hex.slice(0, 7); +} + +function canonical(value: string): string { + const color = toCanonicalThemeColor(value); + if (!color) throw new Error(`Expected a theme color, received ${value}`); + return color; +} + +function expectThemeColors( + colors: Readonly>, + expected: Readonly>, +): void { + for (const [role, value] of Object.entries(expected)) { + expect(asHex(colors[role]!)).toBe(value); + } +} + function contrastRatio(first: string, second: string): number { const toRgb = (value: string) => { - const hex = value.slice(1); + const hex = asHex(value).slice(1); return [0, 1, 2].map( (channel) => Number.parseInt(hex.slice(channel * 2, channel * 2 + 2), 16) / 255, ); @@ -56,8 +80,8 @@ describe("theme files", () => { const dark = createManagedThemeColors("dark", "#ffffff", "#ffff00"); const darkDefaults = getDefaultThemeColors("dark"); - expect(light.canvas).not.toBe("#111827"); - expect(dark.canvas).not.toBe("#ffffff"); + expect(asHex(light.canvas)).not.toBe("#111827"); + expect(asHex(dark.canvas)).not.toBe("#ffffff"); expect(contrastRatio(light.accent, light.canvas)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(dark.accent, dark.canvas)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(light.textMuted, light.canvas)).toBeGreaterThanOrEqual(4.5); @@ -73,7 +97,7 @@ describe("theme files", () => { // Status colors fall back to T3 Code's standard red and amber rather than // the flagship palette's, so no generated theme inherits a brand tint. const channels = (value: string) => - [1, 3, 5].map((index) => Number.parseInt(value.slice(index, index + 2), 16)) as [ + [1, 3, 5].map((index) => Number.parseInt(asHex(value).slice(index, index + 2), 16)) as [ number, number, number, @@ -92,7 +116,7 @@ describe("theme files", () => { expect(warnRed).toBeGreaterThan(warnBlue); expect(warnGreen).toBeGreaterThan(warnBlue); } - expect(dark.error).not.toBe(darkDefaults.error); + expect(asHex(dark.error)).not.toBe(asHex(darkDefaults.error)); }); it("derives readable, distinctive vivid palettes from exact seeds", () => { @@ -108,8 +132,10 @@ describe("theme files", () => { for (const [appearance, canvas, accent] of seeds) { const colors = createVividThemeColors(appearance, canvas, accent); // Exact seeds are honored. - expect(colors.canvas).toBe(canvas); - expect(colors.accent).toBe(accent); + expect(colors.canvas).toMatch(/^oklch\(/); + expect(colors.accent).toMatch(/^oklch\(/); + expect(asHex(colors.canvas)).toBe(canvas); + expect(asHex(colors.accent)).toBe(accent); // Readability is solved per surface. expect(contrastRatio(colors.text, colors.canvas)).toBeGreaterThanOrEqual(7); expect(contrastRatio(colors.textMuted, colors.canvas)).toBeGreaterThanOrEqual(4.5); @@ -131,7 +157,7 @@ describe("theme files", () => { // The companion action is a distinct voice, not the accent again. expect(colors.messageAction).not.toBe(colors.accent); // Update family follows the theme, not the default palette. - expect(colors.update).toBe(accent); + expect(asHex(colors.update)).toBe(accent); } }); @@ -170,13 +196,53 @@ describe("theme files", () => { label: "Ocean dusk", appearance: "dark", colors: { - canvas: "#07152f", - accent: "#67c2ff", - placeholder: "#968d9f", + canvas: canonical("#07152f"), + accent: canonical("#67c2ff"), + placeholder: canonical("#968d9f"), }, }); }); + it("decodes literal CSS color formats into OKLCH without dropping alpha", () => { + const theme = parseThemeFile({ + version: THEME_FILE_VERSION, + name: "Translucent", + appearance: "light", + colors: { + canvas: "oklch(62% 0.2 280deg / 50%)", + accent: "#abcd", + focus: "rgb(10 20 30 / 50%)", + error: "hsl(350 80% 50%)", + warning: "hwb(45 10% 20%)", + update: "lab(60% 40 30)", + messageAction: "lch(60% 50 120)", + sidebar: "oklab(0.6 0.1 -0.1)", + terminalCursor: "color(display-p3 0.8 0.2 0.3)", + terminalSelection: "rebeccapurple", + terminalScrollbar: "transparent", + terminalScrollbarHover: "rgb(10 20 30 / none)", + }, + }); + + expect(theme.colors.canvas).toBe("oklch(0.62 0.2 280 / 0.5)"); + expect(theme.colors.accent).toBe(canonical("#abcd")); + expect(themeColorToHex(theme.colors.accent)).toBe("#aabbccdd"); + expect(themeColorToHex(theme.colors.focus)).toBe("#0a141e80"); + expect(themeColorToHex(theme.colors.terminalSelection)).toBe("#663399"); + expect(theme.colors.terminalScrollbar).toBe("oklch(0 0 0 / 0)"); + expect(themeColorToHex(theme.colors.terminalScrollbarHover)).toBe("#0a141e00"); + for (const role of [ + "error", + "warning", + "update", + "messageAction", + "sidebar", + "terminalCursor", + ] as const) { + expect(theme.colors[role]).toMatch(/^oklch\(/); + } + }); + it("rejects unknown roles and invalid color values", () => { expect(() => parseThemeFile({ @@ -194,16 +260,20 @@ describe("theme files", () => { appearance: "light", colors: { accent: "var(--danger)" }, }), - ).toThrow('The color for "accent" must be a hex color'); + ).toThrow('The color for "accent" must be a literal CSS color'); }); - it("serializes a theme back into the importable file shape", () => { - const serialized = serializeThemeFile(T3_CHAT_THEME); + it("canonicalizes the explicitly exported theme", () => { + const serialized = serializeThemeFile({ + ...T3_CHAT_THEME, + colors: { ...T3_CHAT_THEME.colors, accent: "hsl(263 70% 58%)" }, + }); expect(JSON.parse(serialized)).toMatchObject({ version: THEME_FILE_VERSION, id: T3_CHAT_THEME.id, name: T3_CHAT_THEME.label, appearance: "light", + colors: { accent: canonical("hsl(263 70% 58%)") }, }); }); @@ -257,8 +327,8 @@ describe("theme files", () => { expect(getThemeModes(theme)).toEqual(["light", "dark"]); expect(getThemeColorsForMode(theme, "dark")).toMatchObject({ - canvas: "#101827", - text: "#eef5ff", + canvas: canonical("#101827"), + text: canonical("#eef5ff"), }); expect(getThemeModes(T3_CHAT_THEME)).toEqual(["light", "dark"]); expect(resolveThemeAppearance(T3_CHAT_THEME.id, true, true)).toBe("dark"); @@ -266,13 +336,13 @@ describe("theme files", () => { expect(resolveThemeAppearance(T3_CHAT_THEME.id, false, false, "dark")).toBe("dark"); expect(resolveDesktopTheme(T3_CHAT_THEME.id, false, "dark")).toBe("dark"); expect(JSON.parse(serializeThemeFile(theme)).variants.dark).toMatchObject({ - canvas: "#101827", - text: "#eef5ff", + canvas: canonical("#101827"), + text: canonical("#eef5ff"), }); }); it("keeps the T3 Chat palette faithful and readable", () => { - expect(T3_CHAT_THEME.colors).toMatchObject({ + expectThemeColors(T3_CHAT_THEME.colors, { canvas: "#fdf7fd", chrome: "#fdf7fd", toolbarBorder: "#efbdeb", @@ -287,7 +357,7 @@ describe("theme files", () => { accentSurface: "#f3e6f5", sidebar: "#f2e1f4", }); - expect(T3_CHAT_THEME.variants?.dark).toMatchObject({ + expectThemeColors(T3_CHAT_THEME.variants!.dark!, { canvas: "#1f1a24", chrome: "#1f1a24", surface: "#29232d", @@ -321,8 +391,8 @@ describe("theme files", () => { expect(getThemeModes(theme)).toEqual(["light", "dark"]); expect(theme.sidebarArtwork).toBe(true); expect(themeAllowsSidebarArtwork(theme.id)).toBe(true); - expect(theme.colors.accent).toMatch(/^#[0-9a-f]{6}$/i); - expect(theme.variants?.dark?.accent).toMatch(/^#[0-9a-f]{6}$/i); + expect(theme.colors.accent).toMatch(/^oklch\(/); + expect(theme.variants?.dark?.accent).toMatch(/^oklch\(/); for (const mode of ["light", "dark"] as const) { const colors = getThemeColorsForMode(theme, mode); @@ -380,7 +450,9 @@ describe("theme files", () => { }); expect(getThemeModes(theme)).toEqual(["dark"]); - expect(getThemeColorsForMode(theme, "dark")).toMatchObject({ canvas: "#111827" }); + expect(getThemeColorsForMode(theme, "dark")).toMatchObject({ + canvas: canonical("#111827"), + }); expect(getThemeColorsForMode(theme, "light")).toBeNull(); }); @@ -419,8 +491,16 @@ describe("theme files", () => { vi.unstubAllGlobals(); }); - it("updates a personal theme without changing its id", () => { + it("canonicalizes explicit writes without migrating untouched themes", () => { const stored = new Map(); + const untouchedTheme = { + id: "legacy", + label: "Legacy", + appearance: "dark", + colors: { accent: "#5b6cff", futureRole: "hsl(10 20% 30%)" }, + futureMetadata: { version: 2 }, + }; + stored.set(CUSTOM_THEMES_STORAGE_KEY, JSON.stringify([untouchedTheme])); vi.stubGlobal("window", { localStorage: { getItem: (key: string) => stored.get(key) ?? null, @@ -442,23 +522,175 @@ describe("theme files", () => { const updatedTheme = updateCustomTheme({ ...createdTheme, label: "Aurora Night", - colors: { ...createdTheme.colors, accent: "#7c3aed" }, + colors: { ...createdTheme.colors, accent: "hsl(263 70% 58%)" }, }); expect(updatedTheme).toMatchObject({ id: "aurora", label: "Aurora Night", + colors: { accent: canonical("hsl(263 70% 58%)") }, }); expect(updatedTheme).not.toHaveProperty("sidebarArtwork"); + const storedThemes = JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]"); + expect(storedThemes[0]).toEqual(untouchedTheme); + expect(storedThemes[1]).toMatchObject({ + id: "aurora", + label: "Aurora Night", + colors: { accent: canonical("hsl(263 70% 58%)") }, + }); + expect(storedThemes[1]).not.toHaveProperty("sidebarArtwork"); invalidateCustomThemes(); - expect(getCustomThemes()).toEqual([updatedTheme]); - expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).toMatchObject({ + expect(getCustomThemes().find((theme) => theme.id === "aurora")).toMatchObject({ id: "aurora", + colors: { accent: canonical("hsl(263 70% 58%)") }, + }); + removeCustomTheme("aurora"); + expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")).toEqual([untouchedTheme]); + + vi.unstubAllGlobals(); + invalidateCustomThemes(); + }); + + it("writes from the cached raw snapshot without risking a destructive reread", () => { + const legacyTheme = { + id: "legacy", + label: "Legacy", + appearance: "dark", + colors: { accent: "#5b6cff" }, + futureMetadata: true, + }; + let storedThemes = JSON.stringify([legacyTheme]); + let readCount = 0; + const setItem = vi.fn((_key: string, value: string) => { + storedThemes = value; + }); + vi.stubGlobal("window", { + localStorage: { + getItem: () => { + readCount += 1; + if (readCount > 1) throw new Error("transient read failure"); + return storedThemes; + }, + setItem, + }, + }); + + invalidateCustomThemes(); + expect(getCustomThemes()).toHaveLength(1); + installCustomTheme( + parseThemeFile({ + version: THEME_FILE_VERSION, + id: "aurora", + name: "Aurora", + appearance: "light", + colors: { accent: "hsl(263 70% 58%)" }, + }), + ); + + expect(readCount).toBe(1); + expect(setItem).toHaveBeenCalledOnce(); + expect(JSON.parse(storedThemes)[0]).toEqual(legacyTheme); + + vi.unstubAllGlobals(); + invalidateCustomThemes(); + }); + + it("refuses to overwrite a theme library that could not be read", () => { + const setItem = vi.fn(); + vi.stubGlobal("window", { + localStorage: { + getItem: () => { + throw new Error("storage unavailable"); + }, + setItem, + }, + }); + + invalidateCustomThemes(); + expect(getCustomThemes()).toEqual([]); + expect(() => + installCustomTheme( + parseThemeFile({ + version: THEME_FILE_VERSION, + id: "aurora", + name: "Aurora", + appearance: "light", + colors: { accent: "#5b6cff" }, + }), + ), + ).toThrow(`Failed to read the theme library from ${CUSTOM_THEMES_STORAGE_KEY}.`); + expect(setItem).not.toHaveBeenCalled(); + + vi.unstubAllGlobals(); + invalidateCustomThemes(); + }); + + it("rejects malformed stored entries that reuse an installed theme id", () => { + const storedThemes = JSON.stringify([{ id: "aurora", malformed: true }]); + const setItem = vi.fn(); + vi.stubGlobal("window", { + localStorage: { + getItem: () => storedThemes, + setItem, + }, + }); + + invalidateCustomThemes(); + expect(() => + installCustomTheme( + parseThemeFile({ + version: THEME_FILE_VERSION, + id: "aurora", + name: "Aurora", + appearance: "light", + colors: { accent: "#5b6cff" }, + }), + ), + ).toThrow('A theme named "Aurora" is already installed.'); + expect(setItem).not.toHaveBeenCalled(); + + vi.unstubAllGlobals(); + invalidateCustomThemes(); + }); + + it("collapses duplicate raw entries when their theme is explicitly updated", () => { + const stored = new Map(); + const theme = { + id: "aurora", + label: "Aurora", + appearance: "light", + colors: { accent: "#5b6cff" }, + }; + const untouchedTheme = { id: "future", malformed: true, metadata: { version: 2 } }; + stored.set( + CUSTOM_THEMES_STORAGE_KEY, + JSON.stringify([theme, { id: "aurora", malformed: true }, untouchedTheme]), + ); + vi.stubGlobal("window", { + localStorage: { + getItem: (key: string) => stored.get(key) ?? null, + setItem: (key: string, value: string) => stored.set(key, value), + }, + }); + + invalidateCustomThemes(); + const installedTheme = getCustomThemes()[0]!; + updateCustomTheme({ + ...installedTheme, label: "Aurora Night", + colors: { ...installedTheme.colors, accent: "hsl(263 70% 58%)" }, }); - expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).not.toHaveProperty( - "sidebarArtwork", + + const updatedLibrary = JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]"); + expect(updatedLibrary.filter((entry: { id?: string }) => entry.id === "aurora")).toHaveLength( + 1, ); + expect(updatedLibrary[0]).toMatchObject({ + id: "aurora", + label: "Aurora Night", + colors: { accent: canonical("hsl(263 70% 58%)") }, + }); + expect(updatedLibrary[1]).toEqual(untouchedTheme); vi.unstubAllGlobals(); invalidateCustomThemes(); @@ -530,23 +762,23 @@ describe("stored theme preferences", () => { expect(isKnownThemePreference("missing-theme")).toBe(false); }); - it("keeps stored themes with unknown roles and drops invalid entries", () => { + it("decodes stored colors in memory without writing during reads", () => { + const storedThemes = JSON.stringify([ + { + id: "aurora", + label: "Aurora", + appearance: "light", + colors: { canvas: "#f8fbff", futureRole: "#123456", accent: "not-a-color" }, + variants: { dark: { canvas: "rgb(16 24 39)" } }, + }, + { id: "light", label: "Reserved", appearance: "light", colors: {} }, + { id: "aurora", label: "Duplicate", appearance: "dark", colors: {} }, + ]); + const setItem = vi.fn(); vi.stubGlobal("window", { localStorage: { - getItem: (key: string) => - key === CUSTOM_THEMES_STORAGE_KEY - ? JSON.stringify([ - { - id: "aurora", - label: "Aurora", - appearance: "light", - colors: { canvas: "#f8fbff", futureRole: "#123456", accent: "not-a-color" }, - variants: { light: { canvas: "#101827" } }, - }, - { id: "light", label: "Reserved", appearance: "light", colors: {} }, - { id: "aurora", label: "Duplicate", appearance: "dark", colors: {} }, - ]) - : null, + getItem: (key: string) => (key === CUSTOM_THEMES_STORAGE_KEY ? storedThemes : null), + setItem, }, }); invalidateCustomThemes(); @@ -555,11 +787,16 @@ describe("stored theme preferences", () => { expect(themes).toHaveLength(1); expect(themes[0]).toMatchObject({ id: "aurora", - colors: { canvas: "#f8fbff", accent: getDefaultThemeColors("light").accent }, + colors: { canvas: canonical("#f8fbff"), accent: getDefaultThemeColors("light").accent }, + }); + expect(getThemeModes(themes[0]!)).toEqual(["light", "dark"]); + expect(getThemeColorsForMode(themes[0]!, "dark")?.canvas).toBe(canonical("rgb(16 24 39)")); + expect(setItem).not.toHaveBeenCalled(); + expect(JSON.parse(storedThemes)[0].colors).toEqual({ + canvas: "#f8fbff", + futureRole: "#123456", + accent: "not-a-color", }); - // The variant shadowing the base appearance is dropped so the theme - // round-trips through parseThemeFile on export. - expect(getThemeModes(themes[0]!)).toEqual(["light"]); vi.unstubAllGlobals(); invalidateCustomThemes(); diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 5b58e879b570..05838f5f5bd8 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -1,4 +1,6 @@ import * as Schema from "effect/Schema"; +import "culori/css"; +import { converter, parse } from "culori/fn"; export const T3_CHAT_THEME_ID = "t3-chat" as const; export const T3_CHAT_THEME_LABEL = "T3 Chat"; @@ -129,7 +131,16 @@ const RESERVED_THEME_IDS = new Set([ ]); const customThemeListeners = new Set<() => void>(); -let customThemesSnapshot: ReadonlyArray | null = null; +type CustomThemeLibrarySnapshot = + | Readonly<{ + status: "ready"; + storedThemes: ReadonlyArray; + themes: ReadonlyArray; + }> + | Readonly<{ status: "unavailable"; reason: "malformed" }> + | Readonly<{ status: "unavailable"; reason: "storage-unavailable"; cause: unknown }>; + +let customThemeLibrarySnapshot: CustomThemeLibrarySnapshot | null = null; const themePreviewListeners = new Set<() => void>(); let themePreviewSidebarArtwork: boolean | null = null; @@ -157,10 +168,7 @@ function isThemeAppearance(value: unknown): value is ThemeAppearance { } export function isThemeColor(value: unknown): value is string { - return ( - typeof value === "string" && - /^#(?:[0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(value) - ); + return typeof value === "string" && toCanonicalThemeColor(value) !== null; } function isThemeId(value: unknown): value is string { @@ -180,8 +188,9 @@ function parseStoredThemeColors(value: unknown, appearance: ThemeAppearance): Th // Tolerate unknown roles and malformed values so themes saved by other // builds (for example one that adds a new role) keep their remaining colors. for (const [role, color] of Object.entries(value)) { - if (THEME_COLOR_ROLE_SET.has(role) && isThemeColor(color)) { - colors[role as ThemeColorRole] = color; + const normalized = toCanonicalThemeColor(color); + if (THEME_COLOR_ROLE_SET.has(role) && normalized) { + colors[role as ThemeColorRole] = normalized; } } return colors as ThemeColors; @@ -226,26 +235,43 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null { }; } -function readCustomThemesFromStorage(): ReadonlyArray { - if (typeof window === "undefined") return []; +function readCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot { + if (typeof window === "undefined") { + return { status: "ready", storedThemes: [], themes: [] }; + } + let raw: string | null; try { - const raw = window.localStorage.getItem(CUSTOM_THEMES_STORAGE_KEY); - if (!raw) return []; - const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed)) return []; - - const themes: ThemeDefinition[] = []; - for (const value of parsed) { - const theme = parseStoredTheme(value); - if (theme && !themes.some((existing) => existing.id === theme.id)) { - themes.push(theme); - } - } - return themes; + raw = window.localStorage.getItem(CUSTOM_THEMES_STORAGE_KEY); + } catch (cause) { + return { status: "unavailable", reason: "storage-unavailable", cause }; + } + if (!raw) return { status: "ready", storedThemes: [], themes: [] }; + + let parsed: unknown; + try { + parsed = JSON.parse(raw); } catch { - return []; + return { status: "unavailable", reason: "malformed" }; } + if (!Array.isArray(parsed)) return { status: "unavailable", reason: "malformed" }; + + const themes: ThemeDefinition[] = []; + for (const value of parsed) { + const theme = parseStoredTheme(value); + if (theme && !themes.some((existing) => existing.id === theme.id)) { + themes.push(theme); + } + } + + return { status: "ready", storedThemes: parsed, themes }; +} + +function getCustomThemeLibrarySnapshot(): CustomThemeLibrarySnapshot { + if (customThemeLibrarySnapshot === null) { + customThemeLibrarySnapshot = readCustomThemeLibrarySnapshot(); + } + return customThemeLibrarySnapshot; } function notifyCustomThemeListeners() { @@ -253,15 +279,13 @@ function notifyCustomThemeListeners() { } export function invalidateCustomThemes() { - customThemesSnapshot = null; + customThemeLibrarySnapshot = null; notifyCustomThemeListeners(); } export function getCustomThemes(): ReadonlyArray { - if (customThemesSnapshot === null) { - customThemesSnapshot = readCustomThemesFromStorage(); - } - return customThemesSnapshot; + const snapshot = getCustomThemeLibrarySnapshot(); + return snapshot.status === "ready" ? snapshot.themes : []; } export function subscribeToCustomThemes(listener: () => void): () => void { @@ -470,7 +494,7 @@ const T3_CHAT_DARK_COLORS: ThemeColors = { * stock tokens (index.css) so a draft seeded from the default look paints the * pixels the user is already seeing. Alpha-bearing tokens are flattened over * their real backdrops (canvas, or the sidebar for its rows) because theme - * colors must be opaque hex. + * colors are stored as opaque OKLCH tokens. */ const T3_CODE_LIGHT_THEME_COLORS: ThemeColors = { canvas: "#fcfcfc", @@ -599,7 +623,10 @@ const T3_CODE_DARK_THEME_COLORS: ThemeColors = { * files. */ export function getStandardThemeColors(appearance: ThemeAppearance): ThemeColors { - return appearance === "dark" ? T3_CODE_DARK_THEME_COLORS : T3_CODE_LIGHT_THEME_COLORS; + if (appearance === "dark") { + return (standardDarkThemeColors ??= decodeThemeColors(T3_CODE_DARK_THEME_COLORS)); + } + return (standardLightThemeColors ??= decodeThemeColors(T3_CODE_LIGHT_THEME_COLORS)); } type ThemeRgbColor = { @@ -614,34 +641,81 @@ type ThemeHslColor = { l: number; }; +type ThemeOklch = { L: number; C: number; h: number }; +type ParsedThemeColor = { color: ThemeOklch; alpha: number }; + +let standardLightThemeColors: ThemeColors | undefined; +let standardDarkThemeColors: ThemeColors | undefined; + const THEME_LIGHT_FOREGROUND: ThemeRgbColor = { r: 255, g: 250, b: 255 }; const THEME_DARK_FOREGROUND: ThemeRgbColor = { r: 36, g: 21, b: 35 }; const THEME_WHITE_FOREGROUND: ThemeRgbColor = { r: 255, g: 255, b: 255 }; const THEME_BLACK_FOREGROUND: ThemeRgbColor = { r: 0, g: 0, b: 0 }; -function parseThemeRgbColor(value: string, fallback: ThemeRgbColor): ThemeRgbColor { - const match = value.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{4}|[0-9a-f]{6}|[0-9a-f]{8})$/i); - if (!match) return fallback; - - const raw = match[1]; - if (!raw) return fallback; - const hex = - raw.length <= 4 - ? raw - .slice(0, 3) - .split("") - .map((part) => part.repeat(2)) - .join("") - : raw.slice(0, 6); - if (hex.length !== 6) return fallback; - +const convertToOklch = converter("oklch"); + +function parseThemeColor(value: unknown): ParsedThemeColor | null { + if (typeof value !== "string") return null; + const input = value.trim(); + const parsed = parse(input); + if (!parsed) return null; + const color = convertToOklch(parsed); + const lightness = color.l ?? 0; + const chroma = color.c ?? 0; + const hue = color.h ?? 0; + // CSS missing components behave as zero outside interpolation. Culori omits + // a `none` alpha from its parsed object, so distinguish it from omitted alpha. + const alpha = /\/\s*none\s*\)$/i.test(input) ? 0 : (color.alpha ?? 1); + if (![lightness, chroma, hue, alpha].every(Number.isFinite)) return null; return { - r: Number.parseInt(hex.slice(0, 2), 16), - g: Number.parseInt(hex.slice(2, 4), 16), - b: Number.parseInt(hex.slice(4, 6), 16), + color: { + L: Math.min(1, Math.max(0, lightness)), + C: Math.max(0, chroma), + h: hue, + }, + alpha: Math.min(1, Math.max(0, alpha)), }; } +function formatThemeColorNumber(value: number, precision: number): string { + const rounded = Math.abs(value) < 10 ** -precision / 2 ? 0 : value; + return rounded.toFixed(precision).replace(/(?:\.0+|(?:(\.[0-9]*?)0+))$/, "$1"); +} + +function formatOklchThemeColor(color: ThemeOklch, alpha = 1): string { + const normalizedHue = color.C < 0.0000005 ? 0 : ((color.h % 360) + 360) % 360; + const body = `${formatThemeColorNumber(color.L, 6)} ${formatThemeColorNumber(color.C, 6)} ${formatThemeColorNumber(normalizedHue, 3)}`; + return alpha < 1 ? `oklch(${body} / ${formatThemeColorNumber(alpha, 4)})` : `oklch(${body})`; +} + +/** + * Decode a literal CSS color into the runtime's canonical OKLCH form. Stored + * values use this path in memory without mutating localStorage. + */ +export function toCanonicalThemeColor(value: unknown): string | null { + const parsed = parseThemeColor(value); + return parsed ? formatOklchThemeColor(parsed.color, parsed.alpha) : null; +} + +/** Convert a runtime theme color for hex-only editor and import adapters. */ +export function themeColorToHex(value: string): string | null { + const color = parseThemeColor(value); + const parsed = color ? { rgb: themeOklchToRgb(color.color), alpha: color.alpha } : null; + if (!parsed) return null; + + const opaque = themeRgbToHexColor(parsed.rgb); + if (parsed.alpha >= 1) return opaque; + const alpha = Math.round(parsed.alpha * 255) + .toString(16) + .padStart(2, "0"); + return `${opaque}${alpha}`; +} + +function parseThemeRgbColor(value: string, fallback: ThemeRgbColor): ThemeRgbColor { + const parsed = parseThemeColor(value); + return parsed ? themeOklchToRgb(parsed.color) : fallback; +} + function themeRgbToHexColor(color: ThemeRgbColor): string { return `#${[color.r, color.g, color.b] .map((channel) => @@ -652,6 +726,41 @@ function themeRgbToHexColor(color: ThemeRgbColor): string { .join("")}`; } +function themeRgbToThemeColor(color: ThemeRgbColor): string { + return formatOklchThemeColor(themeRgbToOklch(color)); +} + +function decodeThemeColors(colors: ThemeColors): ThemeColors { + return Object.fromEntries( + THEME_COLOR_ROLES.map((role) => { + const color = toCanonicalThemeColor(colors[role]); + if (!color) { + throw new Error( + `The color for "${role}" must be a literal CSS color such as oklch(0.62 0.2 280).`, + ); + } + return [role, color]; + }), + ) as Record; +} + +function canonicalizeThemeDefinition(theme: ThemeDefinition): ThemeDefinition { + return { + ...theme, + colors: decodeThemeColors(theme.colors), + ...(theme.variants + ? { + variants: Object.fromEntries( + Object.entries(theme.variants).map(([appearance, colors]) => [ + appearance, + decodeThemeColors(colors), + ]), + ) as ThemeVariants, + } + : {}), + }; +} + function themeRgbToHsl(color: ThemeRgbColor): ThemeHslColor { const red = color.r / 255; const green = color.g / 255; @@ -717,10 +826,6 @@ function themeRelativeLuminance(color: ThemeRgbColor): number { // --------------------------------------------------------------------------- // Vivid palette engine: perceptual (OKLCH) derivation for user-created themes. -// Built-in themes keep the legacy derivation so their shipped palettes and the -// boot splash copies stay byte-identical. - -type ThemeOklch = { L: number; C: number; h: number }; function srgbChannelToLinear(channel: number): number { const c = channel / 255; @@ -759,24 +864,30 @@ function oklchToRgbUnclamped({ L, C, h }: ThemeOklch): { r: number; g: number; b }; } -/** Convert to sRGB, walking chroma toward grey until the color is in gamut. */ -function themeOklchToRgb(color: ThemeOklch): ThemeRgbColor { - let { C } = color; - for (let step = 0; step < 12; step += 1) { +/** Find the greatest chroma along the same lightness and hue that fits in sRGB. */ +function mapThemeOklchToSrgbGamut(color: ThemeOklch): ThemeOklch { + const isInGamut = (C: number) => { const linear = oklchToRgbUnclamped({ ...color, C }); - const inGamut = [linear.r, linear.g, linear.b].every( + return [linear.r, linear.g, linear.b].every( (channel) => channel >= -0.0001 && channel <= 1.0001, ); - if (inGamut) { - return { - r: linearChannelToSrgb(linear.r), - g: linearChannelToSrgb(linear.g), - b: linearChannelToSrgb(linear.b), - }; - } - C *= 0.82; + }; + if (isInGamut(color.C)) return color; + + let low = 0; + let high = color.C; + const steps = Math.max(1, Math.ceil(Math.log2(Math.max(color.C, 0.000001) / 0.000001))); + for (let step = 0; step < steps; step += 1) { + const mid = (low + high) / 2; + if (isInGamut(mid)) low = mid; + else high = mid; } - const linear = oklchToRgbUnclamped({ ...color, C: 0 }); + return { ...color, C: low }; +} + +/** Convert to sRGB after applying the palette engine's gamut mapping. */ +function themeOklchToRgb(color: ThemeOklch): ThemeRgbColor { + const linear = oklchToRgbUnclamped(mapThemeOklchToSrgbGamut(color)); return { r: linearChannelToSrgb(linear.r), g: linearChannelToSrgb(linear.g), @@ -784,6 +895,10 @@ function themeOklchToRgb(color: ThemeOklch): ThemeRgbColor { }; } +function themeOklchToThemeColor(color: ThemeOklch): string { + return formatOklchThemeColor(mapThemeOklchToSrgbGamut(color)); +} + /** Binary-search the lightness that reaches the contrast target against a background. */ function solveOklchLightness( base: ThemeOklch, @@ -856,26 +971,24 @@ function standardStatusColors(canvas: ThemeRgbColor): { // tinted one they can fall just short, so lightness is nudged until the // pair clears 4.5 while the hue stays standard. const readableOn = (foreground: string, surface: ThemeRgbColor) => - themeRgbToHexColor( - themeOklchToRgb( - solveOklchLightness( - themeRgbToOklch(parseThemeRgbColor(foreground, canvas)), - surface, - // A hair above 4.5: the solve happens in OKLCH and the result is - // quantized to 8-bit hex, which can shave the last hundredth off. - 4.6, - appearance === "dark" ? "lighter" : "darker", - ), + themeOklchToThemeColor( + solveOklchLightness( + themeRgbToOklch(parseThemeRgbColor(foreground, canvas)), + surface, + // Leave a little headroom for browser color conversion at render time. + 4.6, + appearance === "dark" ? "lighter" : "darker", ), ); const errorSurface = surfaceOf(standard.error); const warningSurface = surfaceOf(standard.warning); return { - ...standard, + error: toCanonicalThemeColor(standard.error)!, errorForeground: readableOn(standard.errorForeground, errorSurface), - errorSurface: themeRgbToHexColor(errorSurface), + errorSurface: themeRgbToThemeColor(errorSurface), + warning: toCanonicalThemeColor(standard.warning)!, warningForeground: readableOn(standard.warningForeground, warningSurface), - warningSurface: themeRgbToHexColor(warningSurface), + warningSurface: themeRgbToThemeColor(warningSurface), }; } @@ -912,7 +1025,7 @@ export function createVividThemeColors( C: chroma, h: hue, }); - const hex = (color: ThemeOklch) => themeRgbToHexColor(themeOklchToRgb(color)); + const themeColor = (color: ThemeOklch) => themeOklchToThemeColor(color); // Text carries a whisper of the accent hue instead of falling back to a // fixed foreground, and is solved to WCAG AAA against the canvas. @@ -956,8 +1069,8 @@ export function createVividThemeColors( const updateSurface = surfaceAt(dark ? 0.14 : 0.09, Math.min(0.12, accent.C * 0.55)); const foregroundOn = (surfaceRgb: ThemeRgbColor): string => - themeRgbToHexColor( - themeOklchToRgb(solveOklchLightness(textBase, surfaceRgb, 4.6, dark ? "lighter" : "darker")), + themeOklchToThemeColor( + solveOklchLightness(textBase, surfaceRgb, 4.6, dark ? "lighter" : "darker"), ); const mutedForeground = foregroundOn(mutedRgb); const placeholder = foregroundOn(surfaceRaisedRgb); @@ -967,58 +1080,58 @@ export function createVividThemeColors( return { ...defaults, ...standardStatusColors(canvasRgb), - canvas: themeRgbToHexColor(canvasRgb), + canvas: themeRgbToThemeColor(canvasRgb), // The top bar shares the canvas so the main panel reads as one surface. - chrome: themeRgbToHexColor(canvasRgb), - toolbar: themeRgbToHexColor(canvasRgb), - toolbarForeground: themeRgbToHexColor(textRgb), - toolbarBorder: hex(surfaceAt(dark ? 0.14 : 0.1, Math.min(0.08, accent.C * 0.4))), - toolbarControl: hex(surfaceAt(dark ? 0.09 : 0.05, tintC * 1.3)), - toolbarControlForeground: themeRgbToHexColor(textRgb), - toolbarControlHover: hex(surfaceAt(dark ? 0.14 : 0.09, tintC * 1.6)), - surface: hex(surface), - surfaceRaised: hex(surfaceRaised), - surfaceOverlay: hex(surfaceOverlay), - text: themeRgbToHexColor(textRgb), - textMuted: themeRgbToHexColor(textMutedRgb), - border: hex(border), - input: hex(input), - focus: themeRgbToHexColor(accentRgb), - accent: themeRgbToHexColor(accentRgb), - accentForeground: themeRgbToHexColor(accentForeground), - secondary: hex(secondary), + chrome: themeRgbToThemeColor(canvasRgb), + toolbar: themeRgbToThemeColor(canvasRgb), + toolbarForeground: themeRgbToThemeColor(textRgb), + toolbarBorder: themeColor(surfaceAt(dark ? 0.14 : 0.1, Math.min(0.08, accent.C * 0.4))), + toolbarControl: themeColor(surfaceAt(dark ? 0.09 : 0.05, tintC * 1.3)), + toolbarControlForeground: themeRgbToThemeColor(textRgb), + toolbarControlHover: themeColor(surfaceAt(dark ? 0.14 : 0.09, tintC * 1.6)), + surface: themeColor(surface), + surfaceRaised: themeColor(surfaceRaised), + surfaceOverlay: themeColor(surfaceOverlay), + text: themeRgbToThemeColor(textRgb), + textMuted: themeRgbToThemeColor(textMutedRgb), + border: themeColor(border), + input: themeColor(input), + focus: themeRgbToThemeColor(accentRgb), + accent: themeRgbToThemeColor(accentRgb), + accentForeground: themeRgbToThemeColor(accentForeground), + secondary: themeColor(secondary), secondaryForeground: foregroundOn(secondaryRgb), - muted: hex(muted), + muted: themeColor(muted), mutedForeground, placeholder, - secondaryLabel: themeRgbToHexColor(textMutedRgb), - iconMuted: themeRgbToHexColor(textMutedRgb), - update: themeRgbToHexColor(accentRgb), + secondaryLabel: themeRgbToThemeColor(textMutedRgb), + iconMuted: themeRgbToThemeColor(textMutedRgb), + update: themeRgbToThemeColor(accentRgb), updateForeground: foregroundOn(themeOklchToRgb(updateSurface)), - updateSurface: hex(updateSurface), - accentSurface: hex(accentSurface), + updateSurface: themeColor(updateSurface), + accentSurface: themeColor(accentSurface), accentSurfaceForeground: foregroundOn(accentSurfaceRgb), - messageSurface: hex(messageSurface), + messageSurface: themeColor(messageSurface), messageForeground: foregroundOn(messageSurfaceRgb), - messageAction: themeRgbToHexColor(actionRgb), - messageActionForeground: themeRgbToHexColor(actionForeground), - messageActionHover: hex(actionHover), - codeBackground: hex(codeBackground), - codeForeground: themeRgbToHexColor(textRgb), - sidebar: hex(sidebar), + messageAction: themeRgbToThemeColor(actionRgb), + messageActionForeground: themeRgbToThemeColor(actionForeground), + messageActionHover: themeColor(actionHover), + codeBackground: themeColor(codeBackground), + codeForeground: themeRgbToThemeColor(textRgb), + sidebar: themeColor(sidebar), sidebarForeground: foregroundOn(sidebarRgb), - sidebarMutedForeground: themeRgbToHexColor(standardMutedThemeText(sidebarRgb, textRgb)), - sidebarControlSurface: hex(surfaceAt(dark ? 0.1 : 0.07, tintC * 1.5)), - sidebarRowHover: hex(surfaceAt(dark ? 0.08 : 0.06, Math.min(0.08, accent.C * 0.45))), - sidebarRowActive: hex(surfaceAt(dark ? 0.12 : 0.09, Math.min(0.1, accent.C * 0.55))), - sidebarRowSelected: hex(surfaceAt(dark ? 0.14 : 0.1, Math.min(0.11, accent.C * 0.6))), - sidebarBorder: hex(surfaceAt(dark ? 0.17 : 0.12, Math.min(0.08, accent.C * 0.4))), - terminalBackground: themeRgbToHexColor(canvasRgb), - terminalForeground: themeRgbToHexColor(textRgb), - terminalCursor: themeRgbToHexColor(accentRgb), - terminalSelection: hex(surfaceAt(dark ? 0.18 : 0.12, Math.min(0.12, accent.C * 0.55))), - terminalScrollbar: hex(surfaceAt(dark ? 0.22 : 0.16, tintC)), - terminalScrollbarHover: hex(surfaceAt(dark ? 0.3 : 0.22, tintC)), + sidebarMutedForeground: themeRgbToThemeColor(standardMutedThemeText(sidebarRgb, textRgb)), + sidebarControlSurface: themeColor(surfaceAt(dark ? 0.1 : 0.07, tintC * 1.5)), + sidebarRowHover: themeColor(surfaceAt(dark ? 0.08 : 0.06, Math.min(0.08, accent.C * 0.45))), + sidebarRowActive: themeColor(surfaceAt(dark ? 0.12 : 0.09, Math.min(0.1, accent.C * 0.55))), + sidebarRowSelected: themeColor(surfaceAt(dark ? 0.14 : 0.1, Math.min(0.11, accent.C * 0.6))), + sidebarBorder: themeColor(surfaceAt(dark ? 0.17 : 0.12, Math.min(0.08, accent.C * 0.4))), + terminalBackground: themeRgbToThemeColor(canvasRgb), + terminalForeground: themeRgbToThemeColor(textRgb), + terminalCursor: themeRgbToThemeColor(accentRgb), + terminalSelection: themeColor(surfaceAt(dark ? 0.18 : 0.12, Math.min(0.12, accent.C * 0.55))), + terminalScrollbar: themeColor(surfaceAt(dark ? 0.22 : 0.16, tintC)), + terminalScrollbarHover: themeColor(surfaceAt(dark ? 0.3 : 0.22, tintC)), }; } @@ -1127,8 +1240,7 @@ function managedThemeAccent( const color = themeHslToRgb({ h: hsl.h, s: saturation, l: lightness }); return { color, lightness, contrast: themeContrastRatio(color, background) }; }); - // Leave a little room for rounding when the generated RGB values become a - // six-digit hex token. + // Leave a little room for browser color conversion at render time. const readableCandidates = candidates.filter((candidate) => candidate.contrast >= 4.7); const pool = readableCandidates.length > 0 ? readableCandidates : candidates; @@ -1210,80 +1322,80 @@ export function createManagedThemeColors( return { ...defaults, ...standardStatusColors(canvas), - update: themeRgbToHexColor(accent), - updateForeground: themeRgbToHexColor(updateForeground), - updateSurface: themeRgbToHexColor(updateSurface), - canvas: themeRgbToHexColor(canvas), - chrome: themeRgbToHexColor(chrome), - toolbar: themeRgbToHexColor(chrome), - toolbarForeground: themeRgbToHexColor(text), - toolbarBorder: themeRgbToHexColor(toolbarBorder), - toolbarControl: themeRgbToHexColor(toolbarControl), - toolbarControlForeground: themeRgbToHexColor(text), - toolbarControlHover: themeRgbToHexColor(accentSurface), - surface: themeRgbToHexColor(canvas), - surfaceRaised: themeRgbToHexColor(surfaceRaised), - surfaceOverlay: themeRgbToHexColor(surfaceOverlay), - text: themeRgbToHexColor(text), - textMuted: themeRgbToHexColor(textMuted), + update: themeRgbToThemeColor(accent), + updateForeground: themeRgbToThemeColor(updateForeground), + updateSurface: themeRgbToThemeColor(updateSurface), + canvas: themeRgbToThemeColor(canvas), + chrome: themeRgbToThemeColor(chrome), + toolbar: themeRgbToThemeColor(chrome), + toolbarForeground: themeRgbToThemeColor(text), + toolbarBorder: themeRgbToThemeColor(toolbarBorder), + toolbarControl: themeRgbToThemeColor(toolbarControl), + toolbarControlForeground: themeRgbToThemeColor(text), + toolbarControlHover: themeRgbToThemeColor(accentSurface), + surface: themeRgbToThemeColor(canvas), + surfaceRaised: themeRgbToThemeColor(surfaceRaised), + surfaceOverlay: themeRgbToThemeColor(surfaceOverlay), + text: themeRgbToThemeColor(text), + textMuted: themeRgbToThemeColor(textMuted), // Borders blend through the accent before lightening so control chrome // carries the theme hue like the hand-tuned palettes (#5c345b, #e0d3e1) // instead of flattening to grey. - border: themeRgbToHexColor( + border: themeRgbToThemeColor( mixThemeRgbColors( mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.22 : 0.1), text, 0.1, ), ), - input: themeRgbToHexColor( + input: themeRgbToThemeColor( mixThemeRgbColors( mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.3 : 0.14), text, appearance === "dark" ? 0.14 : 0.13, ), ), - focus: themeRgbToHexColor(accent), - accent: themeRgbToHexColor(accent), - accentForeground: themeRgbToHexColor(accentForeground), - secondary: themeRgbToHexColor(secondary), - secondaryForeground: themeRgbToHexColor(readableThemeForeground(secondary)), - muted: themeRgbToHexColor(muted), - mutedForeground: themeRgbToHexColor(mutedForeground), - placeholder: themeRgbToHexColor(placeholder), - secondaryLabel: themeRgbToHexColor(textMuted), - iconMuted: themeRgbToHexColor(textMuted), - accentSurface: themeRgbToHexColor(accentSurface), - accentSurfaceForeground: themeRgbToHexColor(readableThemeForeground(accentSurface)), - messageSurface: themeRgbToHexColor(messageSurface), - messageForeground: themeRgbToHexColor(readableThemeForeground(messageSurface)), - messageAction: themeRgbToHexColor(accent), - messageActionForeground: themeRgbToHexColor(accentForeground), - messageActionHover: themeRgbToHexColor(messageActionHover), - codeBackground: themeRgbToHexColor(codeBackground), - codeForeground: themeRgbToHexColor(readableThemeForeground(codeBackground)), - sidebar: themeRgbToHexColor(sidebar), - sidebarForeground: themeRgbToHexColor(readableThemeForeground(sidebar)), - sidebarMutedForeground: themeRgbToHexColor(standardMutedThemeText(sidebar, text)), - sidebarControlSurface: themeRgbToHexColor( + focus: themeRgbToThemeColor(accent), + accent: themeRgbToThemeColor(accent), + accentForeground: themeRgbToThemeColor(accentForeground), + secondary: themeRgbToThemeColor(secondary), + secondaryForeground: themeRgbToThemeColor(readableThemeForeground(secondary)), + muted: themeRgbToThemeColor(muted), + mutedForeground: themeRgbToThemeColor(mutedForeground), + placeholder: themeRgbToThemeColor(placeholder), + secondaryLabel: themeRgbToThemeColor(textMuted), + iconMuted: themeRgbToThemeColor(textMuted), + accentSurface: themeRgbToThemeColor(accentSurface), + accentSurfaceForeground: themeRgbToThemeColor(readableThemeForeground(accentSurface)), + messageSurface: themeRgbToThemeColor(messageSurface), + messageForeground: themeRgbToThemeColor(readableThemeForeground(messageSurface)), + messageAction: themeRgbToThemeColor(accent), + messageActionForeground: themeRgbToThemeColor(accentForeground), + messageActionHover: themeRgbToThemeColor(messageActionHover), + codeBackground: themeRgbToThemeColor(codeBackground), + codeForeground: themeRgbToThemeColor(readableThemeForeground(codeBackground)), + sidebar: themeRgbToThemeColor(sidebar), + sidebarForeground: themeRgbToThemeColor(readableThemeForeground(sidebar)), + sidebarMutedForeground: themeRgbToThemeColor(standardMutedThemeText(sidebar, text)), + sidebarControlSurface: themeRgbToThemeColor( mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.16 : 0.08), ), - sidebarRowHover: themeRgbToHexColor(mixThemeRgbColors(sidebar, accent, 0.12)), - sidebarRowActive: themeRgbToHexColor(mixThemeRgbColors(sidebar, accent, 0.2)), - sidebarRowSelected: themeRgbToHexColor(mixThemeRgbColors(sidebar, accent, 0.24)), - sidebarBorder: themeRgbToHexColor( + sidebarRowHover: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.12)), + sidebarRowActive: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.2)), + sidebarRowSelected: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.24)), + sidebarBorder: themeRgbToThemeColor( mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.35 : 0.12), ), - terminalBackground: themeRgbToHexColor(terminalBackground), - terminalForeground: themeRgbToHexColor(readableThemeForeground(terminalBackground)), - terminalCursor: themeRgbToHexColor(accent), - terminalSelection: themeRgbToHexColor( + terminalBackground: themeRgbToThemeColor(terminalBackground), + terminalForeground: themeRgbToThemeColor(readableThemeForeground(terminalBackground)), + terminalCursor: themeRgbToThemeColor(accent), + terminalSelection: themeRgbToThemeColor( mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.35 : 0.18), ), - terminalScrollbar: themeRgbToHexColor( + terminalScrollbar: themeRgbToThemeColor( mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.42 : 0.22), ), - terminalScrollbarHover: themeRgbToHexColor( + terminalScrollbarHover: themeRgbToThemeColor( mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.55 : 0.32), ), }; @@ -1293,16 +1405,16 @@ export const T3_CHAT_THEME: ThemeDefinition = { id: T3_CHAT_THEME_ID, label: T3_CHAT_THEME_LABEL, appearance: "light", - colors: T3_CHAT_LIGHT_COLORS, + colors: decodeThemeColors(T3_CHAT_LIGHT_COLORS), variants: { - dark: T3_CHAT_DARK_COLORS, + dark: decodeThemeColors(T3_CHAT_DARK_COLORS), }, sidebarArtwork: true, }; /** Theme-file defaults follow the flagship palette for the requested mode. */ export function getDefaultThemeColors(appearance: ThemeAppearance): ThemeColors { - return appearance === "dark" ? T3_CHAT_DARK_COLORS : T3_CHAT_LIGHT_COLORS; + return appearance === "dark" ? T3_CHAT_THEME.variants!.dark! : T3_CHAT_THEME.colors; } /** @@ -1320,9 +1432,9 @@ function themeActionColors( ? THEME_BLACK_FOREGROUND : THEME_WHITE_FOREGROUND; return { - messageAction: action, - messageActionForeground: themeRgbToHexColor(foreground), - messageActionHover: themeRgbToHexColor(mixThemeRgbColors(rgb, towardOpposite, 0.12)), + messageAction: toCanonicalThemeColor(action) ?? themeRgbToThemeColor(rgb), + messageActionForeground: themeRgbToThemeColor(foreground), + messageActionHover: themeRgbToThemeColor(mixThemeRgbColors(rgb, towardOpposite, 0.12)), }; } @@ -1452,39 +1564,72 @@ export function themeIdFromName(name: string): string { export class ThemeLibraryStorageError extends Schema.TaggedErrorClass()( "ThemeLibraryStorageError", - { storageKey: Schema.String, cause: Schema.Defect() }, + { + storageKey: Schema.String, + operation: Schema.Literals(["read", "write"]), + reason: Schema.Literals(["malformed", "storage-unavailable"]), + cause: Schema.optional(Schema.Defect()), + }, ) { override get message(): string { - return `Failed to write the theme library to ${this.storageKey}.`; + const direction = this.operation === "read" ? "from" : "to"; + return `Failed to ${this.operation} the theme library ${direction} ${this.storageKey}.`; } } export const isThemeLibraryStorageError = Schema.is(ThemeLibraryStorageError); -function saveCustomThemes(themes: ReadonlyArray): void { +function saveCustomThemes( + storedThemes: ReadonlyArray, + themes: ReadonlyArray, +): void { if (typeof window === "undefined") return; try { - window.localStorage.setItem(CUSTOM_THEMES_STORAGE_KEY, JSON.stringify(themes)); - customThemesSnapshot = themes; + window.localStorage.setItem(CUSTOM_THEMES_STORAGE_KEY, JSON.stringify(storedThemes)); + customThemeLibrarySnapshot = { status: "ready", storedThemes, themes }; } catch (cause) { - throw new ThemeLibraryStorageError({ storageKey: CUSTOM_THEMES_STORAGE_KEY, cause }); + throw new ThemeLibraryStorageError({ + storageKey: CUSTOM_THEMES_STORAGE_KEY, + operation: "write", + reason: "storage-unavailable", + cause, + }); } notifyCustomThemeListeners(); } +function getWritableCustomThemeLibrary(): Extract { + const snapshot = getCustomThemeLibrarySnapshot(); + if (snapshot.status === "unavailable") { + throw new ThemeLibraryStorageError({ + storageKey: CUSTOM_THEMES_STORAGE_KEY, + operation: "read", + reason: snapshot.reason, + ...("cause" in snapshot ? { cause: snapshot.cause } : {}), + }); + } + return snapshot; +} + +function storedThemeHasId(storedTheme: unknown, themeId: string): boolean { + return isRecord(storedTheme) && storedTheme.id === themeId; +} + export function installCustomTheme(theme: ThemeDefinition): ThemeDefinition { if (RESERVED_THEME_IDS.has(theme.id)) { throw new Error(`The theme id "${theme.id}" is reserved.`); } + const library = getWritableCustomThemeLibrary(); if ( - [...BUILT_IN_THEME_DEFINITIONS, ...getCustomThemes()].some( - (existing) => existing.id === theme.id, - ) + BUILT_IN_THEME_DEFINITIONS.some((existing) => existing.id === theme.id) || + library.storedThemes.some((storedTheme) => storedThemeHasId(storedTheme, theme.id)) ) { throw new Error(`A theme named "${theme.label}" is already installed.`); } - saveCustomThemes([...getCustomThemes(), theme]); - return theme; + const canonicalTheme = canonicalizeThemeDefinition(theme); + const themes = [...library.themes, canonicalTheme]; + saveCustomThemes([...library.storedThemes, canonicalTheme], themes); + return canonicalTheme; } export function updateCustomTheme(theme: ThemeDefinition): ThemeDefinition { @@ -1492,22 +1637,39 @@ export function updateCustomTheme(theme: ThemeDefinition): ThemeDefinition { throw new Error(`The theme id "${theme.id}" is reserved.`); } - const themes = getCustomThemes(); + const library = getWritableCustomThemeLibrary(); + const themes = library.themes; const themeIndex = themes.findIndex((existing) => existing.id === theme.id); if (themeIndex === -1) { throw new Error(`The theme "${theme.label}" is not installed.`); } + const canonicalTheme = canonicalizeThemeDefinition(theme); const nextThemes = [...themes]; - nextThemes[themeIndex] = theme; - saveCustomThemes(nextThemes); - return theme; + nextThemes[themeIndex] = canonicalTheme; + + const nextStoredThemes: unknown[] = []; + let replaced = false; + for (const storedTheme of library.storedThemes) { + if (!storedThemeHasId(storedTheme, theme.id)) { + nextStoredThemes.push(storedTheme); + } else if (!replaced) { + nextStoredThemes.push(canonicalTheme); + replaced = true; + } + } + saveCustomThemes(nextStoredThemes, nextThemes); + return canonicalTheme; } export function removeCustomTheme(themeId: string): void { - const nextThemes = getCustomThemes().filter((theme) => theme.id !== themeId); - if (nextThemes.length === getCustomThemes().length) return; - saveCustomThemes(nextThemes); + const library = getWritableCustomThemeLibrary(); + const nextThemes = library.themes.filter((theme) => theme.id !== themeId); + if (nextThemes.length === library.themes.length) return; + saveCustomThemes( + library.storedThemes.filter((storedTheme) => !storedThemeHasId(storedTheme, themeId)), + nextThemes, + ); } function parseThemeColorOverrides(value: unknown): ThemeColorOverrides { @@ -1518,10 +1680,13 @@ function parseThemeColorOverrides(value: unknown): ThemeColorOverrides { if (!THEME_COLOR_ROLE_SET.has(role)) { throw new Error(`"${role}" is not a supported theme color role.`); } - if (!isThemeColor(color)) { - throw new Error(`The color for "${role}" must be a hex color such as #8b5cf6.`); + const normalized = toCanonicalThemeColor(color); + if (!normalized) { + throw new Error( + `The color for "${role}" must be a literal CSS color such as oklch(0.62 0.2 280).`, + ); } - overrides[role as ThemeColorRole] = color; + overrides[role as ThemeColorRole] = normalized; } if (Object.keys(overrides).length === 0) { throw new Error("Add at least one color role to the theme file."); @@ -1586,14 +1751,15 @@ export function parseThemeFile(value: unknown): ThemeDefinition { } export function serializeThemeFile(theme: ThemeDefinition): string { + const canonicalTheme = canonicalizeThemeDefinition(theme); const file: ThemeFile = { version: THEME_FILE_VERSION, - id: theme.id, - name: theme.label, - appearance: theme.appearance, - colors: theme.colors, - ...(theme.variants ? { variants: theme.variants } : {}), - ...(theme.managed ? { managed: true } : {}), + id: canonicalTheme.id, + name: canonicalTheme.label, + appearance: canonicalTheme.appearance, + colors: canonicalTheme.colors, + ...(canonicalTheme.variants ? { variants: canonicalTheme.variants } : {}), + ...(canonicalTheme.managed ? { managed: true } : {}), }; return `${JSON.stringify(file, null, 2)}\n`; } diff --git a/apps/web/src/vscodeThemeImport.test.ts b/apps/web/src/vscodeThemeImport.test.ts index a736620b24b3..e4fcdb907abb 100644 --- a/apps/web/src/vscodeThemeImport.test.ts +++ b/apps/web/src/vscodeThemeImport.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { getThemeColorsForMode, THEME_FILE_VERSION } from "./themePalette"; +import { getThemeColorsForMode, themeColorToHex, THEME_FILE_VERSION } from "./themePalette"; import { isVsCodeThemeFile, pairVsCodeThemes, @@ -8,9 +8,15 @@ import { resolveThemeLabelCollisions, } from "./vscodeThemeImport"; +function asHex(value: string): string { + const hex = themeColorToHex(value); + if (!hex) throw new Error(`Expected a theme color, received ${value}`); + return hex; +} + function contrastRatio(first: string, second: string): number { const toChannels = (value: string) => { - const hex = value.slice(1); + const hex = asHex(value).slice(1); return [0, 1, 2].map( (channel) => Number.parseInt(hex.slice(channel * 2, channel * 2 + 2), 16) / 255, ); @@ -69,18 +75,18 @@ describe("VS Code theme import", () => { // The slug name is read as words; a displayName would win verbatim. expect(theme.label).toBe("Pierre Dark Soft"); expect(theme.appearance).toBe("dark"); - expect(theme.colors.canvas).toBe("#171717"); - expect(theme.colors.text).toBe("#d4d4d4"); - expect(theme.colors.accent).toBe("#69b1ff"); - expect(theme.colors.sidebar).toBe("#101010"); - expect(theme.colors.terminalBackground).toBe("#101010"); + expect(asHex(theme.colors.canvas)).toBe("#171717"); + expect(asHex(theme.colors.text)).toBe("#d4d4d4"); + expect(asHex(theme.colors.accent)).toBe("#69b1ff"); + expect(asHex(theme.colors.sidebar)).toBe("#101010"); + expect(asHex(theme.colors.terminalBackground)).toBe("#101010"); }); it("flattens alpha overlays onto the surface they sit on", () => { const theme = parseVsCodeThemeFile(VSCODE_DARK); // #1f3e5e59 over the #101010 sidebar, not left semi-transparent. - expect(theme.colors.sidebarRowHover).toMatch(/^#[0-9a-f]{6}$/); - expect(theme.colors.sidebarRowHover).not.toBe("#1f3e5e59"); + expect(theme.colors.sidebarRowHover).toMatch(/^oklch\(/); + expect(asHex(theme.colors.sidebarRowHover)).not.toBe("#1f3e5e59"); expect(theme.colors.sidebarRowSelected).not.toBe(theme.colors.sidebar); }); @@ -88,7 +94,7 @@ describe("VS Code theme import", () => { const theme = parseVsCodeThemeFile(VSCODE_DARK); const colors = getThemeColorsForMode(theme, "dark")!; for (const value of Object.values(colors)) { - expect(value).toMatch(/^#[0-9a-f]{3,8}$/i); + expect(value).toMatch(/^oklch\(/); } expect(contrastRatio(colors.text, colors.canvas)).toBeGreaterThanOrEqual(4.5); expect(contrastRatio(colors.sidebarForeground, colors.sidebar)).toBeGreaterThanOrEqual(4.5); @@ -117,7 +123,7 @@ describe("VS Code theme import", () => { type: "dark", colors: { "editor.background": "#101010", "editor.foreground": "#111111" }, }); - expect(theme.colors.text).not.toBe("#111111"); + expect(asHex(theme.colors.text)).not.toBe("#111111"); expect(contrastRatio(theme.colors.text, theme.colors.canvas)).toBeGreaterThanOrEqual(4.5); }); @@ -134,7 +140,7 @@ describe("VS Code theme import", () => { "terminal.background": "#fbfbfb", }, }); - expect(theme.colors.sidebar).toBe("#fafafa"); + expect(asHex(theme.colors.sidebar)).toBe("#fafafa"); expect( contrastRatio(theme.colors.sidebarForeground, theme.colors.sidebar), ).toBeGreaterThanOrEqual(4.5); @@ -156,10 +162,10 @@ describe("VS Code theme import", () => { "editor.selectionBackground": "color(display-p3 0.308664 0.645271 1.000000 / 0.300000)", }, }); - expect(theme.colors.canvas).toMatch(/^#0[89ab]/); - expect(theme.colors.text).toMatch(/^#f[a-f0-9]/); + expect(asHex(theme.colors.canvas)).toMatch(/^#0[89ab]/); + expect(asHex(theme.colors.text)).toMatch(/^#f[a-f0-9]/); // The P3 blue lands in sRGB blue, not black or a clipped grey. - const accent = theme.colors.accent; + const accent = asHex(theme.colors.accent); const [red, green, blue] = [1, 3, 5].map((index) => Number.parseInt(accent.slice(index, index + 2), 16), ) as [number, number, number]; @@ -192,8 +198,8 @@ describe("VS Code theme import", () => { const github = themes[0]!; expect(github.appearance).toBe("light"); expect(getThemeColorsForMode(github, "dark")).not.toBeNull(); - expect(getThemeColorsForMode(github, "dark")!.canvas).toBe("#101014"); - expect(github.colors.canvas).toBe("#fdfdfd"); + expect(asHex(getThemeColorsForMode(github, "dark")!.canvas)).toBe("#101014"); + expect(asHex(github.colors.canvas)).toBe("#fdfdfd"); // The unpaired dimmed variant stays a single dark theme. expect(getThemeColorsForMode(themes[2]!, "light")).toBeNull(); }); @@ -220,14 +226,15 @@ describe("VS Code theme import", () => { // surface, plain surfaces) must stay near the canvas, not turn blue. const theme = parseVsCodeThemeFile(VSCODE_DARK); const spread = (value: string) => { - const channels = [1, 3, 5].map((index) => Number.parseInt(value.slice(index, index + 2), 16)); + const hex = asHex(value); + const channels = [1, 3, 5].map((index) => Number.parseInt(hex.slice(index, index + 2), 16)); return Math.max(...channels) - Math.min(...channels); }; expect(spread(theme.colors.codeBackground)).toBeLessThanOrEqual(8); expect(spread(theme.colors.surface)).toBeLessThanOrEqual(8); expect(spread(theme.colors.text)).toBeLessThanOrEqual(12); // The accent itself keeps the file's color. - expect(theme.colors.accent).toBe("#69b1ff"); + expect(asHex(theme.colors.accent)).toBe("#69b1ff"); }); it("tells same-named variants apart by their file names", () => { diff --git a/apps/web/src/vscodeThemeImport.ts b/apps/web/src/vscodeThemeImport.ts index 6432e68869a2..0ba19306a2f7 100644 --- a/apps/web/src/vscodeThemeImport.ts +++ b/apps/web/src/vscodeThemeImport.ts @@ -2,6 +2,7 @@ import { createVividThemeColors, getThemeModes, parseThemeFile, + themeColorToHex, THEME_FILE_VERSION, type ThemeAppearance, type ThemeColorRole, @@ -138,7 +139,7 @@ function contrastRatio(first: VsCodeRgb, second: VsCodeRgb): number { } function hexToRgb(value: string): VsCodeRgb { - return parseVsCodeColor(value) ?? { r: 0, g: 0, b: 0, a: 1 }; + return parseVsCodeColor(themeColorToHex(value) ?? value) ?? { r: 0, g: 0, b: 0, a: 1 }; } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8796302bab4c..78b2c686b568 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -570,6 +570,9 @@ importers: class-variance-authority: specifier: ^0.7.1 version: 0.7.1 + culori: + specifier: ^4.0.2 + version: 4.0.2 effect: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) @@ -631,6 +634,9 @@ importers: '@types/compression': specifier: ^1.8.1 version: 1.8.1 + '@types/culori': + specifier: ^4.0.1 + version: 4.0.1 '@types/react': specifier: ~19.2.14 version: 19.2.16 @@ -4794,6 +4800,9 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/culori@4.0.1': + resolution: {integrity: sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} @@ -14972,6 +14981,8 @@ snapshots: dependencies: '@types/node': 24.12.4 + '@types/culori@4.0.1': {} + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 From 2db08457f2f4eaaa713a067b2ea480ca2b583025 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:25:24 +0200 Subject: [PATCH 22/28] fix(web): use upload icon for disabled push action (#6207) --- apps/web/src/components/GitActionsControl.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 7602e7c5bfb3..7b824370b39d 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -367,6 +367,7 @@ function GitQuickActionIcon({ return ; } if (quickAction.label === "Commit") return ; + if (quickAction.label === "Push") return ; return ; } From f0b57ca2313bd7971cefb366d3b3808152b014fc Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:06:05 +0000 Subject: [PATCH 23/28] feat(web): add Open VSX theme search (#5654) Co-authored-by: Julius Marminge Co-authored-by: codex --- apps/web/package.json | 3 + .../components/settings/ThemeEditorPanel.tsx | 37 +- .../components/settings/ThemeImportDialog.tsx | 28 +- .../settings/ThemeSearchSection.tsx | 389 +++++++++ .../src/components/settings/ThemeSettings.tsx | 535 +++++++++--- apps/web/src/openVsxThemes.test.ts | 526 ++++++++++++ apps/web/src/openVsxThemes.ts | 776 ++++++++++++++++++ apps/web/src/themePalette.test.ts | 247 ++++++ apps/web/src/themePalette.ts | 146 +++- apps/web/src/vscodeThemeImport.ts | 4 + pnpm-lock.yaml | 9 + 11 files changed, 2566 insertions(+), 134 deletions(-) create mode 100644 apps/web/src/components/settings/ThemeSearchSection.tsx create mode 100644 apps/web/src/openVsxThemes.test.ts create mode 100644 apps/web/src/openVsxThemes.ts diff --git a/apps/web/package.json b/apps/web/package.json index 0fce19b28630..598feaec0ce9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ "@formkit/auto-animate": "^0.9.0", "@legendapp/list": "catalog:", "@lexical/react": "^0.41.0", + "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", "@pierre/trees": "1.0.0-beta.4", "@t3tools/client-runtime": "workspace:*", @@ -34,6 +35,8 @@ "culori": "^4.0.2", "effect": "catalog:", "jose": "catalog:", + "jsonc-parser": "3.3.1", + "jszip": "3.10.1", "lexical": "^0.41.0", "lucide-react": "^0.564.0", "react": "19.2.6", diff --git a/apps/web/src/components/settings/ThemeEditorPanel.tsx b/apps/web/src/components/settings/ThemeEditorPanel.tsx index b2a87c019695..4d7300d1f050 100644 --- a/apps/web/src/components/settings/ThemeEditorPanel.tsx +++ b/apps/web/src/components/settings/ThemeEditorPanel.tsx @@ -610,7 +610,7 @@ export function ThemeEditorPanel({ [activeAppearance, editingTheme, selectedRole], ); - const handleSubmit = useCallback(() => { + const handleSubmit = () => { if (!name.trim()) { setError("Name your theme first."); return; @@ -645,8 +645,8 @@ export function ThemeEditorPanel({ return; } mergedAppearance = editedModes[0] ?? null; - savedTheme = updateCustomTheme( - parseThemeFile({ + savedTheme = updateCustomTheme({ + ...parseThemeFile({ version: THEME_FILE_VERSION, id: mergeTarget.id, name: mergeTarget.label, @@ -658,7 +658,8 @@ export function ThemeEditorPanel({ }, ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), }), - ); + ...(mergeTarget.collection ? { collection: mergeTarget.collection } : {}), + }); retiredTheme = editingTheme; try { removeCustomTheme(editingTheme.id); @@ -676,8 +677,8 @@ export function ThemeEditorPanel({ } else if (editingTheme) { const baseAppearance = editingTheme.appearance; const variantAppearance = baseAppearance === "light" ? "dark" : "light"; - savedTheme = updateCustomTheme( - parseThemeFile({ + savedTheme = updateCustomTheme({ + ...parseThemeFile({ version: THEME_FILE_VERSION, id: editingTheme.id, name, @@ -688,7 +689,8 @@ export function ThemeEditorPanel({ : {}), ...(isAdvanced ? {} : { managed: true }), }), - ); + ...(editingTheme.collection ? { collection: editingTheme.collection } : {}), + }); } else if (mergeTarget) { if (takenAppearances.includes(activeAppearance)) { setError( @@ -701,8 +703,8 @@ export function ThemeEditorPanel({ // survives when every palette in the theme came from the guided // editor. mergedAppearance = activeAppearance; - savedTheme = updateCustomTheme( - parseThemeFile({ + savedTheme = updateCustomTheme({ + ...parseThemeFile({ version: THEME_FILE_VERSION, id: mergeTarget.id, name: mergeTarget.label, @@ -714,7 +716,8 @@ export function ThemeEditorPanel({ }, ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}), }), - ); + ...(mergeTarget.collection ? { collection: mergeTarget.collection } : {}), + }); } else { savedTheme = installCustomTheme( parseThemeFile({ @@ -762,19 +765,7 @@ export function ThemeEditorPanel({ : "Could not create the theme.", ); } - }, [ - activeAppearance, - colorsByAppearance, - editingTheme, - isAdvanced, - isEditing, - mergeTarget, - name, - onOpenChange, - onSaved, - simpleColorsDirtyByAppearance, - takenAppearances, - ]); + }; const renderNameField = () => (
Day{isPast24Hours ? "Hour" : "Day"} {PROVIDER_LABEL[provider]} @@ -336,29 +392,36 @@ export function UsagePage() {
No activity in this window.
{formatDayShort(day.day)}
+ {"hourStart" in period + ? formatHourShort(period.hourStart, window.timeZone) + : formatDayShort(period.day)} + - {formatUsd(day.byProvider.get(provider)?.costUsd ?? 0)} + {formatUsd(period.byProvider.get(provider)?.costUsd ?? 0)} - {formatUsd(day.costUsd)} + {formatUsd(period.costUsd)} - {formatTokens(day.totalTokens)} + {formatTokens(period.totalTokens)}