diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index 215998631e92..e23d78aa2161 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -93,6 +93,9 @@ const collectorLayer = (requests: Array) => ), ); +// A developer's own OTEL_* variables would otherwise pick the endpoints. +const emptyEnv = ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })); + const encodeObservabilitySettingsFile = Schema.encodeSync( Schema.fromJsonString( Schema.Struct({ observability: Schema.Record(Schema.String, Schema.String) }), @@ -182,7 +185,7 @@ describe("DesktopObservability", () => { assert.isFalse(yield* fileSystem.exists(logPath)); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -260,7 +263,7 @@ describe("DesktopObservability", () => { ); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -300,7 +303,7 @@ describe("DesktopObservability", () => { assert.equal(records.at(-1)?.annotations.details, "code=1"); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -344,7 +347,7 @@ describe("DesktopObservability", () => { assert.isFalse(text.includes("y")); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -378,7 +381,7 @@ describe("DesktopObservability", () => { assert.equal(lines.length, 258); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici)), + Effect.provide(Layer.mergeAll(NodeServices.layer, NodeHttpClient.layerUndici, emptyEnv)), ), ); @@ -424,7 +427,136 @@ describe("DesktopObservability", () => { assert.lengthOf(record?.events ?? [], 0); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests), emptyEnv)), + ); + }); + + it.effect("exports to an OTEL endpoint over Settings, with its own headers and protocol", () => { + 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_HEADERS: "x-scope=desktop", + }); + yield* writeObservabilitySettings(environmentLayer, { + otlpLogsUrl: "https://settings.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop otel export").pipe( + 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.strictEqual(request?.headers["x-otel"], "desktop"); + assert.strictEqual(request?.headers["x-scope"], undefined); + assert.strictEqual(request?.headers["content-type"], "application/json"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + collectorLayer(requests), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", + OTEL_EXPORTER_OTLP_HEADERS: "x-otel=desktop", + OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "http/json", + }, + }), + ), + ), + ), + ); + }); + + it.effect("keeps its service name while OTEL resource attributes add dimensions", () => { + 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", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop service name").pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + assert.lengthOf(requests, 1); + const body = requests[0]?.body ?? ""; + assert.include(body, '"stringValue":"t3code-desktop"'); + assert.include(body, "deployment.environment.name"); + assert.include(body, '"key":"service.namespace","value":{"stringValue":"t3code"}'); + assert.notInclude(body, "renamed"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + collectorLayer(requests), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + OTEL_SERVICE_NAME: "renamed", + OTEL_RESOURCE_ATTRIBUTES: + "service.name=renamed,service.namespace=renamed,deployment.environment.name=development", + }, + }), + ), + ), + ), + ); + }); + + it.effect("exports nothing to Settings for logs an unusable OTEL endpoint claimed", () => { + 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_HEADERS: "x-scope=desktop", + }); + yield* writeObservabilitySettings(environmentLayer, { + otlpLogsUrl: "https://settings.example.com/v1/logs", + }); + + yield* Effect.scoped( + Effect.logInfo("desktop otel off").pipe( + Effect.provide(DesktopObservability.layer.pipe(Layer.provideMerge(environmentLayer))), + ), + ); + + assert.lengthOf(requests, 0); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + collectorLayer(requests), + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com", + OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: "grpc", + }, + }), + ), + ), + ), ); }); @@ -495,7 +627,7 @@ describe("DesktopObservability", () => { ); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests), emptyEnv)), ); }); @@ -519,7 +651,7 @@ describe("DesktopObservability", () => { assert.lengthOf(requests, 0); }).pipe( Effect.scoped, - Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests))), + Effect.provide(Layer.mergeAll(NodeServices.layer, collectorLayer(requests), emptyEnv)), ); }); diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index d39b517759ad..ce8233e56612 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -3,6 +3,7 @@ import { makeLocalFileTracer, makeTraceSink, otlpSerializationLayer, + type SignalExport, } from "@t3tools/shared/observability"; import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; import { @@ -350,8 +351,10 @@ const readPersistedObservabilitySettings: Effect.Effect< }); /** - * Settings is read once for every signal, so the main process cannot - * resolve traces against one revision of the file and logs against another. + * Resolved as the server resolves them, with persisted Settings as the + * fallback. Settings is read once for every signal, so the main process + * cannot resolve traces against one revision of the file and logs against + * another. */ const resolveOtlpEndpoints = Effect.gen(function* () { const otel = yield* OtelEnvironment.load; @@ -367,10 +370,30 @@ const resolveOtlpEndpoints = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const persisted = yield* readPersistedObservabilitySettings; + const signalExport: SignalExport = { + protocol: environment.otlpProtocol, + headers: Option.getOrUndefined(environment.otlpHeaders), + exportIntervalMs: environment.otlpExportIntervalMs, + }; return { - traces: Option.getOrUndefined(environment.otlpTracesUrl) ?? persisted.otlpTracesUrl, - metrics: Option.getOrUndefined(environment.otlpMetricsUrl) ?? persisted.otlpMetricsUrl, - logs: Option.getOrUndefined(environment.otlpLogsUrl) ?? persisted.otlpLogsUrl, + traces: OtelEnvironment.resolveSignalEndpoint( + otel, + "traces", + { url: Option.getOrUndefined(environment.otlpTracesUrl), export: signalExport }, + persisted.otlpTracesUrl, + ), + metrics: OtelEnvironment.resolveSignalEndpoint( + otel, + "metrics", + { url: Option.getOrUndefined(environment.otlpMetricsUrl), export: signalExport }, + persisted.otlpMetricsUrl, + ), + logs: OtelEnvironment.resolveSignalEndpoint( + otel, + "logs", + { url: Option.getOrUndefined(environment.otlpLogsUrl), export: signalExport }, + persisted.otlpLogsUrl, + ), warnings: otel.warnings, resourceAttributes: otel.resourceAttributes, }; @@ -602,11 +625,10 @@ const telemetryLayer = Layer.unwrap( Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const endpoints = yield* resolveOtlpEndpoints; - const headers = Option.getOrUndefined(environment.otlpHeaders); - const serializationLayer = otlpSerializationLayer(environment.otlpProtocol); const resource = { - serviceName: "desktop", + serviceName: "t3code-desktop", attributes: { + "service.namespace": "t3code", "service.runtime": "desktop", "service.mode": environment.isDevelopment ? "development" : "packaged", }, @@ -629,14 +651,19 @@ const telemetryLayer = Layer.unwrap( : [ Logger.consolePretty(), OtlpLogger.make({ - url: endpoints.logs, - exportInterval: `${environment.otlpExportIntervalMs} millis`, - headers, + url: endpoints.logs.url, + exportInterval: `${endpoints.logs.export.exportIntervalMs} millis`, + headers: endpoints.logs.export.headers, resource, }), ], { mergeWithExisting: false }, - ).pipe(Layer.provide(OtlpExporter.layerFlusher), Layer.provide(serializationLayer)); + ).pipe( + Layer.provide(OtlpExporter.layerFlusher), + Layer.provide( + otlpSerializationLayer(endpoints.logs?.export.protocol ?? environment.otlpProtocol), + ), + ); const tracerLayer = Layer.unwrap( Effect.gen(function* () { @@ -651,11 +678,11 @@ const telemetryLayer = Layer.unwrap( endpoints.traces === undefined ? undefined : yield* OtlpTracer.make({ - url: endpoints.traces, - exportInterval: `${environment.otlpExportIntervalMs} millis`, - headers, + url: endpoints.traces.url, + exportInterval: `${endpoints.traces.export.exportIntervalMs} millis`, + headers: endpoints.traces.export.headers, resource, - }).pipe(Effect.provide(serializationLayer)); + }).pipe(Effect.provide(otlpSerializationLayer(endpoints.traces.export.protocol))); const tracer = yield* makeLocalFileTracer({ filePath: tracePath, maxBytes: DESKTOP_LOG_FILE_MAX_BYTES, @@ -679,11 +706,11 @@ const telemetryLayer = Layer.unwrap( // endpoints.metrics === undefined // ? Layer.empty // : OtlpMetrics.layer({ - // url: endpoints.metrics, - // exportInterval: `${environment.otlpExportIntervalMs} millis`, - // headers, + // url: endpoints.metrics.url, + // exportInterval: `${endpoints.metrics.export.exportIntervalMs} millis`, + // headers: endpoints.metrics.export.headers, // resource, - // }).pipe(Layer.provide(serializationLayer)); + // }).pipe(Layer.provide(otlpSerializationLayer(endpoints.metrics.export.protocol))); // Logged once the loggers above are installed, so the warnings use them. const otelWarningsLayer = Layer.effectDiscard( diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 8d297750f784..07644a8af47c 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -780,12 +780,11 @@ describe("DesktopBackendConfiguration", () => { const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); // No settings.json exists here: the endpoints come from the desktop - // process's env, which a WSL child cannot inherit, so the bootstrap - // has to carry them or log export stays off inside the distro. + // environment, and the bootstrap carries them for a WSL child that + // lacks the variables. assert.equal(config.bootstrap.otlpTracesUrl, "http://127.0.0.1:4318/v1/traces"); assert.equal(config.bootstrap.otlpMetricsUrl, "http://127.0.0.1:4318/v1/metrics"); assert.equal(config.bootstrap.otlpLogsUrl, "http://127.0.0.1:4318/v1/logs"); - assert.notInclude(config.env.WSLENV ?? "", "T3CODE_OTLP_LOGS_URL"); }).pipe( Effect.provide( DesktopBackendConfiguration.layer.pipe( @@ -996,6 +995,73 @@ describe("DesktopBackendConfiguration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect( + "resolveWsl carries the standard OTLP endpoint, headers, and protocol into the distro", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-backend-config-test-", + }); + + const standard = { + OTEL_EXPORTER_OTLP_ENDPOINT: "https://collector.example.com:4318/base?api_key=secret", + OTEL_EXPORTER_OTLP_LOGS_HEADERS: "authorization=Bearer%20token", + T3CODE_OTLP_TRACES_URL: "http://t3.example.com:4318/v1/traces", + }; + const previousWslEnv = process.env.WSLENV; + // A developer's own OTLP variables would be forwarded too. + const ambientOtel = Object.entries(process.env).filter( + ([name]) => name.startsWith("OTEL_") || name.startsWith("T3CODE_OTLP_"), + ); + try { + for (const [name] of ambientOtel) delete process.env[name]; + delete process.env.WSLENV; + Object.assign(process.env, standard); + + yield* Effect.gen(function* () { + const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; + const config = yield* configuration.resolveWsl({ port: 5050, distro: null }); + + assert.equal( + config.env.OTEL_EXPORTER_OTLP_ENDPOINT, + "https://collector.example.com:4318/base?api_key=secret", + ); + assert.equal( + config.env.OTEL_EXPORTER_OTLP_LOGS_HEADERS, + "authorization=Bearer%20token", + ); + // Without a flag, WSL passes the values through untranslated. + const wslEnv = (config.env.WSLENV ?? "").split(":"); + assert.include(wslEnv, "OTEL_EXPORTER_OTLP_ENDPOINT"); + assert.include(wslEnv, "OTEL_EXPORTER_OTLP_LOGS_HEADERS"); + assert.equal(config.env.T3CODE_OTLP_TRACES_URL, "http://t3.example.com:4318/v1/traces"); + assert.include(wslEnv, "T3CODE_OTLP_TRACES_URL"); + }).pipe( + Effect.provide( + DesktopBackendConfiguration.layer.pipe( + Layer.provideMerge(serverExposureLayer), + Layer.provideMerge(DesktopAppSettings.layerTest()), + Layer.provideMerge(DesktopWslServerTree.layerTest()), + Layer.provideMerge( + DesktopWslEnvironment.layerTest({ + isAvailable: true, + windowsToWslPath: () => Option.some("/mnt/c/repo/apps/server/src/index.ts"), + getDistroIp: () => Option.some("172.27.0.99"), + }), + ), + Layer.provideMerge(makeEnvironmentLayer(baseDir, { platform: "win32" })), + ), + ), + ); + } finally { + for (const name of Object.keys(standard)) delete process.env[name]; + restoreEnv("WSLENV", previousWslEnv); + for (const [name, value] of ambientOtel) restoreEnv(name, value); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("resolveWsl preserves existing WSLENV entries when forwarding backend secrets", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1008,7 +1074,10 @@ describe("DesktopBackendConfiguration", () => { const previousAnthropicKey = process.env.ANTHROPIC_API_KEY; const previousOtlpHeaders = process.env.T3CODE_OTLP_HEADERS; const previousOtlpProtocol = process.env.T3CODE_OTLP_PROTOCOL; + // A developer's own OTEL_* variables would be forwarded too. + const ambientOtel = Object.entries(process.env).filter(([name]) => name.startsWith("OTEL_")); try { + for (const [name] of ambientOtel) delete process.env[name]; process.env.WSLENV = "GOPATH/p:OPENAI_API_KEY/u:EMPTY::AZURE_DEVOPS_EXT_PAT/u"; process.env.OPENAI_API_KEY = "openai-key"; process.env.ANTHROPIC_API_KEY = "anthropic-key"; @@ -1065,6 +1134,7 @@ describe("DesktopBackendConfiguration", () => { restoreEnv("ANTHROPIC_API_KEY", previousAnthropicKey); restoreEnv("T3CODE_OTLP_HEADERS", previousOtlpHeaders); restoreEnv("T3CODE_OTLP_PROTOCOL", previousOtlpProtocol); + for (const [name, value] of ambientOtel) restoreEnv(name, value); } }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index d400c7a3a878..b3bec8830ec6 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -90,9 +90,8 @@ const DESKTOP_BACKEND_ENV_NAMES = [ ] as const; // Env vars that the WSL backend needs but Windows process.env won't forward -// across the wsl.exe boundary without WSLENV. The dev-server URL is handled -// separately via a `--dev-url` CLI flag because WSLENV translation of -// URL-shaped values (colons / slashes) is unreliable. +// across the wsl.exe boundary without WSLENV. The dev-server URL travels as +// the `--dev-url` CLI flag instead. const WSL_FORWARDED_ENV_NAMES = [ "OPENAI_API_KEY", "ANTHROPIC_API_KEY", @@ -101,6 +100,24 @@ const WSL_FORWARDED_ENV_NAMES = [ "OTEL_SDK_DISABLED", "T3CODE_OTLP_HEADERS", "T3CODE_OTLP_PROTOCOL", + // Forwarded without a WSLENV flag, so the values arrive untranslated. The + // server prefers an OTEL endpoint over the bootstrap envelope, so the T3 URLs + // travel as variables to keep winning inside the distro as they do on Windows. + "T3CODE_OTLP_TRACES_URL", + "T3CODE_OTLP_METRICS_URL", + "T3CODE_OTLP_LOGS_URL", + "OTEL_EXPORTER_OTLP_ENDPOINT", + "OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", + "OTEL_EXPORTER_OTLP_METRICS_ENDPOINT", + "OTEL_EXPORTER_OTLP_LOGS_ENDPOINT", + "OTEL_EXPORTER_OTLP_HEADERS", + "OTEL_EXPORTER_OTLP_TRACES_HEADERS", + "OTEL_EXPORTER_OTLP_METRICS_HEADERS", + "OTEL_EXPORTER_OTLP_LOGS_HEADERS", + "OTEL_EXPORTER_OTLP_PROTOCOL", + "OTEL_EXPORTER_OTLP_TRACES_PROTOCOL", + "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", + "OTEL_EXPORTER_OTLP_LOGS_PROTOCOL", ] as const; const WSL_SERVER_SYSTEM_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; @@ -221,12 +238,11 @@ const readPersistedBackendObservabilitySettings = Effect.gen(function* () { }; }); -// The bootstrap is the only channel that carries an OTLP endpoint to every -// backend. A Windows-native child inherits the desktop process's env, but a -// WSL child gets nothing across wsl.exe that WSLENV does not declare, and -// WSLENV translation of URL-shaped values is unreliable, so the endpoints are -// deliberately not forwarded that way. Env beats the persisted settings file, -// matching the precedence resolveServerConfig and DesktopObservability apply. +// The bootstrap carries the OTLP endpoints to every backend, including a WSL +// child that lacks the variables. The T3 URLs also travel as variables in +// WSL_FORWARDED_ENV_NAMES so they outrank a forwarded OTEL endpoint. Env beats +// the persisted settings file, matching the precedence resolveServerConfig and +// DesktopObservability apply. const readBackendObservabilitySettings = Effect.gen(function* () { const environment = yield* DesktopEnvironment.DesktopEnvironment; const persisted = yield* readPersistedBackendObservabilitySettings; @@ -742,10 +758,8 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl }; // Forward the dev-server URL as an explicit CLI flag so the WSL backend's - // config resolution lands in dev/ instead of userdata/. Inheriting through - // WSLENV is unreliable in practice (URL-shaped values with colons / - // slashes get translated unpredictably depending on flags), and the - // packaged build leaves devServerUrl as None anyway. + // config resolution lands in dev/ instead of userdata/. The packaged build + // leaves devServerUrl as None. const devUrlArgs = Option.match(environment.devServerUrl, { onNone: () => [] as ReadonlyArray, onSome: (url) => ["--dev-url", url.href], diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index d1d108d3bed0..75be079f0de8 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1,7 +1,7 @@ import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; import type { ThreadMoveDestination } from "../threads/threadOrder"; import { computeThreadMoveAvailability } from "../threads/threadOrder"; -import { LegendList } from "@legendapp/list/react-native"; +import { LegendList, type LegendListRef } from "@legendapp/list/react-native"; import { type EnvironmentProject, type EnvironmentThreadShell, @@ -18,7 +18,14 @@ import { import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, Platform, View } from "react-native"; +import { + ActivityIndicator, + Platform, + View, + type GestureResponderEvent, + type NativeScrollEvent, + type NativeSyntheticEvent, +} from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -59,6 +66,7 @@ import { sortHomeProjectScopes, type HomeProjectSortOrder, } from "./homeThreadList"; +import { createSwipeRowActivation } from "./swipe-row-activation"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; import { useMaterialFabScroll } from "./MaterialFabScrollContext"; @@ -130,6 +138,10 @@ interface HomeScreenProps { // measured-height pool expansion. The old tallest-card estimate (~92) fired // that warning on every ordinary shelf expand, so the average wins. const ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT = 72; +// Rows away from the viewport are cheap dormant frames (see +// swipe-row-activation), so render further ahead: a fast fling then reaches +// rows that are already built instead of rows still being rebuilt. +const THREAD_LIST_V2_DRAW_DISTANCE = 1_000; const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44; /** * Top spacing between the list and the Android custom header. The Android @@ -266,8 +278,45 @@ export function HomeScreen(props: HomeScreenProps) { openSwipeableRef.current?.close(); }, []); const onMaterialFabScroll = useMaterialFabScroll(); + const listRef = useRef(null); + const swipeRowActivation = useMemo(() => createSwipeRowActivation(), []); + const activateVisibleRows = useCallback( + (rows: ReadonlyArray) => { + const state = listRef.current?.getState(); + if (state === undefined || !(state.end >= 0)) return; + swipeRowActivation.activate( + rows.slice(Math.max(0, state.start - 2), state.end + 3).map((row) => row.key), + ); + }, + [swipeRowActivation], + ); + // Status-bar, accessibility and programmatic scrolls never arm the scroll + // gate, so every scroll also activates the visible rows once it settles. + const activationTimerRef = useRef | undefined>(undefined); + useEffect(() => () => clearTimeout(activationTimerRef.current), []); + const handleListScroll = useCallback( + (event: NativeSyntheticEvent) => { + onMaterialFabScroll?.(event); + clearTimeout(activationTimerRef.current); + activationTimerRef.current = setTimeout( + () => activateVisibleRows(listRef.current?.getState().data ?? []), + 200, + ); + }, + [activateVisibleRows, onMaterialFabScroll], + ); + const trackListTouches = useCallback( + (event: GestureResponderEvent, started: boolean) => { + const { changedTouches, touches } = event.nativeEvent; + swipeRowActivation.trackTouches( + started ? changedTouches.map((touch) => touch.identifier) : [], + touches.map((touch) => touch.identifier), + ); + }, + [swipeRowActivation], + ); const { swipeEnabled, scrollGateHandlers } = useSwipeableScrollGate({ - onScroll: onMaterialFabScroll, + onScroll: handleListScroll, onScrollBeginDrag: handleScrollBeginDrag, }); @@ -657,6 +706,9 @@ export function HomeScreen(props: HomeScreenProps) { ); useThreadJumpShortcuts(threadListV2Items, props.onSelectThread); + useEffect(() => { + if (swipeEnabled) activateVisibleRows(threadListV2Items); + }, [activateVisibleRows, swipeEnabled, threadListV2Items]); const renderV2Item = useCallback( ({ item }: { readonly item: ThreadListV2ListItem }) => { @@ -764,6 +816,7 @@ export function HomeScreen(props: HomeScreenProps) { onMoveThread={handleMoveThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} + activationKey={item.key} /> ); }, @@ -952,15 +1005,20 @@ export function HomeScreen(props: HomeScreenProps) { {/* Shared with the iPad sidebar: cells are reused across data rebuilds and `itemsAreEqual` keeps a minute tick (or an unrelated shell update) from re-rendering untouched rows. */} - + activateVisibleRows(threadListV2Items)} + onTouchStart={(event) => trackListTouches(event, true)} + onTouchEnd={(event) => trackListTouches(event, false)} + onTouchCancel={(event) => trackListTouches(event, false)} data={threadListV2Items} renderItem={renderV2Item} keyExtractor={v2KeyExtractor} getItemType={(item) => item.type} itemsAreEqual={threadListV2ListItemsAreEqual} estimatedItemSize={ESTIMATED_THREAD_LIST_V2_ROW_HEIGHT} - drawDistance={500} + drawDistance={THREAD_LIST_V2_DRAW_DISTANCE} recycleItems extraData={v2ExtraData} ListHeaderComponent={v2ListHeader} diff --git a/apps/mobile/src/features/home/swipe-row-activation.test.ts b/apps/mobile/src/features/home/swipe-row-activation.test.ts new file mode 100644 index 000000000000..43f8dcf0ca7e --- /dev/null +++ b/apps/mobile/src/features/home/swipe-row-activation.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createSwipeRowActivation } from "./swipe-row-activation"; + +describe("createSwipeRowActivation", () => { + it("activates exactly the requested rows and notifies only on change", () => { + const activation = createSwipeRowActivation(); + const listener = vi.fn(); + activation.subscribe(listener); + + activation.activate(["a", "b"]); + activation.activate(["b", "a"]); + + expect(activation.isActive("a")).toBe(true); + expect(activation.isActive("c")).toBe(false); + expect(listener).toHaveBeenCalledTimes(1); + + activation.activate(["c"]); + expect(activation.isActive("a")).toBe(false); + expect(activation.isActive("c")).toBe(true); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it("defers changes while a finger is on the list so a press is never remounted", () => { + const activation = createSwipeRowActivation(); + activation.activate(["a"]); + + activation.trackTouches(["1"], ["1"]); + activation.activate(["b"]); + activation.activate(["c"]); + expect(activation.isActive("a")).toBe(true); + expect(activation.isActive("c")).toBe(false); + + activation.trackTouches([], []); + expect(activation.isActive("a")).toBe(false); + expect(activation.isActive("b")).toBe(false); + expect(activation.isActive("c")).toBe(true); + }); + + it("ignores fingers that did not start on the list", () => { + const activation = createSwipeRowActivation(); + activation.trackTouches(["1"], ["1", "2"]); + activation.activate(["a"]); + + // The list finger lifts while finger 2 stays on another control. + activation.trackTouches([], ["2"]); + expect(activation.isActive("a")).toBe(true); + }); + + it("drops a list finger whose end event never arrived", () => { + const activation = createSwipeRowActivation(); + activation.trackTouches(["1"], ["1"]); + activation.activate(["a"]); + + activation.trackTouches(["2"], ["2"]); + activation.trackTouches([], []); + expect(activation.isActive("a")).toBe(true); + }); + + it("stops notifying after unsubscribe", () => { + const activation = createSwipeRowActivation(); + const listener = vi.fn(); + const unsubscribe = activation.subscribe(listener); + unsubscribe(); + activation.activate(["a"]); + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/features/home/swipe-row-activation.ts b/apps/mobile/src/features/home/swipe-row-activation.ts new file mode 100644 index 000000000000..d56d12cba56f --- /dev/null +++ b/apps/mobile/src/features/home/swipe-row-activation.ts @@ -0,0 +1,62 @@ +import { createContext, use, useSyncExternalStore } from "react"; + +/** + * Full swipe rows (pan gesture, animated actions, hidden action buttons) only + * exist around the viewport. Every other Home row renders a dormant frame that + * paints the same content with a fraction of the native views, so a row the + * list rebuilds while scrolling is cheap. The scroll gate already disables + * swipes while the list moves, so rows are activated once it rests. + */ +export function createSwipeRowActivation() { + let activeKeys = new Set(); + // Swapping a row's frame remounts it, which would cancel a press or long + // press in progress, so changes wait until every finger that started on the + // list has lifted. + const listTouches = new Set(); + let pendingKeys: ReadonlyArray | null = null; + const listeners = new Set<() => void>(); + const apply = (keys: ReadonlyArray) => { + if (keys.length === activeKeys.size && keys.every((key) => activeKeys.has(key))) return; + activeKeys = new Set(keys); + for (const listener of listeners) listener(); + }; + return { + subscribe(listener: () => void) { + listeners.add(listener); + return () => void listeners.delete(listener); + }, + isActive: (key: string) => activeKeys.has(key), + activate(keys: ReadonlyArray) { + if (listTouches.size > 0) pendingKeys = keys; + else apply(keys); + }, + /** + * `started` are touches that just began on the list; `onScreen` is every + * finger still down anywhere. A finger on another control never holds + * changes, and one whose end event went missing is dropped here. + */ + trackTouches(started: ReadonlyArray, onScreen: ReadonlyArray) { + for (const id of started) listTouches.add(id); + for (const id of listTouches) if (!onScreen.includes(id)) listTouches.delete(id); + if (listTouches.size > 0 || pendingKeys === null) return; + const keys = pendingKeys; + pendingKeys = null; + apply(keys); + }, + }; +} + +export type SwipeRowActivation = ReturnType; + +export const SwipeRowActivationContext = createContext(null); + +const subscribeNever = () => () => {}; + +/** Rows outside an activation provider (e.g. the iPad sidebar) stay live. */ +export function useSwipeRowDormant(key: string | undefined): boolean { + const activation = use(SwipeRowActivationContext); + return useSyncExternalStore( + activation?.subscribe ?? subscribeNever, + () => activation !== null && key !== undefined && !activation.isActive(key), + ); +} diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 44f3c36e6655..e62935c28861 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -40,6 +40,7 @@ import Animated, { } from "react-native-reanimated"; import { AppText as Text } from "../../components/AppText"; +import { SwipeRowActivationContext, type SwipeRowActivation } from "./swipe-row-activation"; import { registerThreadDismissal } from "./thread-dismissal"; // Wide enough for the longest action label ("Unarchive"). @@ -129,11 +130,14 @@ const SwipeableScrollGateContext = createContext(true); export function SwipeableScrollGateProvider(props: { readonly enabled: boolean; + readonly activation?: SwipeRowActivation; readonly children: ReactNode; }) { return ( - {props.children} + + {props.children} + ); } @@ -260,13 +264,32 @@ interface ThreadSwipeableProps { * open/mid-drag state can't leak onto another row. */ readonly resetKey?: string; + /** Paints the row without swipe machinery; see swipe-row-activation. */ + readonly dormant?: boolean; readonly simultaneousWithExternalGesture?: ComponentProps< typeof ReanimatedSwipeable >["simultaneousWithExternalGesture"]; readonly threadTitle: string; } +const closeDormant = () => {}; + export function ThreadSwipeable(props: ThreadSwipeableProps) { + if (props.dormant) { + // Mirrors ReanimatedSwipeable's container and children views. + return ( + + + {props.children(closeDormant)} + + + ); + } // Recycled content gets fresh native and animation state. Late callbacks // from the previous row retain its action, never the replacement's action. return ; diff --git a/apps/mobile/src/features/observability/tracing.ts b/apps/mobile/src/features/observability/tracing.ts index eb73abba292b..ae204413e777 100644 --- a/apps/mobile/src/features/observability/tracing.ts +++ b/apps/mobile/src/features/observability/tracing.ts @@ -25,7 +25,7 @@ export function resolveTracingConfig(): TracingConfig | null { export function makeTracingLayer(config: TracingConfig | null, resource: TracingResource) { return makeRelayClientTracingLayer(config, { - serviceName: "t3-mobile-relay-client", + serviceName: "t3code-mobile", serviceVersion: resource.serviceVersion, runtime: "react-native", client: `mobile-${resource.appVariant}`, diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 31039388dc09..eb089b95060a 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -35,6 +35,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; +import { useSwipeRowDormant } from "../home/swipe-row-activation"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; import { @@ -518,6 +519,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly canMoveDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; + /** List key checked against the Home swipe row activation. */ + readonly activationKey?: string; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; readonly simultaneousSwipeGesture?: ComponentProps< @@ -545,6 +548,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { } = props; const snoozedRow = props.snoozed === true; const pinnedRow = props.pinned === true; + const dormant = useSwipeRowDormant(props.activationKey); const pr = useThreadPr(thread); @@ -1180,6 +1184,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { setCustomSnoozeOpen(false)} onSnooze={handleSnooze} /> )} Effect.succeed(false), }), ), + Layer.provide(Layer.mock(TerminalManager.TerminalManager)({ closeIdle: () => Effect.void })), Layer.provideMerge(runtimeServicesLayer), Layer.provideMerge(gitWorkflowLayer), Layer.provideMerge(textGenerationLayer), diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 31a96304c3e5..aecf82eeac67 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -106,7 +106,6 @@ const makeCliTestServerConfig = (baseDir: string) => otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, mode: "web", port: 0, diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index f9b45caf7442..ef7cdd578899 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -56,7 +56,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, devAllowedOrigins: [], } as const; @@ -999,4 +998,146 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved.otlpLogsUrl).toBe("http://collector.internal:4318/v1/logs"); }), ); + + const minimalWebFlags = (baseDir: string) => ({ + mode: Option.some("web" as const), + port: Option.some(3773), + host: Option.none(), + baseDir: Option.some(baseDir), + cwd: Option.none(), + devUrl: Option.none(), + noBrowser: Option.none(), + bootstrapFd: Option.none(), + autoBootstrapProjectFromCwd: Option.none(), + logWebSocketEvents: Option.none(), + tailscaleServeEnabled: Option.none(), + tailscaleServePort: Option.none(), + }); + + it.effect( + "resolves each signal's endpoint through T3CODE_OTLP_*_URL, an OTEL endpoint, the bootstrap envelope, and persisted Settings, in that order", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-cli-config-otel-precedence-", + }); + const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); + yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); + yield* fs.writeFileString( + derivedPaths.settingsPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + `${JSON.stringify({ observability: { otlpLogsUrl: "http://settings:4318/v1/logs" } })}\n`, + ); + + const fd = yield* openBootstrapFd( + makeDesktopBootstrap({ + otlpMetricsUrl: "http://bootstrap:4318/v1/metrics", + // Blank, not an endpoint: it must not stand in front of Settings. + otlpLogsUrl: "", + }), + ); + + const resolved = yield* resolveServerConfig( + { + ...minimalWebFlags(baseDir), + mode: Option.some("desktop"), + port: Option.some(4888), + bootstrapFd: Option.some(fd), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_OTLP_TRACES_URL: "http://t3:4318/v1/traces", + T3CODE_OTLP_HEADERS: "x-key=secret", + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://otel-traces:4318/custom", + OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: "http://otel-metrics:4318/custom", + OTEL_EXPORTER_OTLP_HEADERS: "x-key=otel", + }, + }), + ), + NetService.layer, + ), + ), + ); + + // T3CODE_OTLP_TRACES_URL wins over the OTEL variable for the same + // signal, and keeps T3 Code's own headers since T3 Code still owns it. + expect(resolved.otlpTracesUrl).toBe("http://t3:4318/v1/traces"); + expect(resolved.otlpTracesExport.headers).toEqual({ "x-key": "secret" }); + // Metrics named no T3CODE_OTLP_METRICS_URL, so the OTEL endpoint wins + // over the bootstrap envelope and brings the OTEL headers and protocol. + expect(resolved.otlpMetricsUrl).toBe("http://otel-metrics:4318/custom"); + expect(resolved.otlpMetricsExport).toEqual({ + ...DEFAULT_SIGNAL_EXPORT, + protocol: "http/protobuf", + headers: { "x-key": "otel" }, + }); + // Logs named no T3 or OTEL endpoint and a blank bootstrap value, so + // Settings answers, and logs keep the shared headers since no OTEL + // endpoint claimed them. + expect(resolved.otlpLogsUrl).toBe("http://settings:4318/v1/logs"); + expect(resolved.otlpLogsExport.headers).toEqual({ "x-key": "secret" }); + }), + ); + + it.effect( + "exports nothing for a signal an OTEL endpoint claimed with a protocol or headers that do not read", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-cli-config-otel-off-", + }); + const derivedPaths = yield* deriveExplicitServerPaths(baseDir, undefined); + yield* fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }); + yield* fs.writeFileString( + derivedPaths.settingsPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off + `${JSON.stringify({ observability: { otlpLogsUrl: "http://settings:4318/v1/logs" } })}\n`, + ); + + const fd = yield* openBootstrapFd( + makeDesktopBootstrap({ otlpMetricsUrl: "http://bootstrap:4318/v1/metrics" }), + ); + + const resolved = yield* resolveServerConfig( + { + ...minimalWebFlags(baseDir), + mode: Option.some("desktop"), + port: Option.some(4888), + bootstrapFd: Option.some(fd), + }, + Option.none(), + ).pipe( + Effect.provide( + Layer.mergeAll( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + T3CODE_OTLP_TRACES_URL: "http://t3:4318/v1/traces", + OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel:4318", + OTEL_EXPORTER_OTLP_HEADERS: "x-key=%zz", + }, + }), + ), + NetService.layer, + ), + ), + ); + + // T3CODE_OTLP_TRACES_URL still wins outright. + expect(resolved.otlpTracesUrl).toBe("http://t3:4318/v1/traces"); + // The OTEL endpoint claimed metrics and logs, so neither the bootstrap + // envelope nor Settings receives them with T3 Code's headers. + expect(resolved.otlpMetricsUrl).toBeUndefined(); + expect(resolved.otlpLogsUrl).toBeUndefined(); + }), + ); }); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 1b6449433139..d724730c953f 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -108,7 +108,6 @@ const EnvServerConfig = Config.all({ otlpExportIntervalMs: Config.Int("T3CODE_OTLP_EXPORT_INTERVAL_MS").pipe( Config.withDefault(10_000), ), - otlpServiceName: Config.String("T3CODE_OTLP_SERVICE_NAME").pipe(Config.withDefault("t3-server")), otlpHeaders: Config.schema(OtlpHeadersFromString, "T3CODE_OTLP_HEADERS").pipe( Config.option, Config.map(Option.getOrUndefined), @@ -396,6 +395,27 @@ export const resolveServerConfig = ( headers: env.otlpHeaders, exportIntervalMs: env.otlpExportIntervalMs, }; + const traces = OtelEnvironment.resolveSignalEndpoint( + otel, + "traces", + { url: env.otlpTracesUrl, export: signalExport }, + bootstrap?.otlpTracesUrl, + persistedObservabilitySettings.otlpTracesUrl, + ); + const metrics = OtelEnvironment.resolveSignalEndpoint( + otel, + "metrics", + { url: env.otlpMetricsUrl, export: signalExport }, + bootstrap?.otlpMetricsUrl, + persistedObservabilitySettings.otlpMetricsUrl, + ); + const logs = OtelEnvironment.resolveSignalEndpoint( + otel, + "logs", + { url: env.otlpLogsUrl, export: signalExport }, + bootstrap?.otlpLogsUrl, + persistedObservabilitySettings.otlpLogsUrl, + ); const config: ServerConfig.ServerConfig["Service"] = { logLevel, @@ -404,23 +424,12 @@ export const resolveServerConfig = ( traceBatchWindowMs: env.traceBatchWindowMs, traceMaxBytes: env.traceMaxBytes, traceMaxFiles: env.traceMaxFiles, - otlpTracesUrl: otel.disabled - ? undefined - : (env.otlpTracesUrl ?? - bootstrap?.otlpTracesUrl ?? - persistedObservabilitySettings.otlpTracesUrl), - otlpMetricsUrl: otel.disabled - ? undefined - : (env.otlpMetricsUrl ?? - bootstrap?.otlpMetricsUrl ?? - persistedObservabilitySettings.otlpMetricsUrl), - otlpLogsUrl: otel.disabled - ? undefined - : (env.otlpLogsUrl ?? bootstrap?.otlpLogsUrl ?? persistedObservabilitySettings.otlpLogsUrl), - otlpTracesExport: signalExport, - otlpMetricsExport: signalExport, - otlpLogsExport: signalExport, - otlpServiceName: env.otlpServiceName, + otlpTracesUrl: traces?.url, + otlpMetricsUrl: metrics?.url, + otlpLogsUrl: logs?.url, + otlpTracesExport: traces?.export ?? signalExport, + otlpMetricsExport: metrics?.export ?? signalExport, + otlpLogsExport: logs?.export ?? signalExport, otelEnvironment: otel, mode, port, diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 6b29ef60927f..04ce0332c14c 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -326,7 +326,6 @@ const makePairServerConfig = Effect.fn(function* (input: { otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, mode: "web", port: state.port, diff --git a/apps/server/src/cloud/relayTracing.ts b/apps/server/src/cloud/relayTracing.ts index e35c94545a5e..eeea28a2b68f 100644 --- a/apps/server/src/cloud/relayTracing.ts +++ b/apps/server/src/cloud/relayTracing.ts @@ -7,14 +7,14 @@ const relayClientTracingConfig = resolveRelayClientTracingConfig(); export const headlessRelayClientTracingLayer = makeRelayClientTracingLayer( relayClientTracingConfig, { - serviceName: "t3-headless-relay-client", + serviceName: "t3code-server", runtime: "node", client: "headless-cli", }, ); export const serverRelayBrokerTracingLayer = makeRelayClientTracingLayer(relayClientTracingConfig, { - serviceName: "t3-server", + serviceName: "t3code-server", runtime: "node", client: "environment-server", component: "relay-broker", diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 344619c0eb93..74ffde8efef0 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -82,7 +82,6 @@ export class ServerConfig extends Context.Service< readonly otlpTracesExport: SignalExport; readonly otlpMetricsExport: SignalExport; readonly otlpLogsExport: SignalExport; - readonly otlpServiceName: string; readonly otelEnvironment: OtelEnvironment.OtelEnvironment; readonly mode: RuntimeMode; readonly port: number; @@ -119,8 +118,9 @@ export const make = (config: ServerConfig["Service"]) => ServerConfig.of(config) * logs report the same service identity to the collector. */ export const otlpResource = (config: ServerConfig["Service"]) => ({ - serviceName: config.otlpServiceName, + serviceName: "t3code-server", attributes: { + "service.namespace": "t3code", "service.runtime": "t3-server", "service.mode": config.mode, }, @@ -222,7 +222,6 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, cwd, baseDir, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 12ae321c1cf3..6eb95c84b477 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -59,7 +59,6 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, cwd: process.cwd(), baseDir, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c7d9417bc75b..254aec4bde7e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -55,6 +55,7 @@ import { import { ProviderAuthService } from "../../provider/Services/ProviderAuthService.ts"; import { makeProviderRegistryLayer } from "../../provider/testUtils/providerRegistryMock.ts"; import { TextGeneration } from "../../textGeneration/TextGeneration.ts"; +import { TerminalManager } from "../../terminal/Manager.ts"; import * as RepositoryIdentityResolver from "../../project/RepositoryIdentityResolver.ts"; import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; @@ -307,6 +308,7 @@ describe("ProviderCommandReactor", () => { }), ); const pruneWorktrees = vi.fn((_: { readonly cwd: string }) => Effect.void); + const closeIdleTerminals = vi.fn((_: { readonly threadId: string }) => Effect.void); const createWorktree = vi.fn( (input: { readonly refName: string; readonly path: string | null }) => Effect.succeed({ worktree: { path: input.path ?? "", refName: input.refName } }), @@ -490,6 +492,7 @@ describe("ProviderCommandReactor", () => { generateThreadTitle, }), ), + Layer.provideMerge(Layer.mock(TerminalManager)({ closeIdle: closeIdleTerminals })), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(SqlitePersistenceMemory), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), @@ -621,6 +624,7 @@ describe("ProviderCommandReactor", () => { renameBranch, pruneWorktrees, createWorktree, + closeIdleTerminals, refreshStatus, generateBranchName, generateThreadTitle, @@ -4339,6 +4343,77 @@ describe("ProviderCommandReactor", () => { expect(thread?.settledOverride).toBe("settled"); expect(thread?.session?.status).toBe("stopped"); expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); + expect(harness.closeIdleTerminals).toHaveBeenCalledWith({ + threadId: ThreadId.make("thread-1"), + }); }), ); + + effectIt.effect("closes idle terminals when a thread without a session settles", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const terminalsClosed = yield* Deferred.make(); + harness.closeIdleTerminals.mockImplementation(() => + Deferred.succeed(terminalsClosed, undefined).pipe(Effect.asVoid), + ); + + yield* harness.engine.dispatch({ + type: "thread.settle", + commandId: CommandId.make("cmd-settle-without-session"), + threadId: ThreadId.make("thread-1"), + }); + yield* Deferred.await(terminalsClosed); + yield* Effect.promise(() => harness.drain()); + + expect(harness.closeIdleTerminals).toHaveBeenCalledWith({ + threadId: ThreadId.make("thread-1"), + }); + expect(harness.stopSession).not.toHaveBeenCalled(); + }), + ); + + effectIt.effect( + "keeps terminals when the thread is un-settled before its settle event runs", + () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const threadId = ThreadId.make("thread-1"); + const firstCloseStarted = yield* Deferred.make(); + const releaseFirstClose = yield* Deferred.make(); + harness.closeIdleTerminals.mockImplementationOnce(() => + Deferred.succeed(firstCloseStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseFirstClose)), + ), + ); + + yield* harness.engine.dispatch({ + type: "thread.settle", + commandId: CommandId.make("cmd-settle-first"), + threadId, + }); + // The reactor is busy with the first settle while the user changes their mind. + yield* Deferred.await(firstCloseStarted); + yield* harness.engine.dispatch({ + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-first"), + threadId, + reason: "user", + }); + yield* harness.engine.dispatch({ + type: "thread.settle", + commandId: CommandId.make("cmd-settle-second"), + threadId, + }); + yield* harness.engine.dispatch({ + type: "thread.unsettle", + commandId: CommandId.make("cmd-unsettle-second"), + threadId, + reason: "user", + }); + yield* Deferred.succeed(releaseFirstClose, undefined); + yield* Effect.promise(() => harness.drain()); + + expect(harness.closeIdleTerminals).toHaveBeenCalledTimes(1); + }), + ); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index bff5a6096421..f6794135a925 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -65,6 +65,7 @@ import { import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import * as TerminalManager from "../../terminal/Manager.ts"; const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError); const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderAdapterValidationError = Schema.is(ProviderAdapterValidationError); @@ -222,6 +223,7 @@ const make = Effect.gen(function* () { const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; + const terminalManager = yield* TerminalManager.TerminalManager; /** Environment settings with the thread's project overrides applied. */ const projectSettingsForThread = Effect.fnUntraced(function* (threadId: ThreadId) { const settings = yield* serverSettingsService.getSettings; @@ -1822,11 +1824,14 @@ const make = Effect.gen(function* () { return; case "thread.settled": { const thread = yield* projectionSnapshotQuery.getThreadShellById(event.payload.threadId); - if ( - Option.isNone(thread) || - thread.value.session == null || - thread.value.session.status === "stopped" - ) { + // A thread re-engaged before this event ran keeps its shells and session. + if (Option.isNone(thread) || thread.value.settledOverride !== "settled") { + return; + } + // Idle shells close so they stop holding the worktree. A terminal that + // runs a command (a dev server, an editor) stays for the user to close. + yield* terminalManager.closeIdle({ threadId: event.payload.threadId }); + if (thread.value.session == null || thread.value.session.status === "stopped") { return; } yield* orchestrationEngine.dispatch({ diff --git a/apps/server/src/persistence/Layers/Sqlite.ts b/apps/server/src/persistence/Layers/Sqlite.ts index 88342cbf1fad..032b44645089 100644 --- a/apps/server/src/persistence/Layers/Sqlite.ts +++ b/apps/server/src/persistence/Layers/Sqlite.ts @@ -32,7 +32,7 @@ export const makeSqlitePersistenceLive = Effect.fn("makeSqlitePersistenceLive")( filename: dbPath, spanAttributes: { "db.name": path.basename(dbPath), - "service.name": "t3-server", + "service.name": "t3code-server", }, }), ); diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 7d623c91b326..2438edca8b1b 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -56,6 +56,7 @@ import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistr import { ServerSettingsService } from "../serverSettings.ts"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import { TextGeneration } from "../textGeneration/TextGeneration.ts"; +import { TerminalManager } from "../terminal/Manager.ts"; import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; import { importRecentAgentThreads } from "./AgentSessionImporter.ts"; @@ -931,6 +932,7 @@ it.layer(integrationLayer)("AgentSessionImporter integration", (it) => { Layer.provide(Layer.mock(GitWorkflowService)({})), Layer.provide(Layer.mock(VcsStatusBroadcaster)({})), Layer.provide(Layer.mock(TextGeneration)({})), + Layer.provide(Layer.mock(TerminalManager)({ closeIdle: () => Effect.void })), Layer.provide(ServerSettingsService.layerTest()), ); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index dd341a7f7859..650f304e8481 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -58,7 +58,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => }); type TerminalOverrides = Pick & - Partial>; + Partial>; const makeTerminalManagerLayer = (overrides: TerminalOverrides) => Layer.succeed(TerminalManager.TerminalManager, { @@ -67,6 +67,7 @@ const makeTerminalManagerLayer = (overrides: TerminalOverrides) => clear: () => Effect.void, restart: () => Effect.die(new Error("unused")), close: () => Effect.void, + closeIdle: () => Effect.void, subscribe: () => Effect.succeed(() => undefined), subscribeMetadata: () => Effect.succeed(() => undefined), ...overrides, @@ -262,6 +263,7 @@ describe("ProjectSetupScriptRunner", () => { listener = null; }); }); + const closeIdle = vi.fn(() => Effect.void); const project = makeProject([ { id: "setup", @@ -329,14 +331,82 @@ describe("ProjectSetupScriptRunner", () => { ]); // The subscription is torn down once the sentinel arrives. expect(listener).toBeNull(); + // A failed run keeps its shell open for a look. + expect(closeIdle).not.toHaveBeenCalled(); }).pipe( - Effect.provide(testLayer(project, { open, write, subscribe })), + Effect.provide(testLayer(project, { open, write, subscribe, closeIdle })), Effect.provideService(HostProcessPlatform, "linux"), Effect.provideService(HostProcessEnvironment, { SHELL: "/bin/zsh" }), ); }, ); + it.effect("closes the idle setup shell after a clean exit", () => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "setup-setup", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + let written = ""; + const write = vi.fn((input: { data: string }) => + Effect.sync(() => void (written = input.data)), + ); + let listener: ((event: TerminalEvent) => Effect.Effect) | null = null; + const subscribe = vi.fn((next: (event: TerminalEvent) => Effect.Effect) => { + listener = next; + return Effect.succeed(() => { + listener = null; + }); + }); + const closeIdle = vi.fn(() => Effect.void); + const project = makeProject([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const result = yield* runner.runForThread({ + threadId: "thread-1", + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + observeCompletion: {}, + }); + if (result.status !== "started" || !result.completion) { + return yield* Effect.die("expected an observed setup run"); + } + const sentinel = /__T3_SETUP_DONE___[0-9a-f]{32}:/.exec(written)?.[0]; + yield* listener!({ + threadId: "thread-1", + terminalId: "setup-setup", + type: "output", + data: `${sentinel}0\r\n`, + }); + + expect((yield* result.completion).exitCode).toBe(0); + expect(closeIdle).toHaveBeenCalledWith({ threadId: "thread-1", terminalId: "setup-setup" }); + }).pipe( + Effect.provide(testLayer(project, { open, write, subscribe, closeIdle })), + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provideService(HostProcessEnvironment, { SHELL: "/bin/zsh" }), + ); + }); + it.effect("unsubscribes from terminal output when the command cannot be written", () => { const open = vi.fn(() => Effect.succeed({ diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 16cbfaa59496..74bc41e5e8cd 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -36,6 +36,7 @@ export interface ProjectSetupScriptRunnerResultStarted { * Resolves when the script's shell prints the completion sentinel. The * exit code is null when the terminal exited or was closed before the * sentinel arrived. Only present when `observeCompletion` was requested. + * An exit code of 0 closes the setup shell if it has nothing left running. */ readonly completion?: Effect.Effect; } @@ -412,6 +413,16 @@ export const make = Effect.gen(function* () { Effect.tapError(() => Effect.sync(() => observed?.unsubscribe())), ); + // A clean run leaves only an idle prompt behind; its output stays in the + // terminal history. A failed run keeps its shell open for a look. + const completion = observed?.completion.pipe( + Effect.tap(({ exitCode }) => + exitCode === 0 + ? terminalManager.closeIdle({ threadId: input.threadId, terminalId }) + : Effect.void, + ), + ); + return { status: "started", scriptId: script.id, @@ -420,7 +431,7 @@ export const make = Effect.gen(function* () { terminalId, cwd, async: script.async !== false, - ...(observed ? { completion: observed.completion } : {}), + ...(completion ? { completion } : {}), } as const; }); diff --git a/apps/server/src/provider/cursorCredentialStore.ts b/apps/server/src/provider/cursorCredentialStore.ts index c071e2a88578..9d7d1c3bccff 100644 --- a/apps/server/src/provider/cursorCredentialStore.ts +++ b/apps/server/src/provider/cursorCredentialStore.ts @@ -1,5 +1,9 @@ +import * as NodeModule from "node:module"; + const CACHE_MS = 5 * 60_000; +const requireForKeyring = NodeModule.createRequire(import.meta.url); + /** Share one Keychain request across usage history and limits in this server process. */ export function makeCachedCursorAccessTokenReader( read: () => Promise, @@ -24,6 +28,6 @@ export function makeCachedCursorAccessTokenReader( /** Read the Cursor CLI's default macOS credential without invoking the shared security binary. */ export const readMacCursorAccessToken = makeCachedCursorAccessTokenReader(async () => { - const { AsyncEntry } = await import("@napi-rs/keyring"); + const { AsyncEntry } = requireForKeyring("@napi-rs/keyring") as typeof import("@napi-rs/keyring"); return (await new AsyncEntry("cursor-access-token", "cursor-user").getPassword()) ?? null; }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 12f37780701d..6836d28e25c1 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -489,7 +489,7 @@ const makeBrowserOtlpPayload = (spanName: string) => url: collector.url, exportInterval: "10 millis", resource: { - serviceName: "t3-web", + serviceName: "t3code-web", attributes: { "service.runtime": "t3-web", "service.mode": "browser", @@ -588,7 +588,6 @@ const buildAppUnderTest = (options?: { otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, mode: "desktop", port: 0, @@ -5283,7 +5282,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { attributes: [ { key: "service.name", - value: { stringValue: "t3-web" }, + value: { stringValue: "t3code-web" }, }, ], }, @@ -5425,7 +5424,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "rpc.method": "server.getSettings", }, resourceAttributes: { - "service.name": "t3-web", + "service.name": "t3code-web", }, scope: { name: "effect", @@ -5556,7 +5555,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { // the stub's utf8 decode even though the surrounding bytes don't. assert.notEqual(forwarded.body[0], "{"); assert.include(forwarded.body, "client.protobuf.test"); - assert.include(forwarded.body, "t3-web"); + assert.include(forwarded.body, "t3code-web"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -5657,7 +5656,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.deepEqual(record.links, []); assert.equal(record.scope.name, scopeSpan.scope.name); assert.deepEqual(record.scope.attributes, {}); - assert.equal(record.resourceAttributes["service.name"], "t3-web"); + assert.equal(record.resourceAttributes["service.name"], "t3code-web"); assert.equal(record.status?.code, String(span.status.code)); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/serverLogger.test.ts b/apps/server/src/serverLogger.test.ts index a5437582d2e3..43843b249eea 100644 --- a/apps/server/src/serverLogger.test.ts +++ b/apps/server/src/serverLogger.test.ts @@ -1,6 +1,7 @@ import * as NodePath from "@effect/platform-node/NodePath"; import { assert, describe, it } from "@effect/vitest"; import * as NodeOS from "node:os"; +import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -57,7 +58,6 @@ const configLayer = (overrides: Partial) = otlpTracesExport: DEFAULT_SIGNAL_EXPORT, otlpMetricsExport: DEFAULT_SIGNAL_EXPORT, otlpLogsExport: DEFAULT_SIGNAL_EXPORT, - otlpServiceName: "t3-server", otelEnvironment: OtelEnvironment.none, cwd: baseDir, baseDir, @@ -146,11 +146,38 @@ describe("ServerLoggerLive", () => { const [request] = requests; assert.strictEqual(request?.url, "https://collector.example.com/v1/logs"); assert.include(request?.body ?? "", "server logger under test"); - assert.include(request?.body ?? "", "t3-server"); + assert.include(request?.body ?? "", "t3code-server"); assert.include(request?.body ?? "", "service.runtime"); }), ); + it.effect("keeps its service name while OTEL resource attributes add dimensions", () => + Effect.gen(function* () { + const requests = yield* logThrough({ + otlpLogsUrl: "https://collector.example.com/v1/logs", + }).pipe( + Effect.provide( + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: { + OTEL_SERVICE_NAME: "renamed", + OTEL_RESOURCE_ATTRIBUTES: + "service.name=renamed,service.namespace=renamed,deployment.environment.name=development", + }, + }), + ), + ), + ); + + assert.lengthOf(requests, 1); + const body = requests[0]?.body ?? ""; + assert.include(body, '"stringValue":"t3code-server"'); + assert.include(body, "deployment.environment.name"); + assert.include(body, '"key":"service.namespace","value":{"stringValue":"t3code"}'); + assert.notInclude(body, "renamed"); + }), + ); + it.effect("stays off the network when no logs endpoint is configured", () => Effect.gen(function* () { const requests = yield* logThrough({}); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 80ab2c43e42c..f04161cfbd3c 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1230,6 +1230,73 @@ it.layer( }), ); + it.effect("closes only a thread's idle shells, ignoring a helper forked from the shell", () => + Effect.gen(function* () { + // FakePtyAdapter assigns pids from 9000 in open order. + const { manager, ptyAdapter } = yield* createManager(5, { + processTable: Effect.succeed([ + { pid: 9000, ppid: 1, name: "zsh" }, + // An async prompt worker: a copy of the shell with no children. + { pid: 100, ppid: 9000, name: "zsh" }, + { pid: 9001, ppid: 1, name: "zsh" }, + { pid: 200, ppid: 9001, name: "node" }, + { pid: 9002, ppid: 1, name: "zsh" }, + // A subshell with a child is real work. + { pid: 300, ppid: 9002, name: "zsh" }, + { pid: 301, ppid: 300, name: "sleep" }, + { pid: 9003, ppid: 1, name: "zsh" }, + ]), + }).pipe(Effect.provide(withHostPlatform("linux"))); + yield* manager.open(openInput({ terminalId: "idle" })); + yield* manager.open(openInput({ terminalId: "dev-server" })); + yield* manager.open(openInput({ terminalId: "subshell" })); + yield* manager.open(openInput({ threadId: "thread-2" })); + + yield* manager.closeIdle({ threadId: "thread-1" }); + + expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([ + true, + false, + false, + false, + ]); + }), + ); + + it.effect("keeps terminals that get input or output while closeIdle checks them", () => + Effect.gen(function* () { + const ptyAdapter = new FakePtyAdapter(); + // The typed command's process misses the snapshot, but its input or echo lands. + let duringCheck: (pid: number) => Effect.Effect = () => Effect.void; + const { manager, getEvents } = yield* createManager(5, { + ptyAdapter, + subprocessPollIntervalMs: 60_000, + subprocessInspector: (pid) => + duringCheck(pid).pipe( + Effect.as({ hasRunningSubprocess: false, childCommand: null, processIds: [] }), + ), + }); + yield* manager.open(openInput({ terminalId: "typed" })); + yield* manager.open(openInput({ terminalId: "echoed" })); + const [typed, echoed] = ptyAdapter.processes; + duringCheck = (pid) => + pid === typed!.pid + ? manager + .write({ threadId: "thread-1", terminalId: "typed", data: "make build\r" }) + .pipe(Effect.orDie) + : Effect.gen(function* () { + echoed!.emitData("make build\r\n"); + yield* waitFor( + Effect.map(getEvents, (events) => events.some((event) => event.type === "output")), + ); + }).pipe(Effect.orDie); + + yield* manager.closeIdle({ threadId: "thread-1" }); + + expect(ptyAdapter.processes.map((process) => process.killed)).toEqual([false, false]); + }), + ); + it.effect("backs off the spawned fallback when the resource monitor snapshot fails", () => Effect.gen(function* () { const fallbackCalls: Array = []; diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 1a65587430d5..75d592c00c0d 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -198,6 +198,17 @@ export class TerminalManager extends Context.Service< */ readonly close: (input: TerminalCloseInput) => Effect.Effect; + /** + * Close a thread's terminals that wait at an idle shell prompt. A terminal + * that runs a command stays open. When `terminalId` is set, only that + * terminal is considered. Used when a thread settles and when a setup + * script finishes. + */ + readonly closeIdle: (input: { + readonly threadId: string; + readonly terminalId?: string; + }) => Effect.Effect; + /** * Subscribe to terminal runtime events with a direct callback. * @@ -275,6 +286,8 @@ interface TerminalSessionState { exitSignal: number | null; updatedAt: string; eventSequence: number; + /** Counts writes, so closeIdle can see input that has not echoed yet. */ + inputCount: number; cols: number; rows: number; process: PtyAdapter.PtyProcess | null; @@ -692,7 +705,17 @@ function deriveSubprocessInspectResult( terminalPid: number, platform: NodeJS.Platform, ): TerminalSubprocessInspectResult { - const childPid = (snapshot.childrenByParent.get(terminalPid) ?? [])[0]; + const commandName = (pid: number) => + normalizeChildCommandName(snapshot.commandById.get(pid) ?? "", platform); + const shellName = commandName(terminalPid); + // Async prompt themes fork the shell into a helper that waits with no + // children of its own. That copy is not a command the user started. + const childPid = (snapshot.childrenByParent.get(terminalPid) ?? []).find( + (pid) => + shellName === null || + commandName(pid) !== shellName || + (snapshot.childrenByParent.get(pid)?.length ?? 0) > 0, + ); if (childPid === undefined) { return { hasRunningSubprocess: false, childCommand: null, processIds: [] }; } @@ -707,7 +730,7 @@ function deriveSubprocessInspectResult( pending.push(pid); } } - const normalized = normalizeChildCommandName(snapshot.commandById.get(childPid) ?? "", platform); + const normalized = commandName(childPid); return { hasRunningSubprocess: true, childCommand: normalized ? truncateTerminalWireLabel(normalized) : null, @@ -2537,6 +2560,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func exitSignal: null, updatedAt: yield* nowIso, eventSequence: 0, + inputCount: 0, cols, rows, process: null, @@ -2877,6 +2901,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func terminalId, }); } + session.inputCount += 1; yield* Effect.try({ try: () => process.write(input.data), catch: (cause) => @@ -2958,6 +2983,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func exitSignal: null, updatedAt: yield* nowIso, eventSequence: 0, + inputCount: 0, cols, rows, process: null, @@ -3042,6 +3068,52 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }), ); + const closeIdle: TerminalManager["Service"]["closeIdle"] = (input) => + withThreadLock( + input.threadId, + Effect.gen(function* () { + const running = (yield* sessionsForThread(input.threadId)).filter( + (session): session is TerminalSessionState & { pid: number } => + session.status === "running" && + Number.isInteger(session.pid) && + (input.terminalId === undefined || session.terminalId === input.terminalId), + ); + if (running.length === 0) return; + // A command started during the process check can miss the snapshot, + // but its input or echo still lands. Both counters only grow, so the + // sum changes when either one does. + const activityMark = (session: TerminalSessionState) => + session.eventSequence + session.inputCount; + const marks = new Map( + running.map((session) => [session.terminalId, activityMark(session)]), + ); + // Inspect now instead of trusting the last poll, so a command started + // since then keeps its terminal. + const { inspector } = yield* acquireSubprocessInspector; + yield* Effect.forEach( + running, + (session) => + inspector(session.pid).pipe( + Effect.flatMap((result) => + result.hasRunningSubprocess || + activityMark(session) !== marks.get(session.terminalId) + ? Effect.void + : closeSession(input.threadId, session.terminalId, false), + ), + ), + { discard: true }, + ); + }), + ).pipe( + // The process check failed, so every terminal stays open. + Effect.catch((error) => + Effect.logWarning("failed to close idle terminals", { + threadId: input.threadId, + error: error.message, + }), + ), + ); + return TerminalManager.of({ open, attachStream, @@ -3050,6 +3122,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func clear, restart, close, + closeIdle, subscribe, subscribeMetadata, }); diff --git a/apps/server/src/usage/cursorUsageReader.ts b/apps/server/src/usage/cursorUsageReader.ts index 81c680afff0b..1e07fcc7a825 100644 --- a/apps/server/src/usage/cursorUsageReader.ts +++ b/apps/server/src/usage/cursorUsageReader.ts @@ -17,6 +17,18 @@ function tokens(value: unknown): number { return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; } +/** + * Maps Cursor's tiered names (`cursor-grok-4.6-high-fast`, + * `claude-fable-5-1-thinking-high`) to the base model's rate-table key. + * Grok resolves through xAI's first-party entry, which has no bare alias. + */ +export function cursorRateModel(model: string): string { + const base = model + .replace(/^cursor-/, "") + .replace(/(?:-thinking)?(?:-(?:none|minimal|low|medium|high|xhigh|max))?(?:-fast)?$/, ""); + return base.startsWith("grok-") ? `xai/${base}` : base; +} + export interface CursorAccountUsageReadResult { readonly accountKey: string | null; readonly records: readonly UsageRecord[]; @@ -232,6 +244,7 @@ export async function readCursorAccountUsage( provider: "cursor", timestampMs, model: event.model, + rateModel: cursorRateModel(event.model), sessionId, totals, reportedCostUsd, diff --git a/apps/server/src/usage/usagePricing.test.ts b/apps/server/src/usage/usagePricing.test.ts index ca340a44a64e..db4f68c2cb42 100644 --- a/apps/server/src/usage/usagePricing.test.ts +++ b/apps/server/src/usage/usagePricing.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; +import { cursorRateModel } from "./cursorUsageReader.ts"; import { cacheSavingsUsd, createOverrideRateTable, @@ -49,6 +50,26 @@ describe("usage pricing", () => { expect(cacheSavingsUsd(table, record("example-model"), overrides)).toBe(1.5); }); + it("prices Cursor cache savings at the base model rate", () => { + const table = parseRateTable({ + "claude-fable-5-1": rate(10e-6, 1e-6), + "xai/grok-4.7": rate(2e-6, 0.5e-6), + "openrouter/x-ai/grok-4.7": rate(3e-6, 0.5e-6), + }); + const cursorRecord = (model: string) => ({ + ...record(model, 0.25), + rateModel: cursorRateModel(model), + }); + + expect(cacheSavingsUsd(table, cursorRecord("claude-fable-5-1-thinking-high"))).toBeCloseTo(9); + expect(cacheSavingsUsd(table, cursorRecord("cursor-grok-4.7-high-fast"))).toBeCloseTo(1.5); + expect(cacheSavingsUsd(table, cursorRecord("default"))).toBe(0); + expect(priceUsage(table, cursorRecord("grok-4.7-xhigh-fast"))).toEqual({ + costUsd: 0.25, + costSource: "providerReported", + }); + }); + it("prices unknown models offline and uses input prices for omitted cache rates", () => { const table = parseRateTable({}); const overrides = createOverrideRateTable({ diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts index 60bf31b93e31..78f6cf2c5cd9 100644 --- a/apps/server/src/usage/usagePricing.ts +++ b/apps/server/src/usage/usagePricing.ts @@ -184,7 +184,10 @@ export function lookupRate(table: RateTable, model: string): ModelRate | null { } /** The parts of a transcript record that decide its price. */ -export type PricedRecord = Pick; +export type PricedRecord = Pick< + UsageRecord, + "model" | "rateModel" | "totals" | "fast" | "reportedCostUsd" +>; export interface PricedUsage { readonly costUsd: number; @@ -208,7 +211,7 @@ export function priceUsage( return { costUsd: reportedCostUsd, costSource: "providerReported" }; } - const rate = override ?? lookupRate(table, model); + const rate = override ?? lookupRate(table, record.rateModel ?? model); if (rate === null) return { costUsd: 0, costSource: "unpriced" }; const standardCostUsd = @@ -232,7 +235,8 @@ export function cacheSavingsUsd( record: PricedRecord, overrides?: RateTable, ): number { - const rate = overrides?.get(record.model.trim()) ?? lookupRate(table, record.model); + const rate = + overrides?.get(record.model.trim()) ?? lookupRate(table, record.rateModel ?? record.model); if (rate === null) return 0; return ( record.totals.cachedInputTokens * diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 5da13168a1af..6e01c2c5a8ed 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -12,6 +12,11 @@ export interface UsageRecord { readonly provider: UsageProviderKind; readonly timestampMs: number; readonly model: string; + /** + * Rate-table key when the provider's display name carries tiers the table + * does not know, such as Cursor's `claude-opus-5-5-high`. Defaults to `model`. + */ + readonly rateModel?: string; readonly sessionId: string; readonly totals: UsageTokenTotals; readonly reportedCostUsd: number | null; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 4a5dff870fd2..47ca4e178700 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1449,6 +1449,44 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); + for (const splitIndex of [false, true]) { + it.effect(`keeps the preceding second cached in review previews (split: ${splitIndex})`, () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, ".gitattributes", "stable.txt filter=probe\n"); + yield* writeTextFile(cwd, "stable.txt", "unchanged\n"); + yield* writeTextFile( + cwd, + ".git/filter.cjs", + 'require("node:fs").appendFileSync(".git/filter-runs", "read\\n"); process.stdin.pipe(process.stdout);', + ); + yield* git(cwd, ["config", "filter.probe.clean", "node .git/filter.cjs"]); + yield* fs.utimes(path.join(cwd, "stable.txt"), 1_699_999_999.5, 1_699_999_999.5); + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "cache stable file"]); + if (splitIndex) yield* git(cwd, ["update-index", "--split-index"]); + const indexPath = path.join(cwd, ".git", "index"); + yield* fs.utimes(indexPath, 1_700_000_000, 1_700_000_000); + const originalIndex = yield* fs.readFile(indexPath); + const originalMtime = (yield* fs.stat(indexPath)).mtime; + yield* writeTextFile(cwd, ".git/filter-runs", ""); + yield* writeTextFile(cwd, "untracked.txt", "new\n"); + const preview = yield* driver.getReviewDiffPreview({ cwd }); + assert.deepStrictEqual( + preview.sources.find((source) => source.kind === "working-tree")!.files, + [{ path: "untracked.txt", previousPath: null, additions: 1, deletions: 0 }], + ); + assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/filter-runs")), ""); + assert.deepStrictEqual(yield* fs.readFile(indexPath), originalIndex); + assert.deepStrictEqual((yield* fs.stat(indexPath)).mtime, originalMtime); + }), + ); + } + for (const [timestamp, splitIndex] of [ [1_700_000_000, false], [1_700_000_000.9999, false], diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index b15cd28b4e92..8ec274a46611 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2358,9 +2358,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (indexExists) { const { mtime } = yield* fileSystem.stat(indexPath); yield* fileSystem.copyFile(indexPath, tempIndexPath); - // A newer copy timestamp hides racily clean edits. Round down before Git reads or rewrites it. + // Node FileSystem.stat truncates bigint timestamps to milliseconds before creating its Date. + // Flooring preserves the source second without making preceding-second files racy. const indexTime = Option.isSome(mtime) - ? Math.max(0, Math.floor((mtime.value.getTime() - 1) / 1000)) + ? Math.max(0, Math.floor(mtime.value.getTime() / 1000)) : 0; yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime); } diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 74e8736f04e7..2a5fe2ba0588 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -29,6 +29,7 @@ import { useMemo, useRef, useState, + type KeyboardEvent as ReactKeyboardEvent, } from "react"; import { EditorContent, useEditor } from "@tiptap/react"; @@ -371,6 +372,16 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { .run(); }, [editor, nodePos]); + // Put the caret right after the chip so Enter sends and typing continues the prompt. + const onRestoreFocus = useCallback(() => { + if (!editor.isEditable) return; + const pos = nodePos(); + if (pos === null) return; + const current = editor.state.doc.nodeAt(pos); + if (!current || current.type.name !== "composer-citation") return; + editor.commands.focus(pos + current.nodeSize); + }, [editor, nodePos]); + return ( ) => { + // Tab from the comment button returns to the caret after the chip. + if ( + !editor.isEditable || + event.key !== "Tab" || + event.shiftKey || + event.altKey || + event.metaKey || + event.ctrlKey || + !(event.target instanceof HTMLElement) || + event.target.dataset.citationCommentTrigger === undefined + ) { + return; + } + event.preventDefault(); + onRestoreFocus(); + }} > @@ -856,6 +885,32 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) { return true; } } + // Shift+Tab from just after a citation reaches its comment button, which + // native tab order skips because the chip lives inside the editor. + if ( + event.key === "Tab" && + event.shiftKey && + !event.altKey && + !event.metaKey && + !event.ctrlKey && + view.state.selection.empty + ) { + const { $from } = view.state.selection; + const citation = $from.nodeBefore; + if (citation?.type.name === "composer-citation") { + const chip = view.nodeDOM($from.pos - citation.nodeSize); + const commentButton = + chip instanceof HTMLElement + ? chip.querySelector("[data-citation-comment-trigger]") + : null; + if (commentButton) { + event.preventDefault(); + event.stopPropagation(); + commentButton.focus(); + return true; + } + } + } if (event.key === "Enter" && (event.isComposing || event.keyCode === 229)) { event.stopPropagation(); return true; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 7cbae330c318..4b8027c14108 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1965,7 +1965,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { showBadge={showInstanceBadge} // Glyph dims, badge stays saturated; offset matches the composer trigger. iconClassName="size-3.5 opacity-60" - badgeClassName="right-[-0.1875rem] bottom-[-0.1875rem] h-3 min-w-3 px-0.5 text-3xs" + badgeClassName="right-[-0.1875rem] bottom-[-0.1875rem] h-3 min-w-3 px-0.5 text-5xs" /> ) : null} diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index 6baf14b18887..e03872553a88 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -39,10 +39,13 @@ export function AssistantCitationChip({ onCancel?: () => void; onSave: (comment: string) => boolean; onSaveAndSend?: (comment: string) => boolean; + /** Returns focus to the host editor when the popover closes instead of to the pencil trigger. */ + onRestoreFocus?: () => void; }; }) { const navigate = useNavigate(); const commentInputRef = useRef(null); + const commentPopupRef = useRef(null); const draftCommentRef = useRef(null); const [unavailableSourceAnchor, setUnavailableSourceAnchor] = useState(null); @@ -155,6 +158,7 @@ export function AssistantCitationChip({ > } >