diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 67dde122e1b6..6c4a00053141 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -117,6 +117,12 @@ const ZOOM_EPSILON = 0.001; const MAX_EVALUATION_BYTES = 64_000; const MAX_VISIBLE_TEXT_LENGTH = 20_000; const MAX_INTERACTIVE_ELEMENTS = 200; +/** + * A `[role]` container's innerText is its whole subtree, which turned one + * snapshot's element list into 60 KB of repeated page text. Names are labels, + * not content, so cap them where they are read. + */ +const MAX_INTERACTIVE_ELEMENT_NAME_LENGTH = 200; const MAX_SCREENSHOT_WIDTH = 1280; /** How long an armed tab keeps the exclusive display-media slot before another tab may take it. */ const RECORDING_ARM_GRACE_MS = 10_000; @@ -3582,7 +3588,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return { tag: element.tagName.toLowerCase(), role: element.getAttribute("role"), - name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "", + name: (element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "").slice(0, ${MAX_INTERACTIVE_ELEMENT_NAME_LENGTH}), selector: selectorFor(element), x: rect.x, y: rect.y, diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 83ad62ccffdc..ea2a80010124 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -64,6 +64,7 @@ const clientSettings: ClientSettings = { legacySidebarEnabled: false, loadBalancingEnabled: false, loadBalancingWeights: { "environment-1": 75, "environment-2": 0 }, + pullRequestMergeMethodOverrides: {}, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 1637a46f7b5d..217334fd754a 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -175,7 +175,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: "1.1.0", + version: "1.1.1", runtimeVersion: { // Development manifests resolve on every launch, so avoid fingerprint's // expensive native-project calculation there. Preview and production stay diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx index dd62f4952d1f..7c20f802477d 100644 --- a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -116,30 +116,34 @@ function PoolWindowCard({ ) : null} - {pool.members.map(({ account, window }, index) => ( - openAccount(account)} - className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle" - > - - - - {index + 1} - - - - ))} + {pool.columns.map(({ account, window }, index) => { + if (!window) return ; + return ( + openAccount(account)} + className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle" + > + + + + {index + 1} + + + + ); + })} - {pool.members.map(({ account, window }, index) => { + {pool.columns.map(({ account, window }, index) => { + if (!window) return null; const credits = account.limits.resetCredits?.availableCount ?? 0; const resetsIn = formatResetsIn(window, now); return ( diff --git a/apps/server/integration/NetworkTransferMeasurement.integration.ts b/apps/server/integration/NetworkTransferMeasurement.integration.ts index 4da20cac9e05..fbbdd8fb156b 100644 --- a/apps/server/integration/NetworkTransferMeasurement.integration.ts +++ b/apps/server/integration/NetworkTransferMeasurement.integration.ts @@ -120,7 +120,7 @@ function rawDataBytes(data: NodeSocket.NodeWS.RawData): number { return data.byteLength; } -export function makeWebSocketTransferRecorder(): WebSocketTransferRecorder { +function makeWebSocketTransferRecorder(): WebSocketTransferRecorder { let socket: NodeWebSocketWithTransport | null = null; // Held separately from the WebSocket so wire totals survive a close, which // is when a reconnect measurement reads them. @@ -176,7 +176,7 @@ export function transferDelta( }; } -export function countingWsRpcProtocolLayer(input: { +function countingWsRpcProtocolLayer(input: { readonly url: string; readonly cookie: string; readonly recorder: WebSocketTransferRecorder; @@ -194,7 +194,7 @@ export function countingWsRpcProtocolLayer(input: { ); } -export const makeCountingWsRpcClient = RpcClient.make(WsRpcGroup); +const makeCountingWsRpcClient = RpcClient.make(WsRpcGroup); export type CountingWsRpcClient = Effect.Success; export interface MeasuredWsClient { diff --git a/apps/server/integration/TransferBudgetReport.integration.ts b/apps/server/integration/TransferBudgetReport.integration.ts index d82688d2863f..fe0b3ed8e648 100644 --- a/apps/server/integration/TransferBudgetReport.integration.ts +++ b/apps/server/integration/TransferBudgetReport.integration.ts @@ -56,7 +56,7 @@ const TRANSFER_BUDGET = { measuredTurnWebSocketMessages: 21, } satisfies ProviderTransferBudget; -export const TRANSFER_BUDGETS: Readonly> = { +const TRANSFER_BUDGETS: Readonly> = { codex: TRANSFER_BUDGET, claudeAgent: TRANSFER_BUDGET, }; diff --git a/apps/server/integration/TransferBudgetScenario.integration.ts b/apps/server/integration/TransferBudgetScenario.integration.ts index 33e149dd2484..ccede4204f66 100644 --- a/apps/server/integration/TransferBudgetScenario.integration.ts +++ b/apps/server/integration/TransferBudgetScenario.integration.ts @@ -26,7 +26,7 @@ import { TRANSFER_HISTORY_TURN_COUNT, } from "./fixtures/transferBudget.ts"; -export const TRANSFER_PROJECT_ID = ProjectId.make("transfer-budget-project"); +const TRANSFER_PROJECT_ID = ProjectId.make("transfer-budget-project"); export const TRANSFER_THREAD_ID = ThreadId.make("transfer-budget-thread"); export const TRANSFER_MEASURED_TURN_INDEX = TRANSFER_HISTORY_TURN_COUNT; diff --git a/apps/server/scripts/t3-sqlite-state.ts b/apps/server/scripts/t3-sqlite-state.ts index b114b0e10d49..aae040470f50 100644 --- a/apps/server/scripts/t3-sqlite-state.ts +++ b/apps/server/scripts/t3-sqlite-state.ts @@ -245,7 +245,7 @@ export const runSqliteState = Effect.fn("runSqliteState")(function* ( ); }); -export const t3SqliteStateCommand = Command.make( +const t3SqliteStateCommand = Command.make( "t3-sqlite-state", { operation: Argument.choice("operation", SqliteStateOperation.literals).pipe( diff --git a/apps/server/src/assets/NativeAppIconResolver.ts b/apps/server/src/assets/NativeAppIconResolver.ts index ba572650d408..89a6d0636012 100644 --- a/apps/server/src/assets/NativeAppIconResolver.ts +++ b/apps/server/src/assets/NativeAppIconResolver.ts @@ -206,6 +206,7 @@ const resolveNativeAppIconUncached = Effect.fn("NativeAppIconResolver.resolveUnc return yield* existingFile(cachePath); }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const hostPlatform = yield* HostProcessPlatform; diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 261b094645b9..4f098e1e6298 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -22,7 +22,7 @@ const ATTACHMENT_ID_PATTERN = new RegExp( ); export const PENDING_ATTACHMENT_THREAD_SEGMENT = "pending"; -export const PENDING_ATTACHMENT_MAX_AGE_MS = 24 * 60 * 60 * 1000; +const PENDING_ATTACHMENT_MAX_AGE_MS = 24 * 60 * 60 * 1000; const PARTIAL_UPLOAD_MAX_AGE_MS = 60 * 60 * 1000; export function toSafeThreadAttachmentSegment(threadId: string): string | null { diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index bbc4a724c1d5..964fe6220d6b 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -38,7 +38,7 @@ import * as SessionStore from "./SessionStore.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; import { layerConfig as SqlitePersistenceLayer } from "../persistence/Layers/Sqlite.ts"; -export const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; +const DEFAULT_SESSION_SUBJECT = "cli-issued-session"; export const INTERNAL_ADMINISTRATIVE_BOOTSTRAP_SUBJECT = "administrative-bootstrap"; export interface IssuedPairingLink { @@ -591,6 +591,7 @@ export function selectRequestCredential( return undefined; } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const policy = yield* EnvironmentAuthPolicy.EnvironmentAuthPolicy; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; @@ -1033,7 +1034,7 @@ export const layer = Layer.effect(EnvironmentAuth, make).pipe( Layer.provideMerge(EnvironmentAuthPolicy.layer), ); -export const storageLayer = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistenceLayer); +const storageLayer = Layer.mergeAll(ServerSecretStore.layer, SqlitePersistenceLayer); export const runtimeLayer = layer.pipe( Layer.provideMerge(storageLayer), diff --git a/apps/server/src/auth/EnvironmentAuthPolicy.ts b/apps/server/src/auth/EnvironmentAuthPolicy.ts index 446b8a8bba95..0c3327d95f07 100644 --- a/apps/server/src/auth/EnvironmentAuthPolicy.ts +++ b/apps/server/src/auth/EnvironmentAuthPolicy.ts @@ -14,6 +14,7 @@ export class EnvironmentAuthPolicy extends Context.Service< } >()("t3/auth/EnvironmentAuthPolicy") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const serverEnvironment = yield* ServerEnvironment.ServerEnvironmentIdentity; diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 14426d0ba039..ec27c0a4e147 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -72,7 +72,6 @@ export const BootstrapCredentialInvalidError = Schema.Union([ UnavailableBootstrapCredentialError, ]); export type BootstrapCredentialInvalidError = typeof BootstrapCredentialInvalidError.Type; -export const isBootstrapCredentialInvalidError = Schema.is(BootstrapCredentialInvalidError); export class ActivePairingLinksLoadError extends Schema.TaggedError()( "ActivePairingLinksLoadError", @@ -173,7 +172,6 @@ export const BootstrapCredentialError = Schema.Union([ BootstrapCredentialInternalError, ]); export type BootstrapCredentialError = typeof BootstrapCredentialError.Type; -export const isBootstrapCredentialError = Schema.is(BootstrapCredentialError); export interface IssuedBootstrapCredential { readonly id: string; diff --git a/apps/server/src/auth/ServerSecretStore.ts b/apps/server/src/auth/ServerSecretStore.ts index dbeb5a7d07dd..e936a1f85c99 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -149,6 +149,7 @@ export class ServerSecretStore extends Context.Service< } >()("t3/auth/ServerSecretStore") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/auth/SessionStore.ts b/apps/server/src/auth/SessionStore.ts index 743b669bfdd8..be8e7627bb20 100644 --- a/apps/server/src/auth/SessionStore.ts +++ b/apps/server/src/auth/SessionStore.ts @@ -349,14 +349,12 @@ export const SessionCredentialInternalError = Schema.Union([ OtherSessionsRevocationError, ]); export type SessionCredentialInternalError = typeof SessionCredentialInternalError.Type; -export const isSessionCredentialInternalError = Schema.is(SessionCredentialInternalError); export const SessionCredentialError = Schema.Union([ SessionCredentialInvalidError, SessionCredentialInternalError, ]); export type SessionCredentialError = typeof SessionCredentialError.Type; -export const isSessionCredentialError = Schema.is(SessionCredentialError); export class SessionStore extends Context.Service< SessionStore, diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index cc74966c41e2..b50d6eae9a18 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -65,7 +65,7 @@ const appendDpopChallengeOnUnauthorized = (error: EnvironmentAuthInvalidError) = return yield* error; }); -export const currentEnvironmentTraceId = Effect.currentParentSpan.pipe( +const currentEnvironmentTraceId = Effect.currentParentSpan.pipe( Effect.map((span) => span.traceId), Effect.orElseSucceed(() => "unavailable"), ); diff --git a/apps/server/src/background/BackgroundPolicy.ts b/apps/server/src/background/BackgroundPolicy.ts index a2a8e99d33ce..ad74bf134a89 100644 --- a/apps/server/src/background/BackgroundPolicy.ts +++ b/apps/server/src/background/BackgroundPolicy.ts @@ -82,7 +82,7 @@ function leaseKey(lease: Pick, lease: ClientActivityLease, now: DateTime.Utc, @@ -208,6 +208,7 @@ function computeSnapshot(input: { }; } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("background.policy.make")(function* () { const hostPowerMonitor = yield* HostPowerMonitor.HostPowerMonitor; const serverSettings = yield* ServerSettingsService; diff --git a/apps/server/src/background/HostPowerMonitor.ts b/apps/server/src/background/HostPowerMonitor.ts index 273548faa47f..3d8c8b35998a 100644 --- a/apps/server/src/background/HostPowerMonitor.ts +++ b/apps/server/src/background/HostPowerMonitor.ts @@ -19,7 +19,7 @@ export class HostPowerMonitor extends Context.Service< } >()("t3/background/HostPowerMonitor") {} -export const makeUnknownSnapshot = ( +const makeUnknownSnapshot = ( source: HostPowerSnapshot["source"], updatedAt: HostPowerSnapshot["updatedAt"], ): HostPowerSnapshot => ({ diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.ts index 077506ff3a84..5d6aa7c9d20a 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.ts @@ -75,6 +75,7 @@ function buildTurnDiffResult( }; } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const checkpointStore = yield* CheckpointStore.CheckpointStore; diff --git a/apps/server/src/checkpointing/CheckpointStore.ts b/apps/server/src/checkpointing/CheckpointStore.ts index f1cc596b209d..0c9e60d76a65 100644 --- a/apps/server/src/checkpointing/CheckpointStore.ts +++ b/apps/server/src/checkpointing/CheckpointStore.ts @@ -98,6 +98,7 @@ export class CheckpointStore extends Context.Service< } >()("t3/checkpointing/CheckpointStore") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const vcsRegistry = yield* VcsDriverRegistry.VcsDriverRegistry; diff --git a/apps/server/src/checkpointing/Utils.ts b/apps/server/src/checkpointing/Utils.ts index 50d0163af5a6..adc089f624a8 100644 --- a/apps/server/src/checkpointing/Utils.ts +++ b/apps/server/src/checkpointing/Utils.ts @@ -1,7 +1,7 @@ import * as Encoding from "effect/Encoding"; import { CheckpointRef, ProjectId, type ThreadId } from "@t3tools/contracts"; -export const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; +const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; export function checkpointRefForThreadTurn(threadId: ThreadId, turnCount: number): CheckpointRef { return CheckpointRef.make( diff --git a/apps/server/src/cli/app.ts b/apps/server/src/cli/app.ts index 6342d4319a57..3fc6deedb0ce 100644 --- a/apps/server/src/cli/app.ts +++ b/apps/server/src/cli/app.ts @@ -81,7 +81,7 @@ function isDesktopPlatform(platform: NodeJS.Platform): platform is DesktopAppAct return platform === "darwin" || platform === "linux" || platform === "win32"; } -export function sendDesktopAppActivationRequest(input: { +function sendDesktopAppActivationRequest(input: { readonly address: string; readonly fallbackAddress?: string; readonly request: DesktopAppActivationRequest; diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index f739a4e2f22c..847edbbc4fe0 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -17,7 +17,7 @@ import { readBootstrapEnvelope } from "../bootstrap.ts"; import * as ServerConfig from "../config.ts"; import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; -export const modeFlag = Flag.choice("mode", ServerConfig.RuntimeMode.literals).pipe( +const modeFlag = Flag.choice("mode", ServerConfig.RuntimeMode.literals).pipe( Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), Flag.optional, ); @@ -69,7 +69,7 @@ const tailscaleServeFlag = Flag.boolean("tailscale-serve").pipe( ), Flag.optional, ); -export const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe( +const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe( Flag.withSchema(PortSchema), Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), Flag.optional, diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 5e62f285be46..7fd376c6f881 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -173,7 +173,7 @@ export const resolveTailscaleLocalTarget = ( return { localPort: state.port }; }; -export const formatPairOutput = (input: { +const formatPairOutput = (input: { readonly serverLabel: string; readonly origin: string; readonly pairingUrl: string; diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index a5829d90481a..8f7ac7bfc8d4 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -324,6 +324,7 @@ export const outOfBandOAuthLogin = Effect.fn("cloud.cli_token.out_of_band_oauth_ }); }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { // Capture exactly the services the login/refresh flows need at build time // (matching the behavior before the out-of-band flow captured the instances), not diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 564d7346d036..cc657bdebf1b 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -111,6 +111,7 @@ const stopConnector = (connector: ActiveConnector | null) => ) : Effect.void; +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const relayClient = yield* RelayClient.RelayClient; diff --git a/apps/server/src/cloud/publicConfig.ts b/apps/server/src/cloud/publicConfig.ts index e977d7cfdf0d..d60a09b6decb 100644 --- a/apps/server/src/cloud/publicConfig.ts +++ b/apps/server/src/cloud/publicConfig.ts @@ -49,21 +49,21 @@ function normalizeSecureUrl(value: string): string | null { } } -export const buildTimeRelayUrl = +const buildTimeRelayUrl = typeof __T3CODE_BUILD_RELAY_URL__ === "undefined" ? "" : (normalizeSecureRelayUrl(__T3CODE_BUILD_RELAY_URL__) ?? ""); -export const buildTimeClerkPublishableKey = readBuildTimeValue( +const buildTimeClerkPublishableKey = readBuildTimeValue( typeof __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__ === "undefined" ? undefined : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__, ); -export const buildTimeClerkCliOAuthClientId = readBuildTimeValue( +const buildTimeClerkCliOAuthClientId = readBuildTimeValue( typeof __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__ === "undefined" ? undefined : __T3CODE_BUILD_CLERK_CLI_OAUTH_CLIENT_ID__, ); -export const buildTimeRelayClientTracing = { +const buildTimeRelayClientTracing = { tracesUrl: readBuildTimeValue( typeof __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_URL__ === "undefined" ? undefined diff --git a/apps/server/src/cloud/serviceProtocol.ts b/apps/server/src/cloud/serviceProtocol.ts index af94c9b4b5c0..bb61866a93ff 100644 --- a/apps/server/src/cloud/serviceProtocol.ts +++ b/apps/server/src/cloud/serviceProtocol.ts @@ -71,7 +71,7 @@ export const isExactServiceVersion = (version: string): boolean => const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); -export function decodeServiceUpdate(value: unknown): ServiceUpdateRecord | undefined { +function decodeServiceUpdate(value: unknown): ServiceUpdateRecord | undefined { if (!isRecord(value)) return undefined; const { id, fromVersion, targetVersion, status } = value; if ( diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 42df3814b070..1486f6b40a2a 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -38,6 +38,8 @@ export interface ServerDerivedPaths { readonly providerStatusCacheDir: string; readonly worktreesDir: string; readonly attachmentsDir: string; + /** Screenshots the agent asks the collaborative browser to keep for the user. */ + readonly browserArtifactsDir: string; readonly logsDir: string; readonly serverLogPath: string; readonly serverTracePath: string; @@ -125,6 +127,7 @@ export const deriveServerPaths = Effect.fn(function* ( providerStatusCacheDir, worktreesDir: join(baseDir, "worktrees"), attachmentsDir, + browserArtifactsDir: join(stateDir, "browser-artifacts"), logsDir, serverLogPath: join(logsDir, "server.log"), serverTracePath: join(logsDir, "server.trace.ndjson"), diff --git a/apps/server/src/desktopUpdate/DesktopAppUpdate.ts b/apps/server/src/desktopUpdate/DesktopAppUpdate.ts index 1e7b54b2474b..9c5d88bd7910 100644 --- a/apps/server/src/desktopUpdate/DesktopAppUpdate.ts +++ b/apps/server/src/desktopUpdate/DesktopAppUpdate.ts @@ -25,7 +25,7 @@ const DESKTOP_INSTALL_TIMEOUT = Duration.minutes(2); /** Progress stage a desktop update state maps to, or null when the state carries no progress worth streaming. */ -export function desktopUpdateProgressStage( +function desktopUpdateProgressStage( state: DesktopUpdateState, ): ServerSelfUpdateProgressStage | null { switch (state.status) { diff --git a/apps/server/src/diagnostics/ProcessDiagnostics.ts b/apps/server/src/diagnostics/ProcessDiagnostics.ts index 2f740aa37b1a..4fa60160e507 100644 --- a/apps/server/src/diagnostics/ProcessDiagnostics.ts +++ b/apps/server/src/diagnostics/ProcessDiagnostics.ts @@ -54,6 +54,7 @@ function canSignalCategory(category: ResourceTelemetryProcessCategory): boolean ); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("makeProcessDiagnostics")(function* () { const telemetry = yield* ResourceTelemetry.ResourceTelemetry; const refreshedTelemetry = telemetry.refresh.pipe(Effect.catch(() => telemetry.latest)); diff --git a/apps/server/src/diagnostics/ProcessResourceMonitor.ts b/apps/server/src/diagnostics/ProcessResourceMonitor.ts index 5f5e32dd28da..aa11e5f42d39 100644 --- a/apps/server/src/diagnostics/ProcessResourceMonitor.ts +++ b/apps/server/src/diagnostics/ProcessResourceMonitor.ts @@ -28,6 +28,7 @@ function isLegacyBackendCategory(category: ResourceTelemetryProcessCategory): bo ); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("makeProcessResourceMonitor")(function* () { const telemetry = yield* ResourceTelemetry.ResourceTelemetry; const readHistory: ProcessResourceMonitor["Service"]["readHistory"] = (input) => diff --git a/apps/server/src/diagnostics/TraceDiagnostics.ts b/apps/server/src/diagnostics/TraceDiagnostics.ts index 85900e6915cb..58b08ea8b572 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.ts @@ -411,6 +411,7 @@ function readTraceFile( ); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/environment/RemoteOpenTargets.ts b/apps/server/src/environment/RemoteOpenTargets.ts index f70dfa68aaab..c0cd658a28c7 100644 --- a/apps/server/src/environment/RemoteOpenTargets.ts +++ b/apps/server/src/environment/RemoteOpenTargets.ts @@ -26,6 +26,7 @@ export class RemoteOpenTargets extends Context.Service< } >()("t3/environment/RemoteOpenTargets") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const net = yield* NetService.NetService; diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index a3840ca0caaa..bf02cd90fbdf 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -179,6 +179,7 @@ const makeIdentity = Effect.gen(function* () { }); }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const path = yield* Path.Path; const serverConfig = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index c9b4a4cca365..e90a59fae8f1 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -142,6 +142,7 @@ function nonRepositoryListRefs(): VcsListRefsResult { }; } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const registry = yield* VcsDriverRegistry.VcsDriverRegistry; const git = yield* GitVcsDriver.GitVcsDriver; diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts index 66ce6096e853..1129edfd3ef1 100644 --- a/apps/server/src/imageMime.ts +++ b/apps/server/src/imageMime.ts @@ -1,6 +1,6 @@ import Mime from "@effect/platform-node/Mime"; -export const IMAGE_EXTENSION_BY_MIME_TYPE: Record = { +const IMAGE_EXTENSION_BY_MIME_TYPE: Record = { "image/avif": ".avif", "image/bmp": ".bmp", "image/gif": ".gif", diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 03cb8c18373d..133e71bc3bcb 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -4,12 +4,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { McpProtocol, McpSchema, McpServer } from "effect/unstable/ai"; import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"; +import * as ServerConfig from "../config.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; @@ -18,7 +21,7 @@ const environmentId = EnvironmentId.make("environment-mcp-test"); const threadId = ThreadId.make("thread-mcp-test"); const tabId = PreviewTabId.make("tab-mcp-test"); const alternateTabId = PreviewTabId.make("tab-mcp-alternate"); -const encodeJsonText = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeJsonText = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const invocation = { environmentId, threadId, @@ -41,9 +44,62 @@ const client = McpSchema.McpServerClient.of({ }); const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe( Layer.provideMerge(McpServer.McpServer.layer), - Layer.provideMerge(PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer))), + Layer.provideMerge(PreviewAutomationBroker.layer), + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-http-server-test-" })), + Layer.provideMerge(NodeServices.layer), ); +const snapshotResult = { + url: "http://example.test/", + title: "Example", + loading: false, + visibleText: "Example", + interactiveElements: [], + accessibilityTree: {}, + consoleEntries: [], + networkEntries: [], + actionTimeline: [], + screenshot: { + mimeType: "image/png", + data: Buffer.from("png").toString("base64"), + width: 10, + height: 5, + }, +}; + +/** Answers every snapshot request on a fresh broker host with the given result. */ +const serveSnapshots = (clientId: string, result: unknown) => + Effect.gen(function* () { + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const connected = yield* Deferred.make(); + const inputs: Array = []; + const events = yield* broker.connect({ clientId, environmentId }); + yield* Stream.runForEach(events, (event) => { + if (event.type === "connected") return Deferred.succeed(connected, undefined); + inputs.push(event.request.input); + return broker.respond({ + clientId, + connectionId: event.connectionId, + requestId: event.request.requestId, + ok: true, + result, + }); + }).pipe(Effect.forkScoped); + yield* Deferred.await(connected); + return inputs; + }); + +const callSnapshot = (args: Record) => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + return yield* server + .callTool({ name: "preview_snapshot", arguments: args }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + }); + it("normalizes empty successful notification responses to accepted", () => { const notificationResponse = McpHttpServer.normalizeMcpHttpResponse( HttpServerResponse.text("", { status: 200, contentType: "application/json" }), @@ -92,7 +148,9 @@ it.effect.each([{}, { includeImage: false }])( ); expect(snapshot.isError).toBe(true); - expect(snapshot.content).toEqual([{ type: "text", text: "Preview snapshot failed." }]); + expect(snapshot.content).toEqual([ + { type: "text", text: "Preview snapshot failed: PreviewAutomationExecutionError." }, + ]); expect(snapshot.structuredContent).toEqual({ error: { _tag: "PreviewAutomationExecutionError", @@ -174,10 +232,16 @@ it.effect.each([ Effect.provideService(McpSchema.McpServerClient, client), ); const metadata = { ...page, title: `Snapshot ${call}`, screenshot }; + const { accessibilityTree: _tree, ...boundedMetadata } = metadata; expect(snapshot.isError).toBe(false); expect(snapshot.structuredContent).toEqual(metadata); - expect(snapshot.content).toEqual([ - { type: "text", text: encodeJsonText(snapshot.structuredContent) }, + const [text, ...rest] = snapshot.content; + expect(text?.type === "text" ? decodeJsonText(text.text) : null).toEqual(boundedMetadata); + expect(rest).toEqual([ + { + type: "text", + text: "Snapshot text was bounded. Omitted: accessibilityTree (use interactiveElements locators or preview_evaluate).", + }, ...(images ? [ { @@ -200,7 +264,7 @@ it.effect.each([ Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), Effect.provideService(McpSchema.McpServerClient, client), ); - expect(nextDefault.content.map((content) => content.type)).toEqual(["text", "image"]); + expect(nextDefault.content.map((content) => content.type)).toEqual(["text", "text", "image"]); expect(nextDefault.structuredContent).toEqual({ ...page, title: "Snapshot 7", screenshot }); expect(requests).toBe(7); }), @@ -221,7 +285,7 @@ it.effect("rejects non-boolean snapshot image options before selecting a browser Effect.provideService(McpSchema.McpServerClient, client), ); expect(result.isError).toBe(true); - expect(result.content).toEqual([{ type: "text", text: "Preview snapshot failed." }]); + expect(result.content).toEqual([{ type: "text", text: "Preview snapshot failed: AiError." }]); expect(result.structuredContent).toEqual({ error: { _tag: "AiError", operation: "snapshot", failureCount: 1 }, }); @@ -229,6 +293,222 @@ it.effect("rejects non-boolean snapshot image options before selecting a browser }).pipe(Effect.provide(TestLayer)), ); +it.effect("saves the snapshot PNG on request and reports its path", () => + Effect.scoped( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const inputs = yield* serveSnapshots("mcp-save-client", snapshotResult); + + const snapshot = yield* callSnapshot({ save: true }); + + expect(snapshot.isError).toBe(false); + // The browser never receives the server-only `save` flag. + expect(inputs).toEqual([{}]); + const structured = snapshot.structuredContent as { readonly screenshotPath?: string }; + const screenshotPath = structured.screenshotPath; + expect(typeof screenshotPath).toBe("string"); + expect(path.dirname(screenshotPath!)).toBe(config.browserArtifactsDir); + expect(path.basename(screenshotPath!)).toMatch( + /^browser-screenshot-example-test-[0-9a-z]+-[0-9a-f]{8}\.png$/, + ); + expect(Buffer.from(yield* fileSystem.readFile(screenshotPath!)).toString()).toBe("png"); + const text = snapshot.content.find((content) => content.type === "text"); + expect(text?.type === "text" ? text.text : "").toContain(screenshotPath); + + const unsaved = yield* callSnapshot({}); + expect(unsaved.structuredContent).not.toHaveProperty("screenshotPath"); + }), + ).pipe(Effect.provide(TestLayer)), +); + +it.effect("reports a tagged error when the screenshot cannot be saved", () => + Effect.scoped( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + // A regular file where the artifacts directory should be makes every write fail. + yield* fileSystem.writeFileString(config.browserArtifactsDir, ""); + yield* serveSnapshots("mcp-save-failure-client", snapshotResult); + + const snapshot = yield* callSnapshot({ save: true }); + + expect(snapshot.isError).toBe(true); + expect(snapshot.content).toEqual([ + { type: "text", text: "Preview snapshot failed: PreviewScreenshotSaveError." }, + ]); + expect(snapshot.structuredContent).toEqual({ + error: { _tag: "PreviewScreenshotSaveError", operation: "snapshot", failureCount: 1 }, + }); + }), + ).pipe(Effect.provide(TestLayer)), +); + +it.effect("keeps the snapshot text under the agent's output ceiling", () => + Effect.scoped( + Effect.gen(function* () { + // Mirrors the real failure: a [role] container whose innerText is the whole + // project list, repeated for several elements, plus a big AX tree. + const pageText = "/Users/theo/Code/project\nClaude, Codex · 79 threads\n".repeat(600); + const element = (name: string, index: number) => ({ + tag: "div", + role: "presentation", + name, + selector: `div:nth-of-type(${index})`, + x: 0, + y: 0, + width: 10, + height: 10, + }); + const oversized = { + ...snapshotResult, + visibleText: pageText, + interactiveElements: [ + element(pageText, 1), + element(pageText, 2), + element(pageText, 3), + element("Continue", 4), + ], + accessibilityTree: { nodes: Array.from({ length: 2_000 }, (_, i) => ({ nodeId: `${i}` })) }, + consoleEntries: Array.from({ length: 100 }, (_, i) => ({ + level: "log", + text: `entry ${i}`, + timestamp: "t", + })), + }; + yield* serveSnapshots("mcp-bounded-client", oversized); + + const snapshot = yield* callSnapshot({ includeImage: false }); + + expect(snapshot.isError).toBe(false); + const [text, notice] = snapshot.content; + expect(text?.type).toBe("text"); + const body = text?.type === "text" ? text.text : ""; + expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( + McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES, + ); + const parsed = decodeJsonText(body) as { + readonly accessibilityTree?: unknown; + readonly visibleText: string; + readonly interactiveElements: ReadonlyArray<{ readonly name: string }>; + readonly consoleEntries: ReadonlyArray<{ readonly text: string }>; + }; + expect(parsed.accessibilityTree).toBeUndefined(); + expect(parsed.visibleText.length).toBeLessThanOrEqual(8_001); + expect(parsed.interactiveElements).toHaveLength(4); + expect(parsed.interactiveElements[0]?.name.length).toBeLessThanOrEqual(201); + expect(parsed.interactiveElements[3]?.name).toBe("Continue"); + expect(parsed.consoleEntries).toHaveLength(40); + expect(parsed.consoleEntries[0]?.text).toBe("entry 60"); + expect(notice?.type === "text" ? notice.text : "").toContain("accessibilityTree"); + expect(notice?.type === "text" ? notice.text : "").toContain("60 older console entries"); + // The structured result is untouched; only the text the agent reads is bounded. + expect(snapshot.structuredContent).toMatchObject({ + accessibilityTree: oversized.accessibilityTree, + }); + }), + ).pipe(Effect.provide(TestLayer)), +); + +it.effect("bounds the snapshot text even when nothing but logs and the title are large", () => + Effect.scoped( + Effect.gen(function* () { + const oversized = { + ...snapshotResult, + title: "t".repeat(70_000), + interactiveElements: [], + consoleEntries: [{ level: "log", text: "x".repeat(70_000), timestamp: "t" }], + }; + yield* serveSnapshots("mcp-bounded-logs-client", oversized); + + const snapshot = yield* callSnapshot({ includeImage: false }); + + const [text] = snapshot.content; + const body = text?.type === "text" ? text.text : ""; + expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( + McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES, + ); + const parsed = decodeJsonText(body) as { + readonly title: string; + readonly consoleEntries: ReadonlyArray<{ readonly text: string }>; + }; + expect(parsed.title.length).toBe(2_049); + expect(parsed.consoleEntries[0]?.text.length).toBe(501); + const notice = snapshot.content[1]; + const noticeText = notice?.type === "text" ? notice.text : ""; + expect(noticeText).toContain("url or title after 2048 characters"); + expect(noticeText).toContain("console entries text after 500 characters"); + }), + ).pipe(Effect.provide(TestLayer)), +); + +it.effect("sheds log entries before locators when every list is full", () => + Effect.scoped( + Effect.gen(function* () { + const long = "x".repeat(2_000); + const oversized = { + ...snapshotResult, + interactiveElements: Array.from({ length: 20 }, (_, i) => ({ + tag: "button", + role: "button", + name: `Button ${i}`, + selector: `#button-${i}`, + x: 0, + y: 0, + width: 10, + height: 10, + })), + consoleEntries: Array.from({ length: 200 }, () => ({ + level: long, + text: long, + timestamp: long, + source: long, + })), + networkEntries: Array.from({ length: 200 }, () => ({ + url: long, + method: long, + status: 200, + failed: false, + errorText: long, + timestamp: long, + })), + actionTimeline: Array.from({ length: 200 }, () => ({ + id: long, + action: long, + status: "succeeded", + startedAt: long, + completedAt: long, + error: long, + })), + }; + yield* serveSnapshots("mcp-full-logs-client", oversized); + + const snapshot = yield* callSnapshot({ includeImage: false }); + + const [text, notice] = snapshot.content; + const body = text?.type === "text" ? text.text : ""; + expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( + McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES, + ); + const parsed = decodeJsonText(body) as { + readonly interactiveElements: ReadonlyArray; + readonly consoleEntries: ReadonlyArray; + readonly networkEntries: ReadonlyArray; + readonly actionTimeline: ReadonlyArray; + }; + // Locators survive; the log lists take the cut. + expect(parsed.interactiveElements).toHaveLength(20); + expect( + parsed.consoleEntries.length + parsed.networkEntries.length + parsed.actionTimeline.length, + ).toBeLessThan(120); + const noticeText = notice?.type === "text" ? notice.text : ""; + expect(noticeText).toContain("40 of 40 actionTimeline"); + expect(noticeText).not.toMatch(/\d+ of \d+ interactiveElements/); + }), + ).pipe(Effect.provide(TestLayer)), +); + it.effect("terminates HTTP MCP sessions with DELETE", () => Effect.scoped( Effect.gen(function* () { @@ -306,33 +586,19 @@ it.effect("registers annotated tools and preserves authenticated request context ok: true, result: event.request.operation === "snapshot" - ? { - url: "http://example.test/", - title: "Example", - loading: false, - visibleText: "Example", - interactiveElements: [], - accessibilityTree: {}, - consoleEntries: [], - networkEntries: [], - actionTimeline: [], - screenshot: { - mimeType: "image/png", - data: Buffer.from("png").toString("base64"), - width: 10, - height: 5, - }, - } - : event.request.operation === "press" - ? undefined - : { - available: true, - visible: true, - tabId, - url: "http://example.test/", - title: "Example", - loading: false, - }, + ? snapshotResult + : event.request.operation === "evaluate" + ? ["Connect", "Continue"] + : event.request.operation === "press" + ? undefined + : { + available: true, + visible: true, + tabId, + url: "http://example.test/", + title: "Example", + loading: false, + }, }); }).pipe(Effect.forkScoped); yield* Effect.yieldNow; @@ -397,6 +663,22 @@ it.effect("registers annotated tools and preserves authenticated request context alternateTabId, ); + // Arrays and primitives are wrapped so structuredContent stays a JSON object. + // Claude Code rejects the whole result otherwise. + const evaluateTool = server.tools.find(({ tool }) => tool.name === "preview_evaluate"); + expect(evaluateTool?.tool.outputSchema).toMatchObject({ type: "object" }); + const evaluated = yield* server + .callTool({ name: "preview_evaluate", arguments: { expression: "buttons()" } }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(evaluated.isError).toBe(false); + expect(evaluated.structuredContent).toEqual({ value: ["Connect", "Continue"] }); + expect(evaluated.content).toEqual([ + { type: "text", text: '{"value":["Connect","Continue"]}' }, + ]); + const actionRequests = [ { name: "preview_click", arguments: { x: 10, y: 10 } }, { name: "preview_type", arguments: { text: "Hello" } }, diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 79bd631c03ca..3f3e48ebe4b2 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -1,8 +1,13 @@ +import * as NodeCrypto from "node:crypto"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +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 Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import type * as Types from "effect/Types"; @@ -10,6 +15,7 @@ import { McpProtocol, McpSchema, McpServer, Tool } from "effect/unstable/ai"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import packageJson from "../../package.json" with { type: "json" }; +import * as ServerConfig from "../config.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as McpSessionRegistry from "./McpSessionRegistry.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; @@ -95,6 +101,204 @@ const McpAuthMiddlewareLive = HttpRouter.middleware<{ provides: McpInvocationContext.McpInvocationContext; }>()(makeMcpAuthMiddleware).layer; +/** + * Claude Code drops every MCP result above 25k tokens (~100 KB of text) and + * hands the agent a truncation notice instead, so a snapshot that carries the + * full accessibility tree and 20 KB of page text loses its locators too. Keep + * the text under that ceiling and tell the agent what was cut. + */ +export const MAX_SNAPSHOT_TEXT_BYTES = 60_000; +const MAX_SNAPSHOT_VISIBLE_TEXT_CHARS = 8_000; +const MAX_SNAPSHOT_ELEMENT_NAME_CHARS = 200; +const MAX_SNAPSHOT_LOG_ENTRIES = 40; +const MAX_SNAPSHOT_LOG_TEXT_CHARS = 500; +const MAX_SNAPSHOT_IDENTIFIER_CHARS = 2_048; + +const encodeJsonText = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const utf8Length = (text: string) => Buffer.byteLength(text, "utf8"); +const cutText = (text: string, max: number) => + text.length > max ? `${text.slice(0, max)}…` : text; + +/** Shortens every string field of a log entry; other fields pass through. */ +const cutEntryStrings = (entry: A): A => + typeof entry === "object" && entry !== null + ? (Object.fromEntries( + Object.entries(entry).map(([key, value]) => [ + key, + typeof value === "string" ? cutText(value, MAX_SNAPSHOT_LOG_TEXT_CHARS) : value, + ]), + ) as A) + : entry; + +const hasLongString = (entry: unknown, max: number) => + typeof entry === "object" && + entry !== null && + Object.values(entry).some((value) => typeof value === "string" && value.length > max); + +type SnapshotMetadata = { + readonly url: string; + readonly title: string; + readonly visibleText: string; + readonly interactiveElements: ReadonlyArray<{ + readonly name: string; + readonly [key: string]: unknown; + }>; + readonly consoleEntries: ReadonlyArray; + readonly networkEntries: ReadonlyArray; + readonly actionTimeline: ReadonlyArray; + readonly [key: string]: unknown; +}; + +/** + * Drops the accessibility tree, shortens page text, element names, identifiers, + * and log strings, keeps only the newest log entries, and finally sheds + * interactive elements until the JSON fits. Returns the text plus notes on + * what is missing so the agent can reach for preview_evaluate. + */ +const boundSnapshotMetadata = ( + metadata: SnapshotMetadata, +): { readonly text: string; readonly omitted: ReadonlyArray } => { + const omitted: Array = []; + const { accessibilityTree, ...withoutTree } = metadata; + if (accessibilityTree !== undefined) { + omitted.push("accessibilityTree (use interactiveElements locators or preview_evaluate)"); + } + const tail = (entries: ReadonlyArray, label: string) => { + if (entries.length > MAX_SNAPSHOT_LOG_ENTRIES) { + omitted.push(`${entries.length - MAX_SNAPSHOT_LOG_ENTRIES} older ${label}`); + } + const kept = entries.slice(-MAX_SNAPSHOT_LOG_ENTRIES); + if (kept.some((entry) => hasLongString(entry, MAX_SNAPSHOT_LOG_TEXT_CHARS))) { + omitted.push(`${label} text after ${MAX_SNAPSHOT_LOG_TEXT_CHARS} characters`); + } + return kept.map(cutEntryStrings); + }; + if ( + metadata.url.length > MAX_SNAPSHOT_IDENTIFIER_CHARS || + metadata.title.length > MAX_SNAPSHOT_IDENTIFIER_CHARS + ) { + omitted.push(`url or title after ${MAX_SNAPSHOT_IDENTIFIER_CHARS} characters`); + } + if ( + metadata.interactiveElements.some( + (element) => element.name.length > MAX_SNAPSHOT_ELEMENT_NAME_CHARS, + ) + ) { + omitted.push(`element names longer than ${MAX_SNAPSHOT_ELEMENT_NAME_CHARS} characters`); + } + if (metadata.visibleText.length > MAX_SNAPSHOT_VISIBLE_TEXT_CHARS) { + omitted.push( + `visibleText after ${MAX_SNAPSHOT_VISIBLE_TEXT_CHARS} characters (use preview_evaluate for more)`, + ); + } + const bounded = { + ...withoutTree, + url: cutText(metadata.url, MAX_SNAPSHOT_IDENTIFIER_CHARS), + title: cutText(metadata.title, MAX_SNAPSHOT_IDENTIFIER_CHARS), + visibleText: cutText(metadata.visibleText, MAX_SNAPSHOT_VISIBLE_TEXT_CHARS), + interactiveElements: metadata.interactiveElements.map((element) => ({ + ...element, + name: cutText(element.name, MAX_SNAPSHOT_ELEMENT_NAME_CHARS), + })), + consoleEntries: tail(metadata.consoleEntries, "console entries"), + networkEntries: tail(metadata.networkEntries, "network entries"), + actionTimeline: tail(metadata.actionTimeline, "action timeline entries"), + }; + + // Per-field caps do not sum below the ceiling: three log arrays of 40 capped + // entries alone can pass 60 KB. Shed the least useful lists first, halving + // one list per round, until the JSON fits. With every list empty the rest + // is bounded by the identifier and visibleText caps, so this terminates. + const shedOrder = [ + "actionTimeline", + "networkEntries", + "consoleEntries", + "interactiveElements", + ] as const; + const lists: Record<(typeof shedOrder)[number], ReadonlyArray> = { + interactiveElements: bounded.interactiveElements, + consoleEntries: bounded.consoleEntries, + networkEntries: bounded.networkEntries, + actionTimeline: bounded.actionTimeline, + }; + const dropped: Record<(typeof shedOrder)[number], number> = { + interactiveElements: 0, + consoleEntries: 0, + networkEntries: 0, + actionTimeline: 0, + }; + let text = encodeJsonText({ ...bounded, ...lists }); + while (utf8Length(text) > MAX_SNAPSHOT_TEXT_BYTES) { + // Elements carry the locators, so they go last; logs shed newest-last. + const key = + shedOrder.find( + (candidate) => candidate !== "interactiveElements" && lists[candidate].length > 0, + ) ?? (lists.interactiveElements.length > 0 ? "interactiveElements" : undefined); + if (key === undefined) break; + const keep = Math.floor(lists[key].length / 2); + dropped[key] += lists[key].length - keep; + // slice(-0) keeps everything, so spell out the empty case. + lists[key] = + keep === 0 + ? [] + : key === "interactiveElements" + ? lists[key].slice(0, keep) + : lists[key].slice(-keep); + text = encodeJsonText({ ...bounded, ...lists }); + } + for (const key of shedOrder) { + if (dropped[key] > 0) { + omitted.push(`${dropped[key]} of ${bounded[key].length} ${key}`); + } + } + return { text, omitted }; +}; + +export class PreviewScreenshotSaveError extends Schema.TaggedError()( + "PreviewScreenshotSaveError", + { screenshotPath: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not save preview screenshot to ${this.screenshotPath}.`; + } +} + +const MAX_SCREENSHOT_SITE_SLUG_LENGTH = 40; + +/** Hostname reduced to a filename-safe slug, matching the desktop's own screenshot names. */ +const screenshotSiteSlug = (rawUrl: string): string => { + try { + const slug = new URL(rawUrl).hostname + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, MAX_SCREENSHOT_SITE_SLUG_LENGTH) + .replace(/-+$/g, ""); + return slug || "site"; + } catch { + return "site"; + } +}; + +/** Writes the snapshot PNG under the browser artifacts directory and returns its path. */ +const saveScreenshot = Effect.fn("McpHttpServer.saveScreenshot")(function* ( + pageUrl: string, + data: Uint8Array, +) { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const millis = yield* Clock.currentTimeMillis; + // Two saves in the same millisecond must not overwrite each other. + const fileName = `browser-screenshot-${screenshotSiteSlug(pageUrl)}-${millis.toString(36)}-${NodeCrypto.randomUUID().slice(0, 8)}.png`; + const screenshotPath = path.join(config.browserArtifactsDir, fileName); + yield* fileSystem.makeDirectory(config.browserArtifactsDir, { recursive: true }).pipe( + Effect.andThen(fileSystem.writeFile(screenshotPath, data)), + Effect.mapError((cause) => new PreviewScreenshotSaveError({ screenshotPath, cause })), + ); + return screenshotPath; +}); + const previewSnapshotFailure = (cause: Cause.Cause) => { if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) { return Effect.failCause(cause).pipe(Effect.orDie); @@ -117,7 +321,8 @@ const previewSnapshotFailure = (cause: Cause.Cause) => { failureCount: failures.length, }, }, - content: [{ type: "text", text: "Preview snapshot failed." }], + // Agents usually see only the text content, so name the tag there too. + content: [{ type: "text", text: `Preview snapshot failed: ${errorTag}.` }], }); return Effect.logWarning("preview snapshot failed", { operation: "snapshot", @@ -129,6 +334,10 @@ const previewSnapshotFailure = (cause: Cause.Cause) => { const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () { const server = yield* McpServer.McpServer; const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + // The MCP tool runner only supplies the client, so hand the save path its services here. + const saveServices = yield* Effect.context< + ServerConfig.ServerConfig | FileSystem.FileSystem | Path.Path + >(); const built = yield* PreviewSnapshotToolkit; const tool = PreviewSnapshotTool; yield* server.addTool({ @@ -160,19 +369,21 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot Effect.flatMap(Effect.fromOption), Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker), Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.matchCauseEffect({ - onFailure: previewSnapshotFailure, - onSuccess: ({ encodedResult }) => { - const snapshot = encodedResult as { + Effect.flatMap(({ encodedResult }) => + Effect.gen(function* () { + const snapshot = encodedResult as SnapshotMetadata & { + readonly url: string; readonly screenshot: { readonly mimeType: "image/png"; readonly data: string; readonly width: number; readonly height: number; }; - readonly [key: string]: unknown; }; const { screenshot, ...page } = snapshot; + const png = new Uint8Array(Buffer.from(screenshot.data, "base64")); + const screenshotPath = + payload?.save === true ? yield* saveScreenshot(snapshot.url, png) : undefined; const metadata = { ...page, screenshot: { @@ -180,26 +391,33 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot width: screenshot.width, height: screenshot.height, }, + ...(screenshotPath === undefined ? {} : { screenshotPath }), }; - return Effect.succeed( - new McpSchema.CallToolResult({ - isError: false, - structuredContent: metadata, - content: [ - { type: "text", text: JSON.stringify(metadata) }, - ...(payload?.includeImage === false - ? [] - : [ - { - type: "image" as const, - data: new Uint8Array(Buffer.from(screenshot.data, "base64")), - mimeType: screenshot.mimeType, - }, - ]), - ], - }), - ); - }, + const bounded = boundSnapshotMetadata(metadata); + return new McpSchema.CallToolResult({ + isError: false, + structuredContent: metadata, + content: [ + { type: "text", text: bounded.text }, + ...(bounded.omitted.length === 0 + ? [] + : [ + { + type: "text" as const, + text: `Snapshot text was bounded. Omitted: ${bounded.omitted.join("; ")}.`, + }, + ]), + ...(payload?.includeImage === false + ? [] + : [{ type: "image" as const, data: png, mimeType: screenshot.mimeType }]), + ], + }); + }), + ), + Effect.provide(saveServices), + Effect.matchCauseEffect({ + onFailure: previewSnapshotFailure, + onSuccess: Effect.succeed, }), ); }), diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index ac4e124ea312..d34c2d3ba3af 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -166,8 +166,8 @@ const handlers = { preview_set_appearance: (input) => invokeTargeted("setColorScheme", input), preview_snapshot: (input) => { - // Output selection is MCP-only; the browser still produces a complete snapshot. - const { includeImage: _includeImage, ...operationInput } = input ?? {}; + // Output selection and saving are MCP-only; the browser still produces a complete snapshot. + const { includeImage: _includeImage, save: _save, ...operationInput } = input ?? {}; return invokeTargeted("snapshot", operationInput); }, preview_click: (input) => @@ -176,7 +176,9 @@ const handlers = { preview_press: (input) => invokeTargeted("press", input).pipe(Effect.as({})), preview_scroll: (input) => invokeTargeted("scroll", input).pipe(Effect.as({})), preview_evaluate: (input) => - invokeTargeted("evaluate", input).pipe(Effect.map((result) => result ?? null)), + invokeTargeted("evaluate", input).pipe( + Effect.map((result) => ({ value: result ?? null })), + ), preview_wait_for: (input) => invokeTargeted("waitFor", input, input.timeoutMs).pipe(Effect.as({})), preview_recording_start: (input) => @@ -200,5 +202,3 @@ export const PreviewStandardToolkitHandlersLive = PreviewStandardToolkit.toLayer export const PreviewSnapshotToolkitHandlersLive = PreviewSnapshotToolkit.toLayer({ preview_snapshot, }); - -export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer(handlers); diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index cc2e572bc373..28a2b96228b5 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -47,7 +47,7 @@ const safeBrowserTool = (tool: T): T => const readonlyBrowserTool = (tool: T): T => safeBrowserTool(tool).annotate(Tool.Readonly, true).annotate(Tool.Idempotent, true) as T; -export const PreviewStatusTool = Tool.make("preview_status", { +const PreviewStatusTool = Tool.make("preview_status", { description: "Report whether a collaborative browser tab is automation-capable, including its URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab.", parameters: PreviewAutomationTabTargetInput, @@ -60,7 +60,7 @@ export const PreviewStatusTool = Tool.make("preview_status", { .annotate(Tool.Destructive, false) .annotate(Tool.Idempotent, true); -export const PreviewOpenTool = browserTool( +const PreviewOpenTool = browserTool( Tool.make("preview_open", { description: "Initialize a collaborative browser tab and open its thread-bound inline preview by default. Set open=false for background-only automation. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab.", @@ -73,7 +73,7 @@ export const PreviewOpenTool = browserTool( .annotate(Tool.Destructive, false), ); -export const PreviewNavigateTool = safeBrowserTool( +const PreviewNavigateTool = safeBrowserTool( Tool.make("preview_navigate", { description: "Navigate a collaborative browser tab. Pass tabId to target a specific tab, plus {url:'https://t3.chat'} for a website or {target:{kind:'environment-port',port:5173}} for a dev server. Exactly one of url or target is required.", @@ -84,7 +84,7 @@ export const PreviewNavigateTool = safeBrowserTool( }).annotate(Tool.Title, "Navigate browser preview"), ); -export const PreviewResizeTool = safeBrowserTool( +const PreviewResizeTool = safeBrowserTool( Tool.make("preview_resize", { description: "Resize a collaborative browser tab, optionally selected by tabId. Use {mode:'fill'}, {mode:'freeform',width:1024,height:768}, or {mode:'preset',preset:'iphone-12-pro',orientation:'portrait'}. This changes CSS layout breakpoints without changing the desktop browser user agent.", @@ -97,7 +97,7 @@ export const PreviewResizeTool = safeBrowserTool( .annotate(Tool.Idempotent, true), ); -export const PreviewSetAppearanceTool = safeBrowserTool( +const PreviewSetAppearanceTool = safeBrowserTool( Tool.make("preview_set_appearance", { description: "Emulate prefers-color-scheme in a collaborative browser tab, optionally selected by tabId. Use {colorScheme:'dark'} or {colorScheme:'light'} to preview the page in that appearance, and {colorScheme:'system'} to clear the override and follow the OS appearance.", @@ -113,7 +113,7 @@ export const PreviewSetAppearanceTool = safeBrowserTool( export const PreviewSnapshotTool = readonlyBrowserTool( Tool.make("preview_snapshot", { description: - "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot. Set includeImage=false for text-only output with the same page metadata.", + "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot. Set includeImage=false for text-only output with the same page metadata. Set save=true to also write the PNG to disk and get screenshotPath back; embed that path in your reply as ![alt](screenshotPath) so the user sees it. This is the only way to show the user a screenshot; the image in the tool result is not saved anywhere.", parameters: Schema.Struct({ ...PreviewAutomationTabTargetInput.fields, includeImage: Schema.optional( @@ -122,6 +122,12 @@ export const PreviewSnapshotTool = readonlyBrowserTool( "Include the PNG image in the tool response. Defaults to true. Set false for text-only output.", }), ), + save: Schema.optional( + Schema.Boolean.annotate({ + description: + "Write the screenshot PNG to disk and return its absolute path as screenshotPath. Defaults to false.", + }), + ), }), success: PreviewAutomationSnapshot, failure: PreviewAutomationError, @@ -129,7 +135,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool( }).annotate(Tool.Title, "Inspect browser page"), ); -export const PreviewClickTool = browserTool( +const PreviewClickTool = browserTool( Tool.make("preview_click", { description: "Click exactly one target in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; selector accepts legacy CSS; x and y must be supplied together.", @@ -140,7 +146,7 @@ export const PreviewClickTool = browserTool( }).annotate(Tool.Title, "Click preview page"), ); -export const PreviewTypeTool = browserTool( +const PreviewTypeTool = browserTool( Tool.make("preview_type", { description: "Insert literal text into one input in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; set clear=true to replace existing text.", @@ -151,7 +157,7 @@ export const PreviewTypeTool = browserTool( }).annotate(Tool.Title, "Type into preview page"), ); -export const PreviewPressTool = browserTool( +const PreviewPressTool = browserTool( Tool.make("preview_press", { description: "Press one keyboard key in the tab selected by tabId, or this agent session's current tab when omitted. Examples: {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}.", @@ -162,7 +168,7 @@ export const PreviewPressTool = browserTool( }).annotate(Tool.Title, "Press key in preview page"), ); -export const PreviewScrollTool = safeBrowserTool( +const PreviewScrollTool = safeBrowserTool( Tool.make("preview_scroll", { description: "Scroll the tab selected by tabId, or this agent session's current tab when omitted. Positive deltaY scrolls down and positive deltaX scrolls right; a locator/selector targets a container.", @@ -173,18 +179,29 @@ export const PreviewScrollTool = safeBrowserTool( }).annotate(Tool.Title, "Scroll preview page"), ); -export const PreviewEvaluateTool = browserTool( +/** + * MCP `structuredContent` must be a JSON object, and Claude Code rejects the + * whole result when it is not. Wrapping keeps arrays, strings, numbers, and + * null valid instead of failing only for non-object expressions. + */ +export const PreviewEvaluateResult = Schema.Struct({ + value: Schema.Unknown.annotate({ + description: "The JSON-serializable value the expression produced, or null.", + }), +}).annotate({ description: "The evaluated expression result." }); + +const PreviewEvaluateTool = browserTool( Tool.make("preview_evaluate", { description: - "Evaluate JavaScript in the tab selected by tabId, or this agent session's current tab when omitted. Returns a serializable result up to 64 KB; the expression may mutate page state.", + "Evaluate JavaScript in the tab selected by tabId, or this agent session's current tab when omitted. Returns {value} with a serializable result up to 64 KB; the expression may mutate page state.", parameters: PreviewAutomationEvaluateInput, - success: Schema.Unknown, + success: PreviewEvaluateResult, failure: PreviewAutomationError, dependencies, }).annotate(Tool.Title, "Evaluate JavaScript in preview"), ); -export const PreviewWaitForTool = readonlyBrowserTool( +const PreviewWaitForTool = readonlyBrowserTool( Tool.make("preview_wait_for", { description: "Wait in the tab selected by tabId, or this agent session's current tab when omitted, until all supplied locator, selector, text, and URL conditions match.", @@ -195,7 +212,7 @@ export const PreviewWaitForTool = readonlyBrowserTool( }).annotate(Tool.Title, "Wait for preview page condition"), ); -export const PreviewRecordingStartTool = safeBrowserTool( +const PreviewRecordingStartTool = safeBrowserTool( Tool.make("preview_recording_start", { description: "Start recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted.", @@ -206,7 +223,7 @@ export const PreviewRecordingStartTool = safeBrowserTool( }).annotate(Tool.Title, "Start browser recording"), ); -export const PreviewRecordingStopTool = safeBrowserTool( +const PreviewRecordingStopTool = safeBrowserTool( Tool.make("preview_recording_stop", { description: "Stop recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted, and transfer the compressed recording once (up to 50 MiB) to an evidence file readable in this agent's environment. Returns its environment-local path after transfer succeeds.", diff --git a/apps/server/src/observability/BrowserTraceCollector.ts b/apps/server/src/observability/BrowserTraceCollector.ts index 300a50fe3308..ee197dd0d7d3 100644 --- a/apps/server/src/observability/BrowserTraceCollector.ts +++ b/apps/server/src/observability/BrowserTraceCollector.ts @@ -10,6 +10,7 @@ export class BrowserTraceCollector extends Context.Service< } >()("t3/observability/BrowserTraceCollector") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = (sink: TraceSink): BrowserTraceCollector["Service"] => BrowserTraceCollector.of({ record: (records) => diff --git a/apps/server/src/orchestration/Errors.ts b/apps/server/src/orchestration/Errors.ts index a0dd1d18eab3..dccb8b4be0f7 100644 --- a/apps/server/src/orchestration/Errors.ts +++ b/apps/server/src/orchestration/Errors.ts @@ -126,13 +126,6 @@ export type OrchestrationEngineError = | OrchestrationCommandJsonParseError | OrchestrationCommandDecodeError; -export function toOrchestrationCommandDecodeError(error: Schema.SchemaError) { - return new OrchestrationCommandDecodeError({ - issue: SchemaIssue.makeFormatterDefault()(error.issue), - cause: error, - }); -} - export function toProjectorDecodeError(eventType: string) { return (error: Schema.SchemaError): OrchestrationProjectorDecodeError => new OrchestrationProjectorDecodeError({ @@ -141,19 +134,3 @@ export function toProjectorDecodeError(eventType: string) { cause: error, }); } - -export function toOrchestrationJsonParseError(cause: unknown) { - return new OrchestrationCommandJsonParseError({ - detail: `Failed to parse orchestration command JSON`, - cause, - }); -} - -export function toListenerCallbackError(listener: "read-model" | "domain-event") { - return (cause: unknown): OrchestrationListenerCallbackError => - new OrchestrationListenerCallbackError({ - listener, - detail: `Failed to invoke orchestration ${listener} listener`, - cause, - }); -} diff --git a/apps/server/src/orchestration/LiveStreamBudget.ts b/apps/server/src/orchestration/LiveStreamBudget.ts index e86127b2b2df..5a3a08e7e4b3 100644 --- a/apps/server/src/orchestration/LiveStreamBudget.ts +++ b/apps/server/src/orchestration/LiveStreamBudget.ts @@ -6,8 +6,8 @@ import * as Exit from "effect/Exit"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -export const LIVE_STREAM_MAX_ITEMS = 1_000; -export const LIVE_STREAM_MAX_SERIALIZED_BYTES = 8 * 1024 * 1024; +const LIVE_STREAM_MAX_ITEMS = 1_000; +const LIVE_STREAM_MAX_SERIALIZED_BYTES = 8 * 1024 * 1024; export interface RetainedLiveItem { readonly value: A; diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.ts index 6e2aa0b3e054..b694bacbbb33 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.ts @@ -77,6 +77,7 @@ export function pullRequestMatchesProject( ); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.ts index 7c55fa37d1f3..1df7855d1e04 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -8,7 +8,7 @@ export interface SettlementPullRequest { } const DAY_MS = 24 * 60 * 60 * 1_000; -export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; +const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; function latestTimestamp(values: ReadonlyArray): string | null { let latest: string | null = null; diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 4830ec9d80e5..d925df5d98f1 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -32,6 +32,7 @@ export class ThreadSettlementReactor extends Context.Service< } >()("t3/orchestration/ThreadSettlementReactor") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index 110a499d37c9..873eab007bea 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -25,7 +25,7 @@ function findThreadById( return readModel.threads.find((thread) => thread.id === threadId); } -export function findProjectById( +function findProjectById( readModel: OrchestrationReadModel, projectId: ProjectId, ): OrchestrationProject | undefined { diff --git a/apps/server/src/orchestration/runtimeLayer.ts b/apps/server/src/orchestration/runtimeLayer.ts index 779042e2f685..ea02e5e2ebf9 100644 --- a/apps/server/src/orchestration/runtimeLayer.ts +++ b/apps/server/src/orchestration/runtimeLayer.ts @@ -8,16 +8,16 @@ import { OrchestrationProjectionSnapshotQueryLive } from "./Layers/ProjectionSna import * as ThreadBackgroundLiveness from "./ThreadBackgroundLiveness.ts"; import * as ThreadPlanProgress from "./ThreadPlanProgress.ts"; -export const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll( +const OrchestrationEventInfrastructureLayerLive = Layer.mergeAll( OrchestrationEventStoreLive, OrchestrationCommandReceiptRepositoryLive, ); -export const OrchestrationProjectionPipelineLayerLive = OrchestrationProjectionPipelineLive.pipe( +const OrchestrationProjectionPipelineLayerLive = OrchestrationProjectionPipelineLive.pipe( Layer.provide(OrchestrationEventStoreLive), ); -export const OrchestrationInfrastructureLayerLive = Layer.mergeAll( +const OrchestrationInfrastructureLayerLive = Layer.mergeAll( OrchestrationProjectionSnapshotQueryLive, OrchestrationEventInfrastructureLayerLive, OrchestrationProjectionPipelineLayerLive, diff --git a/apps/server/src/persistence/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts index e54c977e7ab7..aae55dd2fe06 100644 --- a/apps/server/src/persistence/AuthPairingLinks.ts +++ b/apps/server/src/persistence/AuthPairingLinks.ts @@ -119,6 +119,7 @@ function toPersistenceSqlOrDecodeError( }); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index aebbb839e5ce..37db33556e23 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -200,6 +200,7 @@ function toPersistenceSqlOrDecodeError( }); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index c95f746d3648..bc176f62cc3c 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -72,7 +72,7 @@ import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts"; * Uses Migrator.fromRecord which parses the key format and * returns migrations sorted by ID. */ -export const migrationEntries = [ +const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], [2, "OrchestrationCommandReceipts", Migration0002], [3, "CheckpointDiffBlobs", Migration0003], @@ -126,7 +126,7 @@ export const migrationEntries = [ export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); -export const makeMigrationLoader = (throughId?: number) => +const makeMigrationLoader = (throughId?: number) => Migrator.fromRecord( Object.fromEntries( migrationEntries diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts index d73f56aab9e0..2673512edf10 100644 --- a/apps/server/src/persistence/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -166,6 +166,7 @@ function toPersistenceSqlOrDecodeError( }); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index 3c0169eba40a..a5f5cba9ccd0 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -153,6 +153,7 @@ const buildIdleSnapshot = (input: { updatedAt: input.updatedAt, }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* PreviewManagerMake() { const serverEpoch = NodeCrypto.randomUUID(); const stateRef = yield* SynchronizedRef.make(initialState); diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 4571aeef4c6b..f4d73d62320d 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -289,6 +289,7 @@ const serversEqual = ( return true; }; +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* PortDiscoveryMake() { const net = yield* Net.NetService; const processRunner = yield* ProcessRunner.ProcessRunner; diff --git a/apps/server/src/process/externalLauncher.ts b/apps/server/src/process/externalLauncher.ts index 184c8b519a96..2b7310051b89 100644 --- a/apps/server/src/process/externalLauncher.ts +++ b/apps/server/src/process/externalLauncher.ts @@ -752,6 +752,7 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu ); }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 4aefc3ba43f5..25ed54e44fc6 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -399,6 +399,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( } satisfies ProcessRunOutput; }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("ProcessRunner.make")(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 1dfd1d5df8af..975192e70033 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -615,6 +615,7 @@ function sameTranscriptIdentity( ); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; // Different project imports can arrive concurrently from multiple clients. diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 9c222c969ad3..3bbb0daa7994 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -83,6 +83,7 @@ export class ProjectSetupScriptRunner extends Context.Service< } >()("t3/project/ProjectSetupScriptRunner") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const terminalManager = yield* TerminalManager.TerminalManager; diff --git a/apps/server/src/project/T3ProjectFileLoader.ts b/apps/server/src/project/T3ProjectFileLoader.ts index 473dce695046..105e6b09a317 100644 --- a/apps/server/src/project/T3ProjectFileLoader.ts +++ b/apps/server/src/project/T3ProjectFileLoader.ts @@ -59,6 +59,7 @@ const logT3ProjectFileLoadError = (error: T3ProjectFileLoadError) => }), ); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index b1fcd6a92bbf..e601e9dc9392 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -118,7 +118,7 @@ export function scopeClaudeModelCatalog( return { models: [...builtInModels, ...customCatalogModels] }; } -export function resolveClaudeCatalogModel( +function resolveClaudeCatalogModel( catalog: ClaudeModelCatalog, slugOrAlias: string | null | undefined, ): ClaudeCatalogModel | undefined { @@ -215,7 +215,7 @@ export function isClaudeCatalogUltracodeEffort(effort: string | null | undefined return effort === "ultracode"; } -export function resolveClaudeCatalogContextWindow( +function resolveClaudeCatalogContextWindow( catalog: ClaudeModelCatalog, modelSelection: ModelSelection | undefined, ): string | undefined { diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 236fe79f518c..259ceeb4b775 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -104,7 +104,7 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter { * user and project one. Absent on almost every machine, which is why a missing * file is the normal case rather than an error. */ -export function claudeManagedSettingsPath( +function claudeManagedSettingsPath( path: Path.Path, platform: NodeJS.Platform, environment: NodeJS.ProcessEnv, diff --git a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts index 919a345aa988..9e8e3c5d1f90 100644 --- a/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderAdapterRegistry.ts @@ -80,13 +80,6 @@ export const ProviderAdapterRegistryLive = Layer.effect( makeProviderAdapterRegistry(), ); -// Exposed for tests that want to build a facade over a pre-assembled -// `ProviderInstanceRegistry` without pulling in the whole boot graph. -export { makeProviderAdapterRegistry }; - -// Re-export for consumers that need the accessor shape. The service tag -// itself lives in `Services/ProviderAdapterRegistry.ts`. -export { ProviderAdapterRegistry } from "../Services/ProviderAdapterRegistry.ts"; // Re-export for consumers (including tests) that construct a // `ProviderInstanceId` before calling `getByInstance`. export { ProviderInstanceId }; diff --git a/apps/server/src/provider/Layers/ProviderEventLoggers.ts b/apps/server/src/provider/Layers/ProviderEventLoggers.ts index a4f0b01e8193..517a1c8195c0 100644 --- a/apps/server/src/provider/Layers/ProviderEventLoggers.ts +++ b/apps/server/src/provider/Layers/ProviderEventLoggers.ts @@ -61,6 +61,8 @@ export const NoOpProviderEventLoggers: ProviderEventLoggers["Service"] = { /** * Builds both stream views over one shared store. Setup failures are logged * and downgraded to the no-op service so diagnostics never block startup. + * + * @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const { providerEventLogPath } = yield* ServerConfig; diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts index 0cc4b6c93cb8..67ccbd167c30 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.ts @@ -33,7 +33,6 @@ * @module provider/Layers/ProviderInstanceRegistryLive */ import { - defaultInstanceIdForDriver, providerInstanceConfigEnabledFlag, ProviderInstanceId, type ProviderInstanceConfig, @@ -430,5 +429,3 @@ export const ProviderInstanceRegistryMutableLayer = (input: { ), ), ) as Layer.Layer; - -export { defaultInstanceIdForDriver }; diff --git a/apps/server/src/provider/Layers/codexLaunchArgs.ts b/apps/server/src/provider/Layers/codexLaunchArgs.ts index 771a4f0b6ed6..f64ff263e0e3 100644 --- a/apps/server/src/provider/Layers/codexLaunchArgs.ts +++ b/apps/server/src/provider/Layers/codexLaunchArgs.ts @@ -1,14 +1,13 @@ import { tokenizeCliArgs } from "@t3tools/shared/cliArgs"; -export const T3CODE_CODEX_LAUNCH_ARGS_ENV = "T3CODE_CODEX_LAUNCH_ARGS"; +const T3CODE_CODEX_LAUNCH_ARGS_ENV = "T3CODE_CODEX_LAUNCH_ARGS"; export const resolveCodexLaunchArgs = ( launchArgs?: string, environment: NodeJS.ProcessEnv = process.env, ) => environment[T3CODE_CODEX_LAUNCH_ARGS_ENV]?.trim() || launchArgs?.trim() || ""; -export const codexLaunchArgv = (launchArgs?: string): ReadonlyArray => - tokenizeCliArgs(launchArgs); +const codexLaunchArgv = (launchArgs?: string): ReadonlyArray => tokenizeCliArgs(launchArgs); export const codexAppServerArgs = (launchArgs?: string) => [ "app-server", diff --git a/apps/server/src/provider/Layers/codexResetCredit.ts b/apps/server/src/provider/Layers/codexResetCredit.ts index d8310c7c3ee7..34bd0a77fe27 100644 --- a/apps/server/src/provider/Layers/codexResetCredit.ts +++ b/apps/server/src/provider/Layers/codexResetCredit.ts @@ -45,6 +45,7 @@ export class CodexResetCreditCoordinator extends Context.Service< } >()("t3/provider/Layers/codexResetCredit/CodexResetCreditCoordinator") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const statesRef = yield* Ref.make>(new Map()); diff --git a/apps/server/src/provider/Layers/codexUsageLimits.ts b/apps/server/src/provider/Layers/codexUsageLimits.ts index 66b9511b1e44..74ce04a7895f 100644 --- a/apps/server/src/provider/Layers/codexUsageLimits.ts +++ b/apps/server/src/provider/Layers/codexUsageLimits.ts @@ -67,7 +67,7 @@ function labelForKind(kind: ServerProviderUsageWindow["kind"]): string { * `windowDurationMins`; when it does not, paid plans expose the 5-hour and * weekly pair and Free/Go expose one monthly allowance. */ -export function codexRateLimitsToWindows( +function codexRateLimitsToWindows( snapshot: CodexRateLimitSnapshot, ): ReadonlyArray { // Show the main allowance only. Model-specific notifications (such as Spark) diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index 67a7334f613d..15000630b359 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -207,7 +207,7 @@ export const encodeManifestCache = Schema.encodeEffect( ); /** True when the manifest classifies `slug` as legacy for `driverKind`. */ -export function isLegacyModel( +function isLegacyModel( manifest: ModelManifestData, driverKind: ProviderDriverKind, slug: string, @@ -310,8 +310,8 @@ export class ModelManifest extends Context.Service< } >()("t3/provider/ModelManifest") {} -/** Constant service for tests and callers that only need the bundled data. */ -export const BundledOnlyModelManifest: ModelManifest["Service"] = { +/** Constant service backing the bundled-data test layer. */ +const BundledOnlyModelManifest: ModelManifest["Service"] = { current: Effect.succeed(BUNDLED_MODEL_MANIFEST), refresh: Effect.succeed(BUNDLED_MODEL_MANIFEST), refreshInBackground: Effect.void, diff --git a/apps/server/src/provider/OpenCodeServerOwner.ts b/apps/server/src/provider/OpenCodeServerOwner.ts index cccfcaccd6ef..2f51137de24c 100644 --- a/apps/server/src/provider/OpenCodeServerOwner.ts +++ b/apps/server/src/provider/OpenCodeServerOwner.ts @@ -8,7 +8,7 @@ import * as Semaphore from "effect/Semaphore"; import * as OpenCodeRuntime from "./opencodeRuntime.ts"; -export const OPENCODE_SERVER_IDLE_TTL = "30 seconds"; +const OPENCODE_SERVER_IDLE_TTL = "30 seconds"; interface OpenCodeServerOwnerState { server: OpenCodeRuntime.OpenCodeServerProcess | null; @@ -176,6 +176,7 @@ export const make = Effect.fn("OpenCodeServerOwner.make")(function* (input: { }); }); +/** @public Service construction is part of the canonical Effect module API. */ export const layer = (input: { readonly binaryPath: string; readonly directory: string; diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index cb37df006587..6e625127cb18 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -181,7 +181,7 @@ const AUDIO_MIME_TYPES = new Set([ "audio/x-wav", "audio/webm", ]); -export const ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES = 20 * 1024 * 1024; +const ANTIGRAVITY_MAX_AUDIO_ATTACHMENT_BYTES = 20 * 1024 * 1024; const TEXT_MIME_TYPES = new Set([ "application/json", "application/ld+json", diff --git a/apps/server/src/provider/acp/AntigravityProtocol.ts b/apps/server/src/provider/acp/AntigravityProtocol.ts index 81ec91d265b0..1993c9457af5 100644 --- a/apps/server/src/provider/acp/AntigravityProtocol.ts +++ b/apps/server/src/provider/acp/AntigravityProtocol.ts @@ -72,9 +72,7 @@ const decodeSecurityWarning = Schema.decodeUnknownOption( * The agent marks "Allow Always" on shell and web tools with a prompt injection * warning in `_meta`. Surface it as option text so both clients can show it. */ -export function antigravitySecurityWarning( - option: EffectAcpSchema.PermissionOption, -): string | undefined { +function antigravitySecurityWarning(option: EffectAcpSchema.PermissionOption): string | undefined { const meta = option._meta; if (!Predicate.isObject(meta)) return undefined; const warning = Option.getOrUndefined(decodeSecurityWarning(meta[SECURITY_WARNING_META_KEY])); diff --git a/apps/server/src/provider/antigravityRelease.ts b/apps/server/src/provider/antigravityRelease.ts index c33f09cef465..90ba309c10c6 100644 --- a/apps/server/src/provider/antigravityRelease.ts +++ b/apps/server/src/provider/antigravityRelease.ts @@ -1,4 +1,4 @@ -export const ANTIGRAVITY_RELEASE_VERSION = "agy_acp_server_1.1.1"; +const ANTIGRAVITY_RELEASE_VERSION = "agy_acp_server_1.1.1"; export interface AntigravityReleaseAsset { readonly version: string; diff --git a/apps/server/src/provider/opencodeRuntime.ts b/apps/server/src/provider/opencodeRuntime.ts index ce2840f6c13d..6d7187792a99 100644 --- a/apps/server/src/provider/opencodeRuntime.ts +++ b/apps/server/src/provider/opencodeRuntime.ts @@ -438,9 +438,9 @@ export function openCodeQuestionId( * puts in the prompt. */ const OPENCODE_NATIVE_IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]); -export const OPENCODE_NATIVE_FILE_PART_MAX_BYTES = 20 * 1024 * 1024; +const OPENCODE_NATIVE_FILE_PART_MAX_BYTES = 20 * 1024 * 1024; -export function isOpenCodeNativeFilePart(input: { +function isOpenCodeNativeFilePart(input: { readonly mimeType: string; readonly sizeBytes: number; }): boolean { diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index e8ff090a4ec9..74167a808840 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -33,7 +33,7 @@ const PROVIDER_UPDATE_ACTION_TOAST_MESSAGE = "Install the update now or review p * move on their own, so this mostly bounds how stale a Homebrew "latest" can * get; the npm registry check keeps its own cache. */ -export const MAINTENANCE_CAPABILITIES_CACHE_TTL = Duration.hours(1); +const MAINTENANCE_CAPABILITIES_CACHE_TTL = Duration.hours(1); const compactEnv = (input: Record>): NodeJS.ProcessEnv => Object.fromEntries( diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index e91560312566..121e5aeab7d1 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -211,6 +211,7 @@ function makeUpdateState(input: { }; } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const providerRegistry = yield* ProviderRegistry; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/provider/providerSnapshot.ts b/apps/server/src/provider/providerSnapshot.ts index 4b5d4ae3ee47..8c94b8bb977d 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/apps/server/src/provider/providerSnapshot.ts @@ -105,42 +105,6 @@ export const spawnAndCollect = (binaryPath: string, command: ChildProcess.Comman return result; }).pipe(Effect.scoped); -export function detailFromResult( - result: CommandResult & { readonly timedOut?: boolean }, -): string | undefined { - if (result.timedOut) return "Timed out while running command."; - const stderr = nonEmptyTrimmed(result.stderr); - if (stderr) return stderr; - const stdout = nonEmptyTrimmed(result.stdout); - if (stdout) return stdout; - if (result.code !== 0) { - return `Command exited with code ${result.code}.`; - } - return undefined; -} - -export function extractAuthBoolean(value: unknown): boolean | undefined { - if (globalThis.Array.isArray(value)) { - for (const entry of value) { - const nested = extractAuthBoolean(entry); - if (nested !== undefined) return nested; - } - return undefined; - } - - if (!value || typeof value !== "object") return undefined; - - const record = value as Record; - for (const key of ["authenticated", "isAuthenticated", "loggedIn", "isLoggedIn"] as const) { - if (typeof record[key] === "boolean") return record[key]; - } - for (const key of ["auth", "status", "session", "account"] as const) { - const nested = extractAuthBoolean(record[key]); - if (nested !== undefined) return nested; - } - return undefined; -} - export function parseGenericCliVersion(output: string): string | null { const match = output.match(/\b(\d+\.\d+\.\d+)\b/); return match?.[1] ?? null; diff --git a/apps/server/src/provider/providerUpdateSettings.ts b/apps/server/src/provider/providerUpdateSettings.ts index 308d84a14467..30ad2bf8a286 100644 --- a/apps/server/src/provider/providerUpdateSettings.ts +++ b/apps/server/src/provider/providerUpdateSettings.ts @@ -10,7 +10,7 @@ export interface ProviderSnapshotSettings { readonly enableProviderUpdateChecks: boolean; } -export function makeProviderSnapshotSettings( +function makeProviderSnapshotSettings( provider: Settings, settings: ServerSettings, ): ProviderSnapshotSettings { diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index d9a04de6f607..b2195b1d890c 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; -export const makeProviderRegistryMock = ( +const makeProviderRegistryMock = ( providers: ReadonlyArray = [], ): ProviderRegistryShape => ({ getProviders: Effect.succeed(providers), diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts index 85702bb1d061..91a39e8de3ec 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestCli.ts @@ -261,6 +261,7 @@ function isReviewerName(value: string): boolean { return name.length > 0 && !name.startsWith("-"); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const azure = yield* AzureDevOpsCli.AzureDevOpsCli; diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts index 9b8db662f7e0..632f0d9a550f 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts @@ -378,6 +378,7 @@ function bitbucketReviewPosition( } } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const bitbucket = yield* BitbucketApi.BitbucketApi; diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 22989a94f04a..6152df75fb73 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -714,7 +714,7 @@ export class GitHubPullRequestCli extends Context.Service< * The host is not read off the identity: it travels alongside it, because the identity a * project records is the path below its host and never names the host itself. */ -export function parseRepositorySelector(value: string): { +function parseRepositorySelector(value: string): { readonly owner: string; readonly name: string; } { @@ -980,6 +980,7 @@ function actionArgs( } } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const github = yield* GitHubCli.GitHubCli; const graphQlBudget = yield* GitHubGraphQlBudget.GitHubGraphQlBudget; diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.ts index a1510c4f0a3d..60bf2c55742e 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.ts @@ -511,6 +511,7 @@ function actionArgs( } } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const gitlab = yield* GitLabCli.GitLabCli; diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts index d2caf3ff35bd..f5b4251f7d9a 100644 --- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts +++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts @@ -41,6 +41,8 @@ export function fromProviders( /** * The hosts this build can read change requests from. A host with no entry here still shows up * in the provider list as unimplemented, so its projects are explained rather than missing. + * + * @public Service construction is part of the canonical Effect module API. */ export const make = Effect.map( Effect.all([ diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 8d7b9e98361e..052a67959ad1 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -211,7 +211,7 @@ const makePublishProof = Effect.fn("makePublishProof")(function* (input: { }); // Compact, log-safe view of the fields the awareness phase ladder reads. -export function describeThreadShellForAwareness( +function describeThreadShellForAwareness( thread: Option.Option, ): Record { if (Option.isNone(thread)) { @@ -290,6 +290,7 @@ export function resolveAgentAwarenessRelayActiveThreadIds(input: { .map((thread) => thread.id); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const secrets = yield* ServerSecretStore.ServerSecretStore; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; diff --git a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts index 528c9cf2b229..67842addd7df 100644 --- a/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts +++ b/apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts @@ -327,6 +327,7 @@ export function requireDesktopTelemetryWriteProgress( : Effect.fail(new DesktopTelemetryControlStalled({ fd, remainingBytes })); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make")(function* () { const config = yield* ServerConfig; const serverSettings = yield* ServerSettingsService; diff --git a/apps/server/src/resourceTelemetry/Model.ts b/apps/server/src/resourceTelemetry/Model.ts index a198c4b311f7..2582d7e9c17f 100644 --- a/apps/server/src/resourceTelemetry/Model.ts +++ b/apps/server/src/resourceTelemetry/Model.ts @@ -68,7 +68,7 @@ export interface MergeProcessesResult { readonly deltas: ReadonlyArray; } -export const emptyGroupCounters = (): GroupCounters => ({ +const emptyGroupCounters = (): GroupCounters => ({ cpuTimeMs: 0, ioReadBytes: 0, ioWriteBytes: 0, diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts index aafa88cc9225..40254bfe80aa 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -360,6 +360,7 @@ export function canCommandNativeTelemetrySidecar( return hasHandle && (status === "healthy" || status === "degraded"); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(function* () { const binary = yield* ResourceMonitorBinary.ResourceMonitorBinary; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts index d53fb4791b93..52c123fe6625 100644 --- a/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts +++ b/apps/server/src/resourceTelemetry/ResourceMonitorBinary.ts @@ -90,7 +90,7 @@ export const ResourceMonitorHostLinuxLibc = Context.Reference()("t3/review/ReviewService") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 349644966f26..fd8ee4a4f699 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -564,7 +564,7 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(httpCompressionLayer), ); -export const makeServerLayer = Layer.unwrap( +const makeServerLayer = Layer.unwrap( Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const activation = yield* Deferred.make(); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 1751a130814e..3d04abaa1914 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -147,7 +147,7 @@ export const makeCommandGate = Effect.gen(function* () { } satisfies CommandGate; }); -export const recordStartupHeartbeat = Effect.gen(function* () { +const recordStartupHeartbeat = Effect.gen(function* () { const analytics = yield* AnalyticsService.AnalyticsService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; @@ -466,7 +466,7 @@ const clearContinuationMarkers = ( { concurrency: "unbounded", discard: true }, ); -export const clearProviderSessionContinuationMarkers = (threadIds: ReadonlyArray) => +const clearProviderSessionContinuationMarkers = (threadIds: ReadonlyArray) => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; yield* clearContinuationMarkers(directory, threadIds); @@ -803,6 +803,7 @@ export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( ); }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = (options?: StartupOptions) => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index fe72147dfa0c..0b64d445adf8 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -142,7 +142,7 @@ function providerEnvironmentSecretName(input: { */ const USAGE_LIMIT_SOURCE_KEY_REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022"; -export function usageLimitSourceSecretName(sourceId: string): string { +function usageLimitSourceSecretName(sourceId: string): string { return `usage-limit-source-${Buffer.from(sourceId, "utf8").toString("base64url")}`; } diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.ts b/apps/server/src/sourceControl/AzureDevOpsCli.ts index b0018c1fc4c1..f5e436ef6c56 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.ts @@ -203,8 +203,6 @@ export const AzureDevOpsCliError = Schema.Union([ ]); export type AzureDevOpsCliError = typeof AzureDevOpsCliError.Type; -export const isAzureDevOpsCliError = Schema.is(AzureDevOpsCliError); - export interface AzureDevOpsRepositoryCloneUrls { readonly nameWithOwner: string; readonly url: string; @@ -352,6 +350,7 @@ function decodeAzureDevOpsJson( ); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 20a74cc8a5d7..c2f6a5efc65a 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -1,5 +1,4 @@ import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; import * as AzureDevOpsCli from "./AzureDevOpsCli.ts"; @@ -235,5 +234,3 @@ export const make = Effect.gen(function* () { ), }); }); - -export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index b30e934f8ce6..1223f95f68fd 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -262,7 +262,7 @@ export const BitbucketApiError = Schema.Union([ BitbucketCheckoutError, ]); export type BitbucketApiError = typeof BitbucketApiError.Type; -export const isBitbucketApiError = Schema.is(BitbucketApiError); +const isBitbucketApiError = Schema.is(BitbucketApiError); const RawBitbucketRepositorySchema = Schema.Struct({ full_name: TrimmedNonEmptyString, @@ -606,6 +606,7 @@ function responseError( }); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const config = yield* BitbucketApiEnvConfig; const httpClient = yield* HttpClient.HttpClient; diff --git a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts index ffa3fb1301c9..e27aa4c7dc88 100644 --- a/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts +++ b/apps/server/src/sourceControl/BitbucketSourceControlProvider.ts @@ -1,5 +1,4 @@ import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; @@ -186,8 +185,6 @@ export const make = Effect.gen(function* () { }); }); -export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); - export const makeDiscovery = Effect.gen(function* () { const bitbucket = yield* BitbucketApi.BitbucketApi; diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 57b9ee6e7a63..cd8782c87d9e 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -338,6 +338,7 @@ function deriveRepositoryCloneUrlsFromCreateOutput( }; } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 74f08a9a9127..d82832564a24 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -1,6 +1,5 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import { @@ -322,5 +321,3 @@ export const make = Effect.gen(function* () { ), }); }); - -export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index 9c9b1d4bedd0..9d03e1ab8aa5 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -237,7 +237,6 @@ export const GitLabCliError = Schema.Union([ GitLabNamespaceDecodeError, ]); export type GitLabCliError = typeof GitLabCliError.Type; -export const isGitLabCliError = Schema.is(GitLabCliError); export interface GitLabMergeRequestSummary { readonly number: number; @@ -409,6 +408,7 @@ function parseRepositoryPath(repository: string): { return { namespacePath, projectPath }; } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 28211c6b8509..5eb9b326423f 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -1,5 +1,4 @@ import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; @@ -250,5 +249,3 @@ export const make = Effect.gen(function* () { ), }); }); - -export const layer = Layer.effect(SourceControlProvider.SourceControlProvider, make); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index 660f32283e0f..2628360780e1 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -64,6 +64,7 @@ export class SourceControlDiscovery extends Context.Service< } >()("t3/sourceControl/SourceControlDiscovery") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const config = yield* ServerConfig; const process = yield* VcsProcess.VcsProcess; diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index 5f93dbcaa425..1844e1bfa7cb 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -61,7 +61,7 @@ export function parseSourceControlOwnerRef( return owner && refName ? { owner, refName } : undefined; } -export function normalizeSourceBranch(headSelector: string): string { +function normalizeSourceBranch(headSelector: string): string { return parseSourceControlOwnerRef(headSelector)?.refName ?? headSelector.trim(); } diff --git a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts index b2b9e4513378..69ac90edbfb3 100644 --- a/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlProviderDiscovery.ts @@ -115,7 +115,7 @@ export function providerAuth(input: { }; } -export function unknownAuth(detail?: string): SourceControlProviderAuth { +function unknownAuth(detail?: string): SourceControlProviderAuth { return providerAuth({ status: "unknown", detail }); } diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 9fe089a4184c..e9b61c17a4f4 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -194,6 +194,7 @@ function bindProviderContext( }); } +/** @public Service construction is part of the canonical Effect module API. */ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWithProviders")( function* (registrations: ReadonlyArray) { const config = yield* ServerConfig; diff --git a/apps/server/src/sourceControl/SourceControlRateLimit.ts b/apps/server/src/sourceControl/SourceControlRateLimit.ts index e71516946e7c..b936c456079b 100644 --- a/apps/server/src/sourceControl/SourceControlRateLimit.ts +++ b/apps/server/src/sourceControl/SourceControlRateLimit.ts @@ -82,6 +82,7 @@ export function retryAtFromHeader(value: string | undefined, now: number): numbe return Number.isFinite(retryAt) && retryAt > now ? retryAt : undefined; } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const entries = yield* Ref.make>(new Map()); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index b38fe3d5c302..0addeca9e785 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -77,6 +77,7 @@ function selectRemoteUrl( } } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const config = yield* ServerConfig; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts index 24c0e49fd8f4..d11d1a87077a 100644 --- a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts +++ b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts @@ -5,7 +5,7 @@ import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; -import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; export interface NormalizedAzureDevOpsPullRequestRecord { readonly number: number; @@ -190,8 +190,6 @@ const decodeAzureDevOpsPullRequestList = decodeJsonResult(Schema.Array(Schema.Un const decodeAzureDevOpsPullRequest = decodeJsonResult(AzureDevOpsPullRequestSchema); const decodeAzureDevOpsPullRequestEntry = Schema.decodeUnknownExit(AzureDevOpsPullRequestSchema); -export const formatAzureDevOpsJsonDecodeError = formatSchemaError; - export function decodeAzureDevOpsPullRequestListJson( raw: string, ): Result.Result< diff --git a/apps/server/src/sourceControl/gitHubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts index 822de1e02797..b233d900adf1 100644 --- a/apps/server/src/sourceControl/gitHubPullRequests.ts +++ b/apps/server/src/sourceControl/gitHubPullRequests.ts @@ -5,7 +5,7 @@ import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; -import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; export interface NormalizedGitHubPullRequestRecord { readonly number: number; @@ -114,8 +114,6 @@ const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown const decodeGitHubPullRequest = decodeJsonResult(GitHubPullRequestSchema); const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema); -export const formatGitHubJsonDecodeError = formatSchemaError; - export function decodeGitHubPullRequestListJson( raw: string, ): Result.Result< diff --git a/apps/server/src/sourceControl/gitLabMergeRequests.ts b/apps/server/src/sourceControl/gitLabMergeRequests.ts index 0525260df51b..d03d43411224 100644 --- a/apps/server/src/sourceControl/gitLabMergeRequests.ts +++ b/apps/server/src/sourceControl/gitLabMergeRequests.ts @@ -5,7 +5,7 @@ import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts"; -import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson"; +import { decodeJsonResult } from "@t3tools/shared/schemaJson"; export interface NormalizedGitLabMergeRequestRecord { readonly number: number; @@ -129,8 +129,6 @@ const decodeGitLabMergeRequestList = decodeJsonResult(Schema.Array(Schema.Unknow const decodeGitLabMergeRequest = decodeJsonResult(GitLabMergeRequestSchema); const decodeGitLabMergeRequestEntry = Schema.decodeUnknownExit(GitLabMergeRequestSchema); -export const formatGitLabJsonDecodeError = formatSchemaError; - export function decodeGitLabMergeRequestListJson( raw: string, ): Result.Result< diff --git a/apps/server/src/sourceControl/githubGraphQlBudget.ts b/apps/server/src/sourceControl/githubGraphQlBudget.ts index 9ece27547f5e..05e4b3a5ce24 100644 --- a/apps/server/src/sourceControl/githubGraphQlBudget.ts +++ b/apps/server/src/sourceControl/githubGraphQlBudget.ts @@ -77,6 +77,7 @@ function withRateLimit(document: string): string { return `${document.slice(0, end)}\n ${RATE_LIMIT_SELECTION}\n${document.slice(end)}`; } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const snapshots = yield* Ref.make>(new Map()); diff --git a/apps/server/src/telemetry/AnalyticsService.ts b/apps/server/src/telemetry/AnalyticsService.ts index 423ba8d86f7d..b2e737fe95a4 100644 --- a/apps/server/src/telemetry/AnalyticsService.ts +++ b/apps/server/src/telemetry/AnalyticsService.ts @@ -67,7 +67,7 @@ export class AnalyticsService extends Context.Service< ); } -export function serverOsFromNodePlatform(platform: string): ClientOs { +function serverOsFromNodePlatform(platform: string): ClientOs { switch (platform) { case "darwin": return "macOS"; @@ -82,6 +82,7 @@ export function serverOsFromNodePlatform(platform: string): ClientOs { } } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const telemetryConfig = yield* TelemetryEnvConfig; const httpClient = yield* HttpClient.HttpClient; diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 82664f666f53..174ed4206afc 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1385,6 +1385,7 @@ export const resolveProviderInstanceTerminalEnvironment = Effect.fn( ); }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("TerminalManager.make")(function* () { const { terminalLogsDir } = yield* ServerConfig.ServerConfig; const ptyAdapter = yield* PtyAdapter.PtyAdapter; diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index cc9b2e3926f3..84730d639ac6 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -153,6 +153,7 @@ export const makeTextGenerationFromRegistry = ( ), }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const registry = yield* ProviderInstanceRegistry.ProviderInstanceRegistry; return makeTextGenerationFromRegistry(registry); diff --git a/apps/server/src/textGeneration/TextGenerationPresets.ts b/apps/server/src/textGeneration/TextGenerationPresets.ts index 0f5d03480f49..5fab9219080f 100644 --- a/apps/server/src/textGeneration/TextGenerationPresets.ts +++ b/apps/server/src/textGeneration/TextGenerationPresets.ts @@ -1,10 +1,5 @@ import type { TextGenerationPolicy } from "./TextGenerationPolicy.ts"; -export const defaultTextGenerationPolicy: TextGenerationPolicy = { - kind: "default", - inferRepositoryConventions: false, -}; - export const conventionalCommitsTextGenerationPolicy: TextGenerationPolicy = { kind: "conventional_commits", commitInstructions: diff --git a/apps/server/src/usage/UsageLimitSources.ts b/apps/server/src/usage/UsageLimitSources.ts index a63651f93c21..957a6ab3321e 100644 --- a/apps/server/src/usage/UsageLimitSources.ts +++ b/apps/server/src/usage/UsageLimitSources.ts @@ -60,6 +60,7 @@ function sourceLabel(id: string, config: UsageLimitSourceConfig): string { } } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const api = yield* makeCliproxyApi; const settingsService = yield* ServerSettingsService; diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 01a1195efb60..2ad3893ad4e6 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -23,7 +23,7 @@ import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts"; * `en-CA` yields ISO-ordered parts, which is why it is used here rather than * assembling the day from `Date` getters (those are host-local only). */ -export function makeDayFormatter(timeZone: string): (timestampMs: number) => string { +function makeDayFormatter(timeZone: string): (timestampMs: number) => string { let format: Intl.DateTimeFormat; try { format = new Intl.DateTimeFormat("en-CA", { diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 102058a07d35..224f109147e4 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -26,7 +26,7 @@ import type { CodexScanState, UsageRecord } from "./usageTranscripts.ts"; // entries would keep serving double-counted records forever. // v3: entries carry the parse position and reducer state so a grown file // re-parses only its appended bytes instead of starting over. -export const USAGE_SCAN_CACHE_VERSION = 3 as const; +const USAGE_SCAN_CACHE_VERSION = 3 as const; export interface CachedFile { readonly size: number; diff --git a/apps/server/src/usage/usageTranscripts.ts b/apps/server/src/usage/usageTranscripts.ts index 2aea60709666..5d909379eb10 100644 --- a/apps/server/src/usage/usageTranscripts.ts +++ b/apps/server/src/usage/usageTranscripts.ts @@ -79,7 +79,7 @@ export function mightCarryUsage(line: string, provider: UsageProviderKind): bool */ export const GROK_COST_USD_TICKS_PER_DOLLAR = 10_000_000_000; -export function grokCostTicksToUsd(ticks: unknown): number | null { +function grokCostTicksToUsd(ticks: unknown): number | null { if (typeof ticks !== "number" || !Number.isFinite(ticks) || ticks < 0) return null; return ticks / GROK_COST_USD_TICKS_PER_DOLLAR; } diff --git a/apps/server/src/vcs/VcsProjectConfig.ts b/apps/server/src/vcs/VcsProjectConfig.ts index 98e65751185f..74d3801c0686 100644 --- a/apps/server/src/vcs/VcsProjectConfig.ts +++ b/apps/server/src/vcs/VcsProjectConfig.ts @@ -64,6 +64,7 @@ const logVcsProjectConfigError = (error: VcsProjectConfigError) => }), ); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/server/src/vcs/VcsProvisioningService.ts b/apps/server/src/vcs/VcsProvisioningService.ts index 9febacf2256a..e58099b615db 100644 --- a/apps/server/src/vcs/VcsProvisioningService.ts +++ b/apps/server/src/vcs/VcsProvisioningService.ts @@ -35,6 +35,7 @@ function resolveRequestedKind( return Effect.succeed(kind); } +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const registry = yield* VcsDriverRegistry.VcsDriverRegistry; diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index c00a07f2a7a9..b9fc9e7ee3ab 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -217,6 +217,7 @@ const normalizeCwd = (cwd: string) => Effect.orElseSucceed(() => cwd), ); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const autoPullPolicy = yield* VcsAutoPullPolicy; const workflow = yield* GitWorkflowService.GitWorkflowService; diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 608d3092b6a9..4bdf4d45a8c9 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -128,6 +128,7 @@ const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(fu return path.resolve(expandHomePathWith(input.cwd, path), input.partialPath); }); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 5198f5e84f88..9ef3d4546d1d 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -132,6 +132,7 @@ export class WorkspaceFileSystem extends Context.Service< } >()("t3/workspace/WorkspaceFileSystem") {} +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 8a2548eb2f29..44a9c3397c6f 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -551,6 +551,8 @@ function parseWorkspaceSearchIndexKey(key: string): { * workspace root and variant. WorkspaceSearchIndexMap owns memoization and * idle cleanup; using a default cwd here would mix resources from different * workspaces. + * + * @public Service construction is part of the canonical Effect module API. */ export const layer = (key: string) => { const { cwd, variant } = parseWorkspaceSearchIndexKey(key); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 37038e241154..5f339b0147dc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -189,6 +189,10 @@ import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; import { subscribePreviewAction } from "./preview/previewActionBus"; import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; +import { + isSameSidebarThreadRef, + useSidebarPendingFileDropStore, +} from "../sidebarPendingFileDropStore"; import { selectThreadPreviewMiniPlayer, usePreviewMiniPlayerStore, @@ -7867,6 +7871,53 @@ export default function ChatView(props: ChatViewProps) { void onRevertToTurnCountRef.current(targetTurnCount); }, []); + // Files dropped on a sidebar row land here once the dropped-on thread is + // actually open, then take the exact same path as a workspace drop: + // validate, compress, focus the composer, never send. Kept above the + // no-active-thread early return so hook order never changes. + const pendingSidebarFileDrops = useSidebarPendingFileDropStore((state) => state.pending); + const consumePendingFileDrop = useSidebarPendingFileDropStore( + (state) => state.consumePendingFileDrop, + ); + useEffect(() => { + if (pendingSidebarFileDrops.length === 0) return; + // A promoting draft can mount this view with the server thread id while + // its composer is still draft-keyed; finalization would discard what we + // attach there. Only the canonical thread target may consume a drop. + if ( + typeof composerDraftTarget === "string" || + !pendingSidebarFileDrops.some((drop) => + isSameSidebarThreadRef(composerDraftTarget, drop.threadRef), + ) + ) { + return; + } + if (!activeThread) return; + if (!composerRef.current) { + const raf = window.requestAnimationFrame(() => { + if (!composerRef.current) return; + if (typeof composerDraftTarget === "string") return; + // Consume matches by target, so a newer drop that arrived meanwhile + // is collected too rather than orphaned. + const files = consumePendingFileDrop(composerDraftTarget); + if (files !== null) { + composerRef.current?.addDroppedFiles(files); + } + }); + return () => window.cancelAnimationFrame(raf); + } + const files = consumePendingFileDrop(composerDraftTarget); + if (files !== null) { + composerRef.current.addDroppedFiles(files); + } + }, [ + activeThread, + composerDraftTarget, + composerRef, + consumePendingFileDrop, + pendingSidebarFileDrops, + ]); + // Empty state: no active thread if (!activeThread) { return ; @@ -8093,10 +8144,7 @@ export default function ChatView(props: ChatViewProps) { {...(routeKind === "draft" && draftId ? { draftId } : {})} activeThreadTitle={activeThread.title} isServerThread={isServerThread} - activeProjectName={activeProject?.title} - activeProjectCwd={activeProject?.workspaceRoot ?? null} - activeProjectFaviconPath={activeProject?.faviconPath ?? null} - activeProjectIcon={activeProject?.projectIcon ?? null} + activeProject={activeProject} openInCwd={gitCwd} activeProjectScripts={activeProjectScripts} preferredScriptId={ diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index b11d88b764b1..5ec9872597ef 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -1,8 +1,9 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; -import type { Thread } from "../types"; +import type { Project, Thread } from "../types"; import { buildBrowseGroups, + buildProjectActionItems, buildThreadActionItems, enumerateCommandPaletteItems, filterPinnedBrowseEntries, @@ -112,6 +113,20 @@ describe("enumerateCommandPaletteItems", () => { const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const PROJECT_ID = ProjectId.make("project-1"); +function makeProject(overrides: Partial = {}): Project { + return { + id: PROJECT_ID, + environmentId: LOCAL_ENVIRONMENT_ID, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + ...overrides, + }; +} + function makeThread(overrides: Partial = {}): Thread { return { id: ThreadId.make("thread-1"), @@ -139,6 +154,28 @@ function makeThread(overrides: Partial = {}): Thread { }; } +describe("buildProjectActionItems", () => { + it("shows the grouped display name but keeps the real title for icons", () => { + const project = makeProject({ title: "fleet", workspaceRoot: "/Users/theo/Code/p/fleet" }); + const iconTitles: string[] = []; + const [item] = buildProjectActionItems({ + projects: [{ ...project, displayName: "t3dotgg/fleet" }], + valuePrefix: "project", + icon: (candidate) => { + iconTitles.push(candidate.title); + return null; + }, + runProject: async () => undefined, + }); + + expect(item?.title).toBe("t3dotgg/fleet"); + expect(item?.searchTerms).toEqual( + expect.arrayContaining(["t3dotgg/fleet", "fleet", "/Users/theo/Code/p/fleet"]), + ); + expect(iconTitles).toEqual(["fleet"]); + }); +}); + describe("buildThreadActionItems", () => { it("orders threads by most recent activity and formats timestamps from updatedAt", () => { vi.useFakeTimers(); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 2492ca0cf986..e91de65d5ca9 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -139,20 +139,31 @@ export function enumerateCommandPaletteItems( export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; +// A project as the palette shows it. `displayName` is the grouped label (for +// example "owner/repo" when projects are merged across machines). Keep `title` +// as the real project title: the automatic project icon is derived from it, and +// every other surface uses the real title, so overriding it desyncs the icon. +export type CommandPaletteProject = Project & { readonly displayName: string }; + export function buildProjectActionItems(input: { - projects: ReadonlyArray; + projects: ReadonlyArray; valuePrefix: string; - icon: (project: Project) => ReactNode; - runProject: (project: Project) => Promise; - searchTerms?: (project: Project) => ReadonlyArray; - renderDescription?: (project: Project) => ReactNode; + icon: (project: CommandPaletteProject) => ReactNode; + runProject: (project: CommandPaletteProject) => Promise; + searchTerms?: (project: CommandPaletteProject) => ReadonlyArray; + renderDescription?: (project: CommandPaletteProject) => ReactNode; shortcutCommand?: KeybindingCommand; }): CommandPaletteActionItem[] { return input.projects.map((project) => ({ kind: "action", value: `${input.valuePrefix}:${project.environmentId}:${project.id}`, - searchTerms: [project.title, project.workspaceRoot, ...(input.searchTerms?.(project) ?? [])], - title: project.title, + searchTerms: [ + project.displayName, + project.title, + project.workspaceRoot, + ...(input.searchTerms?.(project) ?? []), + ], + title: project.displayName, description: input.renderDescription?.(project) ?? project.workspaceRoot, icon: input.icon(project), ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index fcf86362707e..d146120f719d 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -178,16 +178,7 @@ import type { Project } from "../types"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; function projectFavicon(project: Project) { - return ( - - ); + return ; } function getEnvironmentBrowsePlatform(os: string | null | undefined): string { @@ -794,7 +785,7 @@ function OpenCommandPaletteDialog(props: { () => projectPickerEntries.map(({ group, targetProject }) => ({ ...targetProject, - title: group.displayName, + displayName: group.displayName, })), [projectPickerEntries], ); @@ -927,18 +918,8 @@ function OpenCommandPaletteDialog(props: { new Map(projects.map((project) => [project.id, project.workspaceRoot])), [projects], ); - const projectFaviconPathById = useMemo( - () => new Map(projects.map((project) => [project.id, project.faviconPath ?? null] as const)), - [projects], - ); - const projectIconByKey = useMemo( - () => - new Map( - projects.map( - (project) => - [`${project.environmentId}:${project.id}`, project.projectIcon ?? null] as const, - ), - ), + const projectByKey = useMemo( + () => new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project])), [projects], ); const projectTitleById = useMemo( @@ -1174,12 +1155,7 @@ function OpenCommandPaletteDialog(props: { ) ?? null; return ( void; - navigateToThread: (threadRef: ScopedThreadRef) => void; + navigateToThread: (threadRef: ScopedThreadRef) => Promise; handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; handleThreadContextMenu: ( threadRef: ScopedThreadRef, @@ -345,6 +347,7 @@ interface SidebarThreadRowProps { prUrl: string, threadRef?: ScopedThreadRef, ) => boolean; + onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; } const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { @@ -372,10 +375,28 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP cancelRename, attemptArchiveThread, openPrLink, + onFileDropThreads, thread, } = props; const threadRef = scopeThreadRef(thread.environmentId, thread.id); const threadKey = scopedThreadKey(threadRef); + const [isFileDragOver, setIsFileDragOver] = useState(false); + const fileDropHandlers = useMemo( + () => + makeWorkspaceFileDropHandlers({ + setDragActive: setIsFileDragOver, + addFiles: (files) => { + onFileDropThreads(threadRef, files); + }, + }), + [onFileDropThreads, threadRef], + ); + useEffect(() => { + if (!isFileDragOver) return; + const clearFileDrag = () => setIsFileDragOver(false); + window.addEventListener("dragend", clearFileDrag); + return () => window.removeEventListener("dragend", clearFileDrag); + }, [isFileDragOver]); const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(isActive); const lastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); @@ -663,6 +684,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ref={rowRef} className="w-full" data-thread-item + {...fileDropHandlers} onMouseLeave={handleMouseLeave} onBlurCapture={handleBlurCapture} > @@ -674,7 +696,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP className={`${resolveThreadRowClassName({ isActive, isSelected, - })} relative isolate`} + })} relative isolate${isFileDragOver ? " ring-1 ring-inset ring-primary/70" : ""}`} onClick={handleRowClick} onDoubleClick={handleRowDoubleClick} onKeyDown={handleRowKeyDown} @@ -916,7 +938,8 @@ interface SidebarProjectThreadListProps { threadRef: ScopedThreadRef, orderedProjectThreadKeys: readonly string[], ) => void; - navigateToThread: (threadRef: ScopedThreadRef) => void; + navigateToThread: (threadRef: ScopedThreadRef) => Promise; + onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; handleThreadContextMenu: ( threadRef: ScopedThreadRef, @@ -968,6 +991,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( attachThreadListAutoAnimateRef, handleThreadClick, navigateToThread, + onFileDropThreads, handleMultiSelectContextMenu, handleThreadContextMenu, clearSelection, @@ -1019,6 +1043,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( confirmArchiveButtonRefs={confirmArchiveButtonRefs} handleThreadClick={handleThreadClick} navigateToThread={navigateToThread} + onFileDropThreads={onFileDropThreads} handleMultiSelectContextMenu={handleMultiSelectContextMenu} handleThreadContextMenu={handleThreadContextMenu} clearSelection={clearSelection} @@ -1136,6 +1161,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec (settings) => settings.sidebarThreadPreviewCount, ); const router = useRouter(); + const queuePendingFileDrop = useSidebarPendingFileDropStore((s) => s.queuePendingFileDrop); + const clearPendingFileDrop = useSidebarPendingFileDropStore((s) => s.clearPendingFileDrop); const { isMobile, setOpenMobile } = useSidebar(); const markThreadUnread = useUiStateStore((state) => state.markThreadUnread); const setProjectExpanded = useUiStateStore((state) => state.setProjectExpanded); @@ -1740,13 +1767,32 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (isMobile) { setOpenMobile(false); } - void router.navigate({ + return router.navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), }); }, [clearSelection, isMobile, router, setOpenMobile, setSelectionAnchor], ); + const handleThreadFileDrop = useCallback( + async (threadRef: ScopedThreadRef, files: File[]) => { + const dropId = queuePendingFileDrop({ threadRef, files }); + const targetPathname = router.buildLocation({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(threadRef), + }).pathname; + if (targetPathname === router.state.location.pathname) return; + try { + await navigateToThread(threadRef); + if (targetPathname !== router.state.location.pathname) { + clearPendingFileDrop(dropId); + } + } catch { + clearPendingFileDrop(dropId); + } + }, + [clearPendingFileDrop, navigateToThread, queuePendingFileDrop, router], + ); const handleThreadClick = useCallback( ( @@ -2322,13 +2368,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec /> )} - + @@ -2415,6 +2455,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec attachThreadListAutoAnimateRef={attachThreadListAutoAnimateRef} handleThreadClick={handleThreadClick} navigateToThread={navigateToThread} + onFileDropThreads={handleThreadFileDrop} handleMultiSelectContextMenu={handleMultiSelectContextMenu} handleThreadContextMenu={handleThreadContextMenu} clearSelection={clearSelection} diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index 98458cfa2dcd..854301bee7bc 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -66,7 +66,14 @@ vi.mock("../state/assets", () => ({ }, })); -import { ProjectFavicon } from "./ProjectFavicon"; +import { ProjectFavicon, type ProjectFaviconProject } from "./ProjectFavicon"; + +function makeProject( + overrides: Partial & + Pick, +): ProjectFaviconProject { + return { environmentId: "environment-test" as EnvironmentId, ...overrides }; +} type ProjectFaviconImageProps = { readonly cacheKey: string; @@ -91,9 +98,7 @@ function resolveImageComponent(): { } { hooks.beginRender(); const element = ProjectFavicon({ - environmentId: "environment-test" as EnvironmentId, - cwd: "/workspace-test", - projectName: "workspace-test", + project: makeProject({ workspaceRoot: "/workspace-test", title: "workspace-test" }), }) as ReactElement; hooks.reset(); @@ -121,9 +126,7 @@ describe("ProjectFavicon", () => { testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; const element = ProjectFavicon({ - environmentId: "environment-test" as EnvironmentId, - cwd: "/workspace/analytics-db", - projectName: "analytics-db", + project: makeProject({ workspaceRoot: "/workspace/analytics-db", title: "analytics-db" }), }) as ReactElement<{ readonly colorClassName?: string; readonly emoji?: string; @@ -139,9 +142,7 @@ describe("ProjectFavicon", () => { testState.faviconUrl = `https://environment.test/api/assets/token/${PROJECT_FAVICON_FALLBACK_MARKER}`; const element = ProjectFavicon({ - environmentId: "environment-test" as EnvironmentId, - cwd: "/workspace/agent-runtime", - projectName: "agent-runtime", + project: makeProject({ workspaceRoot: "/workspace/agent-runtime", title: "agent-runtime" }), }) as ReactElement<{ readonly colorClassName?: string; readonly emoji?: string; @@ -155,11 +156,12 @@ describe("ProjectFavicon", () => { it("renders a saved Lucide icon and color ahead of an uploaded favicon", () => { const element = ProjectFavicon({ - environmentId: "environment-test" as EnvironmentId, - cwd: "/workspace/test", - projectName: "test", - faviconPath: "brand/icon.svg", - projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" }, + project: makeProject({ + workspaceRoot: "/workspace/test", + title: "test", + faviconPath: "brand/icon.svg", + projectIcon: { kind: "lucide", name: "alarm-clock", color: "violet" }, + }), }) as ReactElement<{ readonly children: ReactElement<{ readonly children: ReactElement<{ readonly name: string; readonly className: string }>; @@ -174,11 +176,12 @@ describe("ProjectFavicon", () => { it("renders a saved emoji ahead of an uploaded favicon", () => { const element = ProjectFavicon({ - environmentId: "environment-test" as EnvironmentId, - cwd: "/workspace/test", - projectName: "test", - faviconPath: "brand/icon.svg", - projectIcon: { kind: "emoji", emoji: "🦄" }, + project: makeProject({ + workspaceRoot: "/workspace/test", + title: "test", + faviconPath: "brand/icon.svg", + projectIcon: { kind: "emoji", emoji: "🦄" }, + }), }) as ReactElement<{ readonly emoji: string }>; expect(element.props.emoji).toBe("🦄"); @@ -208,10 +211,11 @@ describe("ProjectFavicon", () => { it("requests a saved favicon path when one is set", () => { ProjectFavicon({ - environmentId: "environment-test" as EnvironmentId, - cwd: "/workspace-test", - projectName: "workspace-test", - faviconPath: "brand/icon.svg", + project: makeProject({ + workspaceRoot: "/workspace-test", + title: "workspace-test", + faviconPath: "brand/icon.svg", + }), }); expect(testState.lastTarget).toMatchObject({ diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index c121f8b44940..15597e786c31 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,4 +1,5 @@ -import type { EnvironmentId, ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; +import type { ProjectIconColor } from "@t3tools/contracts"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { getProjectFaviconResourceKey, isProjectFaviconFallbackUrl, @@ -94,21 +95,33 @@ const PROJECT_ICON_COLOR_BY_NAME: Record = { web: "sky", }; +// The slice of a project that decides its icon. Every surface must pass the +// project record itself (or a snapshot spread from it) so the saved title, favicon +// and icon override always travel together. Passing a display label as the title +// changes the automatic icon, which is how the command palette drifted once. +export type ProjectFaviconProject = Pick< + EnvironmentProject, + "environmentId" | "workspaceRoot" | "title" | "faviconPath" | "projectIcon" +>; + export function ProjectFavicon(input: { - environmentId: EnvironmentId; - cwd: string; - projectName: string; - faviconPath?: string | null | undefined; - projectIcon?: ProjectIconOverride | null | undefined; + project: ProjectFaviconProject; className?: string | undefined; fallbackIcon?: ComponentType<{ className?: string }>; }) { - const src = useAtomValue(projectFaviconUrlAtom(input)); - if (input.projectIcon?.kind === "emoji") { - return ; + const { project } = input; + const src = useAtomValue( + projectFaviconUrlAtom({ + environmentId: project.environmentId, + cwd: project.workspaceRoot, + faviconPath: project.faviconPath, + }), + ); + if (project.projectIcon?.kind === "emoji") { + return ; } - if (input.projectIcon?.kind === "lucide") { - const colorClassName = projectIconColorClassName(input.projectIcon.color); + if (project.projectIcon?.kind === "lucide") { + const colorClassName = projectIconColorClassName(project.projectIcon.color); const iconClassName = cn( "inline-flex size-3.5 shrink-0 items-center justify-center", colorClassName, @@ -118,7 +131,7 @@ export function ProjectFavicon(input: {