diff --git a/.github/scripts/relay-state-output.test.cjs b/.github/scripts/relay-state-output.test.cjs new file mode 100644 index 000000000000..fcb829dd91e8 --- /dev/null +++ b/.github/scripts/relay-state-output.test.cjs @@ -0,0 +1,76 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const { mkdtempSync, readFileSync, rmSync, existsSync } = require("node:fs"); +const { tmpdir } = require("node:os"); +const { join } = require("node:path"); +const { test } = require("node:test"); + +const workflow = readFileSync(join(__dirname, "../workflows/release.yml"), "utf8"); +const step = workflow.match( + /- name: Read production relay tracing config\n[\s\S]*? run: \|\n((?: .*\n|\n)+)/, +); +assert.ok(step, "Could not find the relay state workflow step"); +const script = step[1].replace(/^ /gm, ""); +const config = { + clientTracingUrl: "https://example.invalid/traces", + clientTracingDataset: "fixture-dataset", + clientTracingToken: { __redacted__: "fixture-token" }, +}; +const json = JSON.stringify(config, null, 2); + +function runStep(stdout, exitCode = 0) { + const runnerTemp = mkdtempSync(join(tmpdir(), "t3-relay-state-test-")); + try { + const result = spawnSync( + "bash", + ["-c", 'npx() { printf "%s\\n" "$FIXTURE_STDOUT"; return "$FIXTURE_EXIT"; }\n' + script], + { + encoding: "utf8", + env: { + PATH: process.env.PATH, + RUNNER_TEMP: runnerTemp, + FIXTURE_STDOUT: stdout, + FIXTURE_EXIT: String(exitCode), + }, + }, + ); + assert.ifError(result.error); + const envPath = join(runnerTemp, "relay-client-tracing.env"); + return { + ...result, + envFile: existsSync(envPath) ? readFileSync(envPath, "utf8") : undefined, + }; + } finally { + rmSync(runnerTemp, { recursive: true, force: true }); + } +} + +for (const prefix of [ + "", + "• Refreshing Cloudflare State Store credentials\nāœ“ Refreshing Cloudflare State Store credentials\n", +]) { + test(`extracts tracing config ${prefix ? "after progress output" : "from plain JSON"}`, () => { + const result = runStep(prefix + json); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, "::add-mask::fixture-token\n"); + assert.equal( + result.envFile, + "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=https://example.invalid/traces\n" + + "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=fixture-dataset\n" + + "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=fixture-token\n", + ); + }); +} + +for (const [name, stdout, exitCode] of [ + ["failed CLI even with valid JSON", json, 1], + ["missing JSON", "Refreshing credentials...", 0], + ["malformed JSON", "{not JSON", 0], + ["missing token", JSON.stringify({ ...config, clientTracingToken: null }, null, 2), 0], +]) { + test(`rejects ${name} without writing config`, () => { + const result = runStep(stdout, exitCode); + assert.notEqual(result.status, 0); + assert.equal(result.envFile, undefined); + }); +} diff --git a/.github/workflows/mobile-fingerprint-check.yml b/.github/workflows/mobile-fingerprint-check.yml index 76647a850f36..3d9e7f5359a4 100644 --- a/.github/workflows/mobile-fingerprint-check.yml +++ b/.github/workflows/mobile-fingerprint-check.yml @@ -13,17 +13,9 @@ name: Mobile Fingerprint Check # pnpm), so the comparison is self-consistent; no EXPO_TOKEN needed. on: pull_request: - paths: - - apps/mobile/** - - packages/client-runtime/** - - packages/contracts/** - - packages/shared/** - - assets/** - - scripts/** - - patches/** - - pnpm-lock.yaml - - pnpm-workspace.yaml - - .github/workflows/mobile-fingerprint-check.yml + # Run even when a rebase or base change removes every native input from the + # diff, so the label can be cleared without installing Expo dependencies. + types: [opened, synchronize, reopened, edited] concurrency: group: mobile-fingerprint-check-${{ github.event.pull_request.number }} @@ -44,12 +36,29 @@ jobs: - name: Checkout uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: - # Default pull_request checkout is the merge commit (PR applied on - # top of base), so the "head" fingerprint is the state main would - # actually be in after merging — stale branches compare cleanly. - fetch-depth: 0 + # The merge ref can advance after the event is queued. Pin its commit + # and compare against its first parent, rather than the event's base. + ref: ${{ github.sha }} + fetch-depth: 2 + + - id: changes + name: Detect native fingerprint inputs + run: | + base_sha=$(git rev-parse HEAD^1) + echo "base_sha=$base_sha" >> "$GITHUB_OUTPUT" + paths=$(git diff --no-renames --name-only "$base_sha" HEAD -- \ + apps/mobile/ packages/client-runtime/ packages/contracts/ packages/shared/ \ + assets/ scripts/ patches/ package.json pnpm-lock.yaml pnpm-workspace.yaml \ + .github/workflows/mobile-fingerprint-check.yml) + if [[ -n "$paths" ]]; then + echo "relevant=true" >> "$GITHUB_OUTPUT" + else + echo "relevant=false" >> "$GITHUB_OUTPUT" + echo "No native fingerprint inputs changed; clearing any stale native change label." >> "$GITHUB_STEP_SUMMARY" + fi - name: Setup Vite+ + if: steps.changes.outputs.relevant == 'true' uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1.15.0 with: node-version-file: package.json @@ -59,6 +68,7 @@ jobs: - --filter=@t3tools/mobile... - name: Expose pnpm + if: steps.changes.outputs.relevant == 'true' run: | pnpm_version="$(node --print "require('./package.json').packageManager.split('@').pop()")" vp_pnpm_bin="$HOME/.vite-plus/package_manager/pnpm/$pnpm_version/pnpm/bin" @@ -66,6 +76,7 @@ jobs: "$vp_pnpm_bin/pnpm" --version - name: Fingerprint merge result + if: steps.changes.outputs.relevant == 'true' working-directory: apps/mobile run: | mkdir -p "$RUNNER_TEMP/fp/head" "$RUNNER_TEMP/fp/base" @@ -74,8 +85,11 @@ jobs: done - name: Fingerprint base + if: steps.changes.outputs.relevant == 'true' + env: + BASE_SHA: ${{ steps.changes.outputs.base_sha }} run: | - git checkout --quiet "${{ github.event.pull_request.base.sha }}" + git checkout --quiet "$BASE_SHA" # Re-sync node_modules to the base commit's lockfile before # fingerprinting — a dep-changing PR must not fingerprint the base # against head's installed packages. @@ -87,6 +101,7 @@ jobs: - id: compare name: Compare fingerprints + if: steps.changes.outputs.relevant == 'true' run: | changed="" { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3f6a5d9b333f..4ac1d8be5a8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -324,7 +324,10 @@ jobs: - --filter=t3code-relay... # The deployed stack's outputs, read from Alchemy's state store without - # planning or applying. Redacted values are persisted as + # planning or applying. Select the backend explicitly to avoid loading + # the stack's unrelated provider credentials; suppress informational logs + # and strip the credential-refresh progress prefix before parsing JSON. + # Redacted values are persisted as # {"__redacted__": ""}; the token is masked before it is written. - name: Read production relay tracing config if: steps.creds.outputs.configured == 'true' @@ -332,7 +335,7 @@ jobs: working-directory: infra/relay run: | set -euo pipefail - output="$(npx alchemy state read T3CodeRelay/prod/output --no-input)" + output="$(npx alchemy state read T3CodeRelay/prod/output --backend cloudflare --log-level error --no-input | sed -n '/^{/,$p')" field() { jq -er --arg key "$1" '.[$key] | if type == "object" then .__redacted__ else . end | select(. != null and . != "")' <<<"$output" \ || { echo "Relay stack output is missing $1" >&2; exit 1; } diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index 71bcf5f7aef1..ce0c7d013a99 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -57,7 +57,6 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) => }), setAppUserModelId: () => Effect.void, getAppMetrics: Effect.succeed([]), - isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(true), setDesktopName: () => Effect.void, setDockIcon: (iconPath) => diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index aa28ff8d86eb..fdc69841343c 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -3,6 +3,7 @@ import { assert, describe, it } from "@effect/vitest"; import { ConnectionCatalogDocument } from "@t3tools/client-runtime/platform"; import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; @@ -22,6 +23,11 @@ const textEncoder = new TextEncoder(); const decodeConnectionCatalog = Schema.decodeEffect( Schema.fromJsonString(ConnectionCatalogDocument), ); +const encodeLegacySavedEnvironments = Schema.encodeEffect( + Schema.fromJsonString( + Schema.Struct({ version: Schema.Literal(1), records: Schema.Array(Schema.Unknown) }), + ), +); function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref | null = null) { return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, { isEncryptionAvailable: Effect.succeed(available), @@ -125,7 +131,8 @@ describe("DesktopConnectionCatalogStore", () => { withStore( Effect.gen(function* () { const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; const records: readonly PersistedSavedEnvironmentRecord[] = [ { environmentId: EnvironmentId.make("relay-environment"), @@ -159,11 +166,21 @@ describe("DesktopConnectionCatalogStore", () => { lastConnectedAt: null, }, ]; - yield* savedEnvironments.setRegistry(records); - assert.isTrue( - yield* savedEnvironments.setSecret({ - environmentId: EnvironmentId.make("bearer-environment"), - secret: "legacy-token", + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.savedEnvironmentRegistryPath, + yield* encodeLegacySavedEnvironments({ + version: 1, + records: records.map((record) => + record.environmentId === "bearer-environment" + ? { + ...record, + encryptedBearerToken: Encoding.encodeBase64( + textEncoder.encode("encrypted:legacy-token"), + ), + } + : record, + ), }), ); @@ -218,7 +235,10 @@ describe("DesktopConnectionCatalogStore", () => { assert.equal(catalog.credentials[0].credential.token, "legacy-token"); } - yield* savedEnvironments.setRegistry([]); + yield* fileSystem.writeFileString( + environment.savedEnvironmentRegistryPath, + '{"version":1,"records":[]}', + ); assert.deepEqual(yield* store.get, migrated); }), ), diff --git a/apps/desktop/src/app/DesktopLifecycle.test.ts b/apps/desktop/src/app/DesktopLifecycle.test.ts index 515be6726c94..33c74f5a8b9b 100644 --- a/apps/desktop/src/app/DesktopLifecycle.test.ts +++ b/apps/desktop/src/app/DesktopLifecycle.test.ts @@ -43,7 +43,6 @@ function makeElectronAppLayer( setAboutPanelOptions: () => Effect.void, setAppUserModelId: () => Effect.void, getAppMetrics: Effect.succeed([]), - isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(true), setDesktopName: () => Effect.void, setDockIcon: () => Effect.void, diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index cd90e7951b8d..d7ccfc43b185 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -5,6 +5,8 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import * as DesktopConfig from "./DesktopConfig.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; @@ -49,7 +51,11 @@ const environmentInput = (baseDir: string) => runningUnderArm64Translation: false, }) satisfies DesktopEnvironment.MakeDesktopEnvironmentInput; -const makeEnvironmentLayer = (baseDir: string, isDevelopment = true) => +const makeEnvironmentLayer = ( + baseDir: string, + isDevelopment = true, + env: Readonly> = {}, +) => DesktopEnvironment.layer(environmentInput(baseDir)).pipe( Layer.provide( Layer.mergeAll( @@ -57,11 +63,57 @@ const makeEnvironmentLayer = (baseDir: string, isDevelopment = true) => DesktopConfig.layerTest({ T3CODE_HOME: baseDir, VITE_DEV_SERVER_URL: isDevelopment ? "http://127.0.0.1:5733" : undefined, + ...env, }), ), ), ); +interface ExportedRequest { + readonly url: string; + readonly headers: Readonly>; + readonly body: string; +} + +/** Answers every export with a 200 and keeps what was posted for assertions. */ +const collectorLayer = (requests: Array) => + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + requests.push({ + url: request.url, + headers: request.headers, + body: + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "", + }); + return HttpClientResponse.fromWeb(request, new Response(null, { status: 200 })); + }), + ), + ); + +const encodeObservabilitySettingsFile = Schema.encodeSync( + Schema.fromJsonString( + Schema.Struct({ observability: Schema.Record(Schema.String, Schema.String) }), + ), +); + +const writeObservabilitySettings = Effect.fn(function* ( + environmentLayer: ReturnType, + observability: Readonly>, +) { + const fileSystem = yield* FileSystem.FileSystem; + const { path, serverSettingsPath } = yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment; + }).pipe(Effect.provide(environmentLayer)); + yield* fileSystem.makeDirectory(path.dirname(serverSettingsPath), { recursive: true }); + yield* fileSystem.writeFileString( + serverSettingsPath, + encodeObservabilitySettingsFile({ observability }), + ); +}); + describe("DesktopObservability", () => { it("advances a retained output offset instead of repeatedly copying a full head chunk", () => { const maxBufferedBytes = 1024 * 1024; @@ -328,4 +380,115 @@ describe("DesktopObservability", () => { Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), ), ); + + it.effect("exports main process log records to the configured logs endpoint", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir, true, { + T3CODE_OTLP_LOGS_URL: "https://collector.example.com/v1/logs", + T3CODE_OTLP_HEADERS: "x-scope=desktop", + }); + const tracePath = yield* Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + return environment.path.join(environment.logDir, "desktop.trace.ndjson"); + }).pipe(Effect.provide(environmentLayer)); + + yield* Effect.scoped( + Effect.logInfo("desktop log export").pipe( + Effect.withSpan("desktop-log-export-test"), + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + assert.lengthOf(requests, 1); + const [request] = requests; + assert.strictEqual(request?.url, "https://collector.example.com/v1/logs"); + assert.include(request?.body ?? "", "desktop log export"); + assert.include(request?.body ?? "", "service.runtime"); + assert.strictEqual(request?.headers["x-scope"], "desktop"); + + // The log record is the export now, so the same message must not also + // ride along as an event on the span. + const record = (yield* fileSystem.readFileString(tracePath)) + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => decodeTraceRecordLine(line)) + .find((entry) => entry.name === "desktop-log-export-test"); + assert.notEqual(record, undefined); + assert.lengthOf(record?.events ?? [], 0); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + ); + }); + + it.effect("reads every signal endpoint from Settings when the environment names none", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + const environmentLayer = makeEnvironmentLayer(baseDir); + yield* writeObservabilitySettings(environmentLayer, { + otlpTracesUrl: "https://settings.example.com/v1/traces", + otlpLogsUrl: "https://settings.example.com/v1/logs", + // The main process records no metrics yet, so this endpoint must + // not produce a request. + otlpMetricsUrl: "https://settings.example.com/v1/metrics", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop log export from settings").pipe( + Effect.withSpan("desktop-settings-export-test"), + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + assert.deepEqual(requests.map((request) => request.url).toSorted(), [ + "https://settings.example.com/v1/logs", + "https://settings.example.com/v1/traces", + ]); + assert.include( + requests.find((request) => request.url.endsWith("/v1/logs"))?.body ?? "", + "desktop log export from settings", + ); + assert.include( + requests.find((request) => request.url.endsWith("/v1/traces"))?.body ?? "", + "desktop-settings-export-test", + ); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + ); + }); + + it.effect("stays off the network when no endpoint is configured", () => { + const requests: Array = []; + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-observability-test-", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop log stays local").pipe( + Effect.withSpan("desktop-offline-test"), + Effect.provide( + DesktopObservability.layer.pipe(Layer.provideMerge(makeEnvironmentLayer(baseDir))), + ), + ), + ); + + assert.lengthOf(requests, 0); + }).pipe( + Effect.scoped, + Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + ); + }); }); diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index 07a2fbfaaed9..e5000f527cd2 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -24,7 +24,7 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as Tracer from "effect/Tracer"; -import { OtlpExporter, OtlpLogger, OtlpMetrics, OtlpTracer } from "effect/unstable/observability"; +import { OtlpExporter, OtlpLogger, OtlpTracer } from "effect/unstable/observability"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import { @@ -606,9 +606,9 @@ const otlpResourceFor = (resource: DesktopOtlpResource) => ({ }); /** - * Logs, traces, and metrics for the main process, built together because they - * share one read of the environment and Settings, and because a process gets - * exactly one logger set. + * Logs and traces for the main process, built together because they share one + * read of the environment and Settings, and because a process gets exactly one + * logger set. */ const telemetryLayer = Layer.unwrap( Effect.gen(function* () { @@ -621,25 +621,32 @@ const telemetryLayer = Layer.unwrap( const otlpResource = otlpResourceFor(resolved.resource); - const otlpLogger = - resolved.logs.url === undefined - ? undefined - : OtlpLogger.make({ - url: resolved.logs.url, - exportInterval: `${resolved.logs.exportIntervalMs} millis`, - resource: otlpResource, - ...(resolved.logs.headers === undefined ? {} : { headers: resolved.logs.headers }), - ...(resolved.logs.maxBatchSize === undefined - ? {} - : { maxBatchSize: resolved.logs.maxBatchSize }), - }); - + // `Logger.layer` writes the whole logger set rather than adding to it, so + // every logger the main process wants has to be named in this one call. + // Splitting the OTLP logger back out into a layer of its own silently + // drops either it or the console output. + // + // Swapping `Logger.tracerLogger` out for the OTLP logger matches the + // server: both reach a collector, but the tracer logger covers only + // messages logged inside a recorded span and files them under traces, + // while the OTLP logger carries every message as a log record stamped + // with its trace and span ids. Keeping both would export every in-span + // message twice. const loggerLayer = Logger.layer( - [ - Logger.consolePretty(), - Logger.tracerLogger, - ...(otlpLogger === undefined ? [] : [otlpLogger]), - ], + resolved.logs.url === undefined + ? [Logger.consolePretty(), Logger.tracerLogger] + : [ + Logger.consolePretty(), + OtlpLogger.make({ + url: resolved.logs.url, + exportInterval: `${resolved.logs.exportIntervalMs} millis`, + resource: otlpResource, + ...(resolved.logs.headers === undefined ? {} : { headers: resolved.logs.headers }), + ...(resolved.logs.maxBatchSize === undefined + ? {} + : { maxBatchSize: resolved.logs.maxBatchSize }), + }), + ], { mergeWithExisting: false }, ).pipe( Layer.provide(OtlpExporter.layerFlusher), @@ -685,22 +692,28 @@ const telemetryLayer = Layer.unwrap( Layer.provide(serializationFor(resolved.traces)), ); - const metricsLayer = - resolved.metrics.url === undefined - ? Layer.empty - : OtlpMetrics.layer({ - url: resolved.metrics.url, - exportInterval: `${resolved.metrics.exportIntervalMs} millis`, - resource: otlpResource, - ...(resolved.metrics.headers === undefined - ? {} - : { headers: resolved.metrics.headers }), - ...(resolved.metrics.temporality === undefined - ? {} - : { temporality: resolved.metrics.temporality }), - }).pipe(Layer.provide(serializationFor(resolved.metrics))); - - return Layer.mergeAll(loggerLayer, tracerLayer, metricsLayer); + // Metrics stay off until the main process records one. `OtlpMetrics` + // exports on every interval even when the registry is empty, so wiring it + // up today would post an empty payload every interval to every collector + // the environment points at. Restore this when a desktop metric exists, + // and add it to the `Layer.mergeAll` below. + // + // const metricsLayer = + // resolved.metrics.url === undefined + // ? Layer.empty + // : OtlpMetrics.layer({ + // url: resolved.metrics.url, + // exportInterval: `${resolved.metrics.exportIntervalMs} millis`, + // resource: otlpResource, + // ...(resolved.metrics.headers === undefined + // ? {} + // : { headers: resolved.metrics.headers }), + // ...(resolved.metrics.temporality === undefined + // ? {} + // : { temporality: resolved.metrics.temporality }), + // }).pipe(Layer.provide(serializationFor(resolved.metrics))); + + return Layer.mergeAll(loggerLayer, tracerLayer); }), ); diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index 5a1ef70ad1c2..df2001f1ac20 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -165,9 +165,7 @@ function makeTestInstance(input: MakeInstanceInput) { latest: Effect.succeed(Option.none()), changes: Stream.empty, encoded: input.desktopTelemetryStream ?? Stream.empty, - handleControl: () => Effect.void, - handleControlForSource: (_sourceId, message) => - (input.desktopTelemetryPublisher?.handleControl ?? (() => Effect.void))(message), + handleControlForSource: () => Effect.void, removeControlSource: () => Effect.void, publishUpdateReport: () => Effect.void, updateRequests: Stream.empty, @@ -596,7 +594,7 @@ describe("DesktopBackendManager", () => { const instance = yield* makeTestInstance({ spawnerLayer, desktopTelemetryPublisher: { - handleControl: (message) => + handleControlForSource: (_sourceId, message) => message.type === "setDiagnosticsDemand" ? Deferred.succeed(handled, message.enabled).pipe(Effect.asVoid) : Effect.void, diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index b9a96d72a77a..7859223161b7 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -70,7 +70,6 @@ function makePoolLayer( latest: Effect.succeed(Option.none()), changes: Stream.empty, encoded: Stream.empty, - handleControl: () => Effect.void, handleControlForSource: () => Effect.void, removeControlSource: () => Effect.void, publishUpdateReport: () => Effect.void, diff --git a/apps/desktop/src/electron/ElectronApp.test.ts b/apps/desktop/src/electron/ElectronApp.test.ts index 4189ea793e2d..84f37b6f5f53 100644 --- a/apps/desktop/src/electron/ElectronApp.test.ts +++ b/apps/desktop/src/electron/ElectronApp.test.ts @@ -10,7 +10,6 @@ const { getAppPathMock, getSystemLocaleMock, getVersionMock, - isDefaultProtocolClientMock, onMock, quitMock, relaunchMock, @@ -32,7 +31,6 @@ const { getAppPathMock: vi.fn(() => "/app"), getSystemLocaleMock: vi.fn(() => "en-GB"), getVersionMock: vi.fn(() => "1.2.3"), - isDefaultProtocolClientMock: vi.fn(() => false), onMock: vi.fn(), quitMock: vi.fn(), relaunchMock: vi.fn(), @@ -64,7 +62,6 @@ vi.mock("electron", () => ({ getAppPath: getAppPathMock, getSystemLocale: getSystemLocaleMock, getVersion: getVersionMock, - isDefaultProtocolClient: isDefaultProtocolClientMock, isPackaged: true, name: "T3 Code", on: onMock, diff --git a/apps/desktop/src/electron/ElectronApp.ts b/apps/desktop/src/electron/ElectronApp.ts index b54da535add0..f75ea51552e7 100644 --- a/apps/desktop/src/electron/ElectronApp.ts +++ b/apps/desktop/src/electron/ElectronApp.ts @@ -64,7 +64,6 @@ export class ElectronApp extends Context.Service< ) => Effect.Effect; readonly setAppUserModelId: (id: string) => Effect.Effect; readonly getAppMetrics: Effect.Effect>; - readonly isDefaultProtocolClient: (protocol: string) => Effect.Effect; readonly setAsDefaultProtocolClient: ( protocol: string, path?: string, @@ -166,8 +165,6 @@ export const make = ElectronApp.of({ Electron.app.setAppUserModelId(id); }), getAppMetrics: Effect.sync(() => Electron.app.getAppMetrics()), - isDefaultProtocolClient: (protocol) => - Effect.sync(() => Electron.app.isDefaultProtocolClient(protocol)), setAsDefaultProtocolClient: (protocol, path, args) => Effect.sync(() => { if (path === undefined) { diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index eca3db4ddf85..764056742372 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -1,4 +1,8 @@ import { assert, describe, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -26,6 +30,7 @@ import { getWindowFullscreenState, pasteAsText, pickProjectFavicon, + probeRemoteEditors, } from "./window.ts"; const readyWslConfig: DesktopBackendManager.DesktopBackendStartConfig = { @@ -262,3 +267,34 @@ describe("pickProjectFavicon", () => { }), ); }); + +it.effect.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "finds remote editors installed without PATH launchers", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-remote-editors-" }); + for (const app of ["Cursor", "Visual Studio Code", "WebStorm"]) { + const executable = path.join( + home, + "Applications", + `${app}.app`, + app === "WebStorm" ? "Contents/MacOS/webstorm" : "Contents/Resources/app/bin/code", + ); + yield* fs.makeDirectory(path.dirname(executable), { recursive: true }); + yield* fs.writeFileString(executable, "#!/bin/sh\n"); + yield* fs.chmod(executable, 0o755); + } + const editors = yield* probeRemoteEditors.handler(undefined).pipe( + Effect.provideService(HostProcessEnvironment, { + HOME: home, + PATH: path.join(home, "empty"), + }), + Effect.provideService(HostProcessPlatform, "darwin"), + ); + assert.include(editors, "cursor"); + assert.include(editors, "vscode"); + assert.notInclude(editors, "webstorm"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 81361db37303..75f34ab07ab1 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -14,7 +14,8 @@ import { type PickedThemeFile, } from "@t3tools/contracts"; import { WORKSPACE_IMAGE_PREVIEW_EXTENSIONS } from "@t3tools/shared/filePreview"; -import { isCommandAvailable } from "@t3tools/shared/shell"; +import { resolveEditorCommand } from "@t3tools/shared/editor"; +import * as HostProcess from "@t3tools/shared/hostProcess"; import * as NodeOS from "node:os"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -333,20 +334,13 @@ export const probeRemoteEditors = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PROBE_REMOTE_EDITORS_CHANNEL, payload: Schema.Undefined, result: Schema.Array(EditorId), - // Probes THIS machine (where the renderer runs) for remote-capable editor - // CLIs, unlike the server's probe which walks the environment host's PATH. - // A Finder-launched app can miss PATH entries; an empty result makes the - // renderer fall back to VS Code only, so that fails soft. handler: Effect.fn("desktop.ipc.window.probeRemoteEditors")(function* () { const available: Array = []; + const env = yield* HostProcess.HostProcessEnvironment; for (const editorId of REMOTE_CAPABLE_EDITOR_IDS) { - const commands = EDITORS.find((editor) => editor.id === editorId)?.commands; - if (!commands) continue; - for (const command of commands) { - if (yield* isCommandAvailable(command, { env: process.env })) { - available.push(editorId); - break; - } + const editor = EDITORS.find((editor) => editor.id === editorId); + if (editor && Option.isSome(yield* resolveEditorCommand(editor, env))) { + available.push(editorId); } } return available; diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index 348f6cb3843e..dad53815f3a6 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; @@ -39,16 +40,29 @@ const SavedEnvironmentRegistryDocumentProbe = Schema.Struct({ const SavedEnvironmentRegistryDocumentProbeJson = Schema.fromJsonString( SavedEnvironmentRegistryDocumentProbe, ); -const decodeSavedEnvironmentRegistryDocumentProbe = Schema.decodeEffect( - SavedEnvironmentRegistryDocumentProbeJson, -); const encodeSavedEnvironmentRegistryDocumentProbe = Schema.encodeEffect( SavedEnvironmentRegistryDocumentProbeJson, ); + +const seedSavedEnvironmentRegistry = Effect.fn(function* (encryptedBearerToken?: string) { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + const encoded = yield* encodeSavedEnvironmentRegistryDocumentProbe({ + version: 1, + records: [ + { + ...savedRegistryRecord, + ...(encryptedBearerToken === undefined ? {} : { encryptedBearerToken }), + }, + ], + }); + yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, `${encoded}\n`); +}); + function makeSafeStorageLayer(input: { readonly available: boolean; readonly availabilityError?: unknown; - readonly encryptError?: unknown; readonly decryptError?: unknown; }) { return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, { @@ -60,14 +74,7 @@ function makeSafeStorageLayer(input: { cause: input.availabilityError, }), ), - encryptString: (value) => - input.encryptError === undefined - ? Effect.succeed(textEncoder.encode(`enc:${value}`)) - : Effect.fail( - new ElectronSafeStorage.ElectronSafeStorageEncryptError({ - cause: input.encryptError, - }), - ), + encryptString: (value) => Effect.succeed(textEncoder.encode(`enc:${value}`)), decryptString: (value) => { if (input.decryptError !== undefined) { return Effect.fail( @@ -96,7 +103,6 @@ function makeLayer( options?: { readonly availableSecretStorage?: boolean; readonly availabilityError?: unknown; - readonly encryptError?: unknown; readonly decryptError?: unknown; }, fileSystemLayer: Layer.Layer = NodeServices.layer, @@ -120,7 +126,6 @@ function makeLayer( const safeStorageLayer = makeSafeStorageLayer({ available: options?.availableSecretStorage ?? true, availabilityError: options?.availabilityError, - encryptError: options?.encryptError, decryptError: options?.decryptError, }); const dependencies = Layer.mergeAll( @@ -138,7 +143,6 @@ const withSavedEnvironments = ( options?: { readonly availableSecretStorage?: boolean; readonly availabilityError?: unknown; - readonly encryptError?: unknown; readonly decryptError?: unknown; }, ) => @@ -151,20 +155,13 @@ const withSavedEnvironments = ( }).pipe(Effect.provide(NodeServices.layer), Effect.scoped); describe("DesktopSavedEnvironments", () => { - it.effect("persists and reloads saved environment metadata", () => + it.effect("reads saved environment metadata", () => withSavedEnvironments( Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* savedEnvironments.setRegistry([savedRegistryRecord]); + yield* seedSavedEnvironmentRegistry(); assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]); - const persisted = yield* decodeSavedEnvironmentRegistryDocumentProbe( - yield* fileSystem.readFileString(environment.savedEnvironmentRegistryPath), - ); - assert.equal(persisted.version, 1); - assert.lengthOf(persisted.records, 1); }), ), ); @@ -205,17 +202,12 @@ describe("DesktopSavedEnvironments", () => { ), ); - it.effect("persists encrypted saved environment secrets when encryption is available", () => + it.effect("reads encrypted saved environment secrets when encryption is available", () => withSavedEnvironments( Effect.gen(function* () { const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* savedEnvironments.setRegistry([savedRegistryRecord]); - - assert.isTrue( - yield* savedEnvironments.setSecret({ - environmentId: savedRegistryRecord.environmentId, - secret: "bearer-token", - }), + yield* seedSavedEnvironmentRegistry( + Encoding.encodeBase64(textEncoder.encode("enc:bearer-token")), ); assert.deepEqual( @@ -230,14 +222,8 @@ describe("DesktopSavedEnvironments", () => { withSavedEnvironments( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - const encoded = yield* encodeSavedEnvironmentRegistryDocumentProbe({ - version: 1, - records: [{ ...savedRegistryRecord, encryptedBearerToken: "%%%" }], - }); - yield* fileSystem.writeFileString(environment.savedEnvironmentRegistryPath, `${encoded}\n`); + yield* seedSavedEnvironmentRegistry("%%%"); const error = yield* savedEnvironments .getSecret(savedRegistryRecord.environmentId) @@ -256,17 +242,17 @@ describe("DesktopSavedEnvironments", () => { ), ); - it.effect("returns false when writing secrets while encryption is unavailable", () => + it.effect("returns no secret while encryption is unavailable", () => withSavedEnvironments( Effect.gen(function* () { const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* savedEnvironments.setRegistry([savedRegistryRecord]); + yield* seedSavedEnvironmentRegistry( + Encoding.encodeBase64(textEncoder.encode("enc:bearer-token")), + ); - assert.isFalse( - yield* savedEnvironments.setSecret({ - environmentId: savedRegistryRecord.environmentId, - secret: "next-token", - }), + assert.deepEqual( + yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId), + Option.none(), ); }), { availableSecretStorage: false }, @@ -279,13 +265,12 @@ describe("DesktopSavedEnvironments", () => { Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* savedEnvironments.setRegistry([savedRegistryRecord]); + yield* seedSavedEnvironmentRegistry( + Encoding.encodeBase64(textEncoder.encode("enc:bearer-token")), + ); const error = yield* savedEnvironments - .setSecret({ - environmentId: savedRegistryRecord.environmentId, - secret: "next-token", - }) + .getSecret(savedRegistryRecord.environmentId) .pipe(Effect.flip); assert.instanceOf( @@ -309,45 +294,6 @@ describe("DesktopSavedEnvironments", () => { ); }); - it.effect("removes saved environment secrets", () => - withSavedEnvironments( - Effect.gen(function* () { - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* savedEnvironments.setRegistry([savedRegistryRecord]); - yield* savedEnvironments.setSecret({ - environmentId: savedRegistryRecord.environmentId, - secret: "bearer-token", - }); - - yield* savedEnvironments.removeSecret(savedRegistryRecord.environmentId); - - assert.isTrue( - Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)), - ); - }), - ), - ); - - it.effect("removes saved environment metadata and its embedded secret atomically", () => - withSavedEnvironments( - Effect.gen(function* () { - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* savedEnvironments.setRegistry([savedRegistryRecord]); - yield* savedEnvironments.setSecret({ - environmentId: savedRegistryRecord.environmentId, - secret: "bearer-token", - }); - - yield* savedEnvironments.removeEnvironment(savedRegistryRecord.environmentId); - - assert.deepEqual(yield* savedEnvironments.getRegistry, []); - assert.isTrue( - Option.isNone(yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId)), - ); - }), - ), - ); - it.effect("treats empty saved environment documents as empty", () => withSavedEnvironments( Effect.gen(function* () { @@ -388,13 +334,6 @@ describe("DesktopSavedEnvironments", () => { secretError, DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, ); - const mutationError = yield* savedEnvironments - .setRegistry([savedRegistryRecord]) - .pipe(Effect.flip); - assert.instanceOf( - mutationError, - DesktopSavedEnvironments.DesktopSavedEnvironmentsDocumentDecodeError, - ); }), ), ); @@ -431,77 +370,4 @@ describe("DesktopSavedEnvironments", () => { assert.notEqual(error.message, permissionError.message); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); - - it.effect("reports the failed saved environment write operation and path", () => - Effect.gen(function* () { - const baseFileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const baseDir = yield* baseFileSystem.makeTempDirectoryScoped({ - prefix: "t3-desktop-saved-environments-test-", - }); - const permissionError = PlatformError.systemError({ - _tag: "PermissionDenied", - module: "FileSystem", - method: "makeDirectory", - pathOrDescriptor: path.join(baseDir, "userdata"), - }); - const fileSystemLayer = Layer.succeed( - FileSystem.FileSystem, - FileSystem.makeNoop({ - readFileString: baseFileSystem.readFileString, - makeDirectory: () => Effect.fail(permissionError), - }), - ); - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments.pipe( - Effect.provide(makeLayer(baseDir, undefined, fileSystemLayer)), - ); - - const error = yield* savedEnvironments.setRegistry([savedRegistryRecord]).pipe(Effect.flip); - assert.instanceOf(error, DesktopSavedEnvironments.DesktopSavedEnvironmentsWriteError); - assert.equal(error.operation, "create-directory"); - assert.equal(error.path, path.join(baseDir, "userdata")); - assert.strictEqual(error.cause, permissionError); - assert.equal( - error.message, - `Desktop saved-environment write failed during create-directory at ${path.join(baseDir, "userdata")}.`, - ); - assert.notEqual(error.message, permissionError.message); - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - it.effect("returns false when writing a secret without metadata", () => - withSavedEnvironments( - Effect.gen(function* () { - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - - assert.isFalse( - yield* savedEnvironments.setSecret({ - environmentId: savedRegistryRecord.environmentId, - secret: "bearer-token", - }), - ); - }), - ), - ); - - it.effect("preserves encrypted secrets when metadata is rewritten", () => - withSavedEnvironments( - Effect.gen(function* () { - const savedEnvironments = yield* DesktopSavedEnvironments.DesktopSavedEnvironments; - yield* savedEnvironments.setRegistry([savedRegistryRecord]); - yield* savedEnvironments.setSecret({ - environmentId: savedRegistryRecord.environmentId, - secret: "bearer-token", - }); - - yield* savedEnvironments.setRegistry([savedRegistryRecord]); - - assert.deepEqual(yield* savedEnvironments.getRegistry, [savedRegistryRecord]); - assert.deepEqual( - yield* savedEnvironments.getSecret(savedRegistryRecord.environmentId), - Option.some("bearer-token"), - ); - }), - ), - ); }); diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.ts index 79959bc3c733..f54b464a7dbd 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.ts @@ -1,13 +1,11 @@ import { EnvironmentId, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; import * as Context from "effect/Context"; -import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; @@ -65,37 +63,12 @@ const SavedEnvironmentRegistryDocumentJson = fromLenientJson( const decodeSavedEnvironmentRegistryDocumentJson = Schema.decodeEffect( SavedEnvironmentRegistryDocumentJson, ); -const encodeSavedEnvironmentRegistryDocumentJson = Schema.encodeEffect( - SavedEnvironmentRegistryDocumentJson, -); - -const DesktopSavedEnvironmentsWriteOperation = Schema.Literals([ - "create-temporary-file-name", - "encode-registry", - "create-directory", - "write-temporary-file", - "replace-registry-file", -]); const DesktopSavedEnvironmentSecretProtectionOperation = Schema.Literals([ "check-encryption-availability", - "encrypt-secret", "decrypt-secret", ]); -export class DesktopSavedEnvironmentsWriteError extends Schema.TaggedError()( - "DesktopSavedEnvironmentsWriteError", - { - operation: DesktopSavedEnvironmentsWriteOperation, - path: Schema.String, - cause: Schema.Defect(), - }, -) { - override get message(): string { - return `Desktop saved-environment write failed during ${this.operation} at ${this.path}.`; - } -} - export class DesktopSavedEnvironmentsReadError extends Schema.TaggedError()( "DesktopSavedEnvironmentsReadError", { @@ -152,19 +125,12 @@ export type DesktopSavedEnvironmentsReadRegistryError = | DesktopSavedEnvironmentsReadError | DesktopSavedEnvironmentsDocumentDecodeError; -export type DesktopSavedEnvironmentsMutationError = - | DesktopSavedEnvironmentsReadRegistryError - | DesktopSavedEnvironmentsWriteError; - export type DesktopSavedEnvironmentsGetSecretError = | DesktopSavedEnvironmentsReadRegistryError | DesktopSavedEnvironmentSecretDecodeError | DesktopSavedEnvironmentSecretProtectionError; -export type DesktopSavedEnvironmentsSetSecretError = - | DesktopSavedEnvironmentsMutationError - | DesktopSavedEnvironmentSecretProtectionError; - +/** Reads the previous registry for connection-catalog migration. */ export class DesktopSavedEnvironments extends Context.Service< DesktopSavedEnvironments, { @@ -172,22 +138,10 @@ export class DesktopSavedEnvironments extends Context.Service< readonly PersistedSavedEnvironmentRecord[], DesktopSavedEnvironmentsReadRegistryError >; - readonly setRegistry: ( - records: readonly PersistedSavedEnvironmentRecord[], - ) => Effect.Effect; - readonly removeEnvironment: ( - environmentId: string, - ) => Effect.Effect; + readonly getSecret: ( environmentId: string, ) => Effect.Effect, DesktopSavedEnvironmentsGetSecretError>; - readonly setSecret: (input: { - readonly environmentId: string; - readonly secret: string; - }) => Effect.Effect; - readonly removeSecret: ( - environmentId: string, - ) => Effect.Effect; } >()("@t3tools/desktop/settings/DesktopSavedEnvironments") {} @@ -209,28 +163,6 @@ function toPersistedSavedEnvironmentRecord( }; } -function toSavedEnvironmentStorageRecord( - record: PersistedSavedEnvironmentRecord | PersistedSavedEnvironmentStorageRecord, - encryptedBearerToken: Option.Option, -): PersistedSavedEnvironmentStorageRecord { - const nextRecord = { - environmentId: record.environmentId, - label: record.label, - httpBaseUrl: record.httpBaseUrl, - wsBaseUrl: record.wsBaseUrl, - createdAt: record.createdAt, - lastConnectedAt: record.lastConnectedAt, - }; - const metadata = { - ...(record.desktopSsh ? { desktopSsh: record.desktopSsh } : {}), - ...(record.relayManaged ? { relayManaged: record.relayManaged } : {}), - }; - return Option.match(encryptedBearerToken, { - onNone: () => ({ ...nextRecord, ...metadata }), - onSome: (value) => ({ ...nextRecord, ...metadata, encryptedBearerToken: value }), - }); -} - function normalizeSavedEnvironmentRegistryDocument( document: SavedEnvironmentRegistryStorageDocument, ): SavedEnvironmentRegistryDocument { @@ -272,80 +204,6 @@ function readRegistryDocument( ); } -const writeRegistryDocument = Effect.fn("desktop.savedEnvironments.writeRegistryDocument")( - function* (input: { - readonly fileSystem: FileSystem.FileSystem; - readonly path: Path.Path; - readonly registryPath: string; - readonly document: SavedEnvironmentRegistryDocument; - readonly suffix: string; - }): Effect.fn.Return { - const directory = input.path.dirname(input.registryPath); - const tempPath = `${input.registryPath}.${process.pid}.${input.suffix}.tmp`; - const encoded = yield* encodeSavedEnvironmentRegistryDocumentJson(input.document).pipe( - Effect.mapError( - (cause) => - new DesktopSavedEnvironmentsWriteError({ - operation: "encode-registry", - path: input.registryPath, - cause, - }), - ), - ); - yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe( - Effect.mapError( - (cause) => - new DesktopSavedEnvironmentsWriteError({ - operation: "create-directory", - path: directory, - cause, - }), - ), - ); - yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe( - Effect.mapError( - (cause) => - new DesktopSavedEnvironmentsWriteError({ - operation: "write-temporary-file", - path: tempPath, - cause, - }), - ), - ); - yield* input.fileSystem.rename(tempPath, input.registryPath).pipe( - Effect.mapError( - (cause) => - new DesktopSavedEnvironmentsWriteError({ - operation: "replace-registry-file", - path: input.registryPath, - cause, - }), - ), - ); - }, -); - -function preserveExistingSecrets( - currentDocument: SavedEnvironmentRegistryDocument, - records: readonly PersistedSavedEnvironmentRecord[], -): SavedEnvironmentRegistryDocument { - const encryptedBearerTokenById = new Map( - currentDocument.records.flatMap((record) => - record.encryptedBearerToken - ? [[record.environmentId, record.encryptedBearerToken] as const] - : [], - ), - ); - - return { - version: currentDocument.version, - records: records.map((record) => { - const encryptedBearerToken = encryptedBearerTokenById.get(record.environmentId); - return toSavedEnvironmentStorageRecord(record, Option.fromNullishOr(encryptedBearerToken)); - }), - }; -} - function decodeSecretBytes( environmentId: string, registryPath: string, @@ -368,31 +226,8 @@ function decodeSecretBytes( export const make = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; - const crypto = yield* Crypto.Crypto; - const writeDocument = (document: SavedEnvironmentRegistryDocument) => - crypto.randomUUIDv4.pipe( - Effect.map((uuid) => uuid.replace(/-/g, "")), - Effect.mapError( - (cause) => - new DesktopSavedEnvironmentsWriteError({ - operation: "create-temporary-file-name", - path: environment.savedEnvironmentRegistryPath, - cause, - }), - ), - Effect.flatMap((suffix) => - writeRegistryDocument({ - fileSystem, - path, - registryPath: environment.savedEnvironmentRegistryPath, - document, - suffix, - }), - ), - ); + const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; return DesktopSavedEnvironments.of({ getRegistry: readRegistryDocument(fileSystem, environment.savedEnvironmentRegistryPath).pipe( @@ -401,30 +236,7 @@ export const make = Effect.gen(function* () { ), Effect.withSpan("desktop.savedEnvironments.getRegistry"), ), - setRegistry: Effect.fn("desktop.savedEnvironments.setRegistry")(function* (records) { - const currentDocument = yield* readRegistryDocument( - fileSystem, - environment.savedEnvironmentRegistryPath, - ); - yield* writeDocument(preserveExistingSecrets(currentDocument, records)); - }), - removeEnvironment: Effect.fn("desktop.savedEnvironments.removeEnvironment")( - function* (environmentId) { - yield* Effect.annotateCurrentSpan({ environmentId }); - const document = yield* readRegistryDocument( - fileSystem, - environment.savedEnvironmentRegistryPath, - ); - if (!document.records.some((record) => record.environmentId === environmentId)) { - return; - } - yield* writeDocument({ - version: document.version, - records: document.records.filter((record) => record.environmentId !== environmentId), - }); - }, - ), getSecret: Effect.fn("desktop.savedEnvironments.getSecret")(function* (environmentId) { yield* Effect.annotateCurrentSpan({ environmentId }); const document = yield* readRegistryDocument( @@ -472,85 +284,6 @@ export const make = Effect.gen(function* () { ), ); }), - setSecret: Effect.fn("desktop.savedEnvironments.setSecret")(function* (input) { - const { environmentId, secret } = input; - yield* Effect.annotateCurrentSpan({ environmentId }); - const document = yield* readRegistryDocument( - fileSystem, - environment.savedEnvironmentRegistryPath, - ); - - const encryptionAvailable = yield* safeStorage.isEncryptionAvailable.pipe( - Effect.mapError( - (cause) => - new DesktopSavedEnvironmentSecretProtectionError({ - operation: "check-encryption-availability", - environmentId, - registryPath: environment.savedEnvironmentRegistryPath, - cause, - }), - ), - ); - if (!encryptionAvailable) { - return false; - } - - const encryptedBearerToken = Encoding.encodeBase64( - yield* safeStorage.encryptString(secret).pipe( - Effect.mapError( - (cause) => - new DesktopSavedEnvironmentSecretProtectionError({ - operation: "encrypt-secret", - environmentId, - registryPath: environment.savedEnvironmentRegistryPath, - cause, - }), - ), - ), - ); - let found = false; - const nextDocument: SavedEnvironmentRegistryDocument = { - version: document.version, - records: document.records.map((record) => { - if (record.environmentId !== environmentId) { - return record; - } - - found = true; - return toSavedEnvironmentStorageRecord(record, Option.some(encryptedBearerToken)); - }), - }; - - if (found) { - yield* writeDocument(nextDocument); - } - return found; - }), - removeSecret: Effect.fn("desktop.savedEnvironments.removeSecret")(function* (environmentId) { - yield* Effect.annotateCurrentSpan({ environmentId }); - const document = yield* readRegistryDocument( - fileSystem, - environment.savedEnvironmentRegistryPath, - ); - if ( - !document.records.some( - (record) => - record.environmentId === environmentId && record.encryptedBearerToken !== undefined, - ) - ) { - return; - } - - yield* writeDocument({ - version: document.version, - records: document.records.map((record) => { - if (record.environmentId !== environmentId) { - return record; - } - return toPersistedSavedEnvironmentRecord(record); - }), - }); - }), }); }); diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts index 5a76402b1d34..6a369a4598a2 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.test.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.test.ts @@ -397,16 +397,6 @@ describe("DesktopShellEnvironment", () => { }), ); - it("resolves dbus runtime dir candidates with existence checks", () => { - const busPath = DesktopShellEnvironment.resolveDefaultLinuxDbusSessionBusAddress({ - env: { XDG_RUNTIME_DIR: "/tmp/stale-runtime" }, - uid: 1000, - exists: (path) => path === "/run/user/1000/bus", - }); - - assert.equal(busPath, "unix:path=/run/user/1000/bus"); - }); - it.effect("logs command failures with safe probe context and the exact cause", () => { const env: NodeJS.ProcessEnv = { SHELL: "/bin/bash", diff --git a/apps/desktop/src/shell/DesktopShellEnvironment.ts b/apps/desktop/src/shell/DesktopShellEnvironment.ts index 5c6b67fd58c0..704b061db363 100644 --- a/apps/desktop/src/shell/DesktopShellEnvironment.ts +++ b/apps/desktop/src/shell/DesktopShellEnvironment.ts @@ -122,30 +122,6 @@ const linuxRuntimeDirCandidates = ( return candidates.filter((candidate) => candidate.length > 0); }; -function resolveDefaultLinuxDbusSessionBusPath(input: { - readonly env: NodeJS.ProcessEnv; - readonly uid: number | undefined; - readonly exists?: (path: string) => boolean; -}): string | null { - for (const runtimeDir of linuxRuntimeDirCandidates(input.env, input.uid)) { - const busPath = `${runtimeDir}/bus`; - if (input.exists === undefined || input.exists(busPath)) { - return busPath; - } - } - - return null; -} - -export function resolveDefaultLinuxDbusSessionBusAddress(input: { - readonly env: NodeJS.ProcessEnv; - readonly exists: (path: string) => boolean; - readonly uid: number | undefined; -}): string | null { - const busPath = resolveDefaultLinuxDbusSessionBusPath(input); - return busPath !== null && input.exists(busPath) ? `unix:path=${busPath}` : null; -} - const pathComparisonKey = (entry: string, platform: NodeJS.Platform) => { const normalized = entry.trim().replace(/^"+|"+$/g, ""); return platform === "win32" ? normalized.toLowerCase() : normalized; diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts index b0094f6867bc..b5e724084a21 100644 --- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts +++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts @@ -45,10 +45,6 @@ export type DesktopSshEnvironmentOperationError = export type DesktopSshEnvironmentDiscoverError = SshHostDiscoveryError; -export type DesktopSshEnvironmentError = - | DesktopSshEnvironmentDiscoverError - | DesktopSshEnvironmentOperationError; - export class DesktopSshEnvironment extends Context.Service< DesktopSshEnvironment, { diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts index 120199da6486..5c0bf0998f40 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts @@ -164,10 +164,6 @@ export type DesktopSshPasswordPromptResolveError = | DesktopSshPromptInvalidRequestIdError | DesktopSshPromptExpiredError; -export type DesktopSshPasswordPromptError = - | DesktopSshPasswordPromptRequestError - | DesktopSshPasswordPromptResolveError; - export const DesktopSshPasswordPromptCancellation = Schema.Union([ DesktopSshPromptCancelledError, DesktopSshPromptWindowClosedError, diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts index d705252ef588..cef90b29874b 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.test.ts @@ -39,7 +39,6 @@ function makeElectronAppLayer( onMetricsRead(); return metrics; }), - isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(true), setDesktopName: () => Effect.void, setDockIcon: () => Effect.void, @@ -172,7 +171,7 @@ describe("DesktopTelemetryPublisher", () => { const nextSnapshotFiber = yield* Stream.runHead(publisher.changes).pipe(Effect.forkChild); yield* Effect.yieldNow; - yield* publisher.handleControl({ + yield* publisher.handleControlForSource("primary-backend", { version: 1, type: "setDiagnosticsDemand", enabled: true, @@ -188,7 +187,7 @@ describe("DesktopTelemetryPublisher", () => { type: "setDiagnosticsDemand", enabled: true, }); - yield* publisher.handleControl({ + yield* publisher.handleControlForSource("primary-backend", { version: 1, type: "setDiagnosticsDemand", enabled: false, @@ -308,7 +307,7 @@ describe("DesktopTelemetryPublisher", () => { Effect.forkChild, ); yield* Effect.yieldNow; - yield* publisher.handleControl({ + yield* publisher.handleControlForSource("primary-backend", { version: 1, type: "setHostPowerIntervals", activeIntervalMs: 7_000, @@ -369,7 +368,7 @@ describe("DesktopTelemetryPublisher", () => { Effect.forkChild, ); yield* Effect.yieldNow; - yield* publisher.handleControl({ + yield* publisher.handleControlForSource("primary-backend", { version: 1, type: "setDiagnosticsDemand", enabled: true, diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts index 8fd478c821e7..18ea0a8380a3 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts @@ -63,7 +63,6 @@ export class DesktopTelemetryPublisher extends Context.Service< readonly latest: Effect.Effect>; readonly changes: Stream.Stream; readonly encoded: Stream.Stream; - readonly handleControl: (message: DesktopTelemetryControlMessage) => Effect.Effect; readonly handleControlForSource: ( sourceId: string, message: DesktopTelemetryControlMessage, @@ -365,8 +364,6 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { : Queue.offer(sampleTriggers, undefined).pipe(Effect.asVoid), ), ); - const handleControl: DesktopTelemetryPublisher["Service"]["handleControl"] = (message) => - handleControlForSource("legacy", message); const snapshots = Stream.unwrap( Effect.gen(function* () { @@ -415,7 +412,6 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { latest: Ref.get(latest), changes: Stream.fromPubSub(changes), encoded, - handleControl, handleControlForSource, removeControlSource, publishUpdateReport, diff --git a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts index 0f4cb970da2c..1198429a1700 100644 --- a/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopRemoteUpdates.test.ts @@ -57,7 +57,6 @@ function runRemoteUpdatesTest( latest: Effect.succeedNone, changes: Stream.empty, encoded: Stream.empty, - handleControl: () => Effect.void, handleControlForSource: () => Effect.void, removeControlSource: () => Effect.void, publishUpdateReport: (report) => diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 4b06f5ee510e..5f5cbaa1d7fc 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -41,7 +41,6 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { setAboutPanelOptions: () => Effect.void, setAppUserModelId: () => Effect.void, getAppMetrics: Effect.succeed([]), - isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(true), setDesktopName: () => Effect.void, setDockIcon: () => Effect.void, diff --git a/apps/mobile/clerk-theme.json b/apps/mobile/clerk-theme.json index 119927a04d60..f854f52a0d49 100644 --- a/apps/mobile/clerk-theme.json +++ b/apps/mobile/clerk-theme.json @@ -1,36 +1,36 @@ { "colors": { - "primary": "#262626", - "background": "#F2F2F7", - "input": "#FFFFFF", - "danger": "#DC2626", + "primary": "#1b4ed8", + "background": "#fcfcfc", + "input": "#ffffff", + "danger": "#c10007", "success": "#059669", - "warning": "#D97706", - "foreground": "#262626", - "mutedForeground": "#737373", - "primaryForeground": "#FFFFFF", - "inputForeground": "#262626", - "neutral": "#F5F5F5", - "border": "#E5E5EA", - "ring": "#A3A3A3", - "muted": "#F2F2F7", + "warning": "#bb4d00", + "foreground": "#27272a", + "mutedForeground": "#71717b", + "primaryForeground": "#ffffff", + "inputForeground": "#27272a", + "neutral": "#fafafa", + "border": "#e4e4e7", + "ring": "#1b4ed8", + "muted": "#fafafa", "shadow": "#000000" }, "darkColors": { - "primary": "#F5F5F5", - "background": "#0E0E0E", - "input": "#171717", - "danger": "#FCA5A5", - "success": "#34D399", - "warning": "#FBBF24", - "foreground": "#F5F5F5", - "mutedForeground": "#A3A3A3", - "primaryForeground": "#0A0A0A", - "inputForeground": "#F5F5F5", - "neutral": "#1C1C1C", - "border": "#2A2A2A", - "ring": "#525252", - "muted": "#0E0E0E", + "primary": "#346bf1", + "background": "#0a0a0a", + "input": "#111111", + "danger": "#ff6467", + "success": "#34d399", + "warning": "#ffb900", + "foreground": "#f5f5f5", + "mutedForeground": "#818181", + "primaryForeground": "#ffffff", + "inputForeground": "#f5f5f5", + "neutral": "#111111", + "border": "#191919", + "ring": "#346bf1", + "muted": "#111111", "shadow": "#000000" }, "design": { diff --git a/apps/mobile/generated-uniwind-default-theme-variables.json b/apps/mobile/generated-uniwind-default-theme-variables.json index 02430b86aae3..961a0df24cd4 100644 --- a/apps/mobile/generated-uniwind-default-theme-variables.json +++ b/apps/mobile/generated-uniwind-default-theme-variables.json @@ -1,156 +1,180 @@ { "light": { - "--color-screen": "#f2f2f7", - "--color-sheet": "rgba(242, 242, 247, 0.98)", - "--color-sheet-solid": "#f2f2f7", + "--color-screen": "#fcfcfc", + "--color-sheet": "rgba(252, 252, 252, 0.98)", + "--color-sheet-solid": "#fcfcfc", "--color-card": "#ffffff", - "--color-card-alt": "#f5f5f5", + "--color-grouped-card": "#f4f4f5", + "--color-card-alt": "#fcfcfc", "--color-card-translucent": "rgba(255, 255, 255, 0.8)", - "--color-thread-canvas": "#f5f5f5", + "--color-thread-canvas": "#fcfcfc", "--color-thread-selected": "#ffffff", - "--color-thread-selected-foreground": "#262626", - "--color-thread-selected-foreground-muted": "#737373", - "--color-composer-panel": "rgba(245, 245, 245, 0.88)", - "--color-composer-surface": "rgba(255, 255, 255, 0.94)", - "--color-composer-border": "rgba(0, 0, 0, 0.1)", - "--color-foreground": "#262626", - "--color-foreground-secondary": "#525252", - "--color-foreground-muted": "#737373", - "--color-foreground-tertiary": "#8e8e93", - "--color-border": "rgba(0, 0, 0, 0.08)", - "--color-border-subtle": "rgba(0, 0, 0, 0.06)", - "--color-separator": "rgba(0, 0, 0, 0.04)", - "--color-subtle": "rgba(0, 0, 0, 0.04)", - "--color-subtle-strong": "rgba(0, 0, 0, 0.08)", - "--color-inline-skill-background": "rgba(217, 70, 239, 0.12)", - "--color-inline-skill-border": "rgba(217, 70, 239, 0.25)", - "--color-inline-skill-foreground": "#a21caf", - "--color-primary": "#262626", + "--color-thread-selected-foreground": "#27272a", + "--color-thread-selected-foreground-muted": "#71717b", + "--color-thread-hover": "#fcfcfc", + "--color-row-hover": "#f4f4f5", + "--color-composer-panel": "rgba(252, 252, 252, 0.88)", + "--color-composer-surface": "rgba(244, 244, 245, 0.94)", + "--color-composer-border": "rgba(228, 228, 231, 0.8)", + "--color-foreground": "#27272a", + "--color-foreground-secondary": "#6f6f79", + "--color-foreground-muted": "#6f6f79", + "--color-foreground-tertiary": "#71717b", + "--color-border": "#e4e4e7", + "--color-focus": "#1b4ed8", + "--color-border-subtle": "rgba(228, 228, 231, 0.7)", + "--color-separator": "rgba(228, 228, 231, 0.55)", + "--color-subtle": "#fafafa", + "--color-subtle-strong": "#fafafa", + "--color-inline-skill-background": "#f4f4f5", + "--color-inline-skill-border": "rgba(27, 78, 216, 0.42)", + "--color-inline-skill-foreground": "#18181b", + "--color-primary": "#1b4ed8", "--color-primary-foreground": "#ffffff", + "--color-primary-text": "#1b4ed8", "--color-primary-shadow": "#000000", - "--color-secondary": "#ffffff", - "--color-secondary-foreground": "#262626", - "--color-secondary-border": "rgba(0, 0, 0, 0.08)", - "--color-switch-active-track": "#34c759", + "--color-secondary": "#fafafa", + "--color-secondary-foreground": "#27272a", + "--color-secondary-border": "#e4e4e7", + "--color-switch-active-track": "#1b4ed8", "--color-switch-active-thumb": "#ffffff", - "--color-switch-inactive-track": "rgba(0, 0, 0, 0.08)", - "--color-switch-inactive-thumb": "#8e8e93", - "--color-warning": "#fffbeb", - "--color-warning-border": "#fde68a", - "--color-warning-foreground": "#b45309", - "--color-danger": "#fef2f2", - "--color-danger-border": "rgba(239, 68, 68, 0.12)", - "--color-danger-foreground": "#dc2626", + "--color-switch-inactive-track": "#fafafa", + "--color-switch-inactive-thumb": "#71717b", + "--color-warning": "#fcf4e8", + "--color-warning-border": "rgba(254, 154, 0, 0.32)", + "--color-warning-foreground": "#bb4d00", + "--color-danger": "#fcebec", + "--color-danger-border": "rgba(251, 44, 54, 0.32)", + "--color-danger-foreground": "#c10007", + "--color-update": "#e0e6f7", + "--color-update-foreground": "#1b4ed8", "--color-input": "#ffffff", - "--color-input-border": "rgba(0, 0, 0, 0.1)", - "--color-sidebar-search": "rgba(118, 118, 128, 0.12)", - "--color-placeholder": "#737373", - "--color-icon": "#262626", - "--color-icon-muted": "#525252", - "--color-icon-subtle": "#a3a3a3", - "--color-header": "rgba(255, 255, 255, 0.97)", - "--color-header-border": "rgba(0, 0, 0, 0.06)", - "--color-glass-surface": "rgba(255, 255, 255, 0.72)", - "--color-glass-tint": "rgba(255, 255, 255, 0.18)", - "--color-status-bar": "#f2f2f7", - "--color-md-body": "#111111", - "--color-md-strong": "#000000", - "--color-md-link": "#2563eb", - "--color-md-blockquote-border": "rgba(0, 0, 0, 0.08)", - "--color-md-blockquote-bg": "rgba(0, 0, 0, 0.02)", - "--color-md-code-bg": "rgba(0, 0, 0, 0.04)", - "--color-md-code-text": "#262626", - "--color-md-user-code-bg": "rgba(0, 0, 0, 0.04)", - "--color-md-user-code-text": "#262626", - "--color-md-user-fence-bg": "rgba(0, 0, 0, 0.06)", - "--color-md-user-fence-text": "#262626", - "--color-md-hr": "rgba(0, 0, 0, 0.08)", - "--color-user-bubble": "#ffffff", - "--color-user-bubble-foreground": "#262626", - "--color-user-bubble-foreground-muted": "rgba(38, 38, 38, 0.78)", - "--color-user-bubble-skill-foreground": "#2563eb", + "--color-input-border": "#d4d4d8", + "--color-sidebar-search": "#f4f4f5", + "--color-placeholder": "#6f6f79", + "--color-icon": "#27272a", + "--color-icon-muted": "#71717b", + "--color-icon-subtle": "#71717b", + "--color-header": "rgba(252, 252, 252, 0.97)", + "--color-header-foreground": "#27272a", + "--color-header-border": "#e4e4e7", + "--color-glass-surface": "rgba(255, 255, 255, 0.74)", + "--color-glass-fallback": "rgba(244, 244, 245, 0.94)", + "--color-glass-tint": "rgba(255, 255, 255, 0.22)", + "--color-status-bar": "#fcfcfc", + "--color-md-body": "#27272a", + "--color-md-strong": "#27272a", + "--color-md-link": "#1b4ed8", + "--color-md-blockquote-border": "#e4e4e7", + "--color-md-blockquote-bg": "#fafafa", + "--color-md-code-bg": "#ffffff", + "--color-md-code-text": "#27272a", + "--color-md-user-code-bg": "rgba(39, 39, 42, 0.18)", + "--color-md-user-code-text": "#27272a", + "--color-md-user-fence-bg": "rgba(0, 0, 0, 0.16)", + "--color-md-user-fence-text": "#27272a", + "--color-md-hr": "#e4e4e7", + "--color-user-bubble": "#efeff1", + "--color-user-bubble-foreground": "#27272a", + "--color-user-bubble-foreground-muted": "rgba(39, 39, 42, 0.78)", + "--color-user-bubble-skill-foreground": "#1b4ed8", "--color-backdrop": "rgba(0, 0, 0, 0.22)", - "--color-drawer": "rgba(255, 255, 255, 0.99)", + "--color-drawer": "#fafafa", + "--color-drawer-foreground": "#27272a", + "--color-drawer-foreground-muted": "#71717b", + "--color-drawer-border": "#e4e4e7", "--color-drawer-shadow": "rgba(0, 0, 0, 0.12)", - "--color-dot-separator": "rgba(0, 0, 0, 0.2)", - "--color-wordmark": "#262626", - "--color-chevron": "rgba(0, 0, 0, 0.2)" + "--color-dot-separator": "rgba(113, 113, 123, 0.35)", + "--color-wordmark": "#27272a", + "--color-chevron": "rgba(113, 113, 123, 0.42)" }, "dark": { "--color-screen": "#0a0a0a", - "--color-sheet": "rgba(14, 14, 14, 0.98)", - "--color-sheet-solid": "#0e0e0e", - "--color-card": "#171717", - "--color-card-alt": "#1c1c1c", + "--color-sheet": "rgba(10, 10, 10, 0.98)", + "--color-sheet-solid": "#0a0a0a", + "--color-card": "#111111", + "--color-grouped-card": "#1a1b1b", + "--color-card-alt": "#111111", "--color-card-translucent": "rgba(17, 17, 17, 0.8)", - "--color-thread-canvas": "#1c1c1c", - "--color-thread-selected": "#171717", - "--color-thread-selected-foreground": "#f5f5f5", - "--color-thread-selected-foreground-muted": "#8e8e93", - "--color-composer-panel": "rgba(28, 28, 28, 0.92)", - "--color-composer-surface": "rgba(23, 23, 23, 0.9)", - "--color-composer-border": "rgba(255, 255, 255, 0.08)", + "--color-thread-canvas": "#0a0a0a", + "--color-thread-selected": "#1a1b1b", + "--color-thread-selected-foreground": "#f1f3f7", + "--color-thread-selected-foreground-muted": "#a3a3a3", + "--color-thread-hover": "#131313", + "--color-row-hover": "#141414", + "--color-composer-panel": "rgba(10, 10, 10, 0.92)", + "--color-composer-surface": "rgba(26, 27, 27, 0.9)", + "--color-composer-border": "rgba(25, 25, 25, 0.8)", "--color-foreground": "#f5f5f5", - "--color-foreground-secondary": "#a3a3a3", - "--color-foreground-muted": "#8e8e93", - "--color-foreground-tertiary": "#636366", - "--color-border": "rgba(255, 255, 255, 0.06)", - "--color-border-subtle": "rgba(255, 255, 255, 0.04)", - "--color-separator": "rgba(255, 255, 255, 0.03)", - "--color-subtle": "rgba(255, 255, 255, 0.04)", - "--color-subtle-strong": "rgba(255, 255, 255, 0.08)", - "--color-inline-skill-background": "rgba(217, 70, 239, 0.12)", - "--color-inline-skill-border": "rgba(217, 70, 239, 0.25)", - "--color-inline-skill-foreground": "#f0abfc", - "--color-primary": "#f5f5f5", - "--color-primary-foreground": "#0a0a0a", + "--color-foreground-secondary": "#838383", + "--color-foreground-muted": "#838383", + "--color-foreground-tertiary": "#818181", + "--color-border": "#191919", + "--color-focus": "#346bf1", + "--color-border-subtle": "rgba(25, 25, 25, 0.7)", + "--color-separator": "rgba(25, 25, 25, 0.55)", + "--color-subtle": "#111111", + "--color-subtle-strong": "#111111", + "--color-inline-skill-background": "#141414", + "--color-inline-skill-border": "rgba(52, 107, 241, 0.42)", + "--color-inline-skill-foreground": "#f5f5f5", + "--color-primary": "#346bf1", + "--color-primary-foreground": "#ffffff", + "--color-primary-text": "#4b7cf3", "--color-primary-shadow": "#000000", - "--color-secondary": "rgba(255, 255, 255, 0.04)", + "--color-secondary": "#111111", "--color-secondary-foreground": "#f5f5f5", - "--color-secondary-border": "rgba(255, 255, 255, 0.06)", - "--color-switch-active-track": "#30d158", + "--color-secondary-border": "#191919", + "--color-switch-active-track": "#346bf1", "--color-switch-active-thumb": "#ffffff", - "--color-switch-inactive-track": "rgba(255, 255, 255, 0.06)", - "--color-switch-inactive-thumb": "#8e8e93", - "--color-warning": "rgba(69, 26, 3, 0.4)", - "--color-warning-border": "rgba(120, 53, 15, 0.6)", - "--color-warning-foreground": "#fcd34d", - "--color-danger": "rgba(239, 68, 68, 0.14)", - "--color-danger-border": "rgba(248, 113, 113, 0.18)", - "--color-danger-foreground": "#fca5a5", - "--color-input": "#141414", - "--color-input-border": "rgba(255, 255, 255, 0.08)", - "--color-sidebar-search": "rgba(118, 118, 128, 0.24)", - "--color-placeholder": "#8e8e93", + "--color-switch-inactive-track": "#111111", + "--color-switch-inactive-thumb": "#818181", + "--color-warning": "#312108", + "--color-warning-border": "rgba(254, 154, 0, 0.32)", + "--color-warning-foreground": "#ffb900", + "--color-danger": "#301214", + "--color-danger-border": "rgba(251, 65, 74, 0.32)", + "--color-danger-foreground": "#ff6467", + "--color-update": "#121b34", + "--color-update-foreground": "#51a2ff", + "--color-input": "#111111", + "--color-input-border": "#1e1e1e", + "--color-sidebar-search": "#0a0a0a", + "--color-placeholder": "#838383", "--color-icon": "#f5f5f5", - "--color-icon-muted": "#a3a3a3", - "--color-icon-subtle": "#8e8e93", + "--color-icon-muted": "#818181", + "--color-icon-subtle": "#818181", "--color-header": "rgba(10, 10, 10, 0.97)", - "--color-header-border": "rgba(255, 255, 255, 0.06)", - "--color-glass-surface": "rgba(23, 23, 23, 0.78)", - "--color-glass-tint": "rgba(23, 23, 23, 0.24)", + "--color-header-foreground": "#f5f5f5", + "--color-header-border": "#191919", + "--color-glass-surface": "rgba(17, 17, 17, 0.74)", + "--color-glass-fallback": "rgba(26, 27, 27, 0.9)", + "--color-glass-tint": "rgba(17, 17, 17, 0.22)", "--color-status-bar": "#0a0a0a", - "--color-md-body": "#e5e5e5", + "--color-md-body": "#f5f5f5", "--color-md-strong": "#f5f5f5", - "--color-md-link": "#60a5fa", - "--color-md-blockquote-border": "rgba(255, 255, 255, 0.1)", - "--color-md-blockquote-bg": "rgba(255, 255, 255, 0.03)", - "--color-md-code-bg": "rgba(255, 255, 255, 0.06)", - "--color-md-code-text": "#e5e5e5", - "--color-md-user-code-bg": "rgba(255, 255, 255, 0.06)", - "--color-md-user-code-text": "#e5e5e5", - "--color-md-user-fence-bg": "rgba(255, 255, 255, 0.09)", - "--color-md-user-fence-text": "#e5e5e5", - "--color-md-hr": "rgba(255, 255, 255, 0.08)", - "--color-user-bubble": "#171717", + "--color-md-link": "#3b70f1", + "--color-md-blockquote-border": "#191919", + "--color-md-blockquote-bg": "#111111", + "--color-md-code-bg": "#111111", + "--color-md-code-text": "#f5f5f5", + "--color-md-user-code-bg": "rgba(245, 245, 245, 0.18)", + "--color-md-user-code-text": "#f5f5f5", + "--color-md-user-fence-bg": "rgba(0, 0, 0, 0.28)", + "--color-md-user-fence-text": "#f5f5f5", + "--color-md-hr": "#191919", + "--color-user-bubble": "#161616", "--color-user-bubble-foreground": "#f5f5f5", "--color-user-bubble-foreground-muted": "rgba(245, 245, 245, 0.78)", - "--color-user-bubble-skill-foreground": "#60a5fa", + "--color-user-bubble-skill-foreground": "#4678f2", "--color-backdrop": "rgba(0, 0, 0, 0.48)", - "--color-drawer": "rgba(14, 14, 14, 0.99)", + "--color-drawer": "#000000", + "--color-drawer-foreground": "#f1f3f7", + "--color-drawer-foreground-muted": "#a3a3a3", + "--color-drawer-border": "#141414", "--color-drawer-shadow": "rgba(0, 0, 0, 0.32)", - "--color-dot-separator": "rgba(255, 255, 255, 0.2)", + "--color-dot-separator": "rgba(129, 129, 129, 0.35)", "--color-wordmark": "#f5f5f5", - "--color-chevron": "rgba(255, 255, 255, 0.2)" + "--color-chevron": "rgba(129, 129, 129, 0.42)" } } diff --git a/apps/mobile/generated-uniwind-themes.css b/apps/mobile/generated-uniwind-themes.css index 84c445cf1cb1..2a1ce3629306 100644 --- a/apps/mobile/generated-uniwind-themes.css +++ b/apps/mobile/generated-uniwind-themes.css @@ -2,6 +2,93 @@ @layer theme { :root { @variant light { + --color-screen: #fcfcfc; + --color-sheet: rgba(252, 252, 252, 0.98); + --color-sheet-solid: #fcfcfc; + --color-card: #ffffff; + --color-grouped-card: #f4f4f5; + --color-card-alt: #fcfcfc; + --color-card-translucent: rgba(255, 255, 255, 0.8); + --color-thread-canvas: #fcfcfc; + --color-thread-selected: #ffffff; + --color-thread-selected-foreground: #27272a; + --color-thread-selected-foreground-muted: #71717b; + --color-thread-hover: #fcfcfc; + --color-row-hover: #f4f4f5; + --color-composer-panel: rgba(252, 252, 252, 0.88); + --color-composer-surface: rgba(244, 244, 245, 0.94); + --color-composer-border: rgba(228, 228, 231, 0.8); + --color-foreground: #27272a; + --color-foreground-secondary: #6f6f79; + --color-foreground-muted: #6f6f79; + --color-foreground-tertiary: #71717b; + --color-border: #e4e4e7; + --color-focus: #1b4ed8; + --color-border-subtle: rgba(228, 228, 231, 0.7); + --color-separator: rgba(228, 228, 231, 0.55); + --color-subtle: #fafafa; + --color-subtle-strong: #fafafa; + --color-inline-skill-background: #f4f4f5; + --color-inline-skill-border: rgba(27, 78, 216, 0.42); + --color-inline-skill-foreground: #18181b; + --color-primary: #1b4ed8; + --color-primary-foreground: #ffffff; + --color-primary-text: #1b4ed8; + --color-primary-shadow: #000000; + --color-secondary: #fafafa; + --color-secondary-foreground: #27272a; + --color-secondary-border: #e4e4e7; + --color-switch-active-track: #1b4ed8; + --color-switch-active-thumb: #ffffff; + --color-switch-inactive-track: #fafafa; + --color-switch-inactive-thumb: #71717b; + --color-warning: #fcf4e8; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #bb4d00; + --color-danger: #fcebec; + --color-danger-border: rgba(251, 44, 54, 0.32); + --color-danger-foreground: #c10007; + --color-update: #e0e6f7; + --color-update-foreground: #1b4ed8; + --color-input: #ffffff; + --color-input-border: #d4d4d8; + --color-sidebar-search: #f4f4f5; + --color-placeholder: #6f6f79; + --color-icon: #27272a; + --color-icon-muted: #71717b; + --color-icon-subtle: #71717b; + --color-header: rgba(252, 252, 252, 0.97); + --color-header-foreground: #27272a; + --color-header-border: #e4e4e7; + --color-glass-surface: rgba(255, 255, 255, 0.74); + --color-glass-fallback: rgba(244, 244, 245, 0.94); + --color-glass-tint: rgba(255, 255, 255, 0.22); + --color-status-bar: #fcfcfc; + --color-md-body: #27272a; + --color-md-strong: #27272a; + --color-md-link: #1b4ed8; + --color-md-blockquote-border: #e4e4e7; + --color-md-blockquote-bg: #fafafa; + --color-md-code-bg: #ffffff; + --color-md-code-text: #27272a; + --color-md-user-code-bg: rgba(39, 39, 42, 0.18); + --color-md-user-code-text: #27272a; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.16); + --color-md-user-fence-text: #27272a; + --color-md-hr: #e4e4e7; + --color-user-bubble: #efeff1; + --color-user-bubble-foreground: #27272a; + --color-user-bubble-foreground-muted: rgba(39, 39, 42, 0.78); + --color-user-bubble-skill-foreground: #1b4ed8; + --color-backdrop: rgba(0, 0, 0, 0.22); + --color-drawer: #fafafa; + --color-drawer-foreground: #27272a; + --color-drawer-foreground-muted: #71717b; + --color-drawer-border: #e4e4e7; + --color-drawer-shadow: rgba(0, 0, 0, 0.12); + --color-dot-separator: rgba(113, 113, 123, 0.35); + --color-wordmark: #27272a; + --color-chevron: rgba(113, 113, 123, 0.42); --color-adaptive-amber-50-950-a40: oklch(98.7% 0.022 95.277); --color-adaptive-amber-200-900-a60: oklch(92.4% 0.12 95.746); --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 12%); @@ -59,14 +146,101 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); - --color-clerk-page: #f2f2f7; - --color-clerk-foreground: #262626; - --color-clerk-foreground-muted: #737373; - --color-clerk-border: rgba(229, 229, 234, 0.06); - --color-clerk-danger: #dc2626; + --color-clerk-page: #fcfcfc; + --color-clerk-foreground: #27272a; + --color-clerk-foreground-muted: #71717b; + --color-clerk-border: #e4e4e7; + --color-clerk-danger: #c10007; } @variant dark { + --color-screen: #0a0a0a; + --color-sheet: rgba(10, 10, 10, 0.98); + --color-sheet-solid: #0a0a0a; + --color-card: #111111; + --color-grouped-card: #1a1b1b; + --color-card-alt: #111111; + --color-card-translucent: rgba(17, 17, 17, 0.8); + --color-thread-canvas: #0a0a0a; + --color-thread-selected: #1a1b1b; + --color-thread-selected-foreground: #f1f3f7; + --color-thread-selected-foreground-muted: #a3a3a3; + --color-thread-hover: #131313; + --color-row-hover: #141414; + --color-composer-panel: rgba(10, 10, 10, 0.92); + --color-composer-surface: rgba(26, 27, 27, 0.9); + --color-composer-border: rgba(25, 25, 25, 0.8); + --color-foreground: #f5f5f5; + --color-foreground-secondary: #838383; + --color-foreground-muted: #838383; + --color-foreground-tertiary: #818181; + --color-border: #191919; + --color-focus: #346bf1; + --color-border-subtle: rgba(25, 25, 25, 0.7); + --color-separator: rgba(25, 25, 25, 0.55); + --color-subtle: #111111; + --color-subtle-strong: #111111; + --color-inline-skill-background: #141414; + --color-inline-skill-border: rgba(52, 107, 241, 0.42); + --color-inline-skill-foreground: #f5f5f5; + --color-primary: #346bf1; + --color-primary-foreground: #ffffff; + --color-primary-text: #4b7cf3; + --color-primary-shadow: #000000; + --color-secondary: #111111; + --color-secondary-foreground: #f5f5f5; + --color-secondary-border: #191919; + --color-switch-active-track: #346bf1; + --color-switch-active-thumb: #ffffff; + --color-switch-inactive-track: #111111; + --color-switch-inactive-thumb: #818181; + --color-warning: #312108; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #ffb900; + --color-danger: #301214; + --color-danger-border: rgba(251, 65, 74, 0.32); + --color-danger-foreground: #ff6467; + --color-update: #121b34; + --color-update-foreground: #51a2ff; + --color-input: #111111; + --color-input-border: #1e1e1e; + --color-sidebar-search: #0a0a0a; + --color-placeholder: #838383; + --color-icon: #f5f5f5; + --color-icon-muted: #818181; + --color-icon-subtle: #818181; + --color-header: rgba(10, 10, 10, 0.97); + --color-header-foreground: #f5f5f5; + --color-header-border: #191919; + --color-glass-surface: rgba(17, 17, 17, 0.74); + --color-glass-fallback: rgba(26, 27, 27, 0.9); + --color-glass-tint: rgba(17, 17, 17, 0.22); + --color-status-bar: #0a0a0a; + --color-md-body: #f5f5f5; + --color-md-strong: #f5f5f5; + --color-md-link: #3b70f1; + --color-md-blockquote-border: #191919; + --color-md-blockquote-bg: #111111; + --color-md-code-bg: #111111; + --color-md-code-text: #f5f5f5; + --color-md-user-code-bg: rgba(245, 245, 245, 0.18); + --color-md-user-code-text: #f5f5f5; + --color-md-user-fence-bg: rgba(0, 0, 0, 0.28); + --color-md-user-fence-text: #f5f5f5; + --color-md-hr: #191919; + --color-user-bubble: #161616; + --color-user-bubble-foreground: #f5f5f5; + --color-user-bubble-foreground-muted: rgba(245, 245, 245, 0.78); + --color-user-bubble-skill-foreground: #4678f2; + --color-backdrop: rgba(0, 0, 0, 0.48); + --color-drawer: #000000; + --color-drawer-foreground: #f1f3f7; + --color-drawer-foreground-muted: #a3a3a3; + --color-drawer-border: #141414; + --color-drawer-shadow: rgba(0, 0, 0, 0.32); + --color-dot-separator: rgba(129, 129, 129, 0.35); + --color-wordmark: #f5f5f5; + --color-chevron: rgba(129, 129, 129, 0.42); --color-adaptive-amber-50-950-a40: oklch(27.9% 0.077 45.635 / 40%); --color-adaptive-amber-200-900-a60: oklch(41.4% 0.112 45.904 / 60%); --color-adaptive-amber-500-a12-a16: oklch(76.9% 0.188 70.08 / 16%); @@ -124,32 +298,36 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); - --color-clerk-page: #0e0e0e; + --color-clerk-page: #0a0a0a; --color-clerk-foreground: #f5f5f5; - --color-clerk-foreground-muted: #a3a3a3; - --color-clerk-border: rgba(42, 42, 42, 0.06); - --color-clerk-danger: #fca5a5; + --color-clerk-foreground-muted: #818181; + --color-clerk-border: #191919; + --color-clerk-danger: #ff6467; } @variant t3-chat-light { --color-screen: #fdf7fd; --color-sheet: rgba(253, 247, 253, 0.98); --color-sheet-solid: #fdf7fd; - --color-card: #fdfafd; - --color-card-alt: #faf3fb; - --color-card-translucent: rgba(253, 250, 253, 0.8); - --color-thread-canvas: #faf3fb; - --color-thread-selected: #fdfafd; - --color-thread-selected-foreground: #501854; + --color-card: #faf3fb; + --color-grouped-card: #faf3fb; + --color-card-alt: #fdfafd; + --color-card-translucent: rgba(250, 243, 251, 0.8); + --color-thread-canvas: #fdf7fd; + --color-thread-selected: #f8f8f7; + --color-thread-selected-foreground: #454554; --color-thread-selected-foreground-muted: #ac1668; - --color-composer-panel: rgba(250, 243, 251, 0.88); - --color-composer-surface: rgba(253, 250, 253, 0.94); + --color-thread-hover: #f8f8f7; + --color-row-hover: #eccfe3; + --color-composer-panel: rgba(253, 247, 253, 0.88); + --color-composer-surface: rgba(250, 243, 251, 0.94); --color-composer-border: rgba(238, 225, 237, 0.54); --color-foreground: #501854; --color-foreground-secondary: #ac1668; --color-foreground-muted: #8d1255; --color-foreground-tertiary: #ac1668; --color-border: #eee1ed; + --color-focus: #db2777; --color-border-subtle: rgba(238, 225, 237, 0.7); --color-separator: rgba(238, 225, 237, 0.55); --color-subtle: #eaa7cb; @@ -159,6 +337,7 @@ --color-inline-skill-foreground: #454554; --color-primary: #db2777; --color-primary-foreground: #ffffff; + --color-primary-text: #d32572; --color-primary-shadow: #000000; --color-secondary: #f1c4e6; --color-secondary-foreground: #77347c; @@ -173,7 +352,9 @@ --color-danger: #fde4f1; --color-danger-border: rgba(247, 8, 108, 0.32); --color-danger-foreground: #9d174d; - --color-input: #fdfafd; + --color-update: #fadfef; + --color-update-foreground: #ac1668; + --color-input: #faf3fb; --color-input-border: #e7c1dc; --color-sidebar-search: #f8f8f7; --color-placeholder: #8b5f90; @@ -181,13 +362,15 @@ --color-icon-muted: #ac1668; --color-icon-subtle: #ac1668; --color-header: rgba(253, 247, 253, 0.97); + --color-header-foreground: #501854; --color-header-border: #efbdeb; --color-glass-surface: rgba(255, 255, 255, 0.74); + --color-glass-fallback: rgba(250, 243, 251, 0.94); --color-glass-tint: rgba(255, 255, 255, 0.22); --color-status-bar: #fdf7fd; --color-md-body: #501854; --color-md-strong: #501854; - --color-md-link: #db2777; + --color-md-link: #d62675; --color-md-blockquote-border: #eee1ed; --color-md-blockquote-bg: #eaa7cb; --color-md-code-bg: #f5ecf9; @@ -202,7 +385,10 @@ --color-user-bubble-foreground-muted: rgba(73, 44, 97, 0.78); --color-user-bubble-skill-foreground: #c12269; --color-backdrop: rgba(0, 0, 0, 0.22); - --color-drawer: rgba(242, 225, 244, 0.99); + --color-drawer: #f2e1f4; + --color-drawer-foreground: #454554; + --color-drawer-foreground-muted: #ac1668; + --color-drawer-border: #eceae9; --color-drawer-shadow: rgba(0, 0, 0, 0.12); --color-dot-separator: rgba(172, 22, 104, 0.35); --color-wordmark: #501854; @@ -264,32 +450,36 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); - --color-clerk-page: #f2f2f7; - --color-clerk-foreground: #262626; - --color-clerk-foreground-muted: #737373; - --color-clerk-border: rgba(229, 229, 234, 0.06); - --color-clerk-danger: #dc2626; + --color-clerk-page: #fcfcfc; + --color-clerk-foreground: #27272a; + --color-clerk-foreground-muted: #71717b; + --color-clerk-border: #e4e4e7; + --color-clerk-danger: #c10007; } @variant t3-chat-dark { --color-screen: #1f1a24; --color-sheet: rgba(31, 26, 36, 0.98); --color-sheet-solid: #1f1a24; - --color-card: #2c2631; - --color-card-alt: #29232d; - --color-card-translucent: rgba(44, 38, 49, 0.8); - --color-thread-canvas: #29232d; - --color-thread-selected: #2c2631; - --color-thread-selected-foreground: #f9f8fb; + --color-card: #29232d; + --color-grouped-card: #29232d; + --color-card-alt: #2c2631; + --color-card-translucent: rgba(41, 35, 45, 0.8); + --color-thread-canvas: #1f1a24; + --color-thread-selected: #261922; + --color-thread-selected-foreground: #f4f4f5; --color-thread-selected-foreground-muted: #e7d0dd; - --color-composer-panel: rgba(41, 35, 45, 0.92); - --color-composer-surface: rgba(44, 38, 49, 0.9); + --color-thread-hover: #261922; + --color-row-hover: #463753; + --color-composer-panel: rgba(31, 26, 36, 0.92); + --color-composer-surface: rgba(41, 35, 45, 0.9); --color-composer-border: rgba(39, 36, 44, 0.46); --color-foreground: #f9f8fb; --color-foreground-secondary: #e7d0dd; --color-foreground-muted: #e7d0dd; --color-foreground-tertiary: #e7d0dd; --color-border: #27242c; + --color-focus: #db2777; --color-border-subtle: rgba(39, 36, 44, 0.7); --color-separator: rgba(39, 36, 44, 0.55); --color-subtle: #423a45; @@ -299,6 +489,7 @@ --color-inline-skill-foreground: #f8f1f5; --color-primary: #a3004c; --color-primary-foreground: #fbd0e8; + --color-primary-text: #cc729c; --color-primary-shadow: #000000; --color-secondary: #362d3d; --color-secondary-foreground: #d4c7e1; @@ -313,7 +504,9 @@ --color-danger: #331a2b; --color-danger-border: rgba(157, 23, 77, 0.32); --color-danger-foreground: #fbd0e8; - --color-input: #2c2631; + --color-update: #37152b; + --color-update-foreground: #fbd0e8; + --color-input: #29232d; --color-input-border: #302029; --color-sidebar-search: #261922; --color-placeholder: #968d9f; @@ -321,13 +514,15 @@ --color-icon-muted: #d4c7e1; --color-icon-subtle: #e7d0dd; --color-header: rgba(31, 26, 36, 0.97); + --color-header-foreground: #f9f8fb; --color-header-border: #27242c; --color-glass-surface: rgba(16, 10, 14, 0.74); + --color-glass-fallback: rgba(41, 35, 45, 0.9); --color-glass-tint: rgba(16, 10, 14, 0.22); --color-status-bar: #1f1a24; --color-md-body: #f9f8fb; --color-md-strong: #f9f8fb; - --color-md-link: #a3004c; + --color-md-link: #c66290; --color-md-blockquote-border: #27242c; --color-md-blockquote-bg: #423a45; --color-md-code-bg: #1f1a24; @@ -342,7 +537,10 @@ --color-user-bubble-foreground-muted: rgba(242, 235, 250, 0.78); --color-user-bubble-skill-foreground: #cb709a; --color-backdrop: rgba(0, 0, 0, 0.48); - --color-drawer: rgba(23, 16, 24, 0.99); + --color-drawer: #171018; + --color-drawer-foreground: #f4f4f5; + --color-drawer-foreground-muted: #e7d0dd; + --color-drawer-border: #322028; --color-drawer-shadow: rgba(0, 0, 0, 0.32); --color-dot-separator: rgba(231, 208, 221, 0.35); --color-wordmark: #f9f8fb; @@ -404,32 +602,36 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); - --color-clerk-page: #0e0e0e; + --color-clerk-page: #0a0a0a; --color-clerk-foreground: #f5f5f5; - --color-clerk-foreground-muted: #a3a3a3; - --color-clerk-border: rgba(42, 42, 42, 0.06); - --color-clerk-danger: #fca5a5; + --color-clerk-foreground-muted: #818181; + --color-clerk-border: #191919; + --color-clerk-danger: #ff6467; } @variant grove-light { --color-screen: #f3f7f4; --color-sheet: rgba(243, 247, 244, 0.98); --color-sheet-solid: #f3f7f4; - --color-card: #ecefed; - --color-card-alt: #f3f7f4; - --color-card-translucent: rgba(236, 239, 237, 0.8); + --color-card: #f3f7f4; + --color-grouped-card: #f3f7f4; + --color-card-alt: #ecefed; + --color-card-translucent: rgba(243, 247, 244, 0.8); --color-thread-canvas: #f3f7f4; - --color-thread-selected: #ecefed; + --color-thread-selected: #bad7c9; --color-thread-selected-foreground: #241523; - --color-thread-selected-foreground-muted: #746c73; + --color-thread-selected-foreground-muted: #5d585e; + --color-thread-hover: #cae0d5; + --color-row-hover: #d5e6dd; --color-composer-panel: rgba(243, 247, 244, 0.88); - --color-composer-surface: rgba(236, 239, 237, 0.94); + --color-composer-surface: rgba(243, 247, 244, 0.94); --color-composer-border: rgba(203, 213, 209, 0.54); --color-foreground: #241523; - --color-foreground-secondary: #746c73; + --color-foreground-secondary: #726a71; --color-foreground-muted: #6e696f; --color-foreground-tertiary: #746c73; --color-border: #cbd5d1; + --color-focus: #1b7d50; --color-border-subtle: rgba(203, 213, 209, 0.7); --color-separator: rgba(203, 213, 209, 0.55); --color-subtle: #e6f0ea; @@ -437,13 +639,14 @@ --color-inline-skill-background: #d5e6dd; --color-inline-skill-border: rgba(27, 125, 80, 0.42); --color-inline-skill-foreground: #241523; - --color-primary: #1b7d50; + --color-primary: #8f6410; --color-primary-foreground: #fffaff; + --color-primary-text: #8f6410; --color-primary-shadow: #000000; --color-secondary: #e2ede7; --color-secondary-foreground: #241523; --color-secondary-border: #cbd5d1; - --color-switch-active-track: #1b7d50; + --color-switch-active-track: #8f6410; --color-switch-active-thumb: #fffaff; --color-switch-inactive-track: #e2ede7; --color-switch-inactive-thumb: #6e696f; @@ -453,7 +656,9 @@ --color-danger: #f4e7e5; --color-danger-border: rgba(251, 44, 54, 0.32); --color-danger-foreground: #c10007; - --color-input: #ecefed; + --color-update: #d0e3da; + --color-update-foreground: #125134; + --color-input: #f3f7f4; --color-input-border: #becbc5; --color-sidebar-search: #d3dcd8; --color-placeholder: #716971; @@ -461,13 +666,15 @@ --color-icon-muted: #746c73; --color-icon-subtle: #746c73; --color-header: rgba(243, 247, 244, 0.97); + --color-header-foreground: #241523; --color-header-border: #d5e6dd; --color-glass-surface: rgba(231, 233, 232, 0.74); + --color-glass-fallback: rgba(243, 247, 244, 0.94); --color-glass-tint: rgba(231, 233, 232, 0.22); --color-status-bar: #f3f7f4; --color-md-body: #241523; --color-md-strong: #241523; - --color-md-link: #1b7d50; + --color-md-link: #8f6410; --color-md-blockquote-border: #cbd5d1; --color-md-blockquote-bg: #e6f0ea; --color-md-code-bg: #eef1ef; @@ -482,7 +689,10 @@ --color-user-bubble-foreground-muted: rgba(36, 21, 35, 0.78); --color-user-bubble-skill-foreground: #815a0e; --color-backdrop: rgba(0, 0, 0, 0.22); - --color-drawer: rgba(226, 237, 231, 0.99); + --color-drawer: #e2ede7; + --color-drawer-foreground: #241523; + --color-drawer-foreground-muted: #645f64; + --color-drawer-border: #cbd3d0; --color-drawer-shadow: rgba(0, 0, 0, 0.12); --color-dot-separator: rgba(116, 108, 115, 0.35); --color-wordmark: #241523; @@ -544,32 +754,36 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); - --color-clerk-page: #f2f2f7; - --color-clerk-foreground: #262626; - --color-clerk-foreground-muted: #737373; - --color-clerk-border: rgba(229, 229, 234, 0.06); - --color-clerk-danger: #dc2626; + --color-clerk-page: #fcfcfc; + --color-clerk-foreground: #27272a; + --color-clerk-foreground-muted: #71717b; + --color-clerk-border: #e4e4e7; + --color-clerk-danger: #c10007; } @variant grove-dark { --color-screen: #1b2821; --color-sheet: rgba(27, 40, 33, 0.98); --color-sheet-solid: #1b2821; - --color-card: #36413c; - --color-card-alt: #1b2821; - --color-card-translucent: rgba(54, 65, 60, 0.8); + --color-card: #1b2821; + --color-grouped-card: #1b2821; + --color-card-alt: #36413c; + --color-card-translucent: rgba(27, 40, 33, 0.8); --color-thread-canvas: #1b2821; - --color-thread-selected: #36413c; + --color-thread-selected: #2f5641; --color-thread-selected-foreground: #fffaff; - --color-thread-selected-foreground-muted: #919595; + --color-thread-selected-foreground-muted: #bbc0bf; + --color-thread-hover: #2a4938; + --color-row-hover: #325c46; --color-composer-panel: rgba(27, 40, 33, 0.92); - --color-composer-surface: rgba(54, 65, 60, 0.9); + --color-composer-surface: rgba(27, 40, 33, 0.9); --color-composer-border: rgba(65, 95, 79, 0.46); --color-foreground: #fffaff; - --color-foreground-secondary: #919595; - --color-foreground-muted: #9da5a2; + --color-foreground-secondary: #a6aaaa; + --color-foreground-muted: #a3aba8; --color-foreground-tertiary: #919595; --color-border: #415f4f; + --color-focus: #69d69a; --color-border-subtle: rgba(65, 95, 79, 0.7); --color-separator: rgba(65, 95, 79, 0.55); --color-subtle: #253e31; @@ -577,13 +791,14 @@ --color-inline-skill-background: #325c46; --color-inline-skill-border: rgba(105, 214, 154, 0.42); --color-inline-skill-foreground: #fffaff; - --color-primary: #69d69a; + --color-primary: #e3b34e; --color-primary-foreground: #241523; + --color-primary-text: #e3b34e; --color-primary-shadow: #000000; --color-secondary: #2a4b39; --color-secondary-foreground: #fffaff; --color-secondary-border: #415f4f; - --color-switch-active-track: #69d69a; + --color-switch-active-track: #e3b34e; --color-switch-active-thumb: #241523; --color-switch-inactive-track: #2a4b39; --color-switch-inactive-thumb: #9da5a2; @@ -593,7 +808,9 @@ --color-danger: #3f2c28; --color-danger-border: rgba(251, 65, 74, 0.32); --color-danger-foreground: #ff6668; - --color-input: #36413c; + --color-update: #345f48; + --color-update-foreground: #9ee4be; + --color-input: #1b2821; --color-input-border: #4f725f; --color-sidebar-search: #45554d; --color-placeholder: #a9abab; @@ -601,13 +818,15 @@ --color-icon-muted: #919595; --color-icon-subtle: #919595; --color-header: rgba(27, 40, 33, 0.97); + --color-header-foreground: #fffaff; --color-header-border: #36654c; --color-glass-surface: rgba(68, 77, 73, 0.74); + --color-glass-fallback: rgba(27, 40, 33, 0.9); --color-glass-tint: rgba(68, 77, 73, 0.22); --color-status-bar: #1b2821; --color-md-body: #fffaff; --color-md-strong: #fffaff; - --color-md-link: #69d69a; + --color-md-link: #e3b34e; --color-md-blockquote-border: #415f4f; --color-md-blockquote-bg: #253e31; --color-md-code-bg: #28342e; @@ -622,7 +841,10 @@ --color-user-bubble-foreground-muted: rgba(255, 250, 255, 0.78); --color-user-bubble-skill-foreground: #eed295; --color-backdrop: rgba(0, 0, 0, 0.48); - --color-drawer: rgba(33, 54, 43, 0.99); + --color-drawer: #21362b; + --color-drawer-foreground: #fffaff; + --color-drawer-foreground-muted: #aab0af; + --color-drawer-border: #6f7a75; --color-drawer-shadow: rgba(0, 0, 0, 0.32); --color-dot-separator: rgba(145, 149, 149, 0.35); --color-wordmark: #fffaff; @@ -684,32 +906,36 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); - --color-clerk-page: #0e0e0e; + --color-clerk-page: #0a0a0a; --color-clerk-foreground: #f5f5f5; - --color-clerk-foreground-muted: #a3a3a3; - --color-clerk-border: rgba(42, 42, 42, 0.06); - --color-clerk-danger: #fca5a5; + --color-clerk-foreground-muted: #818181; + --color-clerk-border: #191919; + --color-clerk-danger: #ff6467; } @variant ocean-light { --color-screen: #f5f7f8; --color-sheet: rgba(245, 247, 248, 0.98); --color-sheet-solid: #f5f7f8; - --color-card: #edeff1; - --color-card-alt: #f5f7f8; - --color-card-translucent: rgba(237, 239, 241, 0.8); + --color-card: #f5f7f8; + --color-grouped-card: #f5f7f8; + --color-card-alt: #edeff1; + --color-card-translucent: rgba(245, 247, 248, 0.8); --color-thread-canvas: #f5f7f8; - --color-thread-selected: #edeff1; + --color-thread-selected: #bed4e5; --color-thread-selected-foreground: #241523; - --color-thread-selected-foreground-muted: #746c75; + --color-thread-selected-foreground-muted: #5e5862; + --color-thread-hover: #cdddea; + --color-row-hover: #d8e4ee; --color-composer-panel: rgba(245, 247, 248, 0.88); - --color-composer-surface: rgba(237, 239, 241, 0.94); + --color-composer-surface: rgba(245, 247, 248, 0.94); --color-composer-border: rgba(205, 212, 220, 0.54); --color-foreground: #241523; - --color-foreground-secondary: #746c75; + --color-foreground-secondary: #726a73; --color-foreground-muted: #6f6873; --color-foreground-tertiary: #746c75; --color-border: #cdd4dc; + --color-focus: #2672af; --color-border-subtle: rgba(205, 212, 220, 0.7); --color-separator: rgba(205, 212, 220, 0.55); --color-subtle: #e8eff4; @@ -717,13 +943,14 @@ --color-inline-skill-background: #d8e4ee; --color-inline-skill-border: rgba(38, 114, 175, 0.42); --color-inline-skill-foreground: #241523; - --color-primary: #2672af; + --color-primary: #0a6f75; --color-primary-foreground: #fffaff; + --color-primary-text: #0a6f75; --color-primary-shadow: #000000; --color-secondary: #e4ecf2; --color-secondary-foreground: #241523; --color-secondary-border: #cdd4dc; - --color-switch-active-track: #2672af; + --color-switch-active-track: #0a6f75; --color-switch-active-thumb: #fffaff; --color-switch-inactive-track: #e4ecf2; --color-switch-inactive-thumb: #6f6873; @@ -733,7 +960,9 @@ --color-danger: #f5e6e9; --color-danger-border: rgba(251, 44, 54, 0.32); --color-danger-foreground: #c10007; - --color-input: #edeff1; + --color-update: #d4e1ed; + --color-update-foreground: #194a72; + --color-input: #f5f7f8; --color-input-border: #c0c9d4; --color-sidebar-search: #d5dbe2; --color-placeholder: #716972; @@ -741,13 +970,15 @@ --color-icon-muted: #746c75; --color-icon-subtle: #746c75; --color-header: rgba(245, 247, 248, 0.97); + --color-header-foreground: #241523; --color-header-border: #d8e4ee; --color-glass-surface: rgba(232, 233, 235, 0.74); + --color-glass-fallback: rgba(245, 247, 248, 0.94); --color-glass-tint: rgba(232, 233, 235, 0.22); --color-status-bar: #f5f7f8; --color-md-body: #241523; --color-md-strong: #241523; - --color-md-link: #2672af; + --color-md-link: #0a6f75; --color-md-blockquote-border: #cdd4dc; --color-md-blockquote-bg: #e8eff4; --color-md-code-bg: #f0f1f3; @@ -762,7 +993,10 @@ --color-user-bubble-foreground-muted: rgba(36, 21, 35, 0.78); --color-user-bubble-skill-foreground: #0a6c72; --color-backdrop: rgba(0, 0, 0, 0.22); - --color-drawer: rgba(228, 236, 242, 0.99); + --color-drawer: #e4ecf2; + --color-drawer-foreground: #241523; + --color-drawer-foreground-muted: #655e69; + --color-drawer-border: #cdd2d9; --color-drawer-shadow: rgba(0, 0, 0, 0.12); --color-dot-separator: rgba(116, 108, 117, 0.35); --color-wordmark: #241523; @@ -824,32 +1058,36 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); - --color-clerk-page: #f2f2f7; - --color-clerk-foreground: #262626; - --color-clerk-foreground-muted: #737373; - --color-clerk-border: rgba(229, 229, 234, 0.06); - --color-clerk-danger: #dc2626; + --color-clerk-page: #fcfcfc; + --color-clerk-foreground: #27272a; + --color-clerk-foreground-muted: #71717b; + --color-clerk-border: #e4e4e7; + --color-clerk-danger: #c10007; } @variant ocean-dark { --color-screen: #17212b; --color-sheet: rgba(23, 33, 43, 0.98); --color-sheet-solid: #17212b; - --color-card: #333b45; - --color-card-alt: #17212b; - --color-card-translucent: rgba(51, 59, 69, 0.8); + --color-card: #17212b; + --color-grouped-card: #17212b; + --color-card-alt: #333b45; + --color-card-translucent: rgba(23, 33, 43, 0.8); --color-thread-canvas: #17212b; - --color-thread-selected: #333b45; + --color-thread-selected: #2f495f; --color-thread-selected-foreground: #fffaff; - --color-thread-selected-foreground-muted: #8d8f97; + --color-thread-selected-foreground-muted: #b0b4ba; + --color-thread-hover: #283e50; + --color-row-hover: #324e66; --color-composer-panel: rgba(23, 33, 43, 0.92); - --color-composer-surface: rgba(51, 59, 69, 0.9); + --color-composer-surface: rgba(23, 33, 43, 0.9); --color-composer-border: rgba(64, 85, 103, 0.46); --color-foreground: #fffaff; - --color-foreground-secondary: #8d8f97; - --color-foreground-muted: #969ca6; + --color-foreground-secondary: #a2a3aa; + --color-foreground-muted: #9ea4ad; --color-foreground-tertiary: #8d8f97; --color-border: #405567; + --color-focus: #70b9ee; --color-border-subtle: rgba(64, 85, 103, 0.7); --color-separator: rgba(64, 85, 103, 0.55); --color-subtle: #233544; @@ -857,13 +1095,14 @@ --color-inline-skill-background: #324e66; --color-inline-skill-border: rgba(112, 185, 238, 0.42); --color-inline-skill-foreground: #fffaff; - --color-primary: #70b9ee; + --color-primary: #5bd0d6; --color-primary-foreground: #241523; + --color-primary-text: #5bd0d6; --color-primary-shadow: #000000; --color-secondary: #293f52; --color-secondary-foreground: #fffaff; --color-secondary-border: #405567; - --color-switch-active-track: #70b9ee; + --color-switch-active-track: #5bd0d6; --color-switch-active-thumb: #241523; --color-switch-inactive-track: #293f52; --color-switch-inactive-thumb: #969ca6; @@ -873,7 +1112,9 @@ --color-danger: #3c2630; --color-danger-border: rgba(251, 65, 74, 0.32); --color-danger-foreground: #ff6467; - --color-input: #333b45; + --color-update: #345269; + --color-update-foreground: #a2d2f4; + --color-input: #17212b; --color-input-border: #4f677b; --color-sidebar-search: #424e5a; --color-placeholder: #a4a4ac; @@ -881,13 +1122,15 @@ --color-icon-muted: #8d8f97; --color-icon-subtle: #8d8f97; --color-header: rgba(23, 33, 43, 0.97); + --color-header-foreground: #fffaff; --color-header-border: #36566f; --color-glass-surface: rgba(65, 72, 81, 0.74); + --color-glass-fallback: rgba(23, 33, 43, 0.9); --color-glass-tint: rgba(65, 72, 81, 0.22); --color-status-bar: #17212b; --color-md-body: #fffaff; --color-md-strong: #fffaff; - --color-md-link: #70b9ee; + --color-md-link: #5bd0d6; --color-md-blockquote-border: #405567; --color-md-blockquote-bg: #233544; --color-md-code-bg: #252e38; @@ -902,7 +1145,10 @@ --color-user-bubble-foreground-muted: rgba(255, 250, 255, 0.78); --color-user-bubble-skill-foreground: #75d8dd; --color-backdrop: rgba(0, 0, 0, 0.48); - --color-drawer: rgba(30, 45, 59, 0.99); + --color-drawer: #1e2d3b; + --color-drawer-foreground: #fffaff; + --color-drawer-foreground-muted: #a2a5ae; + --color-drawer-border: #6d757f; --color-drawer-shadow: rgba(0, 0, 0, 0.32); --color-dot-separator: rgba(141, 143, 151, 0.35); --color-wordmark: #fffaff; @@ -964,32 +1210,36 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); - --color-clerk-page: #0e0e0e; + --color-clerk-page: #0a0a0a; --color-clerk-foreground: #f5f5f5; - --color-clerk-foreground-muted: #a3a3a3; - --color-clerk-border: rgba(42, 42, 42, 0.06); - --color-clerk-danger: #fca5a5; + --color-clerk-foreground-muted: #818181; + --color-clerk-border: #191919; + --color-clerk-danger: #ff6467; } @variant ember-light { --color-screen: #f9f7f5; --color-sheet: rgba(249, 247, 245, 0.98); --color-sheet-solid: #f9f7f5; - --color-card: #f1efee; - --color-card-alt: #f9f7f5; - --color-card-translucent: rgba(241, 239, 238, 0.8); + --color-card: #f9f7f5; + --color-grouped-card: #f9f7f5; + --color-card-alt: #f1efee; + --color-card-translucent: rgba(249, 247, 245, 0.8); --color-thread-canvas: #f9f7f5; - --color-thread-selected: #f1efee; + --color-thread-selected: #e5ccc0; --color-thread-selected-foreground: #241523; - --color-thread-selected-foreground-muted: #766c74; + --color-thread-selected-foreground-muted: #62575d; + --color-thread-hover: #ead8cf; + --color-row-hover: #eee0d9; --color-composer-panel: rgba(249, 247, 245, 0.88); - --color-composer-surface: rgba(241, 239, 238, 0.94); + --color-composer-surface: rgba(249, 247, 245, 0.94); --color-composer-border: rgba(221, 210, 206, 0.54); --color-foreground: #241523; - --color-foreground-secondary: #766c74; + --color-foreground-secondary: #746a72; --color-foreground-muted: #74686f; --color-foreground-tertiary: #766c74; --color-border: #ddd2ce; + --color-focus: #ae552a; --color-border-subtle: rgba(221, 210, 206, 0.7); --color-separator: rgba(221, 210, 206, 0.55); --color-subtle: #f4ede9; @@ -997,13 +1247,14 @@ --color-inline-skill-background: #eee0d9; --color-inline-skill-border: rgba(174, 85, 42, 0.42); --color-inline-skill-foreground: #241523; - --color-primary: #ae552a; + --color-primary: #b23535; --color-primary-foreground: #fffaff; + --color-primary-text: #b23535; --color-primary-shadow: #000000; --color-secondary: #f3eae5; --color-secondary-foreground: #241523; --color-secondary-border: #ddd2ce; - --color-switch-active-track: #ae552a; + --color-switch-active-track: #b23535; --color-switch-active-thumb: #fffaff; --color-switch-inactive-track: #f3eae5; --color-switch-inactive-thumb: #74686f; @@ -1013,7 +1264,9 @@ --color-danger: #f9e7e6; --color-danger-border: rgba(251, 44, 54, 0.32); --color-danger-foreground: #c10007; - --color-input: #f1efee; + --color-update: #edddd5; + --color-update-foreground: #71381b; + --color-input: #f9f7f5; --color-input-border: #d4c6c1; --color-sidebar-search: #e2d9d6; --color-placeholder: #736971; @@ -1021,13 +1274,15 @@ --color-icon-muted: #766c74; --color-icon-subtle: #766c74; --color-header: rgba(249, 247, 245, 0.97); + --color-header-foreground: #241523; --color-header-border: #eee0d9; --color-glass-surface: rgba(236, 233, 233, 0.74); + --color-glass-fallback: rgba(249, 247, 245, 0.94); --color-glass-tint: rgba(236, 233, 233, 0.22); --color-status-bar: #f9f7f5; --color-md-body: #241523; --color-md-strong: #241523; - --color-md-link: #ae552a; + --color-md-link: #b23535; --color-md-blockquote-border: #ddd2ce; --color-md-blockquote-bg: #f4ede9; --color-md-code-bg: #f3f1f0; @@ -1042,7 +1297,10 @@ --color-user-bubble-foreground-muted: rgba(36, 21, 35, 0.78); --color-user-bubble-skill-foreground: #b13535; --color-backdrop: rgba(0, 0, 0, 0.22); - --color-drawer: rgba(243, 234, 229, 0.99); + --color-drawer: #f3eae5; + --color-drawer-foreground: #241523; + --color-drawer-foreground-muted: #6a5d64; + --color-drawer-border: #dad0ce; --color-drawer-shadow: rgba(0, 0, 0, 0.12); --color-dot-separator: rgba(118, 108, 116, 0.35); --color-wordmark: #241523; @@ -1104,32 +1362,36 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); - --color-clerk-page: #f2f2f7; - --color-clerk-foreground: #262626; - --color-clerk-foreground-muted: #737373; - --color-clerk-border: rgba(229, 229, 234, 0.06); - --color-clerk-danger: #dc2626; + --color-clerk-page: #fcfcfc; + --color-clerk-foreground: #27272a; + --color-clerk-foreground-muted: #71717b; + --color-clerk-border: #e4e4e7; + --color-clerk-danger: #c10007; } @variant ember-dark { --color-screen: #291e1a; --color-sheet: rgba(41, 30, 26, 0.98); --color-sheet-solid: #291e1a; - --color-card: #433835; - --color-card-alt: #291e1a; - --color-card-translucent: rgba(67, 56, 53, 0.8); + --color-card: #291e1a; + --color-grouped-card: #291e1a; + --color-card-alt: #433835; + --color-card-translucent: rgba(41, 30, 26, 0.8); --color-thread-canvas: #291e1a; - --color-thread-selected: #433835; + --color-thread-selected: #5d3f2d; --color-thread-selected-foreground: #fffaff; - --color-thread-selected-foreground-muted: #968e8f; + --color-thread-selected-foreground-muted: #b9b1b0; + --color-thread-hover: #4f3528; + --color-row-hover: #644330; --color-composer-panel: rgba(41, 30, 26, 0.92); - --color-composer-surface: rgba(67, 56, 53, 0.9); + --color-composer-surface: rgba(41, 30, 26, 0.9); --color-composer-border: rgba(102, 76, 63, 0.46); --color-foreground: #fffaff; - --color-foreground-secondary: #968e8f; - --color-foreground-muted: #a59996; + --color-foreground-secondary: #a8a2a2; + --color-foreground-muted: #aca19f; --color-foreground-tertiary: #968e8f; --color-border: #664c3f; + --color-focus: #f09a64; --color-border-subtle: rgba(102, 76, 63, 0.7); --color-separator: rgba(102, 76, 63, 0.55); --color-subtle: #432e23; @@ -1137,13 +1399,14 @@ --color-inline-skill-background: #644330; --color-inline-skill-border: rgba(240, 154, 100, 0.42); --color-inline-skill-foreground: #fffaff; - --color-primary: #f09a64; + --color-primary: #f78a7a; --color-primary-foreground: #241523; + --color-primary-text: #f78a7a; --color-primary-shadow: #000000; --color-secondary: #513728; --color-secondary-foreground: #fffaff; --color-secondary-border: #664c3f; - --color-switch-active-track: #f09a64; + --color-switch-active-track: #f78a7a; --color-switch-active-thumb: #241523; --color-switch-inactive-track: #513728; --color-switch-inactive-thumb: #a59996; @@ -1153,7 +1416,9 @@ --color-danger: #4a2321; --color-danger-border: rgba(251, 65, 74, 0.32); --color-danger-foreground: #ff6467; - --color-input: #433835; + --color-update: #684631; + --color-update-foreground: #f5bd9a; + --color-input: #291e1a; --color-input-border: #7a5d4d; --color-sidebar-search: #584943; --color-placeholder: #aba3a5; @@ -1161,13 +1426,15 @@ --color-icon-muted: #968e8f; --color-icon-subtle: #968e8f; --color-header: rgba(41, 30, 26, 0.97); + --color-header-foreground: #fffaff; --color-header-border: #6e4934; --color-glass-surface: rgba(79, 69, 67, 0.74); + --color-glass-fallback: rgba(41, 30, 26, 0.9); --color-glass-tint: rgba(79, 69, 67, 0.22); --color-status-bar: #291e1a; --color-md-body: #fffaff; --color-md-strong: #fffaff; - --color-md-link: #f09a64; + --color-md-link: #f78a7a; --color-md-blockquote-border: #664c3f; --color-md-blockquote-bg: #432e23; --color-md-code-bg: #362b27; @@ -1182,7 +1449,10 @@ --color-user-bubble-foreground-muted: rgba(255, 250, 255, 0.78); --color-user-bubble-skill-foreground: #fab6ad; --color-backdrop: rgba(0, 0, 0, 0.48); - --color-drawer: rgba(57, 40, 31, 0.99); + --color-drawer: #39281f; + --color-drawer-foreground: #fffaff; + --color-drawer-foreground-muted: #aca2a1; + --color-drawer-border: #7e716e; --color-drawer-shadow: rgba(0, 0, 0, 0.32); --color-dot-separator: rgba(150, 142, 143, 0.35); --color-wordmark: #fffaff; @@ -1244,32 +1514,36 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); - --color-clerk-page: #0e0e0e; + --color-clerk-page: #0a0a0a; --color-clerk-foreground: #f5f5f5; - --color-clerk-foreground-muted: #a3a3a3; - --color-clerk-border: rgba(42, 42, 42, 0.06); - --color-clerk-danger: #fca5a5; + --color-clerk-foreground-muted: #818181; + --color-clerk-border: #191919; + --color-clerk-danger: #ff6467; } @variant iris-light { --color-screen: #f8f7f9; --color-sheet: rgba(248, 247, 249, 0.98); --color-sheet-solid: #f8f7f9; - --color-card: #f0eff2; - --color-card-alt: #f8f7f9; - --color-card-translucent: rgba(240, 239, 242, 0.8); + --color-card: #f8f7f9; + --color-grouped-card: #f8f7f9; + --color-card-alt: #f0eff2; + --color-card-translucent: rgba(248, 247, 249, 0.8); --color-thread-canvas: #f8f7f9; - --color-thread-selected: #f0eff2; + --color-thread-selected: #d4cce8; --color-thread-selected-foreground: #241523; - --color-thread-selected-foreground-muted: #766c76; + --color-thread-selected-foreground-muted: #605662; + --color-thread-hover: #ded8ed; + --color-row-hover: #e5e0f0; --color-composer-panel: rgba(248, 247, 249, 0.88); - --color-composer-surface: rgba(240, 239, 242, 0.94); + --color-composer-surface: rgba(248, 247, 249, 0.94); --color-composer-border: rgba(214, 209, 222, 0.54); --color-foreground: #241523; - --color-foreground-secondary: #766c76; + --color-foreground-secondary: #746a74; --color-foreground-muted: #726874; --color-foreground-tertiary: #766c76; --color-border: #d6d1de; + --color-focus: #7253b9; --color-border-subtle: rgba(214, 209, 222, 0.7); --color-separator: rgba(214, 209, 222, 0.55); --color-subtle: #f0edf6; @@ -1277,13 +1551,14 @@ --color-inline-skill-background: #e5e0f0; --color-inline-skill-border: rgba(114, 83, 185, 0.42); --color-inline-skill-foreground: #241523; - --color-primary: #7253b9; + --color-primary: #a82c87; --color-primary-foreground: #fffaff; + --color-primary-text: #a82c87; --color-primary-shadow: #000000; --color-secondary: #edeaf4; --color-secondary-foreground: #241523; --color-secondary-border: #d6d1de; - --color-switch-active-track: #7253b9; + --color-switch-active-track: #a82c87; --color-switch-active-thumb: #fffaff; --color-switch-inactive-track: #edeaf4; --color-switch-inactive-thumb: #726874; @@ -1293,7 +1568,9 @@ --color-danger: #f8e6ea; --color-danger-border: rgba(251, 44, 54, 0.32); --color-danger-foreground: #c10007; - --color-input: #f0eff2; + --color-update: #e2ddef; + --color-update-foreground: #4a3678; + --color-input: #f8f7f9; --color-input-border: #ccc5d6; --color-sidebar-search: #ddd9e3; --color-placeholder: #736973; @@ -1301,13 +1578,15 @@ --color-icon-muted: #766c76; --color-icon-subtle: #766c76; --color-header: rgba(248, 247, 249, 0.97); + --color-header-foreground: #241523; --color-header-border: #e5e0f0; --color-glass-surface: rgba(235, 233, 237, 0.74); + --color-glass-fallback: rgba(248, 247, 249, 0.94); --color-glass-tint: rgba(235, 233, 237, 0.22); --color-status-bar: #f8f7f9; --color-md-body: #241523; --color-md-strong: #241523; - --color-md-link: #7253b9; + --color-md-link: #a82c87; --color-md-blockquote-border: #d6d1de; --color-md-blockquote-bg: #f0edf6; --color-md-code-bg: #f2f1f4; @@ -1322,7 +1601,10 @@ --color-user-bubble-foreground-muted: rgba(36, 21, 35, 0.78); --color-user-bubble-skill-foreground: #a82c87; --color-backdrop: rgba(0, 0, 0, 0.22); - --color-drawer: rgba(237, 234, 244, 0.99); + --color-drawer: #edeaf4; + --color-drawer-foreground: #241523; + --color-drawer-foreground-muted: #685d6a; + --color-drawer-border: #d5d0db; --color-drawer-shadow: rgba(0, 0, 0, 0.12); --color-dot-separator: rgba(118, 108, 118, 0.35); --color-wordmark: #241523; @@ -1384,32 +1666,36 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 12%); --color-adaptive-zinc-500-400: oklch(55.2% 0.016 285.938); --color-adaptive-zinc-600-300: oklch(44.2% 0.017 285.786); - --color-clerk-page: #f2f2f7; - --color-clerk-foreground: #262626; - --color-clerk-foreground-muted: #737373; - --color-clerk-border: rgba(229, 229, 234, 0.06); - --color-clerk-danger: #dc2626; + --color-clerk-page: #fcfcfc; + --color-clerk-foreground: #27272a; + --color-clerk-foreground-muted: #71717b; + --color-clerk-border: #e4e4e7; + --color-clerk-danger: #c10007; } @variant iris-dark { --color-screen: #1d1929; --color-sheet: rgba(29, 25, 41, 0.98); --color-sheet-solid: #1d1929; - --color-card: #383443; - --color-card-alt: #1d1929; - --color-card-translucent: rgba(56, 52, 67, 0.8); + --color-card: #1d1929; + --color-grouped-card: #1d1929; + --color-card-alt: #383443; + --color-card-translucent: rgba(29, 25, 41, 0.8); --color-thread-canvas: #1d1929; - --color-thread-selected: #383443; + --color-thread-selected: #3f345e; --color-thread-selected-foreground: #fffaff; - --color-thread-selected-foreground-muted: #8e8a95; + --color-thread-selected-foreground-muted: #a6a2ae; + --color-thread-hover: #352c4f; + --color-row-hover: #433765; --color-composer-panel: rgba(29, 25, 41, 0.92); - --color-composer-surface: rgba(56, 52, 67, 0.9); + --color-composer-surface: rgba(29, 25, 41, 0.9); --color-composer-border: rgba(77, 67, 102, 0.46); --color-foreground: #fffaff; - --color-foreground-secondary: #8e8a95; - --color-foreground-muted: #9690a1; + --color-foreground-secondary: #a09da6; + --color-foreground-muted: #a19cab; --color-foreground-tertiary: #8e8a95; --color-border: #4d4366; + --color-focus: #9d7df2; --color-border-subtle: rgba(77, 67, 102, 0.7); --color-separator: rgba(77, 67, 102, 0.55); --color-subtle: #2d2643; @@ -1417,13 +1703,14 @@ --color-inline-skill-background: #433765; --color-inline-skill-border: rgba(157, 125, 242, 0.42); --color-inline-skill-foreground: #fffaff; - --color-primary: #9d7df2; + --color-primary: #f099d8; --color-primary-foreground: #241523; + --color-primary-text: #f099d8; --color-primary-shadow: #000000; --color-secondary: #362d51; --color-secondary-foreground: #fffaff; --color-secondary-border: #4d4366; - --color-switch-active-track: #9d7df2; + --color-switch-active-track: #f099d8; --color-switch-active-thumb: #241523; --color-switch-inactive-track: #362d51; --color-switch-inactive-thumb: #9690a1; @@ -1433,7 +1720,9 @@ --color-danger: #40202e; --color-danger-border: rgba(251, 65, 74, 0.32); --color-danger-foreground: #ff6467; - --color-input: #383443; + --color-update: #463969; + --color-update-foreground: #bfabf7; + --color-input: #1d1929; --color-input-border: #5d527b; --color-sidebar-search: #494459; --color-placeholder: #a29ea8; @@ -1441,13 +1730,15 @@ --color-icon-muted: #8e8a95; --color-icon-subtle: #8e8a95; --color-header: rgba(29, 25, 41, 0.97); + --color-header-foreground: #fffaff; --color-header-border: #4a3c70; --color-glass-surface: rgba(69, 66, 80, 0.74); + --color-glass-fallback: rgba(29, 25, 41, 0.9); --color-glass-tint: rgba(69, 66, 80, 0.22); --color-status-bar: #1d1929; --color-md-body: #fffaff; --color-md-strong: #fffaff; - --color-md-link: #9d7df2; + --color-md-link: #f099d8; --color-md-blockquote-border: #4d4366; --color-md-blockquote-bg: #2d2643; --color-md-code-bg: #2a2736; @@ -1462,7 +1753,10 @@ --color-user-bubble-foreground-muted: rgba(255, 250, 255, 0.78); --color-user-bubble-skill-foreground: #f099d8; --color-backdrop: rgba(0, 0, 0, 0.48); - --color-drawer: rgba(39, 33, 57, 0.99); + --color-drawer: #272139; + --color-drawer-foreground: #fffaff; + --color-drawer-foreground-muted: #9b97a4; + --color-drawer-border: #736d7e; --color-drawer-shadow: rgba(0, 0, 0, 0.32); --color-dot-separator: rgba(142, 138, 149, 0.35); --color-wordmark: #fffaff; @@ -1524,11 +1818,11 @@ --color-adaptive-zinc-500-a12-a16: oklch(55.2% 0.016 285.938 / 16%); --color-adaptive-zinc-500-400: oklch(70.5% 0.015 286.067); --color-adaptive-zinc-600-300: oklch(87.1% 0.006 286.286); - --color-clerk-page: #0e0e0e; + --color-clerk-page: #0a0a0a; --color-clerk-foreground: #f5f5f5; - --color-clerk-foreground-muted: #a3a3a3; - --color-clerk-border: rgba(42, 42, 42, 0.06); - --color-clerk-danger: #fca5a5; + --color-clerk-foreground-muted: #818181; + --color-clerk-border: #191919; + --color-clerk-danger: #ff6467; } } } diff --git a/apps/mobile/global.css b/apps/mobile/global.css index 2f686e3a1fa9..730b9e1a97f3 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -8,228 +8,6 @@ @variant android { --font-mono: "monospace"; } - - @variant light { - /* Page backgrounds */ - --color-screen: #f2f2f7; - --color-sheet: rgba(242, 242, 247, 0.98); - --color-sheet-solid: #f2f2f7; - - /* Card / surface */ - --color-card: #ffffff; - --color-card-alt: #f5f5f5; - --color-card-translucent: rgba(255, 255, 255, 0.8); - --color-thread-canvas: #f5f5f5; - --color-thread-selected: #ffffff; - --color-thread-selected-foreground: #262626; - --color-thread-selected-foreground-muted: #737373; - --color-composer-panel: rgba(245, 245, 245, 0.88); - --color-composer-surface: rgba(255, 255, 255, 0.94); - --color-composer-border: rgba(0, 0, 0, 0.1); - - /* Text */ - --color-foreground: #262626; - --color-foreground-secondary: #525252; - --color-foreground-muted: #737373; - --color-foreground-tertiary: #8e8e93; - - /* Borders & separators */ - --color-border: rgba(0, 0, 0, 0.08); - --color-border-subtle: rgba(0, 0, 0, 0.06); - --color-separator: rgba(0, 0, 0, 0.04); - - /* Subtle backgrounds (badges, pills, overlays) */ - --color-subtle: rgba(0, 0, 0, 0.04); - --color-subtle-strong: rgba(0, 0, 0, 0.08); - --color-inline-skill-background: rgba(217, 70, 239, 0.12); - --color-inline-skill-border: rgba(217, 70, 239, 0.25); - --color-inline-skill-foreground: #a21caf; - - /* Primary action */ - --color-primary: #262626; - --color-primary-foreground: #ffffff; - --color-primary-shadow: #000000; - - /* Secondary action */ - --color-secondary: #ffffff; - --color-secondary-foreground: #262626; - --color-secondary-border: rgba(0, 0, 0, 0.08); - --color-switch-active-track: #34c759; - --color-switch-active-thumb: #ffffff; - --color-switch-inactive-track: rgba(0, 0, 0, 0.08); - --color-switch-inactive-thumb: #8e8e93; - - /* Warning */ - --color-warning: #fffbeb; - --color-warning-border: #fde68a; - --color-warning-foreground: #b45309; - - /* Danger */ - --color-danger: #fef2f2; - --color-danger-border: rgba(239, 68, 68, 0.12); - --color-danger-foreground: #dc2626; - - /* Inputs */ - --color-input: #ffffff; - --color-input-border: rgba(0, 0, 0, 0.1); - --color-sidebar-search: rgba(118, 118, 128, 0.12); - --color-placeholder: #737373; - - /* Icons */ - --color-icon: #262626; - --color-icon-muted: #525252; - --color-icon-subtle: #a3a3a3; - - /* Header / glass chrome */ - --color-header: rgba(255, 255, 255, 0.97); - --color-header-border: rgba(0, 0, 0, 0.06); - --color-glass-surface: rgba(255, 255, 255, 0.72); - --color-glass-tint: rgba(255, 255, 255, 0.18); - - /* StatusBar */ - --color-status-bar: #f2f2f7; - - /* Markdown */ - --color-md-body: #111111; - --color-md-strong: #000000; - --color-md-link: #2563eb; - --color-md-blockquote-border: rgba(0, 0, 0, 0.08); - --color-md-blockquote-bg: rgba(0, 0, 0, 0.02); - --color-md-code-bg: rgba(0, 0, 0, 0.04); - --color-md-code-text: #262626; - --color-md-user-code-bg: rgba(0, 0, 0, 0.04); - --color-md-user-code-text: #262626; - --color-md-user-fence-bg: rgba(0, 0, 0, 0.06); - --color-md-user-fence-text: #262626; - --color-md-hr: rgba(0, 0, 0, 0.08); - - /* User bubble: a raised card on the thread canvas, matching web's message surface */ - --color-user-bubble: #ffffff; - --color-user-bubble-foreground: #262626; - --color-user-bubble-foreground-muted: rgba(38, 38, 38, 0.78); - --color-user-bubble-skill-foreground: #2563eb; - - /* Drawer / modal backdrop */ - --color-backdrop: rgba(0, 0, 0, 0.22); - --color-drawer: rgba(255, 255, 255, 0.99); - --color-drawer-shadow: rgba(0, 0, 0, 0.12); - - /* Misc */ - --color-dot-separator: rgba(0, 0, 0, 0.2); - --color-wordmark: #262626; - --color-chevron: rgba(0, 0, 0, 0.2); - } - - @variant dark { - /* Page backgrounds */ - --color-screen: #0a0a0a; - --color-sheet: rgba(14, 14, 14, 0.98); - --color-sheet-solid: #0e0e0e; - - /* Card / surface */ - --color-card: #171717; - --color-card-alt: #1c1c1c; - --color-card-translucent: rgba(17, 17, 17, 0.8); - --color-thread-canvas: #1c1c1c; - --color-thread-selected: #171717; - --color-thread-selected-foreground: #f5f5f5; - --color-thread-selected-foreground-muted: #8e8e93; - --color-composer-panel: rgba(28, 28, 28, 0.92); - --color-composer-surface: rgba(23, 23, 23, 0.9); - --color-composer-border: rgba(255, 255, 255, 0.08); - - /* Text */ - --color-foreground: #f5f5f5; - --color-foreground-secondary: #a3a3a3; - --color-foreground-muted: #8e8e93; - --color-foreground-tertiary: #636366; - - /* Borders & separators */ - --color-border: rgba(255, 255, 255, 0.06); - --color-border-subtle: rgba(255, 255, 255, 0.04); - --color-separator: rgba(255, 255, 255, 0.03); - - /* Subtle backgrounds (badges, pills, overlays) */ - --color-subtle: rgba(255, 255, 255, 0.04); - --color-subtle-strong: rgba(255, 255, 255, 0.08); - --color-inline-skill-background: rgba(217, 70, 239, 0.12); - --color-inline-skill-border: rgba(217, 70, 239, 0.25); - --color-inline-skill-foreground: #f0abfc; - - /* Primary action */ - --color-primary: #f5f5f5; - --color-primary-foreground: #0a0a0a; - --color-primary-shadow: #000000; - - /* Secondary action */ - --color-secondary: rgba(255, 255, 255, 0.04); - --color-secondary-foreground: #f5f5f5; - --color-secondary-border: rgba(255, 255, 255, 0.06); - --color-switch-active-track: #30d158; - --color-switch-active-thumb: #ffffff; - --color-switch-inactive-track: rgba(255, 255, 255, 0.06); - --color-switch-inactive-thumb: #8e8e93; - - /* Warning */ - --color-warning: rgba(69, 26, 3, 0.4); - --color-warning-border: rgba(120, 53, 15, 0.6); - --color-warning-foreground: #fcd34d; - - /* Danger */ - --color-danger: rgba(239, 68, 68, 0.14); - --color-danger-border: rgba(248, 113, 113, 0.18); - --color-danger-foreground: #fca5a5; - - /* Inputs */ - --color-input: #141414; - --color-input-border: rgba(255, 255, 255, 0.08); - --color-sidebar-search: rgba(118, 118, 128, 0.24); - --color-placeholder: #8e8e93; - - /* Icons */ - --color-icon: #f5f5f5; - --color-icon-muted: #a3a3a3; - --color-icon-subtle: #8e8e93; - - /* Header / glass chrome */ - --color-header: rgba(10, 10, 10, 0.97); - --color-header-border: rgba(255, 255, 255, 0.06); - --color-glass-surface: rgba(23, 23, 23, 0.78); - --color-glass-tint: rgba(23, 23, 23, 0.24); - - /* StatusBar */ - --color-status-bar: #0a0a0a; - - /* Markdown */ - --color-md-body: #e5e5e5; - --color-md-strong: #f5f5f5; - --color-md-link: #60a5fa; - --color-md-blockquote-border: rgba(255, 255, 255, 0.1); - --color-md-blockquote-bg: rgba(255, 255, 255, 0.03); - --color-md-code-bg: rgba(255, 255, 255, 0.06); - --color-md-code-text: #e5e5e5; - --color-md-user-code-bg: rgba(255, 255, 255, 0.06); - --color-md-user-code-text: #e5e5e5; - --color-md-user-fence-bg: rgba(255, 255, 255, 0.09); - --color-md-user-fence-text: #e5e5e5; - --color-md-hr: rgba(255, 255, 255, 0.08); - - /* User bubble: a raised card on the thread canvas, matching web's message surface */ - --color-user-bubble: #171717; - --color-user-bubble-foreground: #f5f5f5; - --color-user-bubble-foreground-muted: rgba(245, 245, 245, 0.78); - --color-user-bubble-skill-foreground: #60a5fa; - - /* Drawer / modal backdrop */ - --color-backdrop: rgba(0, 0, 0, 0.48); - --color-drawer: rgba(14, 14, 14, 0.99); - --color-drawer-shadow: rgba(0, 0, 0, 0.32); - - /* Misc */ - --color-dot-separator: rgba(255, 255, 255, 0.2); - --color-wordmark: #f5f5f5; - --color-chevron: rgba(255, 255, 255, 0.2); - } } } diff --git a/apps/mobile/metro.config.js b/apps/mobile/metro.config.js index 02a6e2c8666c..9025dc4f6b28 100644 --- a/apps/mobile/metro.config.js +++ b/apps/mobile/metro.config.js @@ -17,6 +17,7 @@ const licenseGeneratorSource = path.join( ); const escapedWorkspaceRoot = workspaceRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const mobileShikiRoot = path.dirname(require.resolve("shiki/package.json", { paths: [__dirname] })); +const generatedDeviceStreamRoot = path.join(__dirname, ".generated", "device-stream"); const resolveShikiDependencyRoot = (packageName) => { const entryPath = require.resolve(packageName, { paths: [mobileShikiRoot] }); let currentDir = path.dirname(entryPath); @@ -46,6 +47,7 @@ config.resolver = { extraNodeModules: { ...config.resolver?.extraNodeModules, "@t3tools/mobile-third-party-licenses": generatedLicenseModuleRoot, + "@t3tools/mobile-device-stream": generatedDeviceStreamRoot, shiki: mobileShikiRoot, "@shikijs/core": resolveShikiDependencyRoot("@shikijs/core"), "@shikijs/engine-javascript": resolveShikiDependencyRoot("@shikijs/engine-javascript"), @@ -93,7 +95,32 @@ async function generateMobileThirdPartyLicenses() { ]); } -module.exports = generateMobileThirdPartyLicenses().then(() => +async function prepareDeviceStream() { + const { generateDeviceStreamScript } = await import( + pathToFileURL(path.join(__dirname, "scripts", "generate-device-stream.mts")).href + ); + await generateDeviceStreamScript(); + if (process.env.NODE_ENV !== "production") { + let rebuild = Promise.resolve(); + for (const [directory, files] of [ + [path.join(__dirname, "src/features/devices"), ["device-stream.browser.ts"]], + [ + path.join(workspaceRoot, "packages/client-runtime/src/device"), + ["stream.ts", "hubAccess.ts"], + ], + ]) { + // The generated module participates in Metro's normal Fast Refresh. + fs.watch(directory, { persistent: false }, (_event, filename) => { + if (filename && !files.includes(String(filename))) return; + rebuild = rebuild.then(generateDeviceStreamScript).catch((error) => { + console.error("Could not rebuild the device stream:", error); + }); + }); + } + } +} + +module.exports = Promise.all([generateMobileThirdPartyLicenses(), prepareDeviceStream()]).then(() => withUniwindConfig(config, { cssEntryFile: "./global.css", extraThemes, diff --git a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt index a4720a461327..35d796b7876f 100644 --- a/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt +++ b/apps/mobile/modules/t3-composer-editor/android/src/main/java/expo/modules/t3composereditor/T3ComposerEditorView.kt @@ -12,6 +12,7 @@ import android.text.Editable import android.text.InputType import android.text.InputFilter import android.text.Spanned +import android.text.TextUtils import android.text.TextWatcher import android.text.style.ReplacementSpan import android.util.TypedValue @@ -254,7 +255,7 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( } fun setPlaceholder(placeholder: String) { - editor.hint = placeholder + editor.placeholder = placeholder } fun setClipboardFragment(fragment: String) { @@ -267,12 +268,14 @@ class T3ComposerEditorView(context: Context, appContext: AppContext) : ExpoView( } else { Typeface.DEFAULT } + editor.applyPlaceholder() } fun setFontSize(fontSize: Float) { editor.textSize = fontSize applyLineHeight() applyTokenSpans() + editor.applyPlaceholder() } fun setLineHeight(lineHeight: Float) { @@ -587,6 +590,37 @@ internal class SelectionAwareEditText(context: Context) : EditText(context) { var maxInputChars = Int.MAX_VALUE var clipboardFragment = "" + /** + * Placeholder shown while the draft is empty. An editable TextView never ellipsizes its hint, + * so a long placeholder wraps once a wide system font or a large font scale (Samsung defaults) + * runs out of width, and the resting composer grows to two lines. The hint is instead cut to + * one line with an ellipsis for whatever width the editor is measured at. + */ + var placeholder = "" + set(value) { + field = value + applyPlaceholder() + } + + fun applyPlaceholder(availableWidth: Int = width - compoundPaddingLeft - compoundPaddingRight) { + val next = + if (availableWidth > 0) { + TextUtils.ellipsize(placeholder, paint, availableWidth.toFloat(), TextUtils.TruncateAt.END) + } else { + placeholder + } + if (hint?.toString() != next.toString()) hint = next + } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + if (MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.UNSPECIFIED) { + applyPlaceholder( + MeasureSpec.getSize(widthMeasureSpec) - compoundPaddingLeft - compoundPaddingRight + ) + } + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } + private fun deleteChip(backwards: Boolean): Boolean { val content = text val start = minOf(selectionStart, selectionEnd) diff --git a/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPlaceholderTest.kt b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPlaceholderTest.kt new file mode 100644 index 000000000000..82bcbeb59a94 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/android/src/test/java/expo/modules/t3composereditor/ComposerPlaceholderTest.kt @@ -0,0 +1,59 @@ +package expo.modules.t3composereditor + +import android.view.View +import android.view.ViewGroup +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [36], manifest = Config.NONE) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class ComposerPlaceholderTest { + private val placeholder = "Ask the repo agent, or run a command…" + private val editor = SelectionAwareEditText(RuntimeEnvironment.getApplication()).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + textSize = 16f + placeholder = this@ComposerPlaceholderTest.placeholder + } + + private fun measureAt(width: Int) { + editor.measure( + View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), + ) + editor.layout(0, 0, width, editor.measuredHeight) + } + + @Test + fun narrowEditorCutsThePlaceholderToOneLine() { + val fullWidth = editor.paint.measureText(placeholder) + val width = (fullWidth / 2).toInt() + measureAt(width) + + val hint = editor.hint.toString() + assertTrue(hint, hint.endsWith("…") && hint.length < placeholder.length) + assertTrue(hint, editor.paint.measureText(hint) <= width) + } + + @Test + fun wideEditorKeepsTheWholePlaceholder() { + measureAt(editor.paint.measureText(placeholder).toInt() + 20) + assertEquals(placeholder, editor.hint.toString()) + } + + @Test + fun placeholderRecoversWhenTheEditorWidens() { + measureAt((editor.paint.measureText(placeholder) / 2).toInt()) + measureAt(editor.paint.measureText(placeholder).toInt() + 20) + assertEquals(placeholder, editor.hint.toString()) + } +} diff --git a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3ContextChip.kt b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3ContextChip.kt index 09b49c956407..df0dd88724e0 100644 --- a/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3ContextChip.kt +++ b/apps/mobile/modules/t3-markdown-text/android/src/main/java/expo/modules/t3markdowntext/T3ContextChip.kt @@ -114,7 +114,15 @@ class T3ContextChip( fun color( value: String, fallback: Int - ): Int = runCatching { Color.parseColor(value) }.getOrDefault(fallback) + ): Int = runCatching { + // React Native hex colors put alpha last; Android's parser puts it first. + val androidColor = if (value.length == 9 && value.startsWith("#")) { + "#${value.takeLast(2)}${value.substring(1, 7)}" + } else { + value + } + Color.parseColor(androidColor) + }.getOrDefault(fallback) private fun blend(accent: Int, base: Int, weight: Float): Int = Color.rgb( (Color.red(accent) * weight + Color.red(base) * (1 - weight)).toInt(), diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift index 9b36d60f94ec..74111988f150 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift @@ -2001,19 +2001,6 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { ) } - private func fileStatusText(_ changeType: String?) -> String { - switch changeType { - case "new": - return "A" - case "deleted": - return "D" - case "renamed": - return "R" - default: - return "" - } - } - private func fileStatusColor(_ changeType: String?) -> UIColor { switch changeType { case "new": @@ -2027,13 +2014,6 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { } } - private func drawStatusPill(_ text: String, rect: CGRect, color: UIColor, font: UIFont) { - let path = UIBezierPath(roundedRect: rect, cornerRadius: rect.height / 2) - color.withAlphaComponent(0.12).setFill() - path.fill() - drawCenteredText(text, rect: rect, color: color, font: font) - } - private func drawFileIcon(rect: CGRect, changeType: String?) { let color = fileStatusColor(changeType) let outerPath = UIBezierPath(roundedRect: rect, cornerRadius: 6) diff --git a/apps/mobile/package.json b/apps/mobile/package.json index b32117c03155..dba137f30472 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -7,6 +7,7 @@ "dev": "expo start", "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --lan", "dev:client:reset": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear --lan", + "dev:client:preview-env": "eas env:exec preview 'EXPO_NO_DOTENV=1 APP_VARIANT=development expo start --dev-client --scheme t3code-dev --lan --port 0'", "dev:client:preview": "eas env:exec preview 'EXPO_NO_DOTENV=1 APP_VARIANT=preview expo start --dev-client --scheme t3code-preview --lan'", "start": "expo start", "start:dev": "APP_VARIANT=development expo start", diff --git a/apps/mobile/plugins/withAndroidModernAlertDialog.cjs b/apps/mobile/plugins/withAndroidModernAlertDialog.cjs index 8c8b2b800060..1a677e35e802 100644 --- a/apps/mobile/plugins/withAndroidModernAlertDialog.cjs +++ b/apps/mobile/plugins/withAndroidModernAlertDialog.cjs @@ -1,5 +1,6 @@ const fs = require("node:fs"); const path = require("node:path"); +const themeVariables = require("../generated-uniwind-default-theme-variables.json"); const { AndroidConfig, withAndroidColors, @@ -11,9 +12,10 @@ const { // React Native's Alert renders an AppCompat AlertDialog on Android, which // inherits the dated framework dialog chrome (square gray panel, teal // all-caps buttons) from the app theme. These resources restyle it with the -// app's uniwind tokens from global.css: --color-card panel, --color-foreground -// text, --color-primary buttons, DM Sans type. The @font resources referenced -// here are embedded by the expo-font plugin config in app.config.ts. +// generated default palette: card panel, foreground text, readable primary +// buttons, DM Sans type. Alert exposes no runtime custom-palette API, so these +// build-time resources use the stock palette for each native appearance. +// The fonts are embedded by the expo-font plugin config in app.config.ts. // AppCompat's default dialog window background is an inset rounded rect, so // the replacement keeps the same 16dp inset to preserve the dialog's margins. @@ -30,21 +32,18 @@ const DIALOG_BACKGROUND_DRAWABLE = ` `; -const COLORS = { - light: { - background: "#FFFFFF", // --color-card - text: "#262626", // --color-foreground - secondaryText: "#525252", // --color-foreground-secondary - buttonText: "#262626", // --color-primary - }, - night: { - background: "#171717", - text: "#F5F5F5", - secondaryText: "#A3A3A3", - buttonText: "#F5F5F5", - }, +const colorsFor = (appearance) => { + const variables = themeVariables[appearance]; + return { + background: variables["--color-card"], + text: variables["--color-foreground"], + secondaryText: variables["--color-foreground-secondary"], + buttonText: variables["--color-primary-text"], + }; }; +const COLORS = { light: colorsFor("light"), night: colorsFor("dark") }; + function assignStyleItem(style, name, value) { style.item = style.item ?? []; const existing = style.item.find((item) => item.$?.name === name); diff --git a/apps/mobile/scripts/generate-device-stream.mts b/apps/mobile/scripts/generate-device-stream.mts new file mode 100644 index 000000000000..249bbab6ca58 --- /dev/null +++ b/apps/mobile/scripts/generate-device-stream.mts @@ -0,0 +1,39 @@ +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; +import { build } from "vite-plus"; + +const mobileRoot = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); + +/** Metro embeds the shared browser transport as a small script in the native WebView. */ +export async function generateDeviceStreamScript() { + const result = await build({ + configFile: false, + logLevel: "silent", + build: { + write: false, + target: "es2022", + minify: true, + lib: { + entry: NodePath.join(mobileRoot, "src/features/devices/device-stream.browser.ts"), + name: "T3DeviceStream", + formats: ["iife"], + }, + }, + }); + const bundles = Array.isArray(result) ? result : [result]; + const chunk = bundles + .flatMap((bundle) => ("output" in bundle ? bundle.output : [])) + .find((output) => output.type === "chunk"); + if (!chunk) throw new Error("Device stream build did not emit a script."); + const root = NodePath.join(mobileRoot, ".generated/device-stream"); + await NodeFSP.mkdir(root, { recursive: true }); + for (const [name, contents] of [ + ["index.js", `module.exports = ${JSON.stringify(chunk.code)};\n`], + ["package.json", '{"main":"index.js"}\n'], + ] as const) { + const destination = NodePath.join(root, name); + const previous = await NodeFSP.readFile(destination, "utf8").catch(() => null); + if (previous !== contents) await NodeFSP.writeFile(destination, contents); + } +} diff --git a/apps/mobile/scripts/generate-uniwind-themes.mts b/apps/mobile/scripts/generate-uniwind-themes.mts index a51728458082..94643c3e413f 100644 --- a/apps/mobile/scripts/generate-uniwind-themes.mts +++ b/apps/mobile/scripts/generate-uniwind-themes.mts @@ -5,17 +5,15 @@ import * as NodePath from "node:path"; import tailwindColors from "tailwindcss/colors"; import { BUILT_IN_THEME_IDS, type BuiltInThemeId } from "@t3tools/shared/themePalettes"; -import clerkTheme from "../clerk-theme.json" with { type: "json" }; import { + createMobileThemeVariables, + getMobileThemeColors, getMobileThemeVariables, - MOBILE_THEME_VARIABLE_NAMES, - themeColorWithAlpha, + DEFAULT_MOBILE_THEME_ID, type MobileThemeAppearance, - type MobileThemeVariables, } from "../src/lib/mobileTheme.ts"; const APPEARANCES = ["light", "dark"] as const; -const GLOBAL_CSS_PATH = NodePath.resolve(import.meta.dirname, "../global.css"); const GENERATED_CSS_PATH = NodePath.resolve(import.meta.dirname, "../generated-uniwind-themes.css"); const GENERATED_NAMES_PATH = NodePath.resolve( import.meta.dirname, @@ -25,6 +23,7 @@ const GENERATED_DEFAULT_VARIABLES_PATH = NodePath.resolve( import.meta.dirname, "../generated-uniwind-default-theme-variables.json", ); +const GENERATED_CLERK_THEME_PATH = NodePath.resolve(import.meta.dirname, "../clerk-theme.json"); type TailwindColorFamily = keyof typeof tailwindColors; type TailwindColorShade = 50 | 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900 | 950; @@ -159,13 +158,38 @@ const variablesFor = (themeId: BuiltInThemeId, appearance: MobileThemeAppearance // Clerk's native screens use one build-time palette per appearance. Custom // profile pages must match it even when the rest of the app uses a named theme. +const clerkColorsFor = (appearance: MobileThemeAppearance) => { + // Native authentication uses plain cards, rather than tonal settings groups. + const variables = createMobileThemeVariables( + getMobileThemeColors(DEFAULT_MOBILE_THEME_ID, appearance), + appearance, + ); + return { + primary: variables["--color-primary"], + background: variables["--color-sheet-solid"], + input: variables["--color-input"], + danger: variables["--color-danger-foreground"], + success: appearance === "dark" ? "#34d399" : "#059669", + warning: variables["--color-warning-foreground"], + foreground: variables["--color-foreground"], + mutedForeground: variables["--color-foreground-muted"], + primaryForeground: variables["--color-primary-foreground"], + inputForeground: variables["--color-foreground"], + neutral: variables["--color-secondary"], + border: variables["--color-border"], + ring: variables["--color-focus"], + muted: variables["--color-subtle"], + shadow: variables["--color-primary-shadow"], + }; +}; + const clerkVariablesFor = (appearance: MobileThemeAppearance) => { - const colors = appearance === "dark" ? clerkTheme.darkColors : clerkTheme.colors; + const colors = clerkColorsFor(appearance); return { "--color-clerk-page": colors.background.toLowerCase(), "--color-clerk-foreground": colors.foreground.toLowerCase(), "--color-clerk-foreground-muted": colors.mutedForeground.toLowerCase(), - "--color-clerk-border": themeColorWithAlpha(colors.border, 0.06), + "--color-clerk-border": colors.border, "--color-clerk-danger": colors.danger.toLowerCase(), }; }; @@ -181,6 +205,7 @@ export const renderUniwindThemesCSS = () => { const variants = [ ...APPEARANCES.map((appearance) => renderVariant(appearance, { + ...getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, appearance), ...adaptiveVariablesFor(appearance), ...clerkVariablesFor(appearance), }), @@ -202,50 +227,27 @@ export const renderUniwindThemesCSS = () => { ].join("\n"); }; -const readVariantBody = (css: string, appearance: MobileThemeAppearance): string => { - const marker = `@variant ${appearance} {`; - const markerIndex = css.indexOf(marker); - if (markerIndex === -1) throw new Error(`Could not find ${marker} in global.css.`); - - const openingBraceIndex = css.indexOf("{", markerIndex); - let depth = 0; - for (let index = openingBraceIndex; index < css.length; index += 1) { - if (css[index] === "{") depth += 1; - if (css[index] !== "}") continue; - depth -= 1; - if (depth === 0) return css.slice(openingBraceIndex + 1, index); - } - throw new Error(`Could not find the end of ${marker} in global.css.`); -}; - -export const readDefaultThemeVariables = (css: string) => - Object.fromEntries( - APPEARANCES.map((appearance) => { - const body = readVariantBody(css, appearance); - const variables = Object.fromEntries( - MOBILE_THEME_VARIABLE_NAMES.map((name) => { - const match = new RegExp(`^\\s*${name}:\\s*([^;]+);`, "mu").exec(body); - if (!match?.[1]) { - throw new Error(`Default ${appearance} theme is missing ${name}.`); - } - return [name, match[1].trim()]; - }), - ) as MobileThemeVariables; - return [appearance, variables]; - }), - ) as Readonly>; - -export const renderDefaultThemeVariablesJSON = (css: string) => - `${JSON.stringify(readDefaultThemeVariables(css), null, 2)}\n`; +export const renderDefaultThemeVariablesJSON = () => + `${JSON.stringify( + Object.fromEntries( + APPEARANCES.map((appearance) => [ + appearance, + getMobileThemeVariables(DEFAULT_MOBILE_THEME_ID, appearance), + ]), + ), + null, + 2, + )}\n`; export const getGeneratedUniwindThemeOutputs = (): ReadonlyArray< readonly [filename: string, contents: string] > => [ [GENERATED_CSS_PATH, renderUniwindThemesCSS()], [GENERATED_NAMES_PATH, `${JSON.stringify(customThemeNames, null, 2)}\n`], + [GENERATED_DEFAULT_VARIABLES_PATH, renderDefaultThemeVariablesJSON()], [ - GENERATED_DEFAULT_VARIABLES_PATH, - renderDefaultThemeVariablesJSON(NodeFS.readFileSync(GLOBAL_CSS_PATH, "utf8")), + GENERATED_CLERK_THEME_PATH, + `${JSON.stringify({ colors: clerkColorsFor("light"), darkColors: clerkColorsFor("dark"), design: { borderRadius: 18 } }, null, 2)}\n`, ], ]; diff --git a/apps/mobile/scripts/generate-uniwind-themes.test.ts b/apps/mobile/scripts/generate-uniwind-themes.test.ts index 31663b8de2ff..a225b7087c86 100644 --- a/apps/mobile/scripts/generate-uniwind-themes.test.ts +++ b/apps/mobile/scripts/generate-uniwind-themes.test.ts @@ -5,9 +5,10 @@ import { describe, expect, it } from "vite-plus/test"; import { customThemeNames, getGeneratedUniwindThemeOutputs, - readDefaultThemeVariables, + renderDefaultThemeVariablesJSON, renderUniwindThemesCSS, } from "./generate-uniwind-themes.mts"; +import { readDefaultMobileThemeVariables } from "../src/lib/mobileTheme.test-support"; describe("generate mobile Uniwind themes", () => { it("keeps the committed outputs current", () => { @@ -44,12 +45,15 @@ describe("generate mobile Uniwind themes", () => { } }); - it("generates the default runtime bridge from the authored CSS", () => { - const css = NodeFS.readFileSync(NodePath.resolve(import.meta.dirname, "../global.css"), "utf8"); - const variables = readDefaultThemeVariables(css); + it("keeps the default runtime bridge and generated CSS on the same palette", () => { + const variables = JSON.parse(renderDefaultThemeVariablesJSON()); - expect(variables.light["--color-screen"]).toBe("#f2f2f7"); + expect(variables.light).toEqual(readDefaultMobileThemeVariables("light")); + expect(variables.dark).toEqual(readDefaultMobileThemeVariables("dark")); + expect(variables.light["--color-screen"]).toBe("#fcfcfc"); + expect(variables.light["--color-drawer"]).toBe("#fafafa"); expect(variables.dark["--color-screen"]).toBe("#0a0a0a"); + expect(variables.dark["--color-drawer"]).toBe("#000000"); expect(Object.keys(variables.light)).toEqual(Object.keys(variables.dark)); }); @@ -77,11 +81,11 @@ describe("generate mobile Uniwind themes", () => { ), name, ).toEqual({ - "--color-clerk-page": isDark ? "#0e0e0e" : "#f2f2f7", - "--color-clerk-foreground": isDark ? "#f5f5f5" : "#262626", - "--color-clerk-foreground-muted": isDark ? "#a3a3a3" : "#737373", - "--color-clerk-border": isDark ? "rgba(42, 42, 42, 0.06)" : "rgba(229, 229, 234, 0.06)", - "--color-clerk-danger": isDark ? "#fca5a5" : "#dc2626", + "--color-clerk-page": isDark ? "#0a0a0a" : "#fcfcfc", + "--color-clerk-foreground": isDark ? "#f5f5f5" : "#27272a", + "--color-clerk-foreground-muted": isDark ? "#818181" : "#71717b", + "--color-clerk-border": isDark ? "#191919" : "#e4e4e7", + "--color-clerk-danger": isDark ? "#ff6467" : "#c10007", }); } }); diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index c750e366c1d3..3de335cfd562 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -11,7 +11,14 @@ import { type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; import { useEffect, useRef } from "react"; -import { Platform, Pressable, ScrollView, StyleSheet, View } from "react-native"; +import { + Platform, + Pressable, + ScrollView, + StyleSheet, + View, + useWindowDimensions, +} from "react-native"; import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/AppText"; @@ -30,6 +37,7 @@ import { import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentComposerSheet"; import { ReviewSheet } from "./features/review/ReviewSheet"; import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen"; +import { DevicePreviewRouteScreen } from "./features/devices/DevicePreviewRouteScreen"; import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet"; import { GitCommitSheet } from "./features/threads/git/GitCommitSheet"; import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet"; @@ -92,6 +100,7 @@ import { transitionIncomingSharePresentation, } from "./features/sharing/incoming-share-presentation"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; +import { deriveLayout } from "./lib/layout"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; import { FORM_SHEET_PRESENTATION_OPTIONS } from "./native/sheet-surface"; import { useThreadOutboxDrain } from "./state/use-thread-outbox-drain"; @@ -338,9 +347,8 @@ const SettingsSheetStack = createNativeStackNavigator({ // the same deep-link URLs the nested config produced. const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId"; -// New-task / add-project flow: nested navigator inside the formSheet (Settings-sheet -// pattern — a plain formSheet screen cannot render a stack header; the header and -// in-sheet pushes come from this nested stack). +// New-task / add-project flow: the nested navigator owns the header and pushes +// whether the flow opens in the workspace or in a compact form sheet. const NewTaskSheetStack = createNativeStackNavigator({ initialRouteName: "NewTask", screenOptions: { @@ -449,6 +457,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([ "SettingsLegal", "SettingsSheet", "ThreadReviewComment", + "ThreadDevicePreview", "ThreadSettingsSheet", ]); @@ -568,7 +577,7 @@ function NotFoundScreen() { ); } -export const RootStack = createNativeStackNavigator({ +const RootStackConfig = createNativeStackNavigator({ initialRouteName: "Home", layout: RootStackLayout, screenOptions: { @@ -595,6 +604,15 @@ export const RootStack = createNativeStackNavigator({ linking: `${THREAD_LINKING_PREFIX}/terminal`, options: SOLID_HEADER_OPTIONS, }), + ThreadDevicePreview: createNativeStackScreen({ + screen: DevicePreviewRouteScreen, + linking: `${THREAD_LINKING_PREFIX}/devices`, + options: { + presentation: "fullScreenModal", + headerShown: false, + gestureEnabled: false, + }, + }), ThreadReview: createNativeStackScreen({ screen: ReviewSheet, linking: `${THREAD_LINKING_PREFIX}/review`, @@ -687,15 +705,6 @@ export const RootStack = createNativeStackNavigator({ options: { gestureEnabled: true, headerShown: false, - // Android pushes settings as a regular full page with an in-screen - // back header; iOS keeps the detented form sheet. - ...(Platform.OS === "android" - ? { presentation: "card" as const } - : { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.92], - sheetGrabberVisible: true, - }), }, }), SettingsLegal: createNativeStackScreen({ @@ -759,15 +768,6 @@ export const RootStack = createNativeStackNavigator({ options: { gestureEnabled: true, headerShown: false, - // Android pushes the flow as a regular full page — the draft should - // read like a thread that just doesn't exist yet; iOS keeps the sheet. - ...(Platform.OS === "android" - ? { presentation: "card" as const } - : { - ...FORM_SHEET_PRESENTATION_OPTIONS, - sheetAllowedDetents: [0.92], - sheetGrabberVisible: true, - }), }, }), NotFound: createNativeStackScreen({ @@ -776,6 +776,32 @@ export const RootStack = createNativeStackNavigator({ }), }, }); + +export const RootStack = RootStackConfig.with(function AdaptiveRootStack({ Navigator }) { + const { width, height } = useWindowDimensions(); + const usesWorkspaceFlowScreens = + Platform.OS === "android" || deriveLayout({ width, height }).usesSplitView; + + return ( + { + if (route.name !== "SettingsSheet" && route.name !== "NewTaskSheet") { + return {}; + } + + // Follow the workspace viewport as it resizes; compact iOS keeps sheets. + return usesWorkspaceFlowScreens + ? { presentation: "card" } + : { + ...FORM_SHEET_PRESENTATION_OPTIONS, + sheetAllowedDetents: [0.92], + sheetGrabberVisible: true, + }; + }} + /> + ); +}); + type RootStackType = typeof RootStack; const navigationPathConfig = { diff --git a/apps/mobile/src/components/AndroidScreenHeader.tsx b/apps/mobile/src/components/AndroidScreenHeader.tsx index 2695eee5f01f..6e2095009b3d 100644 --- a/apps/mobile/src/components/AndroidScreenHeader.tsx +++ b/apps/mobile/src/components/AndroidScreenHeader.tsx @@ -25,7 +25,7 @@ export function AndroidHeaderIconButton(props: { readonly disabled?: boolean; readonly selected?: boolean; }) { - return ; + return ; } export function AndroidScreenHeader(props: { @@ -65,6 +65,7 @@ export function AndroidScreenHeader(props: { ) : null} @@ -72,7 +73,7 @@ export function AndroidScreenHeader(props: { {props.leading} - + {props.title} {props.subtitle ? ( @@ -114,6 +115,7 @@ export function AndroidScreenHeader(props: { )} diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 6002dc48cd91..d85d9db7d780 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -19,7 +19,7 @@ import IconArrowUp from "@tabler/icons-react-native/IconArrowUp"; import IconArrowUpCircle from "@tabler/icons-react-native/IconArrowUpCircle"; import IconArrowUpRight from "@tabler/icons-react-native/IconArrowUpRight"; import IconArrowUpRightCircle from "@tabler/icons-react-native/IconArrowUpRightCircle"; -import IconArrowsMaximize from "@tabler/icons-react-native/IconArrowsMaximize"; +import IconArrowsDiagonal2 from "@tabler/icons-react-native/IconArrowsDiagonal2"; import IconArrowsMinimize from "@tabler/icons-react-native/IconArrowsMinimize"; import IconBellRinging from "@tabler/icons-react-native/IconBellRinging"; import IconBolt from "@tabler/icons-react-native/IconBolt"; @@ -60,9 +60,11 @@ import IconGitBranch from "@tabler/icons-react-native/IconGitBranch"; import IconGitMerge from "@tabler/icons-react-native/IconGitMerge"; import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest"; import IconHammer from "@tabler/icons-react-native/IconHammer"; +import IconHome from "@tabler/icons-react-native/IconHome"; import IconInfoCircle from "@tabler/icons-react-native/IconInfoCircle"; import IconKeyboard from "@tabler/icons-react-native/IconKeyboard"; import IconKeyboardHide from "@tabler/icons-react-native/IconKeyboardHide"; +import IconLock from "@tabler/icons-react-native/IconLock"; import IconLayoutColumns from "@tabler/icons-react-native/IconLayoutColumns"; import IconLayoutSidebar from "@tabler/icons-react-native/IconLayoutSidebar"; import IconLayoutSidebarRight from "@tabler/icons-react-native/IconLayoutSidebarRight"; @@ -120,7 +122,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = { "arrow.turn.left.up": IconArrowBackUp, "arrow.up": IconArrowUp, "arrow.up.circle": IconArrowUpCircle, - "arrow.up.left.and.arrow.down.right": IconArrowsMaximize, + "arrow.up.left.and.arrow.down.right": IconArrowsDiagonal2, "arrow.down.right.and.arrow.up.left": IconArrowsMinimize, "arrow.up.right": IconArrowUpRight, "arrow.up.right.circle": IconArrowUpRightCircle, @@ -161,6 +163,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = { gearshape: IconSettings, globe: IconWorld, hammer: IconHammer, + house: IconHome, "info.circle": IconInfoCircle, internaldrive: IconDatabase, keyboard: IconKeyboard, @@ -194,6 +197,7 @@ const ANDROID_ICON_BY_SF_SYMBOL = { "sidebar.right": IconLayoutSidebarRight, "slider.horizontal.3": IconAdjustmentsHorizontal, "square.and.pencil": IconEdit, + "square.on.square": IconCopy, "square.grid.2x2": IconApps, "square.split.2x1": IconLayoutColumns, star: IconStar, @@ -237,6 +241,7 @@ const ANDROID_ICON_BY_MATERIAL_NAME = { keyboard_arrow_down: IconChevronDown, keyboard_arrow_up: IconChevronUp, keyboard_hide: IconKeyboardHide, + lock: IconLock, more_vert: IconDotsVertical, merge: IconGitMerge, public: IconWorld, diff --git a/apps/mobile/src/components/AppText.tsx b/apps/mobile/src/components/AppText.tsx index 805cdb66a3b4..6f96f4246953 100644 --- a/apps/mobile/src/components/AppText.tsx +++ b/apps/mobile/src/components/AppText.tsx @@ -18,7 +18,7 @@ export function AppText({ className, ...props }: AppTextProps) { return ( ); @@ -42,13 +42,9 @@ export function AppTextInput({ className, ref, ...props }: AppTextInputProps) { className, )} placeholderTextColorClassName="accent-placeholder" - selectionColorClassName={ - Platform.OS === "android" ? "accent-primary/32" : "accent-foreground-secondary" - } - cursorColorClassName={ - Platform.OS === "android" ? "accent-primary" : "accent-foreground-secondary" - } - selectionHandleColorClassName={Platform.OS === "android" ? "accent-primary" : undefined} + selectionColorClassName={"accent-focus/32"} + cursorColorClassName={"accent-focus"} + selectionHandleColorClassName={Platform.OS === "android" ? "accent-focus" : undefined} {...props} /> ); diff --git a/apps/mobile/src/components/ComposerContextSheet.tsx b/apps/mobile/src/components/ComposerContextSheet.tsx index 9fd307c9f520..9bdc10dced99 100644 --- a/apps/mobile/src/components/ComposerContextSheet.tsx +++ b/apps/mobile/src/components/ComposerContextSheet.tsx @@ -187,11 +187,9 @@ export function ComposerContextSheet(props: { onRequestClose={props.onClose} > {Platform.OS === "android" ? ( - + Opening document… diff --git a/apps/mobile/src/components/GlassBackdrop.tsx b/apps/mobile/src/components/GlassBackdrop.tsx index 27104f535dc8..ea909f091738 100644 --- a/apps/mobile/src/components/GlassBackdrop.tsx +++ b/apps/mobile/src/components/GlassBackdrop.tsx @@ -6,12 +6,13 @@ import { themeColorWithAlpha } from "../lib/mobileTheme"; /** Frosted backdrop for containers that clip their children to their shape. */ export function GlassBackdrop(props: { readonly fallbackColor?: ColorValue }) { - const { themeAppearance } = useAppearancePreferences(); + const { themeAppearance, themeVariables } = useAppearancePreferences(); const supportsBlur = Platform.OS === "ios"; - const colorStyle = - props.fallbackColor === undefined - ? undefined - : { backgroundColor: themeColorWithAlpha(String(props.fallbackColor), 1) }; + const color = props.fallbackColor ?? themeVariables["--color-glass-fallback"]; + const colorStyle = { + backgroundColor: + supportsBlur || typeof color !== "string" ? color : themeColorWithAlpha(color, 1), + }; return ( <> @@ -25,11 +26,8 @@ export function GlassBackdrop(props: { readonly fallbackColor?: ColorValue }) { ) : null} ); diff --git a/apps/mobile/src/components/GlassSurface.tsx b/apps/mobile/src/components/GlassSurface.tsx index bfbfff2c66cc..4d899f18a4a0 100644 --- a/apps/mobile/src/components/GlassSurface.tsx +++ b/apps/mobile/src/components/GlassSurface.tsx @@ -1,16 +1,10 @@ import { GlassView, isGlassEffectAPIAvailable } from "expo-glass-effect"; import type { ReactNode, Ref } from "react"; -import { - Platform, - useColorScheme, - View, - type ColorValue, - type ViewProps, - type ViewStyle, -} from "react-native"; +import { Platform, View, type ColorValue, type ViewProps, type ViewStyle } from "react-native"; import { withUniwind } from "uniwind"; import { cn } from "../lib/cn"; +import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; import { GlassBackdrop } from "./GlassBackdrop"; // Explicit mappings keep the native glassEffectStyle enum out of style-array conversion. @@ -45,7 +39,8 @@ export function GlassSurface({ style, ...props }: GlassSurfaceProps) { - const isDarkMode = useColorScheme() === "dark"; + const { themeAppearance } = useAppearancePreferences(); + const isDarkMode = themeAppearance === "dark"; const supportsGlass = Platform.OS === "ios" && isGlassEffectAPIAvailable(); const hasShadow = chrome !== "none" && Platform.OS !== "android"; const surfaceStyle: ViewStyle = { diff --git a/apps/mobile/src/components/MaterialButton.android.tsx b/apps/mobile/src/components/MaterialButton.android.tsx index b5a8417fc799..f2df6464f103 100644 --- a/apps/mobile/src/components/MaterialButton.android.tsx +++ b/apps/mobile/src/components/MaterialButton.android.tsx @@ -35,7 +35,7 @@ export function MaterialButton(props: MaterialButtonProps) { : tone === "danger" ? colors["--color-danger-foreground"] : tone === "text" - ? colors["--color-primary"] + ? colors["--color-primary-text"] : colors["--color-secondary-foreground"]; return ( - {props.label} + + {props.label} + ); } diff --git a/apps/mobile/src/components/MaterialConfirmDialog.android.tsx b/apps/mobile/src/components/MaterialConfirmDialog.android.tsx index 3e65d7185ecc..35057ce10f5f 100644 --- a/apps/mobile/src/components/MaterialConfirmDialog.android.tsx +++ b/apps/mobile/src/components/MaterialConfirmDialog.android.tsx @@ -58,9 +58,9 @@ export function MaterialConfirmDialog(props: MaterialConfirmDialogProps) { colors={{ focusedTextColor: colors["--color-foreground"], unfocusedTextColor: colors["--color-foreground"], - focusedIndicatorColor: colors["--color-primary"], + focusedIndicatorColor: colors["--color-focus"], unfocusedIndicatorColor: colors["--color-border"], - cursorColor: colors["--color-primary"], + cursorColor: colors["--color-focus"], }} > @@ -74,7 +74,10 @@ export function MaterialConfirmDialog(props: MaterialConfirmDialogProps) { ) : null} - + {props.request.cancelText ?? "Cancel"} @@ -84,7 +87,9 @@ export function MaterialConfirmDialog(props: MaterialConfirmDialogProps) { enabled={!props.confirmDisabled} colors={{ contentColor: - colors[props.request.destructive ? "--color-danger" : "--color-primary"], + colors[ + props.request.destructive ? "--color-danger-foreground" : "--color-primary-text" + ], }} > {props.request.confirmText} diff --git a/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx b/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx index 3978712683c0..2882d0d91ec0 100644 --- a/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx +++ b/apps/mobile/src/components/MaterialFloatingActionButton.android.tsx @@ -25,9 +25,9 @@ export function MaterialFloatingActionButton(props: { const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); const typography = useScaledTextRole("footnote"); const primary = props.tone === "primary"; - const containerColor = colors[primary ? "--color-primary" : "--color-thread-selected"]; + const containerColor = colors[primary ? "--color-primary" : "--color-secondary"]; const contentColor = - colors[primary ? "--color-primary-foreground" : "--color-thread-selected-foreground"]; + colors[primary ? "--color-primary-foreground" : "--color-secondary-foreground"]; const Component = props.variant === "extended" ? ExtendedFloatingActionButton @@ -76,9 +76,7 @@ export function MaterialFloatingActionButton(props: { diff --git a/apps/mobile/src/components/MaterialFloatingActionButton.tsx b/apps/mobile/src/components/MaterialFloatingActionButton.tsx index eeed5fd1d138..53230804a4e5 100644 --- a/apps/mobile/src/components/MaterialFloatingActionButton.tsx +++ b/apps/mobile/src/components/MaterialFloatingActionButton.tsx @@ -15,7 +15,7 @@ export function MaterialFloatingActionButton( onPress={props.onPress} className={cn( "min-h-14 min-w-14 flex-row items-center justify-center gap-2 rounded-2xl px-4", - props.tone === "primary" ? "bg-primary" : "bg-thread-selected", + props.tone === "primary" ? "bg-primary" : "bg-secondary", props.className, )} style={props.style} @@ -24,18 +24,14 @@ export function MaterialFloatingActionButton( name={props.icon} size={24} tintColorClassName={ - props.tone === "primary" - ? "accent-primary-foreground" - : "accent-thread-selected-foreground" + props.tone === "primary" ? "accent-primary-foreground" : "accent-secondary-foreground" } /> {props.variant === "extended" && props.expanded !== false ? ( {props.label} diff --git a/apps/mobile/src/components/MaterialIconButton.android.tsx b/apps/mobile/src/components/MaterialIconButton.android.tsx index 9cc6f5dc6ce0..21284249f79d 100644 --- a/apps/mobile/src/components/MaterialIconButton.android.tsx +++ b/apps/mobile/src/components/MaterialIconButton.android.tsx @@ -17,6 +17,7 @@ export function MaterialIconButton(props: { readonly disabled?: boolean; readonly selected?: boolean; readonly variant?: "standard" | "primary" | "tonal" | "danger"; + readonly tintColorClassName?: string; }) { const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); const variant = props.variant ?? "standard"; @@ -40,7 +41,7 @@ export function MaterialIconButton(props: { ? "accent-danger-foreground" : variant === "tonal" ? "accent-secondary-foreground" - : "accent-foreground"; + : (props.tintColorClassName ?? "accent-foreground"); return ( - + ); } diff --git a/apps/mobile/src/components/MaterialSearchField.tsx b/apps/mobile/src/components/MaterialSearchField.tsx index 5ed3945039d1..ab96440f5f09 100644 --- a/apps/mobile/src/components/MaterialSearchField.tsx +++ b/apps/mobile/src/components/MaterialSearchField.tsx @@ -30,9 +30,9 @@ export function MaterialSearchField({ returnKeyType="search" placeholder={placeholder} placeholderTextColorClassName="accent-placeholder" - selectionColorClassName="accent-primary/32" - cursorColorClassName="accent-primary" - selectionHandleColorClassName="accent-primary" + selectionColorClassName="accent-focus/32" + cursorColorClassName="accent-focus" + selectionHandleColorClassName="accent-focus" className="min-w-0 flex-1 py-2 font-sans text-base text-foreground" value={value} onChangeText={onChangeText} diff --git a/apps/mobile/src/components/RowPressable.tsx b/apps/mobile/src/components/RowPressable.tsx index bd52fbe74f11..959a1a8aba97 100644 --- a/apps/mobile/src/components/RowPressable.tsx +++ b/apps/mobile/src/components/RowPressable.tsx @@ -9,22 +9,24 @@ import { useHoverGesture } from "../lib/useHoverGesture"; export function RowPressable({ children, className, - interactionClassName = "bg-primary", + interactionClassName = "bg-row-hover", + interactionOpacity = 1, ...props }: Omit, "children"> & { readonly children: ReactNode; readonly interactionClassName?: string; + readonly interactionOpacity?: number; }) { const { hovered, hoverGesture } = useHoverGesture(props.disabled ?? false); return ( - {({ pressed }) => ( + {() => ( <> {children} diff --git a/apps/mobile/src/components/ScreenHeader.android.tsx b/apps/mobile/src/components/ScreenHeader.android.tsx index edb0b770e93c..61a38c3a00c2 100644 --- a/apps/mobile/src/components/ScreenHeader.android.tsx +++ b/apps/mobile/src/components/ScreenHeader.android.tsx @@ -53,7 +53,7 @@ export function ScreenHeader(props: ScreenHeaderProps) { @@ -95,7 +95,7 @@ export function ScreenHeader(props: ScreenHeaderProps) { @@ -104,7 +104,7 @@ export function ScreenHeader(props: ScreenHeaderProps) { {menuView} @@ -138,7 +138,7 @@ export function ScreenHeader(props: ScreenHeaderProps) { icon: "magnifyingglass", onPress: () => setSearchOpen(true), }, - ...(search.onRefresh + ...(search.refreshInToolbar && search.onRefresh ? [ { accessibilityLabel: search.refreshAccessibilityLabel ?? "Refresh", diff --git a/apps/mobile/src/components/ScreenHeader.tsx b/apps/mobile/src/components/ScreenHeader.tsx index b5e5d1a5dd12..6024ceec08d9 100644 --- a/apps/mobile/src/components/ScreenHeader.tsx +++ b/apps/mobile/src/components/ScreenHeader.tsx @@ -103,7 +103,7 @@ export function ScreenHeader(props: ScreenHeaderProps) { ...props.options, }} /> - {props.sidebar !== false && layout.usesSplitView ? ( + {layout.usesSplitView && (props.sidebar !== false || props.backInSplitView) ? ( {props.backInSplitView && (props.backInSplitView.onPress || props.onBack) ? ( ) : null} - + {props.sidebar !== false ? ( + + ) : null} ) : null} {(props.actions?.length || diff --git a/apps/mobile/src/components/SegmentedControl.tsx b/apps/mobile/src/components/SegmentedControl.tsx index 607062e0579e..e1e61cda26df 100644 --- a/apps/mobile/src/components/SegmentedControl.tsx +++ b/apps/mobile/src/components/SegmentedControl.tsx @@ -40,7 +40,7 @@ export function SegmentedControl( layout={LinearTransition.duration(200) .easing(Easing.out(Easing.cubic)) .reduceMotion(ReduceMotion.System)} - className="absolute inset-y-0 rounded-full bg-subtle-strong" + className="absolute inset-y-0 rounded-full bg-secondary" style={{ width: `${100 / props.options.length}%`, start: `${ @@ -70,7 +70,7 @@ export function SegmentedControl( {option.label} diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index a191a9c7175b..ea150180393c 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -14,6 +14,7 @@ import { SymbolView } from "../../components/AppSymbol"; import { useCallback, useMemo, useRef, type ComponentProps } from "react"; import { ActivityIndicator, + Platform, Pressable, RefreshControl, useWindowDimensions, @@ -63,9 +64,6 @@ function ArchivedThreadsHeader(props: { compactPlaceholder: "Search", mode: "inline", compactToolbar: width < 700, - onRefresh: props.onRefresh, - refreshInToolbar: true, - refreshAccessibilityLabel: "Refresh archived threads", }} menus={[ { @@ -110,6 +108,15 @@ function ArchivedThreadsHeader(props: { }, ], }, + ...(Platform.OS === "android" + ? [ + { + id: "refresh", + title: "Refresh archived threads", + onPress: props.onRefresh, + }, + ] + : []), ], }, ]} diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index feadf6c81893..8fa346c5be45 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -4,20 +4,13 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { EnvironmentId } from "@t3tools/contracts"; import { RelayMobileClientId } from "@t3tools/contracts/relay"; -import { DPOP_UNKNOWN_HINT, ManagedRelay } from "@t3tools/client-runtime/relay"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import { remoteHttpClientLayer } from "@t3tools/client-runtime/rpc"; import { HttpClient } from "effect/unstable/http"; -import { MobilePreferencesStore } from "../../persistence/mobile-preferences"; + import { MobileStorage } from "../../persistence/mobile-storage"; -import { - linkEnvironmentToCloud, - linkEnvironmentToCloudWithPreference, - connectCloudEnvironment, - listCloudEnvironments, - listCloudEnvironmentsWithStatus, - refreshCloudEnvironmentConnection, -} from "./linkEnvironment"; +import { linkEnvironmentToCloudWithPreference } from "./linkEnvironment"; vi.mock("expo-constants", () => ({ default: { @@ -56,8 +49,6 @@ vi.mock("expo-secure-store", () => ({ setItemAsync: vi.fn(), })); -const loadPreferences = vi.fn(() => Effect.succeed({})); - const savedConnection = { environmentId: EnvironmentId.make("env-1"), environmentLabel: "Desktop", @@ -68,8 +59,6 @@ const savedConnection = { bearerToken: "local-bearer", }; -const stableClerkToken = "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyXzEyMyJ9.test"; - const createProofMock = vi.fn( (input: { readonly method: string; readonly url: string; readonly accessToken?: string }) => Effect.succeed(`dpop:${input.method}:${input.url}`), @@ -86,14 +75,6 @@ function cloudClientLayer() { const httpClientLayer = remoteHttpClientLayer((input, init) => globalThis.fetch(input, init)); return Layer.mergeAll( httpClientLayer, - Layer.succeed( - MobilePreferencesStore, - MobilePreferencesStore.of({ - load: loadPreferences(), - savePatch: (patch) => Effect.succeed(patch), - update: () => Effect.succeed({}), - }), - ), Layer.succeed( MobileStorage, MobileStorage.of({ @@ -123,7 +104,6 @@ const withCloudServices = ( | HttpClient.HttpClient | ManagedRelay.ManagedRelayClient | ManagedRelay.ManagedRelayDpopSigner - | MobilePreferencesStore | MobileStorage >, ) => effect.pipe(Effect.provide(cloudClientLayer())); @@ -163,439 +143,12 @@ function requestBodyText(body: BodyInit | null | undefined): string { return body instanceof Uint8Array ? new TextDecoder().decode(body) : String(body ?? ""); } -function validDpopAccessTokenResponse(scope = "environment:status environment:connect") { - return { - access_token: "relay-dpop-token", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "DPoP", - expires_in: 300, - scope, - }; -} - -function listedEnvironment(environmentId: string) { - return { - environmentId: EnvironmentId.make(environmentId), - label: "Desktop", - endpoint: { - httpBaseUrl: `https://${environmentId}.example.test/`, - wsBaseUrl: `wss://${environmentId}.example.test/ws`, - providerKind: "cloudflare_tunnel" as const, - }, - linkedAt: "2026-05-25T00:00:00.000Z", - }; -} - describe("mobile cloud link environment client", () => { beforeEach(() => { vi.restoreAllMocks(); createProofMock.mockClear(); - loadPreferences.mockClear(); }); - it.effect("decodes relay environment list responses before returning records", () => - Effect.gen(function* () { - vi.stubGlobal( - "fetch", - vi.fn(() => - Promise.resolve( - Response.json({ - environments: [ - { - environmentId: "env-1", - label: "Desktop", - endpoint: { - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - linkedAt: "2026-05-25T00:00:00.000Z", - }, - ], - }), - ), - ), - ); - - const records = yield* withCloudServices( - listCloudEnvironments({ clerkToken: "clerk-token" }), - ); - expect(records).toEqual([ - { - environmentId: "env-1", - label: "Desktop", - endpoint: { - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - linkedAt: "2026-05-25T00:00:00.000Z", - }, - ]); - }), - ); - - it.effect("rejects malformed relay environment list responses", () => - Effect.gen(function* () { - vi.stubGlobal( - "fetch", - vi.fn(() => - Promise.resolve( - Response.json({ - environments: [ - { - environmentId: "env-1", - label: "Desktop", - endpoint: { - httpBaseUrl: "", - wsBaseUrl: "wss://desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - linkedAt: "2026-05-25T00:00:00.000Z", - }, - ], - }), - ), - ), - ); - - const error = yield* withCloudServices( - listCloudEnvironments({ clerkToken: "clerk-token" }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "https://relay.example.test/v1/environments failed", - }); - }), - ); - - it.effect("loads signed status for each advertised cloud environment", () => - Effect.gen(function* () { - const fetchMock = vi.fn((url: string | URL, _init?: RequestInit) => { - if (String(url) === "https://relay.example.test/v1/environments") { - return Promise.resolve( - Response.json({ - environments: [ - { - environmentId: "env-1", - label: "Desktop", - endpoint: { - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - linkedAt: "2026-05-25T00:00:00.000Z", - }, - ], - }), - ); - } - if (String(url) === "https://relay.example.test/v1/client/dpop-token") { - return Promise.resolve(Response.json(validDpopAccessTokenResponse())); - } - expect(String(url)).toBe("https://relay.example.test/v1/environments/env-1/status"); - return Promise.resolve( - Response.json({ - environmentId: "env-1", - endpoint: { - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - status: "online", - checkedAt: "2026-05-25T00:01:00.000Z", - descriptor: { - environmentId: "env-1", - label: "Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - }), - ); - }); - vi.stubGlobal("fetch", fetchMock); - - const records = yield* withCloudServices( - listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }), - ); - expect(records).toMatchObject([ - { - environment: { - environmentId: "env-1", - label: "Desktop", - }, - status: { - environmentId: "env-1", - status: "online", - checkedAt: "2026-05-25T00:01:00.000Z", - }, - statusError: null, - }, - ]); - expect(String(fetchMock.mock.calls[2]?.[0])).toBe( - "https://relay.example.test/v1/environments/env-1/status", - ); - expect(fetchMock.mock.calls[2]?.[1]?.method).toBe("POST"); - const statusHeaders = new Headers(fetchMock.mock.calls[2]?.[1]?.headers); - expect(statusHeaders.get("authorization")).toBe("DPoP relay-dpop-token"); - expect(statusHeaders.get("dpop")).toBe( - "dpop:POST:https://relay.example.test/v1/environments/env-1/status", - ); - expect(createProofMock).toHaveBeenCalledWith({ - method: "POST", - url: "https://relay.example.test/v1/environments/env-1/status", - accessToken: "relay-dpop-token", - }); - }), - ); - - it.effect("reuses one valid DPoP access token while probing multiple environment statuses", () => - Effect.gen(function* () { - const fetchMock = vi.fn((url: string | URL, _init?: RequestInit) => { - if (String(url).endsWith("/v1/environments")) { - return Promise.resolve( - Response.json({ - environments: [listedEnvironment("env-1"), listedEnvironment("env-2")], - }), - ); - } - if (String(url).endsWith("/v1/client/dpop-token")) { - return Promise.resolve(Response.json(validDpopAccessTokenResponse())); - } - const environmentId = String(url).includes("/env-1/") ? "env-1" : "env-2"; - return Promise.resolve( - Response.json({ - environmentId, - endpoint: listedEnvironment(environmentId).endpoint, - status: "online", - checkedAt: "2026-05-25T00:01:00.000Z", - descriptor: { - environmentId, - label: "Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - }), - ); - }); - vi.stubGlobal("fetch", fetchMock); - - yield* withCloudServices(listCloudEnvironmentsWithStatus({ clerkToken: stableClerkToken })); - - expect( - fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/v1/client/dpop-token")), - ).toHaveLength(1); - }), - ); - - it.effect("reuses the status-and-connect token when connecting from the cloud list", () => - Effect.gen(function* () { - const fetchMock = vi.fn((url: string | URL, _init?: RequestInit) => { - if (String(url).endsWith("/v1/environments")) { - return Promise.resolve( - Response.json({ - environments: [listedEnvironment("env-1")], - }), - ); - } - if (String(url).endsWith("/v1/client/dpop-token")) { - return Promise.resolve(Response.json(validDpopAccessTokenResponse())); - } - if (String(url).endsWith("/status")) { - return Promise.resolve( - Response.json({ - environmentId: "env-1", - endpoint: listedEnvironment("env-1").endpoint, - status: "online", - checkedAt: "2026-05-25T00:01:00.000Z", - descriptor: { - environmentId: "env-1", - label: "Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - }), - ); - } - if (String(url).endsWith("/connect")) { - return Promise.resolve( - Response.json({ - environmentId: "env-1", - endpoint: listedEnvironment("env-1").endpoint, - credential: "one-time-cloud-credential", - expiresAt: "2026-05-25T00:05:00.000Z", - }), - ); - } - if (String(url).endsWith("/.well-known/t3/environment")) { - return Promise.resolve( - Response.json({ - environmentId: "env-1", - label: "Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }), - ); - } - return Promise.resolve( - Response.json({ - access_token: "environment-dpop-token", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "DPoP", - expires_in: 3600, - scope: "orchestration:read orchestration:operate terminal:operate review:write", - }), - ); - }); - vi.stubGlobal("fetch", fetchMock); - - yield* withCloudServices( - Effect.gen(function* () { - const records = yield* listCloudEnvironmentsWithStatus({ - clerkToken: stableClerkToken, - }); - yield* connectCloudEnvironment({ - clerkToken: stableClerkToken, - environment: records[0]!.environment, - }); - }), - ); - - expect( - fetchMock.mock.calls.filter(([url]) => String(url).endsWith("/v1/client/dpop-token")), - ).toHaveLength(1); - const exchangeRequest = fetchMock.mock.calls.find(([url]) => - String(url).endsWith("/v1/client/dpop-token"), - )?.[1]; - expect(new URLSearchParams(requestBodyText(exchangeRequest?.body)).get("scope")).toBe( - "environment:status environment:connect", - ); - const environmentTokenRequest = fetchMock.mock.calls.find(([url]) => - String(url).endsWith("/oauth/token"), - )?.[1]; - const environmentTokenBody = new URLSearchParams( - requestBodyText(environmentTokenRequest?.body), - ); - expect(environmentTokenBody.get("client_label")).toBe("T3 Code Mobile"); - expect(environmentTokenBody.get("client_device_type")).toBe("mobile"); - expect(environmentTokenBody.get("client_os")).toBe("iOS"); - }), - ); - - it.effect("keeps advertised environments visible when status probing fails", () => - Effect.gen(function* () { - vi.stubGlobal( - "fetch", - vi.fn((url: string | URL) => { - if (String(url) === "https://relay.example.test/v1/environments") { - return Promise.resolve( - Response.json({ - environments: [ - { - environmentId: "env-1", - label: "Desktop", - endpoint: { - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - linkedAt: "2026-05-25T00:00:00.000Z", - }, - ], - }), - ); - } - if (String(url) === "https://relay.example.test/v1/client/dpop-token") { - return Promise.resolve(Response.json(validDpopAccessTokenResponse())); - } - return Promise.resolve(Response.json({ error: "offline" }, { status: 503 })); - }), - ); - - const records = yield* withCloudServices( - listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }), - ); - expect(records).toMatchObject([ - { - environment: { - environmentId: "env-1", - label: "Desktop", - }, - status: null, - statusError: "https://relay.example.test/v1/environments/env-1/status failed", - }, - ]); - }), - ); - - it.effect("rejects status responses for a different advertised environment", () => - Effect.gen(function* () { - vi.stubGlobal( - "fetch", - vi.fn((url: string | URL) => { - if (String(url) === "https://relay.example.test/v1/environments") { - return Promise.resolve( - Response.json({ - environments: [ - { - environmentId: "env-1", - label: "Desktop", - endpoint: { - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - linkedAt: "2026-05-25T00:00:00.000Z", - }, - ], - }), - ); - } - if (String(url) === "https://relay.example.test/v1/client/dpop-token") { - return Promise.resolve(Response.json(validDpopAccessTokenResponse())); - } - return Promise.resolve( - Response.json({ - environmentId: "env-other", - endpoint: { - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - status: "online", - checkedAt: "2026-05-25T00:01:00.000Z", - descriptor: { - environmentId: "env-other", - label: "Other Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }, - }), - ); - }), - ); - - const records = yield* withCloudServices( - listCloudEnvironmentsWithStatus({ clerkToken: "clerk-token" }), - ); - expect(records).toMatchObject([ - { - environment: { - environmentId: "env-1", - label: "Desktop", - }, - status: null, - statusError: "Relay returned status for a different environment.", - }, - ]); - }), - ); - it.effect( "rejects relay link credentials for a different environment before persisting relay config", () => @@ -612,9 +165,10 @@ describe("mobile cloud link environment client", () => { vi.stubGlobal("fetch", fetchMock); const error = yield* withCloudServices( - linkEnvironmentToCloud({ + linkEnvironmentToCloudWithPreference({ clerkToken: "clerk-token", connection: savedConnection, + liveActivitiesEnabled: true, }), ).pipe(Effect.flip); expect(error).toMatchObject({ @@ -644,9 +198,10 @@ describe("mobile cloud link environment client", () => { vi.stubGlobal("fetch", fetchMock); const error = yield* withCloudServices( - linkEnvironmentToCloud({ + linkEnvironmentToCloudWithPreference({ clerkToken: "clerk-token", connection: savedConnection, + liveActivitiesEnabled: true, }), ).pipe(Effect.flip); expect(error._tag).toBe("CloudEnvironmentLinkError"); @@ -681,9 +236,10 @@ describe("mobile cloud link environment client", () => { vi.stubGlobal("fetch", fetchMock); const error = yield* withCloudServices( - linkEnvironmentToCloud({ + linkEnvironmentToCloudWithPreference({ clerkToken: "clerk-token", connection: savedConnection, + liveActivitiesEnabled: true, }), ).pipe(Effect.flip); expect(error).toMatchObject({ @@ -718,9 +274,10 @@ describe("mobile cloud link environment client", () => { vi.stubGlobal("fetch", fetchMock); const error = yield* withCloudServices( - linkEnvironmentToCloud({ + linkEnvironmentToCloudWithPreference({ clerkToken: "clerk-token", connection: savedConnection, + liveActivitiesEnabled: true, }), ).pipe(Effect.flip); expect(error).toMatchObject({ @@ -733,7 +290,6 @@ describe("mobile cloud link environment client", () => { it.effect("preserves disabled Live Activity preferences when linking an environment", () => Effect.gen(function* () { - loadPreferences.mockReturnValueOnce(Effect.succeed({ liveActivitiesEnabled: false })); const bodies: Array = []; const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => { if (init?.body) { @@ -756,9 +312,10 @@ describe("mobile cloud link environment client", () => { vi.stubGlobal("fetch", fetchMock); yield* withCloudServices( - linkEnvironmentToCloud({ + linkEnvironmentToCloudWithPreference({ clerkToken: "clerk-token", connection: savedConnection, + liveActivitiesEnabled: false, }), ); @@ -786,9 +343,8 @@ describe("mobile cloud link environment client", () => { }), ); - it.effect("uses an explicit Live Activity preference when persisted state is unavailable", () => + it.effect("enables Live Activities for both the link challenge and registration", () => Effect.gen(function* () { - loadPreferences.mockReturnValueOnce(Effect.die("persisted preferences must not be read")); const bodies: Array> = []; const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => { if (init?.body) { @@ -824,429 +380,4 @@ describe("mobile cloud link environment client", () => { ]); }), ); - - it.effect( - "does not persist cloud connect bootstrap credentials in saved connection records", - () => - Effect.gen(function* () { - let connectRequestBody = ""; - const fetchMock = vi.fn((url: string | URL, init?: RequestInit) => { - if (String(url).endsWith("/v1/client/dpop-token")) { - return Promise.resolve( - Response.json(validDpopAccessTokenResponse("environment:connect")), - ); - } - if (String(url).endsWith("/.well-known/t3/environment")) { - return Promise.resolve( - Response.json({ - environmentId: "env-1", - label: "Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }), - ); - } - if (String(url).endsWith("/oauth/token")) { - return Promise.resolve( - Response.json({ - access_token: "environment-dpop-token", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "DPoP", - expires_in: 3600, - scope: "orchestration:read orchestration:operate terminal:operate review:write", - }), - ); - } - connectRequestBody = requestBodyText(init?.body); - return Promise.resolve( - Response.json({ - environmentId: "env-1", - endpoint: { - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - credential: "one-time-cloud-credential", - expiresAt: "2026-05-25T00:05:00.000Z", - }), - ); - }); - vi.stubGlobal("fetch", fetchMock); - - const connection = yield* withCloudServices( - connectCloudEnvironment({ - clerkToken: "clerk-token", - environment: { - environmentId: EnvironmentId.make("env-1"), - label: "Desktop", - endpoint: { - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - linkedAt: "2026-05-25T00:00:00.000Z", - }, - }), - ); - - expect(connection.pairingUrl).toBe("https://desktop.example.test/"); - expect(connection.pairingUrl).not.toContain("one-time-cloud-credential"); - expect(connection.bearerToken).toBeNull(); - expect(connection.authenticationMethod).toBe("dpop"); - expect(connection.dpopAccessToken).toBe("environment-dpop-token"); - expect(connection.relayManaged).toBe(true); - // @effect-diagnostics-next-line preferSchemaOverJson:off - expect(JSON.parse(connectRequestBody)).toMatchObject({ - deviceId: "device-1", - clientKeyThumbprint: "client-proof-key-thumbprint", - }); - expect(createProofMock).toHaveBeenCalledWith({ - method: "POST", - url: "https://relay.example.test/v1/environments/env-1/connect", - accessToken: "relay-dpop-token", - }); - expect(createProofMock).toHaveBeenCalledWith({ - method: "POST", - url: "https://desktop.example.test/oauth/token", - }); - }), - ); - - it.effect("refreshes a saved environment against a rotated managed endpoint", () => - Effect.gen(function* () { - vi.stubGlobal( - "fetch", - vi.fn((url: string | URL) => { - if (String(url).endsWith("/v1/client/dpop-token")) { - return Promise.resolve( - Response.json(validDpopAccessTokenResponse("environment:connect")), - ); - } - if (String(url).endsWith("/.well-known/t3/environment")) { - return Promise.resolve( - Response.json({ - environmentId: "env-1", - label: "Rotated Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }), - ); - } - if (String(url).endsWith("/oauth/token")) { - return Promise.resolve( - Response.json({ - access_token: "fresh-environment-dpop-token", - issued_token_type: "urn:ietf:params:oauth:token-type:access_token", - token_type: "DPoP", - expires_in: 3600, - scope: "orchestration:read orchestration:operate terminal:operate review:write", - }), - ); - } - return Promise.resolve( - Response.json({ - environmentId: "env-1", - endpoint: { - httpBaseUrl: "https://rotated-desktop.example.test/", - wsBaseUrl: "wss://rotated-desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - credential: "rotated-one-time-cloud-credential", - expiresAt: "2026-05-25T00:05:00.000Z", - }), - ); - }), - ); - - const connection = yield* withCloudServices( - refreshCloudEnvironmentConnection({ - clerkToken: "clerk-token", - connection: { - environmentId: EnvironmentId.make("env-1"), - environmentLabel: "Desktop", - pairingUrl: "https://desktop.example.test/", - displayUrl: "https://desktop.example.test/", - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - bearerToken: null, - authenticationMethod: "dpop", - relayManaged: true, - }, - }), - ); - - expect(connection).toMatchObject({ - environmentId: "env-1", - environmentLabel: "Rotated Desktop", - displayUrl: "https://rotated-desktop.example.test/", - httpBaseUrl: "https://rotated-desktop.example.test/", - wsBaseUrl: "wss://rotated-desktop.example.test/ws", - dpopAccessToken: "fresh-environment-dpop-token", - }); - }), - ); - - it.effect("rejects relay connect responses for a different environment", () => - Effect.gen(function* () { - vi.stubGlobal( - "fetch", - vi.fn((url: string | URL) => - Promise.resolve( - String(url).endsWith("/v1/client/dpop-token") - ? Response.json(validDpopAccessTokenResponse("environment:connect")) - : Response.json({ - environmentId: "env-other", - endpoint: { - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - credential: "one-time-cloud-credential", - expiresAt: "2026-05-25T00:05:00.000Z", - }), - ), - ), - ); - - const error = yield* withCloudServices( - connectCloudEnvironment({ - clerkToken: "clerk-token", - environment: { - environmentId: EnvironmentId.make("env-1"), - label: "Desktop", - endpoint: { - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - linkedAt: "2026-05-25T00:00:00.000Z", - }, - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Relay returned credentials for a different environment.", - }); - }), - ); - - it.effect("preserves relay DPoP auth failures while connecting environments", () => - Effect.gen(function* () { - vi.stubGlobal( - "fetch", - vi.fn((url: string | URL) => - Promise.resolve( - String(url).endsWith("/v1/client/dpop-token") - ? Response.json(validDpopAccessTokenResponse("environment:connect")) - : Response.json( - { - _tag: "RelayAuthInvalidError", - code: "auth_invalid", - reason: "invalid_dpop", - traceId: "trace-connect", - }, - { status: 401 }, - ), - ), - ), - ); - - const error = yield* withCloudServices( - connectCloudEnvironment({ - clerkToken: "clerk-token", - environment: { - environmentId: EnvironmentId.make("env-1"), - label: "Desktop", - endpoint: { - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - linkedAt: "2026-05-25T00:00:00.000Z", - }, - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: `https://relay.example.test/v1/environments/env-1/connect failed: Relay rejected the DPoP proof. ${DPOP_UNKNOWN_HINT}`, - traceId: "trace-connect", - }); - }), - ); - - it.effect( - "presents clock skew as one possible cause when an older environment rejects DPoP", - () => - Effect.gen(function* () { - vi.stubGlobal( - "fetch", - vi.fn((url: string | URL) => { - const value = String(url); - if (value.endsWith("/v1/client/dpop-token")) { - return Promise.resolve( - Response.json(validDpopAccessTokenResponse("environment:connect")), - ); - } - if (value.endsWith("/v1/environments/env-1/connect")) { - return Promise.resolve( - Response.json({ - environmentId: "env-1", - endpoint: { - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - credential: "one-time-cloud-credential", - expiresAt: "2026-05-25T00:05:00.000Z", - }), - ); - } - if (value.endsWith("/.well-known/t3/environment")) { - return Promise.resolve( - Response.json({ - environmentId: "env-1", - label: "Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }), - ); - } - return Promise.resolve( - Response.json( - { - _tag: "EnvironmentAuthInvalidError", - code: "auth_invalid", - reason: "invalid_credential", - traceId: "trace-environment", - }, - { status: 401 }, - ), - ); - }), - ); - - const error = yield* withCloudServices( - connectCloudEnvironment({ - clerkToken: "clerk-token", - environment: { - environmentId: EnvironmentId.make("env-1"), - label: "Desktop", - endpoint: { - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - linkedAt: "2026-05-25T00:00:00.000Z", - }, - }), - ).pipe(Effect.flip); - - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: `Could not exchange a managed endpoint DPoP access token. ${DPOP_UNKNOWN_HINT}`, - traceId: "trace-environment", - }); - }), - ); - - it.effect("rejects relay connect responses for a different endpoint", () => - Effect.gen(function* () { - vi.stubGlobal( - "fetch", - vi.fn((url: string | URL) => - Promise.resolve( - String(url).endsWith("/v1/client/dpop-token") - ? Response.json(validDpopAccessTokenResponse("environment:connect")) - : Response.json({ - environmentId: "env-1", - endpoint: { - httpBaseUrl: "https://other-desktop.example.test/", - wsBaseUrl: "wss://other-desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - credential: "one-time-cloud-credential", - expiresAt: "2026-05-25T00:05:00.000Z", - }), - ), - ), - ); - - const error = yield* withCloudServices( - connectCloudEnvironment({ - clerkToken: "clerk-token", - environment: { - environmentId: EnvironmentId.make("env-1"), - label: "Desktop", - endpoint: { - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - linkedAt: "2026-05-25T00:00:00.000Z", - }, - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Relay returned credentials for a different endpoint.", - }); - }), - ); - - it.effect( - "rejects managed endpoints whose descriptor does not match the selected environment", - () => - Effect.gen(function* () { - vi.stubGlobal( - "fetch", - vi.fn((url: string | URL) => - Promise.resolve( - String(url).endsWith("/v1/client/dpop-token") - ? Response.json(validDpopAccessTokenResponse("environment:connect")) - : String(url).endsWith("/.well-known/t3/environment") - ? Response.json({ - environmentId: "env-other", - label: "Other Desktop", - platform: { os: "darwin", arch: "arm64" }, - serverVersion: "0.0.0-test", - capabilities: { repositoryIdentity: true }, - }) - : Response.json({ - environmentId: "env-1", - endpoint: { - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - credential: "one-time-cloud-credential", - expiresAt: "2026-05-25T00:05:00.000Z", - }), - ), - ), - ); - - const error = yield* withCloudServices( - connectCloudEnvironment({ - clerkToken: "clerk-token", - environment: { - environmentId: EnvironmentId.make("env-1"), - label: "Desktop", - endpoint: { - httpBaseUrl: "https://desktop.example.test/", - wsBaseUrl: "wss://desktop.example.test/ws", - providerKind: "cloudflare_tunnel", - }, - linkedAt: "2026-05-25T00:00:00.000Z", - }, - }), - ).pipe(Effect.flip); - expect(error).toMatchObject({ - _tag: "CloudEnvironmentLinkError", - message: "Connected endpoint descriptor does not match the selected environment.", - }); - }), - ); }); diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index b8dc8f878e0c..73bd28c550ed 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -4,45 +4,24 @@ import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import { EnvironmentCloudEndpointUnavailableError, - EnvironmentAuthInvalidError, EnvironmentHttpBadRequestError, EnvironmentHttpConflictError, EnvironmentHttpForbiddenError, EnvironmentHttpInternalServerError, EnvironmentHttpUnauthorizedError, } from "@t3tools/contracts"; -import { stripPairingTokenFromUrl } from "@t3tools/shared/remote"; import { - type RelayEnvironmentConnectResponse as RelayEnvironmentConnectResponseType, type RelayEnvironmentLinkResponse as RelayEnvironmentLinkResponseType, - RelayEnvironmentConnectScope, - RelayEnvironmentStatusScope, - type RelayDpopAccessTokenScope, - type RelayClientEnvironmentRecord, - type RelayEnvironmentStatusResponse as RelayEnvironmentStatusResponseType, type RelayManagedEndpointProviderKind, } from "@t3tools/contracts/relay"; -import { exchangeRemoteDpopAccessToken } from "@t3tools/client-runtime/authorization"; -import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; import { findErrorTraceId } from "@t3tools/client-runtime/errors"; -import { - dpopFailureMessage, - ManagedRelay, - relayProtectedErrorMessage, -} from "@t3tools/client-runtime/relay"; +import { ManagedRelay, relayProtectedErrorMessage } from "@t3tools/client-runtime/relay"; import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; -import { authClientMetadata } from "../../lib/authClientMetadata"; import type { SavedRemoteConnection } from "../../lib/connection"; -import * as MobilePreferences from "../../persistence/mobile-preferences"; import * as MobileStorage from "../../persistence/mobile-storage"; import { resolveCloudPublicConfig } from "./publicConfig"; -const RELAY_STATUS_AND_CONNECT_SCOPES = [ - RelayEnvironmentStatusScope, - RelayEnvironmentConnectScope, -] satisfies ReadonlyArray; - function readRelayUrl(): string | null { return resolveCloudPublicConfig().relay.url; } @@ -53,12 +32,6 @@ export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmen readonly traceId?: string; }> {} -export interface CloudEnvironmentRecordWithStatus { - readonly environment: RelayClientEnvironmentRecord; - readonly status: RelayEnvironmentStatusResponseType | null; - readonly statusError: string | null; -} - const isEnvironmentCloudApiError = Schema.is( Schema.Union([ EnvironmentHttpBadRequestError, @@ -69,24 +42,19 @@ const isEnvironmentCloudApiError = Schema.is( EnvironmentCloudEndpointUnavailableError, ]), ); -const isEnvironmentAuthInvalidError = Schema.is(EnvironmentAuthInvalidError); const MANAGED_ENDPOINT_PROVIDER_KIND = "cloudflare_tunnel" satisfies RelayManagedEndpointProviderKind; -function cloudEnvironmentLinkError(message: string, options?: { readonly dpop?: boolean }) { +function cloudEnvironmentLinkError(message: string) { return (cause: unknown) => { const environmentError = findEnvironmentCloudApiError(cause); const traceId = findErrorTraceId(cause); - const dpopAuthError = options?.dpop ? findEnvironmentAuthInvalidError(cause) : null; const detail = environmentError ? `${message.replace(/[.:]$/, "")}: ${environmentError.message}` : withDevCause(message, cause); return new CloudEnvironmentLinkError({ - message: - dpopAuthError?.reason === "invalid_credential" - ? dpopFailureMessage(detail, dpopAuthError.dpopFailureReason) - : detail, + message: detail, cause, ...(traceId === null ? {} : { traceId }), }); @@ -143,16 +111,6 @@ function findEnvironmentCloudApiError(cause: unknown): { readonly message: strin return "cause" in cause ? findEnvironmentCloudApiError(cause.cause) : null; } -function findEnvironmentAuthInvalidError(cause: unknown): EnvironmentAuthInvalidError | null { - if (isEnvironmentAuthInvalidError(cause)) { - return cause; - } - if (typeof cause !== "object" || cause === null) { - return null; - } - return "cause" in cause ? findEnvironmentAuthInvalidError(cause.cause) : null; -} - function requireRelayUrl(): Effect.Effect { const relayUrl = readRelayUrl(); return relayUrl @@ -186,54 +144,6 @@ function ensureLinkedEnvironmentMatches(input: { return Effect.void; } -function endpointMatches( - left: RelayClientEnvironmentRecord["endpoint"], - right: RelayClientEnvironmentRecord["endpoint"], -): boolean { - return ( - left.httpBaseUrl === right.httpBaseUrl && - left.wsBaseUrl === right.wsBaseUrl && - left.providerKind === right.providerKind - ); -} - -function ensureStatusMatchesEnvironment(input: { - readonly environment: RelayClientEnvironmentRecord; - readonly status: RelayEnvironmentStatusResponseType; -}): Effect.Effect { - if (input.status.environmentId !== input.environment.environmentId) { - return new CloudEnvironmentLinkError({ - message: "Relay returned status for a different environment.", - }); - } - if (!endpointMatches(input.status.endpoint, input.environment.endpoint)) { - return new CloudEnvironmentLinkError({ - message: "Relay returned status for a different endpoint.", - }); - } - if ( - input.status.descriptor && - input.status.descriptor.environmentId !== input.environment.environmentId - ) { - return new CloudEnvironmentLinkError({ - message: "Relay returned status descriptor for a different environment.", - }); - } - return Effect.void; -} - -function ensureConnectEndpointMatchesEnvironment(input: { - readonly environment: RelayClientEnvironmentRecord; - readonly connect: RelayEnvironmentConnectResponseType; -}): Effect.Effect { - if (!endpointMatches(input.connect.endpoint, input.environment.endpoint)) { - return new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different endpoint.", - }); - } - return Effect.void; -} - interface LinkEnvironmentToCloudInput { readonly connection: SavedRemoteConnection; readonly clerkToken: string; @@ -328,250 +238,3 @@ export function linkEnvironmentToCloudWithPreference( ); }); } - -export function linkEnvironmentToCloud( - input: LinkEnvironmentToCloudInput, -): Effect.Effect< - void, - CloudEnvironmentLinkError, - LinkEnvironmentToCloudRequirements | MobilePreferences.MobilePreferencesStore -> { - return MobilePreferences.MobilePreferencesStore.pipe( - Effect.flatMap((preferencesStore) => preferencesStore.load), - Effect.mapError(cloudEnvironmentLinkError("Could not load mobile notification preferences.")), - Effect.flatMap((preferences) => - linkEnvironmentToCloudWithPreference({ - ...input, - liveActivitiesEnabled: preferences.liveActivitiesEnabled !== false, - }), - ), - ); -} - -export function listCloudEnvironments(input: { - readonly clerkToken: string; -}): Effect.Effect< - ReadonlyArray, - CloudEnvironmentLinkError, - ManagedRelay.ManagedRelayClient -> { - return Effect.gen(function* () { - const relayUrl = yield* requireRelayUrl(); - const relayClient = yield* ManagedRelay.ManagedRelayClient; - - return yield* relayClient - .listEnvironments({ - clerkToken: input.clerkToken, - }) - .pipe(Effect.mapError(decodedRelayClientError(`${relayUrl}/v1/environments failed`))); - }); -} - -export function getCloudEnvironmentStatus(input: { - readonly clerkToken: string; - readonly environment: RelayClientEnvironmentRecord; - readonly relayScopes?: ReadonlyArray; -}): Effect.Effect< - RelayEnvironmentStatusResponseType, - CloudEnvironmentLinkError, - ManagedRelay.ManagedRelayClient -> { - return Effect.gen(function* () { - const relayUrl = yield* requireRelayUrl(); - const relayClient = yield* ManagedRelay.ManagedRelayClient; - const status = yield* relayClient - .getEnvironmentStatus({ - clerkToken: input.clerkToken, - scopes: input.relayScopes ?? [RelayEnvironmentStatusScope], - environmentId: input.environment.environmentId, - }) - .pipe( - Effect.mapError( - decodedRelayClientError( - `${relayUrl}/v1/environments/${encodeURIComponent(input.environment.environmentId)}/status failed`, - ), - ), - ); - yield* ensureStatusMatchesEnvironment({ environment: input.environment, status }); - return status; - }); -} - -export function loadCloudEnvironmentStatuses(input: { - readonly clerkToken: string; - readonly environments: ReadonlyArray; -}): Effect.Effect< - ReadonlyArray, - CloudEnvironmentLinkError, - ManagedRelay.ManagedRelayClient -> { - return Effect.forEach( - input.environments, - (environment) => - getCloudEnvironmentStatus({ - clerkToken: input.clerkToken, - environment, - relayScopes: RELAY_STATUS_AND_CONNECT_SCOPES, - }).pipe( - Effect.match({ - onFailure: (error) => ({ - environment, - status: null, - statusError: error.message, - }), - onSuccess: (status) => ({ - environment, - status, - statusError: null, - }), - }), - ), - { concurrency: "unbounded" }, - ); -} - -export function listCloudEnvironmentsWithStatus(input: { - readonly clerkToken: string; -}): Effect.Effect< - ReadonlyArray, - CloudEnvironmentLinkError, - ManagedRelay.ManagedRelayClient -> { - return Effect.gen(function* () { - const environments = yield* listCloudEnvironments(input); - return yield* loadCloudEnvironmentStatuses({ - clerkToken: input.clerkToken, - environments, - }); - }); -} - -const loadAgentAwarenessDeviceId = Effect.fn("mobile.cloud.loadAgentAwarenessDeviceId")( - function* () { - const storage = yield* MobileStorage.MobileStorage; - return yield* storage.loadOrCreateAgentAwarenessDeviceId.pipe( - Effect.mapError(cloudEnvironmentLinkError("Could not load the mobile device id.")), - ); - }, -); - -const connectRelayManagedEnvironment = Effect.fn("mobile.cloud.connectRelayManagedEnvironment")( - function* (input: { - readonly clerkToken: string; - readonly environmentId: RelayClientEnvironmentRecord["environmentId"]; - readonly expectedEnvironment?: RelayClientEnvironmentRecord; - }) { - yield* Effect.annotateCurrentSpan({ "environment.id": input.environmentId }); - const relayUrl = yield* requireRelayUrl(); - const relayClient = yield* ManagedRelay.ManagedRelayClient; - - const deviceId = yield* loadAgentAwarenessDeviceId(); - const connect = yield* relayClient - .connectEnvironment({ - clerkToken: input.clerkToken, - scopes: [RelayEnvironmentConnectScope], - environmentId: input.environmentId, - deviceId, - }) - .pipe( - Effect.mapError( - decodedRelayClientError( - `${relayUrl}/v1/environments/${encodeURIComponent(input.environmentId)}/connect failed`, - ), - ), - ); - if (connect.environmentId !== input.environmentId) { - return yield* new CloudEnvironmentLinkError({ - message: "Relay returned credentials for a different environment.", - }); - } - if (input.expectedEnvironment) { - yield* ensureConnectEndpointMatchesEnvironment({ - environment: input.expectedEnvironment, - connect, - }); - } - - const descriptor = yield* fetchRemoteEnvironmentDescriptor({ - httpBaseUrl: connect.endpoint.httpBaseUrl, - }).pipe( - Effect.mapError( - cloudEnvironmentLinkError("Could not fetch the connected environment descriptor."), - ), - ); - if (descriptor.environmentId !== connect.environmentId) { - return yield* new CloudEnvironmentLinkError({ - message: "Connected endpoint descriptor does not match the selected environment.", - }); - } - const signer = yield* ManagedRelay.ManagedRelayDpopSigner; - const bootstrapDpop = yield* signer - .createProof({ - method: "POST", - url: new URL("/oauth/token", connect.endpoint.httpBaseUrl).toString(), - }) - .pipe(Effect.mapError(cloudEnvironmentLinkError("Could not create bootstrap DPoP proof."))); - const bootstrap = yield* exchangeRemoteDpopAccessToken({ - httpBaseUrl: connect.endpoint.httpBaseUrl, - credential: connect.credential, - dpopProof: bootstrapDpop, - clientMetadata: authClientMetadata(), - }).pipe( - Effect.mapError( - cloudEnvironmentLinkError("Could not exchange a managed endpoint DPoP access token.", { - dpop: true, - }), - ), - ); - const pairingUrl = new URL(connect.endpoint.httpBaseUrl); - pairingUrl.hash = new URLSearchParams([["token", connect.credential]]).toString(); - - return { - environmentId: descriptor.environmentId, - environmentLabel: descriptor.label, - pairingUrl: stripPairingTokenFromUrl(pairingUrl).toString(), - displayUrl: connect.endpoint.httpBaseUrl, - httpBaseUrl: connect.endpoint.httpBaseUrl, - wsBaseUrl: connect.endpoint.wsBaseUrl, - bearerToken: null, - authenticationMethod: "dpop", - dpopAccessToken: bootstrap.access_token, - relayManaged: true, - } satisfies SavedRemoteConnection; - }, -); - -export function connectCloudEnvironment(input: { - readonly clerkToken: string; - readonly environment: RelayClientEnvironmentRecord; -}): Effect.Effect< - SavedRemoteConnection, - CloudEnvironmentLinkError, - | HttpClient.HttpClient - | ManagedRelay.ManagedRelayClient - | ManagedRelay.ManagedRelayDpopSigner - | MobileStorage.MobileStorage -> { - return connectRelayManagedEnvironment({ - clerkToken: input.clerkToken, - environmentId: input.environment.environmentId, - expectedEnvironment: input.environment, - }); -} - -export function refreshCloudEnvironmentConnection(input: { - readonly clerkToken: string; - readonly connection: SavedRemoteConnection; -}): Effect.Effect< - SavedRemoteConnection, - CloudEnvironmentLinkError, - | HttpClient.HttpClient - | ManagedRelay.ManagedRelayClient - | ManagedRelay.ManagedRelayDpopSigner - | MobileStorage.MobileStorage -> { - return connectRelayManagedEnvironment({ - clerkToken: input.clerkToken, - environmentId: input.connection.environmentId, - }); -} diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index cccc6425ec13..2828392ba8f3 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -41,16 +41,14 @@ interface CloudEnvironmentRowsProps { readonly showcaseAvailableEnvironments?: ReadonlyArray; readonly showcaseSignedIn?: boolean; /** - * Hide the "T3 Connect" section title + refresh button for hosts that - * provide their own chrome (the onboarding sheet's native header and - * pull-to-refresh). + * Hide the "T3 Connect" section title when the host provides its own header. */ readonly showHeader?: boolean; } /** * "T3 Connect" section: every environment published to the signed-in account, - * with connect switches, availability status, refresh, and loading/error + * with connect switches, availability status, and loading/error * states. Shared between the Settings environments screen and the T3 Connect * onboarding sheet. * @@ -120,29 +118,8 @@ function CloudEnvironmentRowsContent( return ( {showHeader ? ( - + T3 Connect - {discoveryAvailable ? ( - { - void controller.refreshRelayEnvironments(); - }} - className="h-9 w-9 items-center justify-center rounded-full bg-subtle active:opacity-70 disabled:opacity-50" - > - {controller.relayDiscovery.isRefreshing ? ( - - ) : ( - - )} - - ) : null} ) : null} diff --git a/apps/mobile/src/features/connection/ConnectionStatusDot.tsx b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx index 43c61249a748..6c01bc8824ae 100644 --- a/apps/mobile/src/features/connection/ConnectionStatusDot.tsx +++ b/apps/mobile/src/features/connection/ConnectionStatusDot.tsx @@ -10,10 +10,16 @@ import Animated, { } from "react-native-reanimated"; import type { RemoteClientConnectionState } from "../../lib/connection"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { themeColorWithAlpha, type MobileThemeVariables } from "../../lib/mobileTheme"; export type ConnectionStatusDotState = RemoteClientConnectionState; -function statusDotTone(state: ConnectionStatusDotState): { +function statusDotTone( + state: ConnectionStatusDotState, + theme: MobileThemeVariables, + dark: boolean, +): { readonly dotColor: string; readonly haloColor: string; } { @@ -23,25 +29,25 @@ function statusDotTone(state: ConnectionStatusDotState): { case "available": case "unsupported": return { - dotColor: "#9ca3af", - haloColor: "rgba(156,163,175,0.42)", + dotColor: theme["--color-icon-muted"], + haloColor: themeColorWithAlpha(theme["--color-icon-muted"], 0.42), }; case "connected": return { - dotColor: "#34d399", - haloColor: "rgba(52,211,153,0.48)", + dotColor: dark ? "#34d399" : "#059669", + haloColor: themeColorWithAlpha(dark ? "#34d399" : "#059669", 0.48), }; case "connecting": case "reconnecting": return { - dotColor: "#f59e0b", - haloColor: "rgba(245,158,11,0.5)", + dotColor: theme["--color-warning-foreground"], + haloColor: themeColorWithAlpha(theme["--color-warning-foreground"], 0.5), }; case "offline": case "error": return { - dotColor: "#ef4444", - haloColor: "rgba(239,68,68,0.48)", + dotColor: theme["--color-danger-foreground"], + haloColor: themeColorWithAlpha(theme["--color-danger-foreground"], 0.48), }; } } @@ -78,7 +84,8 @@ export function ConnectionStatusDot(props: { readonly size?: number; }) { const pulseProgress = usePulseAnimation(props.pulse); - const tone = statusDotTone(props.state); + const { themeAppearance, themeVariables } = useAppearancePreferences(); + const tone = statusDotTone(props.state, themeVariables, themeAppearance === "dark"); const dotSize = props.size ?? 10; const haloSize = dotSize + 4; const containerSize = haloSize + 4; diff --git a/apps/mobile/src/features/connection/connectionTone.ts b/apps/mobile/src/features/connection/connectionTone.ts index 05a654085675..a2d85096e351 100644 --- a/apps/mobile/src/features/connection/connectionTone.ts +++ b/apps/mobile/src/features/connection/connectionTone.ts @@ -18,8 +18,8 @@ export function connectionTone(state: RemoteClientConnectionState): StatusTone { case "connecting": return { label: "Connecting", - pillClassName: "bg-primary/10", - textClassName: "text-foreground-secondary", + pillClassName: "bg-update", + textClassName: "text-update-foreground", }; case "unsupported": return { diff --git a/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx new file mode 100644 index 000000000000..82b2b90fecad --- /dev/null +++ b/apps/mobile/src/features/devices/DevicePreviewRouteScreen.tsx @@ -0,0 +1,311 @@ +import { useIsFocused, useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { createNativeStackNavigator } from "@react-navigation/native-stack"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { ActivityIndicator, Alert, AppState, Platform, Pressable, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AppText } from "../../components/AppText"; +import { ScreenHeader, type ScreenHeaderMenuItem } from "../../components/ScreenHeader"; +import { NativeHeaderToolbar } from "../../native/StackHeader"; +import { deviceEnvironment, refreshDeviceHubAccess, useDeviceHubAccess } from "../../state/device"; +import { useEnvironmentQuery } from "../../state/query"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { DeviceStreamWebView, type DeviceStreamRef } from "./DeviceStreamWebView"; +import { + selectedThreadDevicePreview, + threadDevicePreviews, + type ThreadDevicePreview, +} from "./threadDevicePreviews"; + +const DevicePreviewStack = createNativeStackNavigator<{ DevicePreview: undefined }>(); + +type DevicePreviewRouteScreenProps = StaticScreenProps<{ + readonly environmentId: string; + readonly threadId: string; +}>; + +/** The nested native stack supplies the navigation bar inside the modal. */ +export function DevicePreviewRouteScreen({ route }: DevicePreviewRouteScreenProps) { + const navigation = useNavigation(); + const onClose = useCallback(() => navigation.goBack(), [navigation]); + return ( + + + + {() => ( + + )} + + + + ); +} + +function DevicePreviewScreen({ + environmentId, + threadId, + onClose, +}: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly onClose: () => void; +}) { + const insets = useSafeAreaInsets(); + const { themeVariables } = useAppearancePreferences(); + const focused = useIsFocused(); + const [foreground, setForeground] = useState(AppState.currentState === "active"); + const [selectedKey, setSelectedKey] = useState(null); + const [inputConnected, setInputConnected] = useState(false); + const [streamAttempt, setStreamAttempt] = useState(0); + const [shuttingDown, setShuttingDown] = useState(false); + const shutdown = useAtomCommand(deviceEnvironment.shutdown, { reportFailure: false }); + const streamRef = useRef(null); + const state = useEnvironmentQuery(deviceEnvironment.state({ environmentId, input: {} })); + const previews = useMemo( + () => threadDevicePreviews(state.data, threadId), + [state.data, threadId], + ); + const preview = selectedThreadDevicePreview(previews, selectedKey); + const onInputConnected = useCallback( + async (connected: boolean) => setInputConnected(connected), + [], + ); + useEffect(() => { + const subscription = AppState.addEventListener("change", (state) => + setForeground(state === "active"), + ); + return () => subscription.remove(); + }, []); + useEffect(() => { + if (focused && state.data !== null && previews.length === 0) onClose(); + }, [focused, state.data, previews.length, onClose]); + + const shutDownDevice = async () => { + if (!preview || shuttingDown) return; + setShuttingDown(true); + try { + const result = await shutdown({ + environmentId, + input: { + hostId: preview.session.hostId, + deviceId: preview.session.deviceId, + platform: preview.session.platform, + }, + }); + if (result._tag === "Failure") { + Alert.alert("Could not shut down device", String(Cause.squash(result.cause))); + } + } finally { + setShuttingDown(false); + } + }; + + const controls: ScreenHeaderMenuItem[] = [ + { + id: "reload", + title: "Reload stream", + icon: "arrow.clockwise", + disabled: !preview || shuttingDown, + onPress: () => { + setInputConnected(false); + setStreamAttempt((attempt) => attempt + 1); + }, + }, + ...(preview?.session.platform === "android" + ? [ + { + id: "back", + title: "Back", + icon: "arrow.left", + disabled: !inputConnected, + onPress: () => streamRef.current?.back(), + }, + ] + : []), + { + id: "app-switcher", + title: "App switcher", + icon: "square.on.square", + disabled: !inputConnected, + onPress: () => streamRef.current?.appSwitcher(), + }, + ...(preview?.session.platform === "ios" + ? [ + { + id: "rotate", + title: "Rotate device", + icon: "arrow.clockwise", + disabled: !inputConnected, + onPress: () => streamRef.current?.rotate(), + }, + ] + : []), + { + id: "shutdown", + title: shuttingDown ? "Shutting down…" : "Shut down device", + icon: "power", + disabled: !preview || shuttingDown, + onPress: () => void shutDownDevice(), + }, + ]; + return ( + + streamRef.current?.home(), + }, + ]} + menus={[ + { + title: "Device options", + icon: "ellipsis", + items: [ + ...(previews.length > 1 + ? [ + { + id: "devices", + title: "Devices", + inline: true, + items: previews.map((device) => ({ + id: device.key, + title: device.name, + subtitle: device.description, + selected: device.key === preview?.key, + onPress: () => setSelectedKey(device.key), + })), + }, + ] + : []), + ...controls, + ], + }, + ]} + /> + {Platform.OS === "ios" ? ( + + + + ) : null} + {preview && focused && foreground ? ( + + ) : ( + + {state.error ? ( + <> + + {state.error} + + + Retry + + + ) : focused && foreground ? ( + + ) : null} + + )} + + ); +} + +function OpenDevicePreview({ + environmentId, + preview, + streamRef, + onInputConnected, +}: { + readonly environmentId: EnvironmentId; + readonly preview: ThreadDevicePreview; + readonly streamRef: RefObject; + readonly onInputConnected: (connected: boolean) => Promise; +}) { + const { session } = preview; + const { themeVariables } = useAppearancePreferences(); + const { access, error, refresh } = useDeviceHubAccess(environmentId, session.hostId); + const onUnauthorized = useCallback( + async () => refreshDeviceHubAccess(environmentId), + [environmentId], + ); + useEffect(() => { + refreshDeviceHubAccess(environmentId); + return () => void onInputConnected(false); + }, [environmentId, onInputConnected]); + return access ? ( + + ) : ( + + {error ? ( + <> + + {error} + + + Retry + + + ) : ( + <> + + Connecting to device... + + )} + + ); +} diff --git a/apps/mobile/src/features/devices/DeviceStreamWebView.tsx b/apps/mobile/src/features/devices/DeviceStreamWebView.tsx new file mode 100644 index 000000000000..9a5cef83ae3f --- /dev/null +++ b/apps/mobile/src/features/devices/DeviceStreamWebView.tsx @@ -0,0 +1,109 @@ +import deviceStreamScript from "@t3tools/mobile-device-stream"; +import { useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, type Ref } from "react"; +import { Platform } from "react-native"; +import { WebView } from "react-native-webview"; + +import { + deviceStreamDocument, + deviceStreamMessage, + type DeviceStreamConfiguration, +} from "./device-stream-document"; + +export interface DeviceStreamRef { + home: () => void; + back: () => void; + appSwitcher: () => void; + rotate: () => void; +} + +type NativeStreamBridge = { + readonly ref?: Ref; + readonly onUnauthorized: () => Promise; + readonly onInputConnected: (connected: boolean) => Promise; +}; + +export function DeviceStreamWebView({ + ref, + ...props +}: DeviceStreamConfiguration & NativeStreamBridge) { + const [attempt, setAttempt] = useState(0); + const configuration = JSON.stringify({ + access: props.access, + platform: props.platform, + deviceId: props.deviceId, + colors: props.colors, + }); + return ( + setAttempt((attempt) => attempt + 1)} + /> + ); +} + +function DeviceStreamDocumentView({ + ref, + configuration, + background, + onUnauthorized, + onInputConnected, + onRetry, +}: NativeStreamBridge & { + readonly configuration: string; + readonly background: string; + readonly onRetry: () => void; +}) { + const webView = useRef(null); + const source = useMemo( + () => ({ + html: deviceStreamDocument(configuration, deviceStreamScript), + // Android WebCodecs needs a secure document; streams still use the environment's URLs. + baseUrl: Platform.OS === "android" ? "https://localhost/" : "file:///", + }), + [configuration], + ); + const command = (button: keyof DeviceStreamRef) => { + webView.current?.injectJavaScript( + `window.T3DeviceStream?.command(${JSON.stringify(button)}); true;`, + ); + }; + useImperativeHandle(ref, () => ({ + home: () => command("home"), + back: () => command("back"), + appSwitcher: () => command("appSwitcher"), + rotate: () => command("rotate"), + })); + useLayoutEffect(() => { + const view = webView.current; + return () => view?.injectJavaScript("window.T3DeviceStream?.stop(); true;"); + }, []); + return ( + void onInputConnected(false)} + onShouldStartLoadWithRequest={(request) => + request.url === "about:blank" || request.url === source.baseUrl + } + onMessage={(event) => { + const message = deviceStreamMessage(event.nativeEvent.data); + if (message?.type === "unauthorized") void onUnauthorized(); + else if (message?.type === "input") void onInputConnected(message.connected); + else if (message?.type === "retry") onRetry(); + }} + /> + ); +} diff --git a/apps/mobile/src/features/devices/device-preview-button.tsx b/apps/mobile/src/features/devices/device-preview-button.tsx new file mode 100644 index 000000000000..01e789946711 --- /dev/null +++ b/apps/mobile/src/features/devices/device-preview-button.tsx @@ -0,0 +1,41 @@ +import { Pressable, View } from "react-native"; + +import { AppText } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; + +export function DevicePreviewButton(props: { + readonly count: number; + readonly onPress: () => void; + readonly compact?: boolean; +}) { + const compact = props.compact ?? true; + return ( + + + {!compact ? ( + + {props.count === 1 ? "One device open" : `${props.count} devices open`} + + ) : props.count > 1 ? ( + + {props.count} + + ) : null} + + ); +} diff --git a/apps/mobile/src/features/devices/device-stream-document.test.ts b/apps/mobile/src/features/devices/device-stream-document.test.ts new file mode 100644 index 000000000000..ffc08667a147 --- /dev/null +++ b/apps/mobile/src/features/devices/device-stream-document.test.ts @@ -0,0 +1,49 @@ +import * as NodeVM from "node:vm"; +import { describe, expect, it } from "vite-plus/test"; + +import { deviceStreamDocument, deviceStreamMessage } from "./device-stream-document"; + +describe("native device stream document", () => { + it("keeps ticket and device values from terminating the embedded script", () => { + const deviceId = ''; + const configuration = JSON.stringify({ deviceId, ticket: "" }); + const html = deviceStreamDocument( + configuration, + "var T3DeviceStream={start(input){return input}};", + ); + const script = html.match(/";'); + expect(html.match(/<\/script>/g)).toHaveLength(1); + expect(html).toContain('var text="<\\/script>";'); + }); +}); + +describe("native device stream messages", () => { + it("accepts authentication renewal and input connection changes", () => { + expect(deviceStreamMessage('{"type":"unauthorized"}')).toEqual({ type: "unauthorized" }); + expect(deviceStreamMessage('{"type":"input","connected":true}')).toEqual({ + type: "input", + connected: true, + }); + expect(deviceStreamMessage('{"type":"input","connected":false}')).toEqual({ + type: "input", + connected: false, + }); + expect(deviceStreamMessage('{"type":"retry"}')).toEqual({ type: "retry" }); + }); + + it.each([ + "invalid JSON", + "null", + '"input"', + "{}", + '{"type":"input","connected":"yes"}', + '{"type":"unknown"}', + ])("ignores invalid bridge messages: %s", (data) => expect(deviceStreamMessage(data)).toBeNull()); +}); diff --git a/apps/mobile/src/features/devices/device-stream-document.ts b/apps/mobile/src/features/devices/device-stream-document.ts new file mode 100644 index 000000000000..f3b753d5e99c --- /dev/null +++ b/apps/mobile/src/features/devices/device-stream-document.ts @@ -0,0 +1,43 @@ +import type { DeviceHubAccess } from "@t3tools/client-runtime/device/hub-access"; +import type { DevicePlatform } from "@t3tools/contracts"; + +export interface DeviceStreamConfiguration { + readonly access: DeviceHubAccess; + readonly platform: DevicePlatform; + readonly deviceId: string; + readonly colors: { + readonly background: string; + readonly foreground: string; + readonly muted: string; + readonly buttonBackground: string; + readonly buttonForeground: string; + readonly buttonBorder: string; + }; +} + +export function deviceStreamDocument(configuration: string, script: string) { + // Tickets and device names are data, including any HTML delimiter characters. + const safeConfiguration = configuration.replace(/`; +} + +export function deviceStreamMessage(data: string) { + try { + const message: unknown = JSON.parse(data); + if (typeof message !== "object" || message === null || !("type" in message)) return null; + if (message.type === "unauthorized" || message.type === "retry") { + return { type: message.type } as const; + } + if ( + message.type === "input" && + "connected" in message && + typeof message.connected === "boolean" + ) { + return { type: message.type, connected: message.connected } as const; + } + } catch { + // Ignore messages that are not part of the stream bridge. + } + return null; +} diff --git a/apps/mobile/src/features/devices/device-stream.browser.ts b/apps/mobile/src/features/devices/device-stream.browser.ts new file mode 100644 index 000000000000..efa81d9a043a --- /dev/null +++ b/apps/mobile/src/features/devices/device-stream.browser.ts @@ -0,0 +1,223 @@ +import { + createDeviceStreamClient, + type DeviceScreenSize, +} from "@t3tools/client-runtime/device/stream"; + +import type { DeviceStreamConfiguration } from "./device-stream-document"; + +declare global { + interface Window { + ReactNativeWebView: { postMessage: (message: string) => void }; + } +} + +let activeClient: ReturnType | null = null; +let activeImage: HTMLImageElement | null = null; + +export function stop() { + activeClient?.stop(); + activeClient = null; + activeImage?.removeAttribute("src"); + activeImage = null; +} + +export function command(button: "home" | "back" | "appSwitcher" | "rotate") { + if (button === "rotate") activeClient?.rotate(); + else activeClient?.pressButton(button); +} + +/** Bundled into the existing native WebView without React or Expo's web runtime. */ +export function start(configuration: DeviceStreamConfiguration) { + stop(); + // oxlint-disable-next-line unicorn/require-post-message-target-origin -- The native WebView bridge takes one string. + const post = (message: object) => window.ReactNativeWebView.postMessage(JSON.stringify(message)); + const { colors, platform } = configuration; + Object.assign(document.documentElement.style, { height: "100%", overflow: "hidden" }); + Object.assign(document.body.style, { + margin: "0", + height: "100%", + overflow: "hidden", + background: colors.background, + color: colors.foreground, + fontFamily: "system-ui", + }); + const container = document.createElement("div"); + Object.assign(container.style, { + position: "fixed", + inset: "0", + containerType: "size", + display: "flex", + alignItems: "center", + justifyContent: "center", + }); + const frame = document.createElement("div"); + frame.setAttribute("role", "application"); + frame.setAttribute( + "aria-label", + platform === "ios" ? "iOS Simulator screen" : "Android Emulator screen", + ); + frame.tabIndex = 0; + Object.assign(frame.style, { + position: "relative", + touchAction: "none", + userSelect: "none", + webkitUserSelect: "none", + webkitTouchCallout: "none", + outline: "none", + }); + const canvas = document.createElement("canvas"); + const image = document.createElement("img"); + image.alt = ""; + image.draggable = false; + image.style.display = "none"; + const overlay = document.createElement("div"); + overlay.setAttribute("role", "status"); + Object.assign(overlay.style, { + position: "fixed", + inset: "0", + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + gap: "16px", + padding: "24px", + textAlign: "center", + background: colors.background, + }); + const detail = document.createElement("span"); + const retry = document.createElement("button"); + retry.textContent = "Retry"; + Object.assign(retry.style, { + padding: "12px 24px", + borderRadius: "20px", + border: `1px solid ${colors.buttonBorder}`, + background: colors.buttonBackground, + color: colors.buttonForeground, + font: "inherit", + display: "none", + }); + retry.addEventListener("click", () => post({ type: "retry" })); + overlay.append(detail, retry); + const inputStatus = document.createElement("div"); + inputStatus.setAttribute("role", "status"); + inputStatus.textContent = "Reconnecting device controls..."; + Object.assign(inputStatus.style, { + position: "fixed", + bottom: "12px", + left: "0", + right: "0", + textAlign: "center", + pointerEvents: "none", + fontSize: "13px", + color: colors.muted, + background: colors.background, + }); + frame.append(canvas, image); + container.append(frame); + document.body.replaceChildren(container, overlay, inputStatus); + + let pointerId: number | null = null; + let inputConnected = false; + let streaming = false; + const layout = (screen: DeviceScreenSize | null) => { + const landscape = + screen?.orientation === "landscape_left" || screen?.orientation === "landscape_right"; + const aspect = screen + ? landscape + ? Math.max(screen.width, screen.height) / Math.min(screen.width, screen.height) + : Math.min(screen.width, screen.height) / Math.max(screen.width, screen.height) + : 9 / 19.5; + const rotation = + platform === "ios" && screen && screen.width <= screen.height + ? screen.orientation === "landscape_left" + ? 90 + : screen.orientation === "landscape_right" + ? -90 + : screen.orientation === "portrait_upside_down" + ? 180 + : 0 + : 0; + const sideways = Math.abs(rotation) === 90; + frame.style.width = `min(100cqw, ${aspect * 100}cqh)`; + frame.style.height = `min(100cqh, ${100 / aspect}cqw)`; + for (const media of [canvas, image]) { + Object.assign(media.style, { + position: "absolute", + width: sideways ? `${100 / aspect}%` : "100%", + height: sideways ? `${100 * aspect}%` : "100%", + left: "50%", + top: "50%", + transform: `translate(-50%, -50%) rotate(${rotation}deg)`, + pointerEvents: "none", + }); + } + }; + layout(null); + const unauthorized = () => { + if (activeClient === client) post({ type: "unauthorized" }); + }; + const client = createDeviceStreamClient( + { ...configuration, preferMjpeg: platform === "ios" }, + canvas, + { + onStatus: (status, message) => { + streaming = status === "streaming"; + overlay.style.display = streaming ? "none" : "flex"; + inputStatus.style.display = streaming && !inputConnected ? "block" : "none"; + detail.textContent = + status === "error" ? (message ?? "Device stream failed.") : "Connecting to device..."; + retry.style.display = status === "error" ? "block" : "none"; + }, + onScreen: layout, + onMjpegFallback: (url) => { + canvas.style.display = "none"; + image.style.display = "block"; + image.src = url; + }, + onUnauthorized: unauthorized, + onInputConnected: (connected) => { + inputConnected = connected; + inputStatus.style.display = streaming && !connected ? "block" : "none"; + post({ type: "input", connected }); + }, + }, + ); + activeClient = client; + activeImage = image; + image.addEventListener("error", unauthorized); + const touch = (event: PointerEvent, phase: "begin" | "move" | "end") => { + const rect = frame.getBoundingClientRect(); + client.sendTouch( + phase, + Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width)), + Math.max(0, Math.min(1, (event.clientY - rect.top) / rect.height)), + ); + }; + frame.addEventListener("pointerdown", (event) => { + if (!inputConnected || pointerId !== null) return; + event.preventDefault(); + pointerId = event.pointerId; + frame.setPointerCapture(event.pointerId); + frame.focus(); + touch(event, "begin"); + }); + frame.addEventListener("pointermove", (event) => { + if (pointerId === event.pointerId) touch(event, "move"); + }); + const endTouch = (event: PointerEvent) => { + if (pointerId !== event.pointerId) return; + pointerId = null; + touch(event, "end"); + }; + frame.addEventListener("pointerup", endTouch); + frame.addEventListener("pointercancel", endTouch); + frame.addEventListener("lostpointercapture", endTouch); + frame.addEventListener("keydown", (event) => { + event.preventDefault(); + client.sendKey(event, "down"); + }); + frame.addEventListener("keyup", (event) => client.sendKey(event, "up")); + window.addEventListener("pagehide", stop, { once: true }); + post({ type: "input", connected: false }); + client.start(); +} diff --git a/apps/mobile/src/features/devices/threadDevicePreviews.test.ts b/apps/mobile/src/features/devices/threadDevicePreviews.test.ts new file mode 100644 index 000000000000..90582dbdc572 --- /dev/null +++ b/apps/mobile/src/features/devices/threadDevicePreviews.test.ts @@ -0,0 +1,106 @@ +import { ThreadId, type DeviceServiceState } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { selectedThreadDevicePreview, threadDevicePreviews } from "./threadDevicePreviews"; + +const threadId = ThreadId.make("thread"); +const state: DeviceServiceState = { + hosts: [ + { + id: "local", + kind: "local", + label: "Local", + platforms: [], + hubInstalled: true, + agentDeviceInstalled: true, + }, + { + id: "remote", + kind: "ssh", + label: "Mac mini", + platforms: [], + hubInstalled: true, + agentDeviceInstalled: true, + }, + ], + hostStatus: "ready", + hostStatuses: {}, + devices: [ + { + hostId: "local", + id: "emulator-5554", + platform: "android", + name: "Pixel 9", + version: "Android 16", + booted: true, + physical: false, + }, + { + hostId: "remote", + id: "emulator-5554", + platform: "android", + name: "Pixel 8", + version: "Android 15", + booted: true, + physical: false, + }, + ], + sessions: [ + { + threadId, + hostId: "local", + deviceId: "emulator-5554", + platform: "android", + openedAt: "2026-09-18T00:00:00Z", + }, + { + threadId, + hostId: "remote", + deviceId: "emulator-5554", + platform: "android", + openedAt: "2026-09-18T00:01:00Z", + }, + { + threadId: ThreadId.make("another-thread"), + hostId: "local", + deviceId: "iphone", + platform: "ios", + openedAt: "2026-09-18T00:02:00Z", + }, + ], + onboardingCompleted: true, + agentAccessEnabled: true, + hubBasePath: "/api/device-hub", + revision: 1, +}; + +describe("thread device previews", () => { + it("keeps device names and selection distinct when hosts have the same Android serial", () => { + const previews = threadDevicePreviews(state, threadId); + expect(previews.map((preview) => preview.name)).toEqual(["Pixel 9", "Pixel 8"]); + expect(new Set(previews.map((preview) => preview.key)).size).toBe(2); + expect(selectedThreadDevicePreview(previews, previews[1]!.key)?.description).toBe( + "Android 15 Ā· Mac mini", + ); + }); + + it("selects another open device when the agent closes the selected session", () => { + const previews = threadDevicePreviews(state, threadId); + const selectedKey = previews[1]!.key; + const afterClose = threadDevicePreviews({ ...state, sessions: [state.sessions[0]!] }, threadId); + expect(selectedThreadDevicePreview(afterClose, selectedKey)?.name).toBe("Pixel 9"); + expect(selectedThreadDevicePreview([], selectedKey)).toBeNull(); + }); + + it("can view an open session before device discovery metadata arrives", () => { + const previews = threadDevicePreviews({ ...state, hosts: [], devices: [] }, threadId); + expect(previews[0]?.name).toBe("Android Emulator"); + expect(previews[0]?.session.deviceId).toBe("emulator-5554"); + expect(previews[0]?.description).toBe(""); + }); + + it("offers no devices before state arrives or in a thread without sessions", () => { + expect(threadDevicePreviews(null, threadId)).toEqual([]); + expect(threadDevicePreviews(state, ThreadId.make("empty-thread"))).toEqual([]); + }); +}); diff --git a/apps/mobile/src/features/devices/threadDevicePreviews.ts b/apps/mobile/src/features/devices/threadDevicePreviews.ts new file mode 100644 index 000000000000..aaf87ca9784e --- /dev/null +++ b/apps/mobile/src/features/devices/threadDevicePreviews.ts @@ -0,0 +1,28 @@ +import type { DeviceServiceState, ThreadId } from "@t3tools/contracts"; + +/** Host identity is part of the selection because Android serials repeat across hosts. */ +export function threadDevicePreviews(state: DeviceServiceState | null, threadId: ThreadId) { + return (state?.sessions ?? []) + .filter((session) => session.threadId === threadId) + .map((session) => { + const device = state?.devices.find( + (device) => device.hostId === session.hostId && device.id === session.deviceId, + ); + const host = state?.hosts.find((host) => host.id === session.hostId); + return { + key: JSON.stringify([session.hostId, session.deviceId]), + session, + name: device?.name ?? (session.platform === "ios" ? "iOS Simulator" : "Android Emulator"), + description: [device?.version, host?.label].filter(Boolean).join(" Ā· "), + }; + }); +} + +export type ThreadDevicePreview = ReturnType[number]; + +export function selectedThreadDevicePreview( + previews: ReadonlyArray, + selectedKey: string | null, +) { + return previews.find((preview) => preview.key === selectedKey) ?? previews[0] ?? null; +} diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index f2dfd3f15a97..df18347f336c 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -234,15 +234,6 @@ export function FileTreeBrowser(props: { ], ); - if (props.error && props.entries.length === 0) { - return ( - - Files unavailable - {props.error} - - ); - } - // SPIKE: render the FlatList as the screen's DIRECT content (no wrapping View), and // mirror the Home ScrollView exactly — `contentInsetAdjustmentBehavior: "automatic"` // with NO manual contentInset. iOS only applies the nav-bar top inset + scroll-edge @@ -250,6 +241,7 @@ export function FileTreeBrowser(props: { // flex-1 Views is ignored, which is why the tree rendered under the header with no blur. return ( item.node.path} @@ -270,7 +262,7 @@ export function FileTreeBrowser(props: { renderItem={renderItem} ListHeaderComponent={ <> - {props.error ? ( + {props.error && props.entries.length > 0 ? ( {props.error} @@ -284,7 +276,25 @@ export function FileTreeBrowser(props: { } ListEmptyComponent={ - {props.isPending ? ( + {props.error && props.entries.length === 0 ? ( + <> + Files unavailable + + {props.error} + + + Try again + + + ) : props.isPending ? ( ) : ( <> diff --git a/apps/mobile/src/features/files/MaterialFilesHeader.tsx b/apps/mobile/src/features/files/MaterialFilesHeader.tsx index 8936ae9b37ed..61d007ea5c3e 100644 --- a/apps/mobile/src/features/files/MaterialFilesHeader.tsx +++ b/apps/mobile/src/features/files/MaterialFilesHeader.tsx @@ -3,6 +3,7 @@ import { BackHandler, Keyboard, type TextInput, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidHeaderIconButton, AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu"; import { MaterialSearchField } from "../../components/MaterialSearchField"; /** Keep Files search in the same header row on compact and expanded layouts. */ @@ -55,12 +56,24 @@ export function MaterialFilesHeader(props: { icon: "magnifyingglass", onPress: () => setSearchOpen(true), }, - { - accessibilityLabel: "Refresh files", - icon: "arrow.clockwise", - onPress: props.onRefresh, - }, ]} + trailing={ + { + if (nativeEvent.event === "refresh") props.onRefresh(); + }} + > + {(open) => ( + + )} + + } /> {searching ? ( diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index 761849725839..c7c2310ba936 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -74,7 +74,7 @@ const HighlightedSourceLine = memo(function HighlightedSourceLine(props: { ); } diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index f62ba6b7abba..c3c906e3d515 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -55,16 +55,6 @@ export function ThreadFileNavigatorPane(props: { const nativeHeaderRightBarButtonItems = useMemo( () => [ - { - accessibilityLabel: "Refresh files", - icon: { name: "arrow.clockwise", type: "sfSymbol" as const }, - identifier: "thread-file-navigator-refresh", - onPress: entriesQuery.refresh, - sharesBackground: false, - tintColor: foregroundColor, - type: "button" as const, - width: 44, - }, { accessibilityLabel: "Close files", icon: { name: "xmark", type: "sfSymbol" as const }, @@ -76,7 +66,7 @@ export function ThreadFileNavigatorPane(props: { width: 44, }, ] as ComponentProps["headerRightBarButtonItems"], - [entriesQuery.refresh, foregroundColor, toggleAuxiliaryPane], + [foregroundColor, toggleAuxiliaryPane], ); const fileTree = ( diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 00060668ba5d..1ef8ea7b286b 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -11,10 +11,6 @@ import { useWorkspaceState } from "../../state/workspace"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; -import { - AndroidWorkspaceSidebarButton, - WorkspaceSidebarToolbar, -} from "../layout/workspace-sidebar-toolbar"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; import { checkForAppUpdateOnLaunch, startAppUpdateForegroundRecheck } from "../updates/app-updates"; import { AndroidHomeFabLayout } from "./AndroidHomeFab"; @@ -121,18 +117,16 @@ export function HomeRouteScreen() { : { title: "", headerTitle: "", unstable_headerLeftItems: () => [] } } /> - navigation.navigate("NewTaskSheet", { screen: "NewTask" })} /> - } - /> - {Platform.OS === "android" ? ( - } /> + ) : null} + {Platform.OS === "android" ? : null} void; }) { + const foregroundClassName = props.selected + ? "text-thread-selected-foreground" + : "text-foreground"; + const mutedForegroundClassName = props.selected + ? "text-thread-selected-foreground-muted" + : "text-foreground-muted"; return ( - + - + {props.item.title} {props.searchMatch ? ( - + ) : props.item.detail ? ( - + {props.item.detail} ) : null} {props.index < 9 ? ( - + ⌘{props.index + 1} ) : null} @@ -117,6 +137,7 @@ export function CommandPalette(props: { readonly onCommand: (command: HardwareKeyboardCommand) => void; }) { const navigation = useNavigation(); + const { themeVariables } = useAppearancePreferences(); const { selectThread } = useAdaptiveWorkspaceLayout(); const runCommand = props.onCommand; const projects = useProjects(); @@ -392,14 +413,15 @@ export function CommandPalette(props: { className="flex-1 items-center justify-center p-4" > close()} /> (null); const [primarySidebarPreferredVisible, setPrimarySidebarPreferredVisible] = useState(true); + const showPrimarySidebar = pathname === "/" || primarySidebarPreferredVisible; const [supplementaryPanePreferredVisible, setSupplementaryPanePreferredVisible] = useState(true); const [supplementaryPanePreferredWidth, setSupplementaryPanePreferredWidth] = useState< number | null @@ -262,17 +263,9 @@ function AdaptiveWorkspaceLayoutContent( viewportWidth: width, preferredWidth: fileInspectorPreferredWidth ?? undefined, reservedLeadingWidth: - shouldRenderPrimarySidebar && primarySidebarPreferredVisible - ? (layout.listPaneWidth ?? 0) - : 0, + shouldRenderPrimarySidebar && showPrimarySidebar ? (layout.listPaneWidth ?? 0) : 0, }), - [ - fileInspectorPreferredWidth, - layout, - primarySidebarPreferredVisible, - shouldRenderPrimarySidebar, - width, - ], + [fileInspectorPreferredWidth, layout, showPrimarySidebar, shouldRenderPrimarySidebar, width], ); const auxiliaryPaneRole: WorkspaceAuxiliaryPaneRole = focusedAuxiliaryPaneRole ?? (/\/files(?:\/|$)/.test(pathname) ? "inspector" : "supplementary"); @@ -289,7 +282,7 @@ function AdaptiveWorkspaceLayoutContent( deriveWorkspacePaneLayout({ layout, viewportWidth: width, - primarySidebarPreferredVisible, + primarySidebarPreferredVisible: showPrimarySidebar, auxiliaryPanePreferredVisible, auxiliaryPaneRole, auxiliaryPanePreferredWidth: auxiliaryPanePreferredWidth ?? undefined, @@ -299,7 +292,7 @@ function AdaptiveWorkspaceLayoutContent( auxiliaryPaneRole, auxiliaryPanePreferredWidth, layout, - primarySidebarPreferredVisible, + showPrimarySidebar, width, ], ); @@ -357,13 +350,16 @@ function AdaptiveWorkspaceLayoutContent( }; }, []); const togglePrimarySidebar = useCallback(() => { + if (pathname === "/") { + return; + } if (!panes.primarySidebarVisible && panes.primarySidebarSuppressedByAuxiliary) { setFileInspectorPreferredVisible(false); setPrimarySidebarPreferredVisible(true); return; } setPrimarySidebarPreferredVisible((current) => !current); - }, [panes.primarySidebarSuppressedByAuxiliary, panes.primarySidebarVisible]); + }, [panes.primarySidebarSuppressedByAuxiliary, panes.primarySidebarVisible, pathname]); const revealPrimarySidebar = useCallback(() => { if (panes.primarySidebarSuppressedByAuxiliary) { setFileInspectorPreferredVisible(false); @@ -374,7 +370,11 @@ function AdaptiveWorkspaceLayoutContent( togglePrimarySidebar(); return true; }, [togglePrimarySidebar]); - useHardwareKeyboardCommand("toggleSidebar", handleToggleSidebarCommand); + const sidebarCommands = useMemo( + () => (pathname === "/" ? [] : (["toggleSidebar"] as const)), + [pathname], + ); + useHardwareKeyboardCommand(sidebarCommands, handleToggleSidebarCommand); const showAuxiliaryPane = useCallback((role: WorkspaceAuxiliaryPaneRole) => { if (role === "inspector") { setFocusedAuxiliaryPaneRole("inspector"); diff --git a/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx b/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx index 26677327575a..c5ef71422f4c 100644 --- a/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx +++ b/apps/mobile/src/features/layout/workspace-sidebar-toolbar.tsx @@ -1,5 +1,3 @@ -import { NativeHeaderToolbar } from "../../native/StackHeader"; -import type { ReactNode } from "react"; import { Platform } from "react-native"; import { AndroidHeaderIconButton } from "../../components/AndroidScreenHeader"; @@ -12,39 +10,9 @@ export function AndroidWorkspaceSidebarButton() { return ( ); } - -export function WorkspaceSidebarToolbar( - props: { - readonly children?: ReactNode; - readonly afterSidebarButton?: ReactNode; - } = {}, -) { - const { layout, panes, togglePrimarySidebar } = useAdaptiveWorkspaceLayout(); - - if (Platform.OS === "android" || !layout.usesSplitView) { - return null; - } - - return ( - - {props.children} - - {props.afterSidebarButton} - - ); -} diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 4da58b3309ef..84a2938785de 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -129,7 +129,7 @@ function SectionTitle(props: { readonly children: string }) { diff --git a/apps/mobile/src/features/review/ReviewSheet.tsx b/apps/mobile/src/features/review/ReviewSheet.tsx index cb55570f9116..2fac5376e5b1 100644 --- a/apps/mobile/src/features/review/ReviewSheet.tsx +++ b/apps/mobile/src/features/review/ReviewSheet.tsx @@ -262,7 +262,7 @@ const ReviewFileNavigatorRow = memo(function ReviewFileNavigatorRow(props: { Platform.OS === "android" ? cn( "mt-1 min-h-12 justify-center rounded-[20px] px-3 py-2 active:bg-subtle", - selected && "bg-thread-selected", + selected && "bg-subtle-strong", ) : selected ? "mt-1 min-h-12 justify-center rounded-xl bg-subtle-strong px-3 py-2" @@ -281,8 +281,10 @@ const ReviewFileNavigatorRow = memo(function ReviewFileNavigatorRow(props: { {file.path} - +{file.additions} - -{file.deletions} + + +{file.additions} + + -{file.deletions} ); diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts index 21c86a2beab4..6f55fef7c2a7 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.test.ts @@ -97,6 +97,19 @@ function appTheme(themeId: MobileThemeId, appearance: MobileThemeAppearance) { : getMobileThemeVariables(themeId, appearance); } +function contrastRatio(first: string, second: string): number { + const luminance = (hex: string) => { + const [red, green, blue] = [1, 3, 5].map((offset) => { + const channel = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255; + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * red! + 0.7152 * green! + 0.0722 * blue!; + }; + const a = luminance(first); + const b = luminance(second); + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05); +} + describe("getCachedNativeReviewDiffData", () => { it.each([true, false])( "preserves available diff rows before a notice (has excerpt: %s)", @@ -251,6 +264,10 @@ describe("createNativeReviewDiffTheme", () => { for (const color of Object.values(theme)) { expect(color, `${themeId}/${appearance}`).toMatch(/^#[\da-f]{6}$/i); } + expect( + contrastRatio(theme.hunkText, theme.hunkBackground), + `${themeId}/${appearance} hunk text`, + ).toBeGreaterThanOrEqual(4.5); } } }); @@ -261,7 +278,7 @@ describe("createNativeReviewDiffTheme", () => { const variables = { ...appTheme("material-you", appearance), "--color-screen": "#101214FF", - "--color-sheet": "#20222480", + "--color-md-code-bg": "#20222480", "--color-md-code-text": "#E3E2E6FF", "--color-foreground-muted": "#C7C5D080", "--color-border": "#44464F80", @@ -273,7 +290,7 @@ describe("createNativeReviewDiffTheme", () => { expect(theme.text).toBe("#e3e2e6"); expect(theme.mutedText).toBe("#707076"); expect(theme.border).toBe("#2e3036"); - expect(theme.hunkText).toBe("#a8c7fa"); + expect(contrastRatio(theme.hunkText, theme.hunkBackground)).toBeGreaterThanOrEqual(4.5); for (const color of Object.values(theme)) { expect(color).toMatch(/^#[\da-f]{6}$/i); } diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index b35d561a249d..8bea04c524dd 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -177,20 +177,19 @@ export function createNativeReviewDiffTheme( appTheme["--color-screen"], scheme === "dark" ? "#000000" : "#ffffff", ); - const background = opaqueNativeHexColor(appTheme["--color-sheet"], screen); + const background = opaqueNativeHexColor(appTheme["--color-md-code-bg"], screen); const nativeColor = (color: string) => opaqueNativeHexColor(color, background); if (scheme === "dark") { return { - // Match the app surface (--color-sheet) so code views blend with the rest of - // the app instead of using a distinct code-editor background. + // Code surfaces share the desktop palette rather than the sheet behind them. background, text: nativeColor(appTheme["--color-md-code-text"]), mutedText: nativeColor(appTheme["--color-foreground-muted"]), headerBackground: background, border: nativeColor(appTheme["--color-border"]), hunkBackground: nativeColor(appTheme["--color-subtle-strong"]), - hunkText: nativeColor(appTheme["--color-primary"]), + hunkText: nativeColor(appTheme["--color-foreground"]), addBackground: "#0d2f28", deleteBackground: "#391415", addBar: "#00cab1", @@ -201,15 +200,13 @@ export function createNativeReviewDiffTheme( } return { - // Match the app surface (--color-sheet) so code views blend with the rest of the - // app instead of using a distinct code-editor background. background, text: nativeColor(appTheme["--color-md-code-text"]), mutedText: nativeColor(appTheme["--color-foreground-muted"]), headerBackground: background, border: nativeColor(appTheme["--color-border"]), hunkBackground: nativeColor(appTheme["--color-subtle-strong"]), - hunkText: nativeColor(appTheme["--color-primary"]), + hunkText: nativeColor(appTheme["--color-foreground"]), addBackground: "#e5f8f5", deleteBackground: "#ffe6e7", addBar: "#00cab1", diff --git a/apps/mobile/src/features/review/reviewPerf.ts b/apps/mobile/src/features/review/reviewPerf.ts index 2ae1095b1c6a..2613ff0c3dbb 100644 --- a/apps/mobile/src/features/review/reviewPerf.ts +++ b/apps/mobile/src/features/review/reviewPerf.ts @@ -42,33 +42,6 @@ export function measureReviewWork(name: string, callback: () => T): T { } } -export async function measureReviewAsyncWork( - name: string, - callback: () => Promise, -): Promise { - if (!isReviewPerfEnabled()) { - return callback(); - } - - const perf = getPerformance(); - const marker = `${REVIEW_PERF_PREFIX}.${name}.${reviewPerfSequence++}`; - const startMark = `${marker}.start`; - const endMark = `${marker}.end`; - const startedAt = perf?.now?.() ?? Date.now(); - - perf?.mark?.(startMark); - try { - return await callback(); - } finally { - const durationMs = (perf?.now?.() ?? Date.now()) - startedAt; - perf?.mark?.(endMark); - perf?.measure?.(`${REVIEW_PERF_PREFIX}.${name}`, startMark, endMark); - perf?.clearMarks?.(startMark); - perf?.clearMarks?.(endMark); - console.log(`[review-perf] ${name}`, { durationMs: Number(durationMs.toFixed(2)) }); - } -} - export function markReviewEvent(name: string, details?: Record): void { if (!isReviewPerfEnabled()) { return; diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index 52e0b83be34d..3050f1f67ee8 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -15,14 +15,12 @@ import * as Schema from "effect/Schema"; import { resolveReviewHighlighterEngine, resolveReviewHighlighterEnginePreference, - type ReviewHighlighterEngine, } from "./reviewHighlighterEngine"; import { createIncrementalSnippet } from "./incrementalSnippet"; import type { ReviewRenderableLineRow } from "./reviewModel"; import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs"; export type ReviewDiffTheme = "light" | "dark"; -export type { ReviewHighlighterEngine }; export class ReviewHighlighterEngineInitializationError extends Schema.TaggedError()( "ReviewHighlighterEngineInitializationError", @@ -181,7 +179,6 @@ const languageAliases: Record = { txt: "text", }; let highlighterPromise: Promise | null = null; -let activeHighlighterEnginePromise: Promise | null = null; type LoadedLanguageModule = { default: Parameters[0]; @@ -254,10 +251,7 @@ async function getHighlighter(): Promise { engine: nativeEngineModule.createNativeEngine(), }); logReviewHighlighterDiagnostic("using native engine"); - return { - highlighter, - engine: "native" as const, - }; + return highlighter; } } catch (error) { nativeInitializationError = new ReviewHighlighterEngineInitializationError({ @@ -308,55 +302,18 @@ async function getHighlighter(): Promise { logReviewHighlighterDiagnostic("using javascript engine", { resolvedEngine: engine, }); - return { - highlighter, - engine, - }; + return highlighter; })(); - highlighterPromise = configuredHighlighterPromise - .then((result) => result.highlighter) - .catch((error) => { - highlighterPromise = null; - activeHighlighterEnginePromise = null; - throw error; - }); - activeHighlighterEnginePromise = configuredHighlighterPromise - .then((result) => result.engine) - .catch((error) => { - activeHighlighterEnginePromise = null; - throw error; - }); + highlighterPromise = configuredHighlighterPromise.catch((error) => { + highlighterPromise = null; + throw error; + }); } return highlighterPromise; } -export async function getActiveReviewHighlighterEngine(): Promise { - await getHighlighter(); - return activeHighlighterEnginePromise ?? Promise.resolve("javascript"); -} - -export async function prepareReviewHighlighter(): Promise { - await getHighlighter(); -} - -export async function prepareReviewHighlighterLanguages( - languages: ReadonlyArray, -): Promise { - const highlighter = await getHighlighter(); - await Promise.all( - languages.map(async (language) => { - const candidate = resolveLanguageAlias(language); - if (candidate === "text" || !(candidate in languageImports)) { - return; - } - - await loadSingleLanguage(highlighter, candidate); - }), - ); -} - function resolveLanguageAlias(language: string): string { const normalized = language.toLowerCase(); return languageAliases[normalized] ?? normalized; diff --git a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx index ce6077db6163..7ca791afec6d 100644 --- a/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsEnvironmentsRouteScreen.tsx @@ -1,16 +1,23 @@ import { ScreenScrollView as ScrollView } from "../../components/ScreenScrollView"; import { useNavigation } from "@react-navigation/native"; +import { useAtomValue } from "@effect/atom-react"; +import { managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; import type { EnvironmentId } from "@t3tools/contracts"; -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; +import { Platform, RefreshControl } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { SettingsScreen } from "./components/SettingsScreen"; +import { AndroidAnchoredMenu } from "../../components/AndroidAnchoredMenu"; +import { AndroidHeaderIconButton } from "../../components/AndroidScreenHeader"; import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; import { LocalEnvironmentList } from "../connection/LocalEnvironmentList"; import { GitHubRoutingSettings } from "../connection/GitHubRoutingSettings"; import { splitEnvironmentSections } from "../connection/environmentSections"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import { useRemoteConnections } from "../../state/use-remote-environment-registry"; +import { relayEnvironmentDiscovery } from "../../state/relay"; +import { useAtomCommand } from "../../state/use-atom-command"; import { applyShowcaseLocalEnvironmentDisplayUrls, resolveShowcaseEnvironmentUpdateDisplayUrl, @@ -42,6 +49,24 @@ export function SettingsEnvironmentsRouteScreen() { : environmentSections.connectedCloudEnvironments; const [expandedId, setExpandedId] = useState(null); const headerIconColor = useUniwindTheme()["--color-icon"]; + const relaySession = useAtomValue(managedRelaySessionAtom); + const refreshRelayEnvironments = useAtomCommand( + relayEnvironmentDiscovery.refresh, + "relay environment refresh", + ); + const [isRefreshingCloud, setIsRefreshingCloud] = useState(false); + const cloudRefreshPendingRef = useRef(false); + async function refreshCloudEnvironments() { + if (!relaySession || cloudRefreshPendingRef.current) return; + cloudRefreshPendingRef.current = true; + setIsRefreshingCloud(true); + try { + await refreshRelayEnvironments(); + } finally { + cloudRefreshPendingRef.current = false; + setIsRefreshingCloud(false); + } + } const handleToggle = useCallback((environmentId: EnvironmentId) => { setExpandedId((prev) => (prev === environmentId ? null : environmentId)); @@ -76,6 +101,31 @@ export function SettingsEnvironmentsRouteScreen() { return ( { + if (nativeEvent.event === "refresh") void refreshCloudEnvironments(); + }} + > + {(open) => ( + + )} + + ) : undefined + } actions={[ { accessibilityLabel: "Add environment", @@ -90,6 +140,7 @@ export function SettingsEnvironmentsRouteScreen() { ]} > void refreshCloudEnvironments()} + /> + ) : undefined + } > @@ -235,11 +235,11 @@ export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailPro onPress={() => void Linking.openURL(sourceUrl)} className="min-h-12 flex-row items-center gap-2 self-start py-2 active:opacity-60" > - Project source + Project source @@ -247,7 +247,7 @@ export function SettingsOpenSourceLicenseRouteScreen({ route }: LicenseDetailPro ) : null} - + {entry.noticeText} diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index c8a3b92abede..1b2aeb7cc891 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -6,7 +6,8 @@ import { deriveProjectGroupLabel } from "@t3tools/client-runtime/state/project-g import { useSafeAreaInsets } from "react-native-safe-area-context"; import { hasCloudPublicConfig } from "../cloud/publicConfig"; -import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; +import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; +import { NativeHeaderToolbar } from "../../native/StackHeader"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; @@ -18,6 +19,8 @@ import { import { useSettingsEnvironmentFilter } from "./settings-environment-filter"; export function SettingsRouteScreen() { + const navigation = useNavigation(); + const { layout } = useAdaptiveWorkspaceLayout(); const content = hasCloudPublicConfig() ? ( ) : ( @@ -26,7 +29,15 @@ export function SettingsRouteScreen() { return ( <> - + {Platform.OS === "ios" && layout.usesSplitView ? ( + + navigation.goBack()} + /> + + ) : null} {Platform.OS === "android" ? ( }> diff --git a/apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx b/apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx index bb4bb4d0a8fe..6760504bfa28 100644 --- a/apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx +++ b/apps/mobile/src/features/settings/appearance/sections/CodeAppearanceSection.tsx @@ -26,7 +26,7 @@ export function CodeAppearanceSection() { ); return ( - + + + + @@ -78,7 +78,7 @@ export function AutoSettleDaysField(props: AutoSettleDaysFieldProps) { + diff --git a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx index 20176064be52..c8d9abbc5fa8 100644 --- a/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx +++ b/apps/mobile/src/features/threads/CustomSnoozeSheet.ios.tsx @@ -1,27 +1,22 @@ import { - Button, DatePicker, Host, HStack, Picker, Popover, + RNHostView, Spacer, Text, VStack, } from "@expo/ui/swift-ui"; import { - accessibilityAddTraits, accessibilityHidden, - buttonBorderShape, - buttonStyle, clipped, - controlSize, font, datePickerStyle, foregroundStyle, frame, labelsHidden, - labelStyle, padding, pickerStyle, tag, @@ -32,12 +27,17 @@ import { resolveCustomSnooze, type CustomSnoozeInput, } from "@t3tools/client-runtime/state/thread-settled"; -import { useState } from "react"; -import { useWindowDimensions } from "react-native"; +import { useState, type ReactNode } from "react"; +import { NavigationContainer, NavigationIndependentTree } from "@react-navigation/native"; +import { createNativeStackNavigator } from "@react-navigation/native-stack"; +import { ScrollView, useWindowDimensions, View } from "react-native"; +import { useMobileNavigationTheme } from "../../lib/useMobileNavigationTheme"; +import { NativeHeaderToolbar } from "../../native/StackHeader"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; const durationAmounts = Array.from({ length: 99 }, (_, index) => index + 1); +const SnoozeStack = createNativeStackNavigator<{ CustomSnooze: undefined }>(); const modes = [ { value: "date", label: "Date and time" }, { value: "duration", label: "Duration" }, @@ -52,13 +52,15 @@ export function CustomSnoozeSheet(props: { readonly onClose: () => void; readonly onSnooze: (snoozedUntil: string) => void; }) { - const { width } = useWindowDimensions(); + const { width, height } = useWindowDimensions(); const [mode, setMode] = useState("date"); const [date, setDate] = useState(() => new Date(Date.now() + 3_600_000)); const [amount, setAmount] = useState(2); const [unit, setUnit] = useState<"minutes" | "hours" | "days">("hours"); const [error, setError] = useState(null); const { themeVariables: colors, themeAppearance } = useAppearancePreferences(); + const popoverWidth = Math.min(360, width - 32); + const popoverHeight = Math.min(error ? 364 : 324, height - 96); const updateDate = (value: Date) => { setDate(value); setError(null); @@ -98,133 +100,179 @@ export function CustomSnoozeSheet(props: { /> - - -