From 68ddf7285757518dcaea07e79fb136d9a202c520 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 12 Jun 2026 12:08:54 +0100 Subject: [PATCH 01/55] Add provider skill listing to workspace - Propagate cwd through provider status probes - Add server RPC for workspace skill discovery - Load workspace skills in the composer UI --- .agents/skills/bogus/SKILL.md | 6 + .../src/provider/Drivers/CodexDriver.ts | 8 +- .../src/provider/Drivers/CursorDriver.ts | 7 +- .../server/src/provider/Drivers/GrokDriver.ts | 7 +- .../src/provider/Layers/CodexProvider.ts | 38 ++++- .../provider/Layers/CursorProvider.test.ts | 31 ++-- .../src/provider/Layers/CursorProvider.ts | 16 ++- .../src/provider/Layers/GrokProvider.test.ts | 3 + .../src/provider/Layers/GrokProvider.ts | 6 +- .../provider/Layers/ProviderRegistry.test.ts | 74 +++++----- apps/server/src/ws.ts | 99 +++++++++++++ apps/web/src/components/chat/ChatComposer.tsx | 24 +++- .../environments/runtime/connection.test.ts | 1 + .../runtime/service.savedEnvironments.test.ts | 1 + .../service.threadSubscriptions.test.ts | 1 + .../src/lib/providerWorkspaceSkillsState.ts | 135 ++++++++++++++++++ apps/web/src/localApi.ts | 4 + packages/client-runtime/src/wsRpcClient.ts | 3 + packages/contracts/src/ipc.ts | 5 + packages/contracts/src/rpc.ts | 11 ++ packages/contracts/src/server.ts | 19 +++ 21 files changed, 440 insertions(+), 59 deletions(-) create mode 100644 .agents/skills/bogus/SKILL.md create mode 100644 apps/web/src/lib/providerWorkspaceSkillsState.ts diff --git a/.agents/skills/bogus/SKILL.md b/.agents/skills/bogus/SKILL.md new file mode 100644 index 00000000000..506636b7b26 --- /dev/null +++ b/.agents/skills/bogus/SKILL.md @@ -0,0 +1,6 @@ +--- +name: bogus +description: A useless skill to test only skill discovery. Can be committed, will remove manually before merging to main. +--- + +Nothing. diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 441edda479f..984ca6c1bc3 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -112,6 +112,7 @@ export const CodexDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const eventLoggers = yield* ProviderEventLoggers; + const serverConfig = yield* ServerConfig; const processEnv = mergeProviderInstanceEnvironment(environment); const homeLayout = yield* resolveCodexHomeLayout(config); const continuationIdentity = codexContinuationIdentity(homeLayout); @@ -159,7 +160,12 @@ export const CodexDriver: ProviderDriver = { // in as instance rebuilds from the registry rather than in-place // updates. Pre-provide `ChildProcessSpawner` so the check fits // `makeManagedServerProvider.checkProvider`'s `R = never`. - const checkProvider = checkCodexProviderStatus(effectiveConfig, undefined, processEnv).pipe( + const checkProvider = checkCodexProviderStatus( + effectiveConfig, + serverConfig.cwd, + undefined, + processEnv, + ).pipe( Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index ba532864c45..e9eb1011a45 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -99,6 +99,7 @@ export const CursorDriver: ProviderDriver = { const path = yield* Path.Path; const httpClient = yield* HttpClient.HttpClient; const eventLoggers = yield* ProviderEventLoggers; + const serverConfig = yield* ServerConfig; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -123,7 +124,11 @@ export const CursorDriver: ProviderDriver = { }); const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe( + const checkProvider = checkCursorProviderStatus( + effectiveConfig, + serverConfig.cwd, + processEnv, + ).pipe( Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index ab01439ffd3..ef5871437ef 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -81,6 +81,7 @@ export const GrokDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const eventLoggers = yield* ProviderEventLoggers; + const serverConfig = yield* ServerConfig; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -105,7 +106,11 @@ export const GrokDriver: ProviderDriver = { }); const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe( + const checkProvider = checkGrokProviderStatus( + effectiveConfig, + serverConfig.cwd, + processEnv, + ).pipe( Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 89d7421b232..e01db62f7a0 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -267,6 +267,41 @@ const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( return models; }); +export const listCodexProviderSkills = Effect.fn("listCodexProviderSkills")(function* (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv; +}) { + const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; + const clientContext = yield* Layer.build( + CodexClient.layerCommand({ + command: input.binaryPath, + args: ["app-server"], + cwd: input.cwd, + env: { + ...(input.environment ?? process.env), + ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), + }, + }), + ); + const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( + Effect.provide(clientContext), + ); + + yield* client.request("initialize", buildCodexInitializeParams()); + yield* client.notify("initialized", undefined); + const accountResponse = yield* client.request("account/read", {}); + if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { + return []; + } + + const response = yield* client.request("skills/list", { + cwds: [input.cwd], + }); + return parseCodexSkillsListResponse(response, input.cwd); +}); + export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { return { clientInfo: { @@ -438,6 +473,7 @@ function accountProbeStatus(account: CodexAppServerProviderSnapshot["account"]): export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(function* ( codexSettings: CodexSettings, + cwd: string, probe: (input: { readonly binaryPath: string; readonly homePath?: string; @@ -478,7 +514,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const probeResult = yield* probe({ binaryPath: codexSettings.binaryPath, homePath: codexSettings.homePath, - cwd: process.cwd(), + cwd, customModels: codexSettings.customModels, environment, }).pipe( diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 60a7312eea3..b7de1ee6cb4 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -422,6 +422,7 @@ describe("checkCursorProviderStatus", () => { apiEndpoint: "", customModels: [], }, + process.cwd(), { ...process.env, T3_ACP_REQUEST_LOG_PATH: requestLogPath, @@ -444,12 +445,15 @@ describe("discoverCursorModelsViaAcp", () => { const wrapperPath = await runNode(makeMockAgentWrapper()); const models = await Effect.runPromise( - discoverCursorModelsViaAcp({ - enabled: true, - binaryPath: wrapperPath, - apiEndpoint: "", - customModels: [], - }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + discoverCursorModelsViaAcp( + { + enabled: true, + binaryPath: wrapperPath, + apiEndpoint: "", + customModels: [], + }, + process.cwd(), + ).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); expect(models.map((model) => model.slug)).toEqual([ @@ -466,12 +470,15 @@ describe("discoverCursorModelsViaAcp", () => { ); await Effect.runPromise( - discoverCursorModelsViaAcp({ - enabled: true, - binaryPath: wrapperPath, - apiEndpoint: "", - customModels: [], - }).pipe(Effect.provide(NodeServices.layer)), + discoverCursorModelsViaAcp( + { + enabled: true, + binaryPath: wrapperPath, + apiEndpoint: "", + customModels: [], + }, + process.cwd(), + ).pipe(Effect.provide(NodeServices.layer)), ); const exitLog = await runNode(waitForFileContent(exitLogPath)); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index facdb5a5ff1..21cacc81e8a 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -394,6 +394,7 @@ function buildCursorDiscoveredModelsFromAvailableModelsResponse( const makeCursorAcpProbeRuntime = ( cursorSettings: CursorSettings, + cwd: string, environment: NodeJS.ProcessEnv = process.env, ) => Effect.gen(function* () { @@ -406,10 +407,10 @@ const makeCursorAcpProbeRuntime = ( ...(cursorSettings.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []), "acp", ], - cwd: process.cwd(), + cwd, env: environment, }, - cwd: process.cwd(), + cwd, clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, authMethodId: "cursor_login", clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, @@ -420,10 +421,11 @@ const makeCursorAcpProbeRuntime = ( const withCursorAcpProbeRuntime = ( cursorSettings: CursorSettings, + cwd: string, useRuntime: (acp: AcpSessionRuntime["Service"]) => Effect.Effect, environment: NodeJS.ProcessEnv = process.env, ) => - makeCursorAcpProbeRuntime(cursorSettings, environment).pipe( + makeCursorAcpProbeRuntime(cursorSettings, cwd, environment).pipe( Effect.flatMap(useRuntime), Effect.scoped, ); @@ -542,10 +544,12 @@ export function resolveCursorAcpConfigUpdates( const discoverCursorModelsViaListAvailableModels = ( cursorSettings: CursorSettings, + cwd: string, environment: NodeJS.ProcessEnv = process.env, ) => withCursorAcpProbeRuntime( cursorSettings, + cwd, (acp) => Effect.gen(function* () { yield* acp.start(); @@ -558,8 +562,9 @@ const discoverCursorModelsViaListAvailableModels = ( export const discoverCursorModelsViaAcp = ( cursorSettings: CursorSettings, + cwd: string, environment: NodeJS.ProcessEnv = process.env, -) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment); +) => discoverCursorModelsViaListAvailableModels(cursorSettings, cwd, environment); export function getCursorFallbackModels( cursorSettings: Pick, @@ -967,6 +972,7 @@ const runCursorAboutCommand = ( export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( cursorSettings: CursorSettings, + cwd: string, environment: NodeJS.ProcessEnv = process.env, ): Effect.fn.Return< ServerProviderDraft, @@ -1062,7 +1068,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( let discoveryWarning: string | undefined; if (parsed.auth.status !== "unauthenticated") { const discoveryExit = yield* Effect.exit( - discoverCursorModelsViaAcp(cursorSettings, environment).pipe( + discoverCursorModelsViaAcp(cursorSettings, cwd, environment).pipe( Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS), ), ); diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 75d0982565e..71f01fdbaf1 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -44,6 +44,7 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { enabled: true, binaryPath: "/definitely/not/installed/grok-binary", }), + process.cwd(), ); expect(snapshot.enabled).toBe(true); expect(snapshot.installed).toBe(false); @@ -68,6 +69,7 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { return yield* checkGrokProviderStatus( decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + process.cwd(), ); }), ); @@ -95,6 +97,7 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { return yield* checkGrokProviderStatus( decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + process.cwd(), ); }), ); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index bead8b1a407..dfce0b35111 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -131,6 +131,7 @@ function buildGrokDiscoveredModelsFromSessionModelState( const discoverGrokModelsViaAcp = ( grokSettings: GrokSettings, + cwd: string, environment: NodeJS.ProcessEnv = process.env, ) => Effect.gen(function* () { @@ -139,7 +140,7 @@ const discoverGrokModelsViaAcp = ( grokSettings, environment, childProcessSpawner, - cwd: process.cwd(), + cwd, clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, }); const started = yield* acp.start(); @@ -162,6 +163,7 @@ const runGrokVersionCommand = ( export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, + cwd: string, environment: NodeJS.ProcessEnv = process.env, ): Effect.fn.Return { const checkedAt = DateTime.formatIso(yield* DateTime.now); @@ -244,7 +246,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func }); } - const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe( + const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, cwd, environment).pipe( Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS), Effect.exit, ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 56b80f6c4a2..2a38a1db2df 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -304,21 +304,28 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T describe("checkCodexProviderStatus", () => { it.effect("uses the app-server account and model list for provider status", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - skills: [ - { - name: "github:gh-fix-ci", - path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", - enabled: true, - displayName: "CI Debug", - shortDescription: "Debug failing GitHub Actions checks", - }, - ], - }), - ), + let observedCwd: string | null = null; + const status = yield* checkCodexProviderStatus( + defaultCodexSettings, + "/tmp/t3-code-cwd", + (input) => { + observedCwd = input.cwd; + return Effect.succeed( + makeCodexProbeSnapshot({ + skills: [ + { + name: "github:gh-fix-ci", + path: "/Users/test/.codex/skills/gh-fix-ci/SKILL.md", + enabled: true, + displayName: "CI Debug", + shortDescription: "Debug failing GitHub Actions checks", + }, + ], + }), + ); + }, ); + assert.strictEqual(observedCwd, "/tmp/t3-code-cwd"); assert.strictEqual(status.status, "ready"); assert.strictEqual(status.installed, true); assert.strictEqual(status.version, "1.0.0"); @@ -348,7 +355,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T it.effect("returns unauthenticated when app-server requires OpenAI auth", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + const status = yield* checkCodexProviderStatus(defaultCodexSettings, process.cwd(), () => Effect.succeed( makeCodexProbeSnapshot({ account: { @@ -372,15 +379,18 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T "returns ready with unknown auth when app-server does not require OpenAI auth", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => - Effect.succeed( - makeCodexProbeSnapshot({ - account: { - account: null, - requiresOpenaiAuth: false, - }, - }), - ), + const status = yield* checkCodexProviderStatus( + defaultCodexSettings, + process.cwd(), + () => + Effect.succeed( + makeCodexProbeSnapshot({ + account: { + account: null, + requiresOpenaiAuth: false, + }, + }), + ), ); assert.strictEqual(status.status, "ready"); @@ -390,7 +400,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T it.effect("returns an api key label for codex api key auth", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + const status = yield* checkCodexProviderStatus(defaultCodexSettings, process.cwd(), () => Effect.succeed( makeCodexProbeSnapshot({ account: { @@ -410,7 +420,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T it.effect("returns an Amazon Bedrock label for codex Bedrock auth", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + const status = yield* checkCodexProviderStatus(defaultCodexSettings, process.cwd(), () => Effect.succeed( makeCodexProbeSnapshot({ account: { @@ -430,7 +440,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T it.effect("returns unavailable when codex is missing", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + const status = yield* checkCodexProviderStatus(defaultCodexSettings, process.cwd(), () => Effect.fail( new CodexErrors.CodexAppServerSpawnError({ command: "codex app-server", @@ -451,10 +461,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T it.effect("closes the app-server probe scope when provider status times out", () => Effect.gen(function* () { const killCalls = yield* Ref.make(0); - const statusFiber = yield* checkCodexProviderStatus(defaultCodexSettings).pipe( - Effect.provide(hangingScopedSpawnerLayer(killCalls)), - Effect.forkChild, - ); + const statusFiber = yield* checkCodexProviderStatus( + defaultCodexSettings, + process.cwd(), + ).pipe(Effect.provide(hangingScopedSpawnerLayer(killCalls)), Effect.forkChild); yield* Effect.yieldNow; yield* TestClock.adjust("11 seconds"); @@ -1421,7 +1431,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T it.effect("skips codex probes entirely when the provider is disabled", () => Effect.gen(function* () { - const status = yield* checkCodexProviderStatus(disabledCodexSettings).pipe( + const status = yield* checkCodexProviderStatus(disabledCodexSettings, process.cwd()).pipe( Effect.provide(failingSpawnerLayer("spawn codex ENOENT")), ); assert.strictEqual(status.enabled, false); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0f2a8f790bf..8de217f5cd9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -5,6 +5,8 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -40,6 +42,10 @@ import { OrchestrationReplayEventsError, FilesystemBrowseError, EnvironmentAuthorizationError, + CodexSettings, + ServerProviderSkillsListError, + type ServerProviderSkillsListResult, + type ProviderInstanceId, ThreadId, type TerminalAttachStreamEvent, type TerminalError, @@ -51,6 +57,7 @@ import { import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; import { ServerConfig } from "./config.ts"; @@ -65,6 +72,13 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; +import { deriveProviderInstanceConfigMap } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; +import { listCodexProviderSkills } from "./provider/Layers/CodexProvider.ts"; +import { + materializeCodexShadowHome, + resolveCodexHomeLayout, +} from "./provider/Drivers/CodexHomeLayout.ts"; +import { mergeProviderInstanceEnvironment } from "./provider/ProviderInstanceEnvironment.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; @@ -102,6 +116,7 @@ import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError); +const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -139,6 +154,7 @@ const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope], [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], + [WS_METHODS.serverListProviderSkills, AuthOrchestrationReadScope], [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], [WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope], [WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope], @@ -243,6 +259,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const providerRegistry = yield* ProviderRegistry; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const config = yield* ServerConfig; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; const startup = yield* ServerRuntimeStartup; @@ -267,6 +286,82 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const relayClient = yield* RelayClient.RelayClient; + const listProviderSkills = Effect.fn("ws.listProviderSkills")(function* (input: { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + }): Effect.fn.Return { + const providers = yield* providerRegistry.getProviders; + const snapshot = providers.find((provider) => provider.instanceId === input.instanceId); + if (!snapshot) { + return yield* new ServerProviderSkillsListError({ + message: `Provider instance '${input.instanceId}' was not found.`, + }); + } + if (snapshot.driver !== "codex") { + return { skills: snapshot.skills }; + } + + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: "Failed to read provider settings.", + cause, + }), + ), + ); + const instanceConfig = deriveProviderInstanceConfigMap(settings)[input.instanceId]; + if (!instanceConfig || instanceConfig.driver !== "codex") { + return yield* new ServerProviderSkillsListError({ + message: `Codex provider instance '${input.instanceId}' is not configured.`, + }); + } + + const decodedConfig = yield* decodeCodexSettings(instanceConfig.config ?? {}).pipe( + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: `Failed to decode Codex provider settings for '${input.instanceId}'.`, + cause, + }), + ), + ); + const effectiveConfig = { + ...decodedConfig, + enabled: instanceConfig.enabled ?? decodedConfig.enabled, + }; + const homeLayout = yield* resolveCodexHomeLayout(effectiveConfig).pipe( + Effect.provideService(Path.Path, path), + ); + yield* materializeCodexShadowHome(homeLayout).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: `Failed to prepare Codex home for '${input.instanceId}': ${cause.message}`, + cause, + }), + ), + ); + const skills = yield* listCodexProviderSkills({ + binaryPath: effectiveConfig.binaryPath, + ...(homeLayout.effectiveHomePath ? { homePath: homeLayout.effectiveHomePath } : {}), + cwd: input.cwd, + environment: mergeProviderInstanceEnvironment(instanceConfig.environment ?? []), + }).pipe( + Effect.scoped, + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: `Failed to list Codex skills for '${input.cwd}'.`, + cause, + }), + ), + ); + return { skills }; + }); const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ message: `The authenticated token is missing required scope: ${requiredScope}.`, @@ -1000,6 +1095,10 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ).pipe(Effect.map((providers) => ({ providers }))), { "rpc.aggregate": "server" }, ), + [WS_METHODS.serverListProviderSkills]: (input) => + observeRpcEffect(WS_METHODS.serverListProviderSkills, listProviderSkills(input), { + "rpc.aggregate": "server", + }), [WS_METHODS.serverUpdateProvider]: (input) => observeRpcEffect( WS_METHODS.serverUpdateProvider, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8d89ccdd396..12de8644679 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -53,6 +53,7 @@ import { removeInlineTerminalContextPlaceholder, } from "../../lib/terminalContext"; import { useComposerPathSearch } from "../../lib/composerPathSearchState"; +import { useProviderWorkspaceSkills } from "../../lib/providerWorkspaceSkillsState"; import { shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, @@ -890,11 +891,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerTriggerKind = composerTrigger?.kind ?? null; const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; const isPathTrigger = composerTriggerKind === "path"; + const isSkillTrigger = composerTriggerKind === "skill"; const workspaceEntries = useComposerPathSearch({ environmentId, cwd: isPathTrigger ? gitCwd : null, query: isPathTrigger ? pathTriggerQuery : null, }); + const providerWorkspaceSkills = useProviderWorkspaceSkills({ + environmentId, + instanceId: selectedProviderStatus?.instanceId ?? null, + cwd: gitCwd, + enabled: isSkillTrigger, + fallbackSkills: selectedProviderStatus?.skills ?? [], + }); const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; @@ -950,7 +959,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return searchSlashCommandItems(slashCommandItems, query); } if (composerTrigger.kind === "skill") { - return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map( + return searchProviderSkills(providerWorkspaceSkills.skills, composerTrigger.query).map( (skill) => ({ id: `skill:${selectedProvider}:${skill.name}`, type: "skill" as const, @@ -965,7 +974,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); } return []; - }, [composerTrigger, selectedProvider, selectedProviderStatus, workspaceEntries.entries]); + }, [ + composerTrigger, + providerWorkspaceSkills.skills, + selectedProvider, + selectedProviderStatus, + workspaceEntries.entries, + ]); const composerMenuOpen = Boolean(composerTrigger); const composerMenuSearchKey = composerTrigger @@ -1030,7 +1045,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ]); const isComposerMenuLoading = - composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending; + (composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending) || + (composerTriggerKind === "skill" && providerWorkspaceSkills.isPending); const composerMenuEmptyState = useMemo(() => { if (composerTriggerKind === "skill") { return "No skills found. Try / to browse provider commands."; @@ -2307,7 +2323,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? composerTerminalContexts : [] } - skills={selectedProviderStatus?.skills ?? []} + skills={providerWorkspaceSkills.skills} {...(showMobilePendingAnswerActions ? { className: "max-sm:pb-11" } : {})} onRemoveTerminalContext={removeComposerTerminalContextFromDraft} onChange={onPromptChange} diff --git a/apps/web/src/environments/runtime/connection.test.ts b/apps/web/src/environments/runtime/connection.test.ts index 392db299339..550b7a31f38 100644 --- a/apps/web/src/environments/runtime/connection.test.ts +++ b/apps/web/src/environments/runtime/connection.test.ts @@ -31,6 +31,7 @@ function createTestClient(config?: { readonly emitInitialSnapshot?: boolean }) { }), subscribeAuthAccess: () => () => undefined, refreshProviders: vi.fn(async () => undefined), + listProviderSkills: vi.fn(async () => ({ skills: [] })), upsertKeybinding: vi.fn(async () => undefined), getSettings: vi.fn(async () => undefined), updateSettings: vi.fn(async () => undefined), diff --git a/apps/web/src/environments/runtime/service.savedEnvironments.test.ts b/apps/web/src/environments/runtime/service.savedEnvironments.test.ts index e7c15ec6b32..0220956080a 100644 --- a/apps/web/src/environments/runtime/service.savedEnvironments.test.ts +++ b/apps/web/src/environments/runtime/service.savedEnvironments.test.ts @@ -173,6 +173,7 @@ function createClient() { subscribeLifecycle: vi.fn(() => () => undefined), subscribeAuthAccess: vi.fn(() => () => undefined), refreshProviders: vi.fn(async () => undefined), + listProviderSkills: vi.fn(async () => ({ skills: [] })), upsertKeybinding: vi.fn(async () => undefined), getSettings: vi.fn(async () => undefined), updateSettings: vi.fn(async () => undefined), diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts index 675a4868032..cde158c43e5 100644 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -141,6 +141,7 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { server: { getConfig: vi.fn(), refreshProviders: vi.fn(), + listProviderSkills: vi.fn(), discoverSourceControl: vi.fn(), updateProvider: vi.fn(), upsertKeybinding: vi.fn(), diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts new file mode 100644 index 00000000000..f8c85b48755 --- /dev/null +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -0,0 +1,135 @@ +import type { EnvironmentId, ProviderInstanceId, ServerProviderSkill } from "@t3tools/contracts"; +import { useEffect, useMemo, useState } from "react"; + +import { + readEnvironmentConnection, + subscribeEnvironmentConnections, + subscribeProviderInvalidations, +} from "../environments/runtime"; + +export interface ProviderWorkspaceSkillsTarget { + readonly environmentId: EnvironmentId | null; + readonly instanceId: ProviderInstanceId | null; + readonly cwd: string | null; + readonly enabled: boolean; + readonly fallbackSkills: ReadonlyArray; +} + +export interface ProviderWorkspaceSkillsState { + readonly skills: ReadonlyArray; + readonly isPending: boolean; + readonly error: string | null; +} + +const cache = new Map>(); + +function targetKey(target: Omit): string | null { + if ( + !target.enabled || + target.environmentId === null || + target.instanceId === null || + target.cwd === null + ) { + return null; + } + return `${target.environmentId}:${target.instanceId}:${target.cwd}`; +} + +export function invalidateProviderWorkspaceSkills(): void { + cache.clear(); +} + +subscribeProviderInvalidations(invalidateProviderWorkspaceSkills); + +export function useProviderWorkspaceSkills( + target: ProviderWorkspaceSkillsTarget, +): ProviderWorkspaceSkillsState { + const stableTarget = useMemo( + () => ({ + environmentId: target.environmentId, + instanceId: target.instanceId, + cwd: target.cwd, + enabled: target.enabled, + }), + [target.cwd, target.enabled, target.environmentId, target.instanceId], + ); + const key = targetKey(stableTarget); + const [connectionVersion, setConnectionVersion] = useState(0); + const [state, setState] = useState(() => ({ + skills: target.fallbackSkills, + isPending: false, + error: null, + })); + + useEffect( + () => subscribeEnvironmentConnections(() => setConnectionVersion((version) => version + 1)), + [], + ); + + useEffect(() => { + if ( + key === null || + stableTarget.environmentId === null || + stableTarget.instanceId === null || + stableTarget.cwd === null + ) { + setState({ skills: target.fallbackSkills, isPending: false, error: null }); + return; + } + + const cached = cache.get(key); + if (cached) { + setState({ skills: cached, isPending: false, error: null }); + return; + } + + const connection = readEnvironmentConnection(stableTarget.environmentId); + if (!connection) { + setState({ + skills: target.fallbackSkills, + isPending: false, + error: "Remote connection is not ready.", + }); + return; + } + + let cancelled = false; + setState((current) => ({ + skills: current.skills.length > 0 ? current.skills : target.fallbackSkills, + isPending: true, + error: null, + })); + void connection.client.server + .listProviderSkills({ + instanceId: stableTarget.instanceId, + cwd: stableTarget.cwd, + }) + .then((result) => { + if (cancelled) return; + cache.set(key, result.skills); + setState({ skills: result.skills, isPending: false, error: null }); + }) + .catch((error: unknown) => { + if (cancelled) return; + setState({ + skills: target.fallbackSkills, + isPending: false, + error: error instanceof Error ? error.message : "Failed to list provider skills.", + }); + }); + + return () => { + cancelled = true; + }; + }, [ + connectionVersion, + key, + stableTarget.cwd, + stableTarget.enabled, + stableTarget.environmentId, + stableTarget.instanceId, + target.fallbackSkills, + ]); + + return state; +} diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index c5ee3f277ca..7b68a7b9ed1 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -125,6 +125,10 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { rpcClient ? rpcClient.server.refreshProviders() : Promise.reject(unavailableLocalBackendError()), + listProviderSkills: (input) => + rpcClient + ? rpcClient.server.listProviderSkills(input) + : Promise.reject(unavailableLocalBackendError()), updateProvider: (input) => rpcClient ? rpcClient.server.updateProvider(input) diff --git a/packages/client-runtime/src/wsRpcClient.ts b/packages/client-runtime/src/wsRpcClient.ts index c1c683616b2..cdadb13c8f1 100644 --- a/packages/client-runtime/src/wsRpcClient.ts +++ b/packages/client-runtime/src/wsRpcClient.ts @@ -130,6 +130,7 @@ export interface WsRpcClient { readonly refreshProviders: ( input?: RpcInput, ) => ReturnType>; + readonly listProviderSkills: RpcUnaryMethod; readonly discoverSourceControl: RpcUnaryNoArgMethod< typeof WS_METHODS.serverDiscoverSourceControl >; @@ -290,6 +291,8 @@ export function createWsRpcClient( getConfig: () => transport.request((client) => client[WS_METHODS.serverGetConfig]({})), refreshProviders: (input) => transport.request((client) => client[WS_METHODS.serverRefreshProviders](input ?? {})), + listProviderSkills: (input) => + transport.request((client) => client[WS_METHODS.serverListProviderSkills](input)), discoverSourceControl: () => transport.request((client) => client[WS_METHODS.serverDiscoverSourceControl]({})), updateProvider: (input) => diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 1d8656ddf4f..c152e88098b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -32,6 +32,8 @@ import type { ServerProcessDiagnosticsResult, ServerProcessResourceHistoryInput, ServerProcessResourceHistoryResult, + ServerProviderSkillsListInput, + ServerProviderSkillsListResult, ServerProviderUpdateInput, ServerProviderUpdatedPayload, ServerRemoveKeybindingResult, @@ -508,6 +510,9 @@ export interface LocalApi { refreshProviders: (input?: { readonly instanceId?: ProviderInstanceId; }) => Promise; + listProviderSkills: ( + input: ServerProviderSkillsListInput, + ) => Promise; updateProvider: (input: ServerProviderUpdateInput) => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; removeKeybinding: (input: ServerRemoveKeybindingInput) => Promise; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 5a145f3f657..8508b97f7ee 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -93,6 +93,9 @@ import { ServerLifecycleStreamEvent, ServerRemoveKeybindingInput, ServerRemoveKeybindingResult, + ServerProviderSkillsListError, + ServerProviderSkillsListInput, + ServerProviderSkillsListResult, ServerProviderUpdatedPayload, ServerTraceDiagnosticsResult, ServerProcessDiagnosticsResult, @@ -160,6 +163,7 @@ export const WS_METHODS = { // Server meta serverGetConfig: "server.getConfig", serverRefreshProviders: "server.refreshProviders", + serverListProviderSkills: "server.listProviderSkills", serverUpdateProvider: "server.updateProvider", serverUpsertKeybinding: "server.upsertKeybinding", serverRemoveKeybinding: "server.removeKeybinding", @@ -221,6 +225,12 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv error: EnvironmentAuthorizationError, }); +export const WsServerListProviderSkillsRpc = Rpc.make(WS_METHODS.serverListProviderSkills, { + payload: ServerProviderSkillsListInput, + success: ServerProviderSkillsListResult, + error: Schema.Union([ServerProviderSkillsListError, EnvironmentAuthorizationError]), +}); + export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { payload: ServerProviderUpdateInput, success: ServerProviderUpdatedPayload, @@ -548,6 +558,7 @@ export const WsSubscribeAuthAccessRpc = Rpc.make(WS_METHODS.subscribeAuthAccess, export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, WsServerRefreshProvidersRpc, + WsServerListProviderSkillsRpc, WsServerUpdateProviderRpc, WsServerUpsertKeybindingRpc, WsServerRemoveKeybindingRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 1aa280ad63b..bfc69c09917 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -91,6 +91,25 @@ export const ServerProviderSkill = Schema.Struct({ }); export type ServerProviderSkill = typeof ServerProviderSkill.Type; +export const ServerProviderSkillsListInput = Schema.Struct({ + instanceId: ProviderInstanceId, + cwd: TrimmedNonEmptyString, +}); +export type ServerProviderSkillsListInput = typeof ServerProviderSkillsListInput.Type; + +export const ServerProviderSkillsListResult = Schema.Struct({ + skills: Schema.Array(ServerProviderSkill), +}); +export type ServerProviderSkillsListResult = typeof ServerProviderSkillsListResult.Type; + +export class ServerProviderSkillsListError extends Schema.TaggedErrorClass()( + "ServerProviderSkillsListError", + { + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) {} + /** * Availability of a configured provider instance from the runtime's POV. * From ace7991c39a14bb08603f01d8fe4547148c74d21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 12 Jun 2026 12:15:23 +0100 Subject: [PATCH 02/55] Address CodeRabbit review comments - Clarify bogus skill as a durable discovery test fixture - Stabilize composer fallback skill array identity --- .agents/skills/bogus/SKILL.md | 2 +- apps/web/src/components/chat/ChatComposer.tsx | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.agents/skills/bogus/SKILL.md b/.agents/skills/bogus/SKILL.md index 506636b7b26..22a9efed62e 100644 --- a/.agents/skills/bogus/SKILL.md +++ b/.agents/skills/bogus/SKILL.md @@ -1,6 +1,6 @@ --- name: bogus -description: A useless skill to test only skill discovery. Can be committed, will remove manually before merging to main. +description: A test fixture skill used to validate reproducible skill discovery behavior. --- Nothing. diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 12de8644679..02122e6020d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -756,6 +756,10 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], ); + const selectedProviderFallbackSkills = useMemo( + () => selectedProviderStatus?.skills ?? [], + [selectedProviderStatus], + ); const selectedProviderModels = useMemo>( () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], @@ -902,7 +906,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) instanceId: selectedProviderStatus?.instanceId ?? null, cwd: gitCwd, enabled: isSkillTrigger, - fallbackSkills: selectedProviderStatus?.skills ?? [], + fallbackSkills: selectedProviderFallbackSkills, }); const composerMenuItems = useMemo(() => { From 5bc3d6e4c7ce55f5844d55806ac793fd4a4e992d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 12 Jun 2026 12:24:15 +0100 Subject: [PATCH 03/55] Fix disabled Codex skill listing - Skip Codex skill spawning for disabled instances - Move bogus skill fixture out of workspace discovery --- .../server/integration/fixtures}/skills/bogus/SKILL.md | 0 apps/server/src/ws.ts | 3 +++ 2 files changed, 3 insertions(+) rename {.agents => apps/server/integration/fixtures}/skills/bogus/SKILL.md (100%) diff --git a/.agents/skills/bogus/SKILL.md b/apps/server/integration/fixtures/skills/bogus/SKILL.md similarity index 100% rename from .agents/skills/bogus/SKILL.md rename to apps/server/integration/fixtures/skills/bogus/SKILL.md diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8de217f5cd9..46919c811ad 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -330,6 +330,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ...decodedConfig, enabled: instanceConfig.enabled ?? decodedConfig.enabled, }; + if (!effectiveConfig.enabled) { + return { skills: snapshot.skills }; + } const homeLayout = yield* resolveCodexHomeLayout(effectiveConfig).pipe( Effect.provideService(Path.Path, path), ); From c34dff39f1732c00af358e965834bdfffc94531e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 12 Jun 2026 13:37:32 +0100 Subject: [PATCH 04/55] Fix Codex skill listing refresh and validation - Refresh workspace skill cache on provider and connection changes - Validate Codex skill cwd before spawning the app server - Cover server.listProviderSkills RPC branches --- .../src/provider/Layers/CodexProvider.ts | 6 +- apps/server/src/server.test.ts | 187 ++++++++++++++++++ apps/server/src/ws.ts | 31 ++- .../src/lib/providerWorkspaceSkillsState.ts | 78 ++++++-- 4 files changed, 283 insertions(+), 19 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index e01db62f7a0..a9bd16d7780 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -271,16 +271,18 @@ export const listCodexProviderSkills = Effect.fn("listCodexProviderSkills")(func readonly binaryPath: string; readonly homePath?: string; readonly cwd: string; - readonly environment?: NodeJS.ProcessEnv; + readonly environment: NodeJS.ProcessEnv; }) { const resolvedHomePath = input.homePath ? expandHomePath(input.homePath) : undefined; + // The app-server command layer is scoped; callers must run this effect with + // `Effect.scoped` so the spawned process finalizer is released. const clientContext = yield* Layer.build( CodexClient.layerCommand({ command: input.binaryPath, args: ["app-server"], cwd: input.cwd, env: { - ...(input.environment ?? process.env), + ...input.environment, ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }, }), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 0bf2f6589f0..605ea4dc152 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -24,6 +24,8 @@ import { ProviderDriverKind, ProviderInstanceId, ResolvedKeybindingRule, + type ServerProvider, + type ServerProviderSkill, ThreadId, WS_METHODS, WsRpcGroup, @@ -1115,6 +1117,24 @@ const responseJsonEffect = (response: HttpClientResponse.HttpClientResponse) const responseOk = (response: HttpClientResponse.HttpClientResponse) => response.status >= 200 && response.status < 300; +const makeServerProviderSnapshot = ( + input: Partial & { + readonly instanceId: ProviderInstanceId; + readonly driver: ProviderDriverKind; + }, +): ServerProvider => ({ + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-04-11T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + ...input, +}); + const getAuthenticatedSessionCookieHeader = (credential = defaultDesktopBootstrapToken) => Effect.gen(function* () { const { response, cookie } = yield* bootstrapBrowserSession(credential); @@ -4417,6 +4437,173 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc server.listProviderSkills errors for missing provider", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.serverListProviderSkills]({ + instanceId: ProviderInstanceId.make("codex"), + cwd: process.cwd(), + }), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "ServerProviderSkillsListError"); + assert.equal(result.failure.message, "Provider instance 'codex' was not found."); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "routes websocket rpc server.listProviderSkills returns non-Codex snapshot skills", + () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("claudeAgent"); + const skill: ServerProviderSkill = { + name: "plan", + path: "/providers/claudeAgent/skills/plan/SKILL.md", + enabled: true, + }; + const providers = [ + makeServerProviderSnapshot({ + instanceId, + driver: ProviderDriverKind.make("claudeAgent"), + skills: [skill], + }), + ]; + + yield* buildAppUnderTest({ + layers: { + providerRegistry: { + getProviders: Effect.succeed(providers), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.serverListProviderSkills]({ + instanceId, + cwd: "/definitely/not/a/real/workspace/path", + }), + ), + ); + + assert.deepEqual(response.skills, [skill]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "routes websocket rpc server.listProviderSkills returns disabled Codex snapshot skills", + () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("codex"); + const driver = ProviderDriverKind.make("codex"); + const skill: ServerProviderSkill = { + name: "fallback", + path: "/providers/codex/skills/fallback/SKILL.md", + enabled: true, + }; + const providers = [ + makeServerProviderSnapshot({ + instanceId, + driver, + skills: [skill], + }), + ]; + + yield* buildAppUnderTest({ + layers: { + providerRegistry: { + getProviders: Effect.succeed(providers), + }, + serverSettings: { + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { + driver, + enabled: false, + config: {}, + }, + }, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.serverListProviderSkills]({ + instanceId, + cwd: "/definitely/not/a/real/workspace/path", + }), + ), + ); + + assert.deepEqual(response.skills, [skill]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("routes websocket rpc server.listProviderSkills validates enabled Codex cwd", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("codex"); + const driver = ProviderDriverKind.make("codex"); + const providers = [ + makeServerProviderSnapshot({ + instanceId, + driver, + }), + ]; + + yield* buildAppUnderTest({ + layers: { + providerRegistry: { + getProviders: Effect.succeed(providers), + }, + serverSettings: { + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { + driver, + enabled: true, + config: {}, + }, + }, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.serverListProviderSkills]({ + instanceId, + cwd: "/definitely/not/a/real/workspace/path", + }), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "ServerProviderSkillsListError"); + assertInclude( + result.failure.message, + "Invalid Codex skills cwd '/definitely/not/a/real/workspace/path'", + ); + assertInclude( + result.failure.message, + "Workspace root does not exist: /definitely/not/a/real/workspace/path", + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect( "routes websocket rpc subscribeServerLifecycle replays snapshot and streams updates", () => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 46919c811ad..f3ebeb17ebb 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -86,7 +86,10 @@ import { redactServerSettingsForClient, ServerSettingsService } from "./serverSe import { TerminalManager } from "./terminal/Services/Manager.ts"; import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; -import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; +import { + WorkspacePathOutsideRootError, + WorkspacePaths, +} from "./workspace/Services/WorkspacePaths.ts"; import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts"; import { VcsProvisioningService } from "./vcs/VcsProvisioningService.ts"; import { GitWorkflowService } from "./git/GitWorkflowService.ts"; @@ -118,6 +121,16 @@ const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchComma const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError); const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); +function describeUnknownCause(cause: unknown): string { + if (cause instanceof Error) { + return cause.message; + } + if (typeof cause === "string") { + return cause; + } + return "Unknown error"; +} + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< @@ -262,6 +275,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const workspacePaths = yield* WorkspacePaths; const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; const startup = yield* ServerRuntimeStartup; @@ -333,6 +347,15 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => if (!effectiveConfig.enabled) { return { skills: snapshot.skills }; } + const normalizedCwd = yield* workspacePaths.normalizeWorkspaceRoot(input.cwd).pipe( + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: `Invalid Codex skills cwd '${input.cwd}': ${cause.message}`, + cause, + }), + ), + ); const homeLayout = yield* resolveCodexHomeLayout(effectiveConfig).pipe( Effect.provideService(Path.Path, path), ); @@ -342,7 +365,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => Effect.mapError( (cause) => new ServerProviderSkillsListError({ - message: `Failed to prepare Codex home for '${input.instanceId}': ${cause.message}`, + message: `Failed to prepare Codex home for '${input.instanceId}': ${describeUnknownCause(cause)}`, cause, }), ), @@ -350,7 +373,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const skills = yield* listCodexProviderSkills({ binaryPath: effectiveConfig.binaryPath, ...(homeLayout.effectiveHomePath ? { homePath: homeLayout.effectiveHomePath } : {}), - cwd: input.cwd, + cwd: normalizedCwd, environment: mergeProviderInstanceEnvironment(instanceConfig.environment ?? []), }).pipe( Effect.scoped, @@ -358,7 +381,7 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => Effect.mapError( (cause) => new ServerProviderSkillsListError({ - message: `Failed to list Codex skills for '${input.cwd}'.`, + message: `Failed to list Codex skills (provider: '${input.instanceId}', cwd: '${normalizedCwd}').`, cause, }), ), diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts index f8c85b48755..9c8b9401c07 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -1,5 +1,5 @@ import type { EnvironmentId, ProviderInstanceId, ServerProviderSkill } from "@t3tools/contracts"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { readEnvironmentConnection, @@ -22,33 +22,79 @@ export interface ProviderWorkspaceSkillsState { } const cache = new Map>(); +const CACHE_MAX_ENTRIES = 100; + +const listeners = new Set<() => void>(); +let unsubscribeEnvironmentConnections: (() => void) | null = null; +let unsubscribeProviderInvalidations: (() => void) | null = null; + +function notifyListeners(): void { + for (const listener of listeners) { + listener(); + } +} + +function clearCacheAndNotify(): void { + invalidateProviderWorkspaceSkills(); + notifyListeners(); +} + +function setCachedSkills(key: string, skills: ReadonlyArray): void { + if (cache.has(key)) { + cache.delete(key); + } + cache.set(key, skills); + while (cache.size > CACHE_MAX_ENTRIES) { + const oldestKey = cache.keys().next().value; + if (oldestKey === undefined) break; + cache.delete(oldestKey); + } +} + +function subscribeWorkspaceSkillChanges(listener: () => void): () => void { + listeners.add(listener); + if (listeners.size === 1) { + unsubscribeEnvironmentConnections = subscribeEnvironmentConnections(clearCacheAndNotify); + unsubscribeProviderInvalidations = subscribeProviderInvalidations(clearCacheAndNotify); + } + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + unsubscribeEnvironmentConnections?.(); + unsubscribeEnvironmentConnections = null; + unsubscribeProviderInvalidations?.(); + unsubscribeProviderInvalidations = null; + invalidateProviderWorkspaceSkills(); + } + }; +} function targetKey(target: Omit): string | null { if ( !target.enabled || target.environmentId === null || target.instanceId === null || - target.cwd === null + target.cwd === null || + target.cwd.trim().length === 0 ) { return null; } - return `${target.environmentId}:${target.instanceId}:${target.cwd}`; + return `${target.environmentId}:${target.instanceId}:${target.cwd.trim()}`; } export function invalidateProviderWorkspaceSkills(): void { cache.clear(); } -subscribeProviderInvalidations(invalidateProviderWorkspaceSkills); - export function useProviderWorkspaceSkills( target: ProviderWorkspaceSkillsTarget, ): ProviderWorkspaceSkillsState { + const fallbackSkillsRef = useRef(target.fallbackSkills); const stableTarget = useMemo( () => ({ environmentId: target.environmentId, instanceId: target.instanceId, - cwd: target.cwd, + cwd: target.cwd?.trim() || null, enabled: target.enabled, }), [target.cwd, target.enabled, target.environmentId, target.instanceId], @@ -61,8 +107,15 @@ export function useProviderWorkspaceSkills( error: null, })); + useEffect(() => { + fallbackSkillsRef.current = target.fallbackSkills; + if (key === null) { + setState({ skills: target.fallbackSkills, isPending: false, error: null }); + } + }, [key, target.fallbackSkills]); + useEffect( - () => subscribeEnvironmentConnections(() => setConnectionVersion((version) => version + 1)), + () => subscribeWorkspaceSkillChanges(() => setConnectionVersion((version) => version + 1)), [], ); @@ -73,7 +126,7 @@ export function useProviderWorkspaceSkills( stableTarget.instanceId === null || stableTarget.cwd === null ) { - setState({ skills: target.fallbackSkills, isPending: false, error: null }); + setState({ skills: fallbackSkillsRef.current, isPending: false, error: null }); return; } @@ -86,7 +139,7 @@ export function useProviderWorkspaceSkills( const connection = readEnvironmentConnection(stableTarget.environmentId); if (!connection) { setState({ - skills: target.fallbackSkills, + skills: fallbackSkillsRef.current, isPending: false, error: "Remote connection is not ready.", }); @@ -95,7 +148,7 @@ export function useProviderWorkspaceSkills( let cancelled = false; setState((current) => ({ - skills: current.skills.length > 0 ? current.skills : target.fallbackSkills, + skills: current.skills.length > 0 ? current.skills : fallbackSkillsRef.current, isPending: true, error: null, })); @@ -106,13 +159,13 @@ export function useProviderWorkspaceSkills( }) .then((result) => { if (cancelled) return; - cache.set(key, result.skills); + setCachedSkills(key, result.skills); setState({ skills: result.skills, isPending: false, error: null }); }) .catch((error: unknown) => { if (cancelled) return; setState({ - skills: target.fallbackSkills, + skills: fallbackSkillsRef.current, isPending: false, error: error instanceof Error ? error.message : "Failed to list provider skills.", }); @@ -128,7 +181,6 @@ export function useProviderWorkspaceSkills( stableTarget.enabled, stableTarget.environmentId, stableTarget.instanceId, - target.fallbackSkills, ]); return state; From 1a1547fbe6ab0d1c65344aa2c6a8f7fee8cc5095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 12 Jun 2026 13:47:26 +0100 Subject: [PATCH 05/55] Fix workspace skill refresh state - Track the active workspace key in provider skill state - Reset pending skills when switching workspace targets --- .../src/lib/providerWorkspaceSkillsState.ts | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts index 9c8b9401c07..ff1837faa9a 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -21,6 +21,10 @@ export interface ProviderWorkspaceSkillsState { readonly error: string | null; } +interface InternalProviderWorkspaceSkillsState extends ProviderWorkspaceSkillsState { + readonly key: string | null; +} + const cache = new Map>(); const CACHE_MAX_ENTRIES = 100; @@ -101,7 +105,8 @@ export function useProviderWorkspaceSkills( ); const key = targetKey(stableTarget); const [connectionVersion, setConnectionVersion] = useState(0); - const [state, setState] = useState(() => ({ + const [state, setState] = useState(() => ({ + key, skills: target.fallbackSkills, isPending: false, error: null, @@ -110,7 +115,7 @@ export function useProviderWorkspaceSkills( useEffect(() => { fallbackSkillsRef.current = target.fallbackSkills; if (key === null) { - setState({ skills: target.fallbackSkills, isPending: false, error: null }); + setState({ key: null, skills: target.fallbackSkills, isPending: false, error: null }); } }, [key, target.fallbackSkills]); @@ -126,19 +131,20 @@ export function useProviderWorkspaceSkills( stableTarget.instanceId === null || stableTarget.cwd === null ) { - setState({ skills: fallbackSkillsRef.current, isPending: false, error: null }); + setState({ key, skills: fallbackSkillsRef.current, isPending: false, error: null }); return; } const cached = cache.get(key); if (cached) { - setState({ skills: cached, isPending: false, error: null }); + setState({ key, skills: cached, isPending: false, error: null }); return; } const connection = readEnvironmentConnection(stableTarget.environmentId); if (!connection) { setState({ + key, skills: fallbackSkillsRef.current, isPending: false, error: "Remote connection is not ready.", @@ -148,7 +154,11 @@ export function useProviderWorkspaceSkills( let cancelled = false; setState((current) => ({ - skills: current.skills.length > 0 ? current.skills : fallbackSkillsRef.current, + key, + skills: + current.key === key && current.skills.length > 0 + ? current.skills + : fallbackSkillsRef.current, isPending: true, error: null, })); @@ -160,11 +170,12 @@ export function useProviderWorkspaceSkills( .then((result) => { if (cancelled) return; setCachedSkills(key, result.skills); - setState({ skills: result.skills, isPending: false, error: null }); + setState({ key, skills: result.skills, isPending: false, error: null }); }) .catch((error: unknown) => { if (cancelled) return; setState({ + key, skills: fallbackSkillsRef.current, isPending: false, error: error instanceof Error ? error.message : "Failed to list provider skills.", @@ -183,5 +194,9 @@ export function useProviderWorkspaceSkills( stableTarget.instanceId, ]); - return state; + return { + skills: state.skills, + isPending: state.isPending, + error: state.error, + }; } From b38045bb8496608eef4e8e37d39401c6bf270558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 12 Jun 2026 14:47:51 +0100 Subject: [PATCH 06/55] Remove test skill file --- apps/server/integration/fixtures/skills/bogus/SKILL.md | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 apps/server/integration/fixtures/skills/bogus/SKILL.md diff --git a/apps/server/integration/fixtures/skills/bogus/SKILL.md b/apps/server/integration/fixtures/skills/bogus/SKILL.md deleted file mode 100644 index 22a9efed62e..00000000000 --- a/apps/server/integration/fixtures/skills/bogus/SKILL.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -name: bogus -description: A test fixture skill used to validate reproducible skill discovery behavior. ---- - -Nothing. From 6acc26490ae76d46741bd639d2453566f37df1c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 12 Jun 2026 16:10:38 +0100 Subject: [PATCH 07/55] Fix workspace-scoped Codex skill loading - Keep pending skill lookups scoped to the active workspace key - Surface Codex skill-list timeout errors in the composer - Add regression coverage for stale pending skills --- apps/server/src/ws.ts | 14 ++++++- apps/web/src/components/chat/ChatComposer.tsx | 7 ++-- .../lib/providerWorkspaceSkillsState.test.ts | 42 +++++++++++++++++++ .../src/lib/providerWorkspaceSkillsState.ts | 22 +++++++--- 4 files changed, 75 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/lib/providerWorkspaceSkillsState.test.ts diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index f3ebeb17ebb..d9d0e4d00e2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -131,6 +131,13 @@ function describeUnknownCause(cause: unknown): string { return "Unknown error"; } +function describeCodexSkillListFailure(cause: unknown, input: { instanceId: string; cwd: string }) { + if (Cause.isTimeoutError(cause)) { + return `Timed out listing Codex skills after ${Duration.toSeconds(CODEX_SKILL_LIST_TIMEOUT)}s (provider: '${input.instanceId}', cwd: '${input.cwd}').`; + } + return `Failed to list Codex skills (provider: '${input.instanceId}', cwd: '${input.cwd}').`; +} + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< @@ -156,6 +163,7 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< } const PROVIDER_STATUS_DEBOUNCE_MS = 200; +const CODEX_SKILL_LIST_TIMEOUT = Duration.seconds(15); const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope], @@ -377,11 +385,15 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => environment: mergeProviderInstanceEnvironment(instanceConfig.environment ?? []), }).pipe( Effect.scoped, + Effect.timeout(CODEX_SKILL_LIST_TIMEOUT), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), Effect.mapError( (cause) => new ServerProviderSkillsListError({ - message: `Failed to list Codex skills (provider: '${input.instanceId}', cwd: '${normalizedCwd}').`, + message: describeCodexSkillListFailure(cause, { + instanceId: input.instanceId, + cwd: normalizedCwd, + }), cause, }), ), diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 02122e6020d..3d83c7f2ae3 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -895,7 +895,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerTriggerKind = composerTrigger?.kind ?? null; const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; const isPathTrigger = composerTriggerKind === "path"; - const isSkillTrigger = composerTriggerKind === "skill"; const workspaceEntries = useComposerPathSearch({ environmentId, cwd: isPathTrigger ? gitCwd : null, @@ -905,7 +904,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) environmentId, instanceId: selectedProviderStatus?.instanceId ?? null, cwd: gitCwd, - enabled: isSkillTrigger, + enabled: true, fallbackSkills: selectedProviderFallbackSkills, }); @@ -1053,12 +1052,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) (composerTriggerKind === "skill" && providerWorkspaceSkills.isPending); const composerMenuEmptyState = useMemo(() => { if (composerTriggerKind === "skill") { - return "No skills found. Try / to browse provider commands."; + return providerWorkspaceSkills.error ?? "No skills found. Try / to browse provider commands."; } return composerTriggerKind === "path" ? "No matching files or folders." : "No matching command."; - }, [composerTriggerKind]); + }, [composerTriggerKind, providerWorkspaceSkills.error]); // ------------------------------------------------------------------ // Provider traits UI diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.test.ts b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts new file mode 100644 index 00000000000..b14d38ed9c8 --- /dev/null +++ b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts @@ -0,0 +1,42 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("../environments/runtime", () => ({ + readEnvironmentConnection: vi.fn(() => null), + subscribeEnvironmentConnections: vi.fn(() => () => undefined), + subscribeProviderInvalidations: vi.fn(() => () => undefined), +})); + +import { resolvePendingProviderWorkspaceSkills } from "./providerWorkspaceSkillsState"; + +function skill(name: string): ServerProviderSkill { + return { + name, + path: `/skills/${name}/SKILL.md`, + enabled: true, + }; +} + +describe("resolvePendingProviderWorkspaceSkills", () => { + it("preserves current skills while refreshing the same workspace key", () => { + const currentSkills = [skill("repo-local")]; + + expect( + resolvePendingProviderWorkspaceSkills({ + currentKey: "environment:codex:/repo", + nextKey: "environment:codex:/repo", + currentSkills, + }), + ).toBe(currentSkills); + }); + + it("does not expose previous or snapshot skills while a different workspace key is pending", () => { + const pendingSkills = resolvePendingProviderWorkspaceSkills({ + currentKey: "environment:codex:/old-repo", + nextKey: "environment:codex:/new-repo", + currentSkills: [skill("old-repo-skill"), skill("snapshot-skill")], + }); + + expect(pendingSkills).toEqual([]); + }); +}); diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts index ff1837faa9a..c0fd2ee02d8 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -27,6 +27,7 @@ interface InternalProviderWorkspaceSkillsState extends ProviderWorkspaceSkillsSt const cache = new Map>(); const CACHE_MAX_ENTRIES = 100; +const EMPTY_SKILLS: ReadonlyArray = []; const listeners = new Set<() => void>(); let unsubscribeEnvironmentConnections: (() => void) | null = null; @@ -90,6 +91,16 @@ export function invalidateProviderWorkspaceSkills(): void { cache.clear(); } +export function resolvePendingProviderWorkspaceSkills(input: { + readonly currentKey: string | null; + readonly nextKey: string; + readonly currentSkills: ReadonlyArray; +}): ReadonlyArray { + return input.currentKey === input.nextKey && input.currentSkills.length > 0 + ? input.currentSkills + : EMPTY_SKILLS; +} + export function useProviderWorkspaceSkills( target: ProviderWorkspaceSkillsTarget, ): ProviderWorkspaceSkillsState { @@ -155,10 +166,11 @@ export function useProviderWorkspaceSkills( let cancelled = false; setState((current) => ({ key, - skills: - current.key === key && current.skills.length > 0 - ? current.skills - : fallbackSkillsRef.current, + skills: resolvePendingProviderWorkspaceSkills({ + currentKey: current.key, + nextKey: key, + currentSkills: current.skills, + }), isPending: true, error: null, })); @@ -176,7 +188,7 @@ export function useProviderWorkspaceSkills( if (cancelled) return; setState({ key, - skills: fallbackSkillsRef.current, + skills: EMPTY_SKILLS, isPending: false, error: error instanceof Error ? error.message : "Failed to list provider skills.", }); From 00cebadb8bf7e91f9300d7ef0c1367dcfd019a1b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 18 Jun 2026 23:12:27 -0700 Subject: [PATCH 08/55] Bound workspace skill discovery requests Co-authored-by: codex --- .../src/provider/Layers/CodexProvider.ts | 28 ++- .../src/provider/ProviderSkillsLister.test.ts | 67 ++++++ .../src/provider/ProviderSkillsLister.ts | 198 ++++++++++++++++++ apps/server/src/ws.ts | 153 ++------------ apps/web/src/components/chat/ChatComposer.tsx | 6 +- .../src/lib/providerWorkspaceSkillsState.ts | 121 ++--------- packages/client-runtime/src/state/server.ts | 10 +- 7 files changed, 326 insertions(+), 257 deletions(-) create mode 100644 apps/server/src/provider/ProviderSkillsLister.test.ts create mode 100644 apps/server/src/provider/ProviderSkillsLister.ts diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 3650b59c44e..8343a97e1b9 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -286,15 +286,25 @@ export const listCodexProviderSkills = Effect.fn("listCodexProviderSkills")(func env: environment, extendEnv: true, }); - const child = yield* spawner.spawn( - ChildProcess.make(spawnCommand.command, spawnCommand.args, { - cwd: input.cwd, - env: environment, - extendEnv: true, - forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, - shell: spawnCommand.shell, - }), - ); + const child = yield* spawner + .spawn( + ChildProcess.make(spawnCommand.command, spawnCommand.args, { + cwd: input.cwd, + env: environment, + extendEnv: true, + forceKillAfter: CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER, + shell: spawnCommand.shell, + }), + ) + .pipe( + Effect.mapError( + (cause) => + new CodexErrors.CodexAppServerSpawnError({ + command: `${input.binaryPath} app-server`, + cause, + }), + ), + ); const clientContext = yield* Layer.build(CodexClient.layerChildProcess(child)); const client = yield* Effect.service(CodexClient.CodexAppServerClient).pipe( Effect.provide(clientContext), diff --git a/apps/server/src/provider/ProviderSkillsLister.test.ts b/apps/server/src/provider/ProviderSkillsLister.test.ts new file mode 100644 index 00000000000..38233740ea4 --- /dev/null +++ b/apps/server/src/provider/ProviderSkillsLister.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Ref from "effect/Ref"; + +import { makeBoundedRequestCache } from "./ProviderSkillsLister.ts"; + +describe("makeBoundedRequestCache", () => { + it.effect("coalesces concurrent requests for the same key", () => + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const release = yield* Deferred.make(); + const requests = yield* makeBoundedRequestCache({ + capacity: 8, + concurrency: 2, + timeToLive: "1 second", + lookup: (key: string) => + Ref.update(calls, (count) => count + 1).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(`value:${key}`), + ), + }); + + const first = yield* Effect.forkChild(requests.get("repo")); + const second = yield* Effect.forkChild(requests.get("repo")); + yield* Effect.yieldNow; + expect(yield* Ref.get(calls)).toBe(1); + + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(first)).toBe("value:repo"); + expect(yield* Fiber.join(second)).toBe("value:repo"); + expect(yield* Ref.get(calls)).toBe(1); + }), + ); + + it.effect("bounds concurrent lookups across different keys", () => + Effect.gen(function* () { + const active = yield* Ref.make(0); + const maxActive = yield* Ref.make(0); + const release = yield* Deferred.make(); + const requests = yield* makeBoundedRequestCache({ + capacity: 8, + concurrency: 2, + timeToLive: "1 second", + lookup: (key: string) => + Effect.acquireUseRelease( + Ref.updateAndGet(active, (count) => count + 1).pipe( + Effect.tap((count) => Ref.update(maxActive, (maximum) => Math.max(maximum, count))), + ), + () => Deferred.await(release).pipe(Effect.as(key)), + () => Ref.update(active, (count) => count - 1), + ), + }); + + const fibers = yield* Effect.forEach(["one", "two", "three"], requests.get, { + concurrency: "unbounded", + discard: false, + }).pipe(Effect.forkChild); + yield* Effect.yieldNow; + expect(yield* Ref.get(maxActive)).toBe(2); + + yield* Deferred.succeed(release, undefined); + expect(yield* Fiber.join(fibers)).toEqual(["one", "two", "three"]); + }), + ); +}); diff --git a/apps/server/src/provider/ProviderSkillsLister.ts b/apps/server/src/provider/ProviderSkillsLister.ts new file mode 100644 index 00000000000..6079456e610 --- /dev/null +++ b/apps/server/src/provider/ProviderSkillsLister.ts @@ -0,0 +1,198 @@ +import { + CodexSettings, + ServerProviderSkillsListError, + type ProviderInstanceId, + type ServerProviderSkillsListResult, +} from "@t3tools/contracts"; +import * as Cache from "effect/Cache"; +import * as Cause from "effect/Cause"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { materializeCodexShadowHome, resolveCodexHomeLayout } from "./Drivers/CodexHomeLayout.ts"; +import { listCodexProviderSkills } from "./Layers/CodexProvider.ts"; +import { deriveProviderInstanceConfigMap } from "./Layers/ProviderInstanceRegistryHydration.ts"; +import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; +import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts"; + +const CODEX_SKILL_LIST_TIMEOUT = Duration.seconds(15); +const PROVIDER_SKILLS_CACHE_CAPACITY = 64; +const PROVIDER_SKILLS_CACHE_TTL = Duration.seconds(1); +const PROVIDER_SKILLS_MAX_CONCURRENCY = 4; +const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); + +export interface ProviderSkillsListInput { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; +} + +export interface BoundedRequestCache { + readonly get: (key: Key) => Effect.Effect; +} + +export const makeBoundedRequestCache = Effect.fn("makeBoundedRequestCache")(function* < + Key, + A, + E, + R, +>(options: { + readonly capacity: number; + readonly concurrency: number; + readonly timeToLive: Duration.Input; + readonly lookup: (key: Key) => Effect.Effect; +}): Effect.fn.Return, never, R> { + const semaphore = yield* Semaphore.make(options.concurrency); + const cache = yield* Cache.make({ + capacity: options.capacity, + timeToLive: options.timeToLive, + lookup: (key: Key) => semaphore.withPermits(1)(options.lookup(key)), + }); + return { + get: (key) => Cache.get(cache, key), + }; +}); + +function describeUnknownCause(cause: unknown): string { + if (cause instanceof Error) return cause.message; + if (typeof cause === "string") return cause; + return "Unknown error"; +} + +function describeCodexSkillListFailure( + cause: unknown, + input: { readonly instanceId: string; readonly cwd: string }, +): string { + if (Cause.isTimeoutError(cause)) { + return `Timed out listing Codex skills after ${Duration.toSeconds(CODEX_SKILL_LIST_TIMEOUT)}s (provider: '${input.instanceId}', cwd: '${input.cwd}').`; + } + return `Failed to list Codex skills (provider: '${input.instanceId}', cwd: '${input.cwd}').`; +} + +function requestKey(input: ProviderSkillsListInput): string { + return JSON.stringify([input.instanceId, input.cwd]); +} + +function parseRequestKey(key: string): ProviderSkillsListInput { + const [instanceId, cwd] = JSON.parse(key) as [ProviderInstanceId, string]; + return { instanceId, cwd }; +} + +export const makeProviderSkillsLister = Effect.fn("makeProviderSkillsLister")(function* () { + const providerRegistry = yield* ProviderRegistry; + const serverSettings = yield* ServerSettingsService; + const workspacePaths = yield* WorkspacePaths; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const listUncached = Effect.fn("ProviderSkillsLister.listUncached")(function* ( + input: ProviderSkillsListInput, + ): Effect.fn.Return { + const providers = yield* providerRegistry.getProviders; + const snapshot = providers.find((provider) => provider.instanceId === input.instanceId); + if (!snapshot) { + return yield* new ServerProviderSkillsListError({ + message: `Provider instance '${input.instanceId}' was not found.`, + }); + } + if (snapshot.driver !== "codex") { + return { skills: snapshot.skills }; + } + + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: "Failed to read provider settings.", + cause, + }), + ), + ); + const instanceConfig = deriveProviderInstanceConfigMap(settings)[input.instanceId]; + if (!instanceConfig || instanceConfig.driver !== "codex") { + return yield* new ServerProviderSkillsListError({ + message: `Codex provider instance '${input.instanceId}' is not configured.`, + }); + } + + const decodedConfig = yield* decodeCodexSettings(instanceConfig.config ?? {}).pipe( + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: `Failed to decode Codex provider settings for '${input.instanceId}'.`, + cause, + }), + ), + ); + const effectiveConfig = { + ...decodedConfig, + enabled: instanceConfig.enabled ?? decodedConfig.enabled, + }; + if (!effectiveConfig.enabled) { + return { skills: snapshot.skills }; + } + + const normalizedCwd = yield* workspacePaths.normalizeWorkspaceRoot(input.cwd).pipe( + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: `Invalid Codex skills cwd '${input.cwd}': ${cause.message}`, + cause, + }), + ), + ); + const homeLayout = yield* resolveCodexHomeLayout(effectiveConfig).pipe( + Effect.provideService(Path.Path, path), + ); + yield* materializeCodexShadowHome(homeLayout).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: `Failed to prepare Codex home for '${input.instanceId}': ${describeUnknownCause(cause)}`, + cause, + }), + ), + ); + const skills = yield* listCodexProviderSkills({ + binaryPath: effectiveConfig.binaryPath, + ...(homeLayout.effectiveHomePath ? { homePath: homeLayout.effectiveHomePath } : {}), + cwd: normalizedCwd, + environment: mergeProviderInstanceEnvironment(instanceConfig.environment ?? []), + }).pipe( + Effect.scoped, + Effect.timeout(CODEX_SKILL_LIST_TIMEOUT), + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: describeCodexSkillListFailure(cause, { + instanceId: input.instanceId, + cwd: normalizedCwd, + }), + cause, + }), + ), + ); + return { skills }; + }); + + const requests = yield* makeBoundedRequestCache({ + capacity: PROVIDER_SKILLS_CACHE_CAPACITY, + concurrency: PROVIDER_SKILLS_MAX_CONCURRENCY, + timeToLive: PROVIDER_SKILLS_CACHE_TTL, + lookup: (key: string) => listUncached(parseRequestKey(key)), + }); + + return Effect.fn("ProviderSkillsLister.list")(function* (input: ProviderSkillsListInput) { + return yield* requests.get(requestKey(input)); + }); +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index de181e565ae..f178799821b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -5,8 +5,6 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -46,10 +44,8 @@ import { FilesystemBrowseError, AssetAccessError, EnvironmentAuthorizationError, - CodexSettings, ServerProviderSkillsListError, type ServerProviderSkillsListResult, - type ProviderInstanceId, ThreadId, type TerminalAttachStreamEvent, type TerminalError, @@ -61,7 +57,6 @@ import { import { clamp } from "effect/Number"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; -import { ChildProcessSpawner } from "effect/unstable/process"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; import { ServerConfig } from "./config.ts"; @@ -76,13 +71,10 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import { ProviderRegistry } from "./provider/Services/ProviderRegistry.ts"; -import { deriveProviderInstanceConfigMap } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; -import { listCodexProviderSkills } from "./provider/Layers/CodexProvider.ts"; import { - materializeCodexShadowHome, - resolveCodexHomeLayout, -} from "./provider/Drivers/CodexHomeLayout.ts"; -import { mergeProviderInstanceEnvironment } from "./provider/ProviderInstanceEnvironment.ts"; + makeProviderSkillsLister, + type ProviderSkillsListInput, +} from "./provider/ProviderSkillsLister.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; @@ -94,10 +86,7 @@ import { issueAssetUrl } from "./assets/AssetAccess.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; -import { - WorkspacePathOutsideRootError, - WorkspacePaths, -} from "./workspace/Services/WorkspacePaths.ts"; +import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; import { VcsStatusBroadcaster } from "./vcs/VcsStatusBroadcaster.ts"; import { VcsProvisioningService } from "./vcs/VcsProvisioningService.ts"; import { GitWorkflowService } from "./git/GitWorkflowService.ts"; @@ -127,25 +116,6 @@ import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); const isWorkspacePathOutsideRootError = Schema.is(WorkspacePathOutsideRootError); -const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); - -function describeUnknownCause(cause: unknown): string { - if (cause instanceof Error) { - return cause.message; - } - if (typeof cause === "string") { - return cause; - } - return "Unknown error"; -} - -function describeCodexSkillListFailure(cause: unknown, input: { instanceId: string; cwd: string }) { - if (Cause.isTimeoutError(cause)) { - return `Timed out listing Codex skills after ${Duration.toSeconds(CODEX_SKILL_LIST_TIMEOUT)}s (provider: '${input.instanceId}', cwd: '${input.cwd}').`; - } - return `Failed to list Codex skills (provider: '${input.instanceId}', cwd: '${input.cwd}').`; -} - const nowIso = Effect.map(DateTime.now, DateTime.formatIso); function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< @@ -171,7 +141,6 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< } const PROVIDER_STATUS_DEBOUNCE_MS = 200; -const CODEX_SKILL_LIST_TIMEOUT = Duration.seconds(15); const RPC_REQUIRED_SCOPE = new Map([ [ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope], @@ -285,7 +254,12 @@ function toAuthAccessStreamEvent( } } -const makeWsRpcLayer = (currentSession: AuthenticatedSession) => +const makeWsRpcLayer = ( + currentSession: AuthenticatedSession, + listProviderSkills: ( + input: ProviderSkillsListInput, + ) => Effect.Effect, +) => WsRpcGroup.toLayer( Effect.gen(function* () { const currentSessionId = currentSession.sessionId; @@ -306,10 +280,6 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const providerRegistry = yield* ProviderRegistry; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const config = yield* ServerConfig; - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const workspacePaths = yield* WorkspacePaths; const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; const startup = yield* ServerRuntimeStartup; @@ -334,98 +304,6 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const relayClient = yield* RelayClient.RelayClient; - const listProviderSkills = Effect.fn("ws.listProviderSkills")(function* (input: { - readonly instanceId: ProviderInstanceId; - readonly cwd: string; - }): Effect.fn.Return { - const providers = yield* providerRegistry.getProviders; - const snapshot = providers.find((provider) => provider.instanceId === input.instanceId); - if (!snapshot) { - return yield* new ServerProviderSkillsListError({ - message: `Provider instance '${input.instanceId}' was not found.`, - }); - } - if (snapshot.driver !== "codex") { - return { skills: snapshot.skills }; - } - - const settings = yield* serverSettings.getSettings.pipe( - Effect.mapError( - (cause) => - new ServerProviderSkillsListError({ - message: "Failed to read provider settings.", - cause, - }), - ), - ); - const instanceConfig = deriveProviderInstanceConfigMap(settings)[input.instanceId]; - if (!instanceConfig || instanceConfig.driver !== "codex") { - return yield* new ServerProviderSkillsListError({ - message: `Codex provider instance '${input.instanceId}' is not configured.`, - }); - } - - const decodedConfig = yield* decodeCodexSettings(instanceConfig.config ?? {}).pipe( - Effect.mapError( - (cause) => - new ServerProviderSkillsListError({ - message: `Failed to decode Codex provider settings for '${input.instanceId}'.`, - cause, - }), - ), - ); - const effectiveConfig = { - ...decodedConfig, - enabled: instanceConfig.enabled ?? decodedConfig.enabled, - }; - if (!effectiveConfig.enabled) { - return { skills: snapshot.skills }; - } - const normalizedCwd = yield* workspacePaths.normalizeWorkspaceRoot(input.cwd).pipe( - Effect.mapError( - (cause) => - new ServerProviderSkillsListError({ - message: `Invalid Codex skills cwd '${input.cwd}': ${cause.message}`, - cause, - }), - ), - ); - const homeLayout = yield* resolveCodexHomeLayout(effectiveConfig).pipe( - Effect.provideService(Path.Path, path), - ); - yield* materializeCodexShadowHome(homeLayout).pipe( - Effect.provideService(FileSystem.FileSystem, fileSystem), - Effect.provideService(Path.Path, path), - Effect.mapError( - (cause) => - new ServerProviderSkillsListError({ - message: `Failed to prepare Codex home for '${input.instanceId}': ${describeUnknownCause(cause)}`, - cause, - }), - ), - ); - const skills = yield* listCodexProviderSkills({ - binaryPath: effectiveConfig.binaryPath, - ...(homeLayout.effectiveHomePath ? { homePath: homeLayout.effectiveHomePath } : {}), - cwd: normalizedCwd, - environment: mergeProviderInstanceEnvironment(instanceConfig.environment ?? []), - }).pipe( - Effect.scoped, - Effect.timeout(CODEX_SKILL_LIST_TIMEOUT), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - Effect.mapError( - (cause) => - new ServerProviderSkillsListError({ - message: describeCodexSkillListFailure(cause, { - instanceId: input.instanceId, - cwd: normalizedCwd, - }), - cause, - }), - ), - ); - return { skills }; - }); const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ message: `The authenticated token is missing required scope: ${requiredScope}.`, @@ -1776,8 +1654,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ); export const websocketRpcRouteLayer = Layer.unwrap( - Effect.succeed( - HttpRouter.add( + Effect.gen(function* () { + const listProviderSkills = yield* makeProviderSkillsLister(); + return HttpRouter.add( "GET", "/ws", Effect.gen(function* () { @@ -1794,7 +1673,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( disableTracing: true, }).pipe( Effect.provide( - makeWsRpcLayer(session).pipe( + makeWsRpcLayer(session, listProviderSkills).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ProviderMaintenanceRunner.layer), @@ -1833,6 +1712,6 @@ export const websocketRpcRouteLayer = Layer.unwrap( EnvironmentInternalError: HttpServerRespondable.toResponse, }), ), - ), - ), + ); + }), ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 82ebb6f1809..0f68a77e97a 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -126,6 +126,7 @@ import { useMediaQuery } from "../../hooks/useMediaQuery"; import type { ReviewCommentContext } from "../../reviewCommentContext"; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; +const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const runtimeModeConfig: Record< RuntimeMode, @@ -783,10 +784,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => selectedProviderEntry?.snapshot ?? null, [selectedProviderEntry], ); - const selectedProviderFallbackSkills = useMemo( - () => selectedProviderStatus?.skills ?? [], - [selectedProviderStatus], - ); + const selectedProviderFallbackSkills = selectedProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS; const selectedProviderModels = useMemo>( () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts index d35a3f9609a..dafa99ecf43 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -1,5 +1,5 @@ import type { EnvironmentId, ProviderInstanceId, ServerProviderSkill } from "@t3tools/contracts"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { serverEnvironment } from "../state/server"; import { useEnvironmentQuery } from "../state/query"; @@ -18,26 +18,8 @@ export interface ProviderWorkspaceSkillsState { readonly error: string | null; } -interface InternalProviderWorkspaceSkillsState extends ProviderWorkspaceSkillsState { - readonly key: string | null; -} - -const cache = new Map>(); -const CACHE_MAX_ENTRIES = 100; const EMPTY_SKILLS: ReadonlyArray = []; -function setCachedSkills(key: string, skills: ReadonlyArray): void { - if (cache.has(key)) { - cache.delete(key); - } - cache.set(key, skills); - while (cache.size > CACHE_MAX_ENTRIES) { - const oldestKey = cache.keys().next().value; - if (oldestKey === undefined) break; - cache.delete(oldestKey); - } -} - function targetKey(target: Omit): string | null { if ( !target.enabled || @@ -51,10 +33,6 @@ function targetKey(target: Omit return `${target.environmentId}:${target.instanceId}:${target.cwd.trim()}`; } -export function invalidateProviderWorkspaceSkills(): void { - cache.clear(); -} - export function resolvePendingProviderWorkspaceSkills(input: { readonly currentKey: string | null; readonly nextKey: string; @@ -68,7 +46,6 @@ export function resolvePendingProviderWorkspaceSkills(input: { export function useProviderWorkspaceSkills( target: ProviderWorkspaceSkillsTarget, ): ProviderWorkspaceSkillsState { - const fallbackSkillsRef = useRef(target.fallbackSkills); const stableTarget = useMemo( () => ({ environmentId: target.environmentId, @@ -79,91 +56,31 @@ export function useProviderWorkspaceSkills( [target.cwd, target.enabled, target.environmentId, target.instanceId], ); const key = targetKey(stableTarget); - const skillsAtom = - key === null || - stableTarget.environmentId === null || - stableTarget.instanceId === null || - stableTarget.cwd === null - ? null - : serverEnvironment.listProviderSkills({ + const query = useEnvironmentQuery( + key !== null && stableTarget.environmentId !== null && stableTarget.instanceId !== null + ? serverEnvironment.providerSkills({ environmentId: stableTarget.environmentId, input: { instanceId: stableTarget.instanceId, - cwd: stableTarget.cwd, + cwd: stableTarget.cwd!, }, - }); - const skillQuery = useEnvironmentQuery(skillsAtom); - const [state, setState] = useState(() => ({ - key, - skills: target.fallbackSkills, - isPending: false, - error: null, - })); - - useEffect(() => { - fallbackSkillsRef.current = target.fallbackSkills; - if (key === null) { - setState({ key: null, skills: target.fallbackSkills, isPending: false, error: null }); - } - }, [key, target.fallbackSkills]); + }) + : null, + ); + const previousFallbackSkillsRef = useRef(target.fallbackSkills); useEffect(() => { - if ( - key === null || - stableTarget.environmentId === null || - stableTarget.instanceId === null || - stableTarget.cwd === null - ) { - setState({ key, skills: fallbackSkillsRef.current, isPending: false, error: null }); - return; - } - - const cached = cache.get(key); - if (cached && skillQuery.isPending) { - setState({ key, skills: cached, isPending: false, error: null }); - return; - } - - if (skillQuery.data) { - setCachedSkills(key, skillQuery.data.skills); - setState({ key, skills: skillQuery.data.skills, isPending: false, error: null }); - return; - } - - if (skillQuery.error) { - setState({ - key, - skills: EMPTY_SKILLS, - isPending: false, - error: skillQuery.error, - }); - return; - } - - setState((current) => ({ - key, - skills: resolvePendingProviderWorkspaceSkills({ - currentKey: current.key, - nextKey: key, - currentSkills: current.skills, - }), - isPending: skillQuery.isPending, - error: null, - })); - }, [ - key, - skillQuery.data, - skillQuery.error, - skillQuery.isPending, - stableTarget.cwd, - stableTarget.enabled, - stableTarget.environmentId, - stableTarget.instanceId, - ]); + if (previousFallbackSkillsRef.current === target.fallbackSkills) return; + previousFallbackSkillsRef.current = target.fallbackSkills; + if (key !== null) query.refresh(); + }, [key, query, target.fallbackSkills]); + if (key === null) { + return { skills: target.fallbackSkills, isPending: false, error: null }; + } return { - skills: state.skills, - isPending: state.isPending, - error: state.error, + skills: query.data?.skills ?? EMPTY_SKILLS, + isPending: query.isPending, + error: query.error, }; } diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 355568ebc06..f103d8ea346 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -133,6 +133,11 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:process-resource-history", tag: WS_METHODS.serverGetProcessResourceHistory, }), + providerSkills: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:provider-skills", + tag: WS_METHODS.serverListProviderSkills, + staleTimeMs: 30_000, + }), configProjection, welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:welcome", @@ -150,11 +155,6 @@ export function createServerEnvironmentAtoms( key: ({ environmentId }) => environmentId, }, }), - listProviderSkills: createEnvironmentRpcQueryAtomFamily(runtime, { - label: "environment-data:server:provider-skills", - tag: WS_METHODS.serverListProviderSkills, - staleTimeMs: 5_000, - }), updateProvider: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:update-provider", tag: WS_METHODS.serverUpdateProvider, From 5c37b879d1885d52ff49783e6aeedb422d889804 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 19 Jun 2026 18:00:51 +0100 Subject: [PATCH 09/55] Preserve workspace skills during refresh - Keep prior workspace skills visible while refreshes are pending - Add tests for loaded, pending, workspace-switch, and empty states --- .../lib/providerWorkspaceSkillsState.test.ts | 59 ++++++++++++++++++- .../src/lib/providerWorkspaceSkillsState.ts | 29 ++++++++- 2 files changed, 86 insertions(+), 2 deletions(-) diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.test.ts b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts index d11475c4d62..26c30ae0dc1 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.test.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts @@ -1,7 +1,10 @@ import type { ServerProviderSkill } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { resolvePendingProviderWorkspaceSkills } from "./providerWorkspaceSkillsState"; +import { + resolvePendingProviderWorkspaceSkills, + resolveProviderWorkspaceSkills, +} from "./providerWorkspaceSkillsState"; function skill(name: string): ServerProviderSkill { return { @@ -34,3 +37,57 @@ describe("resolvePendingProviderWorkspaceSkills", () => { expect(pendingSkills).toEqual([]); }); }); + +describe("resolveProviderWorkspaceSkills", () => { + it("uses loaded skills as soon as workspace data is available", () => { + const loadedSkills = [skill("repo-local")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: loadedSkills, + isPending: false, + currentKey: null, + currentSkills: [], + }), + ).toBe(loadedSkills); + }); + + it("preserves current skills while refreshing the same workspace", () => { + const currentSkills = [skill("repo-local")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: null, + isPending: true, + currentKey: "environment:codex:/repo", + currentSkills, + }), + ).toBe(currentSkills); + }); + + it("clears current skills while loading a different workspace", () => { + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/new-repo", + nextSkills: null, + isPending: true, + currentKey: "environment:codex:/old-repo", + currentSkills: [skill("old-repo-skill")], + }), + ).toEqual([]); + }); + + it("clears skills after a non-pending query with no data", () => { + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: null, + isPending: false, + currentKey: "environment:codex:/repo", + currentSkills: [skill("repo-local")], + }), + ).toEqual([]); + }); +}); diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts index dafa99ecf43..0fe102c36f5 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -43,6 +43,18 @@ export function resolvePendingProviderWorkspaceSkills(input: { : EMPTY_SKILLS; } +export function resolveProviderWorkspaceSkills(input: { + readonly nextKey: string; + readonly nextSkills: ReadonlyArray | null; + readonly isPending: boolean; + readonly currentKey: string | null; + readonly currentSkills: ReadonlyArray; +}): ReadonlyArray { + if (input.nextSkills !== null) return input.nextSkills; + if (!input.isPending) return EMPTY_SKILLS; + return resolvePendingProviderWorkspaceSkills(input); +} + export function useProviderWorkspaceSkills( target: ProviderWorkspaceSkillsTarget, ): ProviderWorkspaceSkillsState { @@ -74,12 +86,27 @@ export function useProviderWorkspaceSkills( previousFallbackSkillsRef.current = target.fallbackSkills; if (key !== null) query.refresh(); }, [key, query, target.fallbackSkills]); + const previousWorkspaceSkillsRef = useRef<{ + readonly key: string; + readonly skills: ReadonlyArray; + } | null>(null); + useEffect(() => { + if (key === null || query.data === null) return; + previousWorkspaceSkillsRef.current = { key, skills: query.data.skills }; + }, [key, query.data]); if (key === null) { return { skills: target.fallbackSkills, isPending: false, error: null }; } + const previousWorkspaceSkills = previousWorkspaceSkillsRef.current; return { - skills: query.data?.skills ?? EMPTY_SKILLS, + skills: resolveProviderWorkspaceSkills({ + nextKey: key, + nextSkills: query.data?.skills ?? null, + isPending: query.isPending, + currentKey: previousWorkspaceSkills?.key ?? null, + currentSkills: previousWorkspaceSkills?.skills ?? EMPTY_SKILLS, + }), isPending: query.isPending, error: query.error, }; From 05e81b614e753468826ca34bf46de225fc36ed55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 19 Jun 2026 18:18:40 +0100 Subject: [PATCH 10/55] Harden provider workspace skill cache - Preserve and clear cached workspace skills by query state - Cover pending data, empty data, and workspace switches --- .../lib/providerWorkspaceSkillsState.test.ts | 125 ++++++++++++++++++ .../src/lib/providerWorkspaceSkillsState.ts | 67 +++++++--- 2 files changed, 172 insertions(+), 20 deletions(-) diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.test.ts b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts index 26c30ae0dc1..d301f251ac2 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.test.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts @@ -2,6 +2,7 @@ import type { ServerProviderSkill } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { + resolveNextProviderWorkspaceSkillsSnapshot, resolvePendingProviderWorkspaceSkills, resolveProviderWorkspaceSkills, } from "./providerWorkspaceSkillsState"; @@ -53,6 +54,35 @@ describe("resolveProviderWorkspaceSkills", () => { ).toBe(loadedSkills); }); + it("uses loaded skills even when the query is still pending", () => { + const loadedSkills = [skill("repo-local")]; + const currentSkills = [skill("stale-repo-local")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: loadedSkills, + isPending: true, + currentKey: "environment:codex:/repo", + currentSkills, + }), + ).toBe(loadedSkills); + }); + + it("uses an empty loaded skill list as available workspace data", () => { + const loadedSkills: ReadonlyArray = []; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: loadedSkills, + isPending: true, + currentKey: "environment:codex:/repo", + currentSkills: [skill("repo-local")], + }), + ).toBe(loadedSkills); + }); + it("preserves current skills while refreshing the same workspace", () => { const currentSkills = [skill("repo-local")]; @@ -79,6 +109,39 @@ describe("resolveProviderWorkspaceSkills", () => { ).toEqual([]); }); + it("does not leak skills during rapid workspace switches", () => { + const repoASkills = [skill("repo-a")]; + const repoBSkills = [skill("repo-b")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo-b", + nextSkills: null, + isPending: true, + currentKey: "environment:codex:/repo-a", + currentSkills: repoASkills, + }), + ).toEqual([]); + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo-a", + nextSkills: null, + isPending: true, + currentKey: "environment:codex:/repo-b", + currentSkills: repoBSkills, + }), + ).toEqual([]); + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo-a", + nextSkills: null, + isPending: true, + currentKey: "environment:codex:/repo-a", + currentSkills: repoASkills, + }), + ).toBe(repoASkills); + }); + it("clears skills after a non-pending query with no data", () => { expect( resolveProviderWorkspaceSkills({ @@ -91,3 +154,65 @@ describe("resolveProviderWorkspaceSkills", () => { ).toEqual([]); }); }); + +describe("resolveNextProviderWorkspaceSkillsSnapshot", () => { + it("stores settled workspace skills for the active key", () => { + const loadedSkills = [skill("repo-local")]; + + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "environment:codex:/repo", + skills: loadedSkills, + isPending: false, + current: null, + }), + ).toEqual({ + key: "environment:codex:/repo", + skills: loadedSkills, + }); + }); + + it("preserves the current snapshot while pending", () => { + const current = { + key: "environment:codex:/repo", + skills: [skill("repo-local")], + }; + + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "environment:codex:/repo", + skills: [skill("fresh-repo-local")], + isPending: true, + current, + }), + ).toBe(current); + }); + + it("clears the snapshot when the target is disabled", () => { + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: null, + skills: [skill("repo-local")], + isPending: false, + current: { + key: "environment:codex:/repo", + skills: [skill("repo-local")], + }, + }), + ).toBeNull(); + }); + + it("clears the snapshot after a settled query without data", () => { + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "environment:codex:/repo", + skills: null, + isPending: false, + current: { + key: "environment:codex:/repo", + skills: [skill("repo-local")], + }, + }), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts index 0fe102c36f5..fbfd7b3584e 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -20,6 +20,22 @@ export interface ProviderWorkspaceSkillsState { const EMPTY_SKILLS: ReadonlyArray = []; +export interface ProviderWorkspaceSkillsSnapshotInput { + readonly currentKey: string | null; + readonly nextKey: string; + readonly currentSkills: ReadonlyArray; +} + +export interface ProviderWorkspaceSkillsResolutionInput extends ProviderWorkspaceSkillsSnapshotInput { + readonly nextSkills: ReadonlyArray | null; + readonly isPending: boolean; +} + +export interface ProviderWorkspaceSkillsSnapshot { + readonly key: string; + readonly skills: ReadonlyArray; +} + function targetKey(target: Omit): string | null { if ( !target.enabled || @@ -33,28 +49,37 @@ function targetKey(target: Omit return `${target.environmentId}:${target.instanceId}:${target.cwd.trim()}`; } -export function resolvePendingProviderWorkspaceSkills(input: { - readonly currentKey: string | null; - readonly nextKey: string; - readonly currentSkills: ReadonlyArray; -}): ReadonlyArray { +export function resolvePendingProviderWorkspaceSkills( + input: ProviderWorkspaceSkillsSnapshotInput, +): ReadonlyArray { return input.currentKey === input.nextKey && input.currentSkills.length > 0 ? input.currentSkills : EMPTY_SKILLS; } -export function resolveProviderWorkspaceSkills(input: { - readonly nextKey: string; - readonly nextSkills: ReadonlyArray | null; - readonly isPending: boolean; - readonly currentKey: string | null; - readonly currentSkills: ReadonlyArray; -}): ReadonlyArray { +/** + * Query result arrays are readonly cache values, so these helpers preserve references + * and rely on callers to keep them immutable. + */ +export function resolveProviderWorkspaceSkills( + input: ProviderWorkspaceSkillsResolutionInput, +): ReadonlyArray { if (input.nextSkills !== null) return input.nextSkills; if (!input.isPending) return EMPTY_SKILLS; return resolvePendingProviderWorkspaceSkills(input); } +export function resolveNextProviderWorkspaceSkillsSnapshot(input: { + readonly key: string | null; + readonly skills: ReadonlyArray | null; + readonly isPending: boolean; + readonly current: ProviderWorkspaceSkillsSnapshot | null; +}): ProviderWorkspaceSkillsSnapshot | null { + if (input.key === null) return null; + if (input.skills === null) return input.isPending ? input.current : null; + return input.isPending ? input.current : { key: input.key, skills: input.skills }; +} + export function useProviderWorkspaceSkills( target: ProviderWorkspaceSkillsTarget, ): ProviderWorkspaceSkillsState { @@ -86,14 +111,16 @@ export function useProviderWorkspaceSkills( previousFallbackSkillsRef.current = target.fallbackSkills; if (key !== null) query.refresh(); }, [key, query, target.fallbackSkills]); - const previousWorkspaceSkillsRef = useRef<{ - readonly key: string; - readonly skills: ReadonlyArray; - } | null>(null); + const previousWorkspaceSkillsRef = useRef(null); + const querySkills = query.data?.skills ?? null; useEffect(() => { - if (key === null || query.data === null) return; - previousWorkspaceSkillsRef.current = { key, skills: query.data.skills }; - }, [key, query.data]); + previousWorkspaceSkillsRef.current = resolveNextProviderWorkspaceSkillsSnapshot({ + key, + skills: querySkills, + isPending: query.isPending, + current: previousWorkspaceSkillsRef.current, + }); + }, [key, query.isPending, querySkills]); if (key === null) { return { skills: target.fallbackSkills, isPending: false, error: null }; @@ -102,7 +129,7 @@ export function useProviderWorkspaceSkills( return { skills: resolveProviderWorkspaceSkills({ nextKey: key, - nextSkills: query.data?.skills ?? null, + nextSkills: querySkills, isPending: query.isPending, currentKey: previousWorkspaceSkills?.key ?? null, currentSkills: previousWorkspaceSkills?.skills ?? EMPTY_SKILLS, From e5496d67f8c76ba6ba3b24c84d3cbb57595da392 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 19 Jun 2026 11:51:35 -0700 Subject: [PATCH 11/55] Cover provider skill discovery failures Co-authored-by: codex --- apps/server/scripts/acp-mock-agent.ts | 5 + .../scripts/codex-skills-mock-app-server.ts | 81 +++++++++++++ .../src/provider/Layers/CodexProvider.test.ts | 111 +++++++++++++++++- .../provider/Layers/CursorProvider.test.ts | 37 ++++++ .../src/provider/Layers/GrokProvider.test.ts | 45 +++++++ .../src/provider/ProviderSkillsLister.ts | 46 +++++--- 6 files changed, 307 insertions(+), 18 deletions(-) create mode 100644 apps/server/scripts/codex-skills-mock-app-server.ts diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 2b5da74eef0..64a763bb54e 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -13,6 +13,7 @@ import type * as AcpSchema from "effect-acp/schema"; const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH; +const cwdLogPath = process.env.T3_ACP_CWD_LOG_PATH; const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; const emitInterleavedAssistantToolCalls = process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; @@ -30,6 +31,10 @@ const permissionOptionIds = { }; const sessionId = "mock-session-1"; +if (cwdLogPath) { + appendFileSync(cwdLogPath, `${process.cwd()}\n`, "utf8"); +} + let currentModeId = "ask"; let currentModelId = "default"; let parameterizedModelPicker = false; diff --git a/apps/server/scripts/codex-skills-mock-app-server.ts b/apps/server/scripts/codex-skills-mock-app-server.ts new file mode 100644 index 00000000000..77cf02d3252 --- /dev/null +++ b/apps/server/scripts/codex-skills-mock-app-server.ts @@ -0,0 +1,81 @@ +#!/usr/bin/env node +// @effect-diagnostics nodeBuiltinImport:off +import { appendFileSync } from "node:fs"; + +const cwdLogPath = process.env.T3_CODEX_CWD_LOG_PATH; +const exitLogPath = process.env.T3_CODEX_EXIT_LOG_PATH; +const hangSkillsList = process.env.T3_CODEX_HANG_SKILLS_LIST === "1"; + +function appendLog(path: string | undefined, line: string): void { + if (path) appendFileSync(path, `${line}\n`, "utf8"); +} + +function respond(id: number | string, result: unknown): void { + process.stdout.write(`${JSON.stringify({ id, result })}\n`); +} + +process.once("SIGTERM", () => { + appendLog(exitLogPath, "SIGTERM"); + process.exit(0); +}); +process.once("exit", (code) => appendLog(exitLogPath, `exit:${code}`)); + +let remainder = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + remainder += chunk; + const lines = remainder.split("\n"); + remainder = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.trim()) continue; + const message = JSON.parse(line) as Record; + const id = message.id; + if ((typeof id !== "number" && typeof id !== "string") || typeof message.method !== "string") { + continue; + } + + switch (message.method) { + case "initialize": + appendLog(cwdLogPath, process.cwd()); + respond(id, { + userAgent: "t3code-codex-skills-test", + codexHome: process.cwd(), + platformFamily: "unix", + platformOs: "linux", + }); + break; + case "account/read": + respond(id, { + account: { type: "chatgpt", email: "test@example.com", planType: "plus" }, + requiresOpenaiAuth: false, + }); + break; + case "skills/list": + if (!hangSkillsList) { + respond(id, { + data: [ + { + cwd: process.cwd(), + errors: [], + skills: [ + { + name: "workspace-skill", + description: "A workspace-scoped test skill.", + shortDescription: "Workspace test skill", + path: `${process.cwd()}/.agents/skills/workspace-skill/SKILL.md`, + scope: "repo", + enabled: true, + }, + ], + }, + ], + }); + } + break; + default: + respond(id, {}); + } + } +}); +process.stdin.on("end", () => process.exit(0)); diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 0e21b76306b..52287e59bc8 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,6 +1,60 @@ -import { assert, it } from "@effect/vitest"; +import * as NodeOS from "node:os"; +import { setTimeout as delay } from "node:timers/promises"; -import { mapCodexModelCapabilities } from "./CodexProvider.ts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, expect, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; +import * as Path from "effect/Path"; +import * as TestClock from "effect/testing/TestClock"; + +import { listCodexProviderSkills, mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { listCodexProviderSkillsWithTimeout } from "../ProviderSkillsLister.ts"; + +const resolveMockAppServerPath = Effect.fn("resolveMockAppServerPath")(function* () { + const path = yield* Path.Path; + return yield* path.fromFileUrl( + new URL("../../../scripts/codex-skills-mock-app-server.ts", import.meta.url), + ); +}); + +const makeMockAppServer = Effect.fn("makeMockAppServer")(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const mockAppServerPath = yield* resolveMockAppServerPath(); + const directory = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "codex-skills-provider-", + }); + const binaryPath = path.join(directory, "codex"); + const command = [process.execPath, mockAppServerPath] + .map((argument) => JSON.stringify(argument)) + .join(" "); + yield* fileSystem.writeFileString(binaryPath, `#!/bin/sh\nexec ${command} "$@"\n`); + yield* fileSystem.chmod(binaryPath, 0o755); + const workspaceDirectory = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "codex-skills-workspace-", + }); + return { + binaryPath, + cwd: yield* fileSystem.realPath(workspaceDirectory), + cwdLogPath: path.join(directory, "cwd.log"), + exitLogPath: path.join(directory, "exit.log"), + }; +}); + +const waitForFileContent = Effect.fn("waitForFileContent")(function* (filePath: string) { + const fileSystem = yield* FileSystem.FileSystem; + for (let attempt = 0; attempt < 40; attempt += 1) { + const content = yield* fileSystem.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); + if (content.trim()) return content; + yield* Effect.promise(() => delay(50)); + } + return yield* Effect.die(`Timed out waiting for file content at ${filePath}`); +}); it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ @@ -102,3 +156,56 @@ it("uses standard routing when the catalog has no default service tier", () => { }, ]); }); + +describe("listCodexProviderSkills", () => { + it.effect("lists workspace skills from the configured cwd", () => + Effect.gen(function* () { + const fixture = yield* makeMockAppServer(); + const skills = yield* listCodexProviderSkills({ + binaryPath: fixture.binaryPath, + cwd: fixture.cwd, + environment: { + ...process.env, + T3_CODEX_CWD_LOG_PATH: fixture.cwdLogPath, + }, + }).pipe(Effect.scoped); + + expect(skills).toEqual([ + { + name: "workspace-skill", + description: "A workspace-scoped test skill.", + shortDescription: "Workspace test skill", + path: `${fixture.cwd}/.agents/skills/workspace-skill/SKILL.md`, + scope: "repo", + enabled: true, + }, + ]); + expect((yield* waitForFileContent(fixture.cwdLogPath)).trim()).toBe(fixture.cwd); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("reports timeouts and terminates the app-server", () => + Effect.gen(function* () { + const fixture = yield* makeMockAppServer(); + const fiber = yield* listCodexProviderSkillsWithTimeout({ + instanceId: ProviderInstanceId.make("codex"), + binaryPath: fixture.binaryPath, + cwd: fixture.cwd, + environment: { + ...process.env, + T3_CODEX_CWD_LOG_PATH: fixture.cwdLogPath, + T3_CODEX_EXIT_LOG_PATH: fixture.exitLogPath, + T3_CODEX_HANG_SKILLS_LIST: "1", + }, + }).pipe(Effect.forkChild); + + yield* waitForFileContent(fixture.cwdLogPath); + yield* TestClock.adjust("15 seconds"); + const error = yield* Fiber.join(fiber).pipe(Effect.flip); + expect(error.message).toBe( + `Timed out listing Codex skills after 15s (provider: 'codex', cwd: '${fixture.cwd}').`, + ); + expect(yield* waitForFileContent(fixture.exitLogPath)).toContain("SIGTERM"); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index b7de1ee6cb4..b0bea27cd1e 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -441,6 +441,43 @@ describe("checkCursorProviderStatus", () => { }); describe("discoverCursorModelsViaAcp", () => { + it("starts the ACP process in the configured cwd", async () => { + const fixture = await runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-provider-cwd-", + }); + const workspaceDirectory = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "cursor-provider-workspace-", + }); + return { + cwd: yield* fileSystem.realPath(workspaceDirectory), + cwdLogPath: path.join(directory, "cwd.log"), + wrapperPath: yield* makeMockAgentWrapper(), + }; + }), + ); + + await runNode( + discoverCursorModelsViaAcp( + { + enabled: true, + binaryPath: fixture.wrapperPath, + apiEndpoint: "", + customModels: [], + }, + fixture.cwd, + { ...process.env, T3_ACP_CWD_LOG_PATH: fixture.cwdLogPath }, + ).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + + await expect(runNode(waitForFileContent(fixture.cwdLogPath))).resolves.toBe(`${fixture.cwd}\n`); + }); + it("keeps the ACP probe runtime alive long enough to discover models", async () => { const wrapperPath = await runNode(makeMockAgentWrapper()); diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 71f01fdbaf1..ca247ec4195 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -1,3 +1,5 @@ +import * as NodeOS from "node:os"; + import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -37,6 +39,49 @@ describe("buildInitialGrokProviderSnapshot", () => { }); it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { + it.effect("starts ACP model discovery in the configured cwd", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-grok-cwd-" }); + const workspaceDirectory = yield* fileSystem.makeTempDirectory({ + directory: NodeOS.tmpdir(), + prefix: "t3code-grok-workspace-", + }); + const cwd = yield* fileSystem.realPath(workspaceDirectory); + const cwdLogPath = path.join(directory, "cwd.log"); + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const grokPath = path.join(directory, "grok"); + const command = [process.execPath, mockAgentPath] + .map((argument) => JSON.stringify(argument)) + .join(" "); + yield* fileSystem.writeFileString( + grokPath, + [ + "#!/bin/sh", + 'if [ "$1" = "--version" ]; then', + ' printf "grok-cli 0.0.99\\n"', + " exit 0", + "fi", + `exec ${command} "$@"`, + "", + ].join("\n"), + ); + yield* fileSystem.chmod(grokPath, 0o755); + + const snapshot = yield* checkGrokProviderStatus( + decodeGrokSettings({ enabled: true, binaryPath: grokPath }), + cwd, + { ...process.env, T3_ACP_CWD_LOG_PATH: cwdLogPath }, + ); + + expect(snapshot.status).toBe("ready"); + expect((yield* fileSystem.readFileString(cwdLogPath)).trim()).toBe(cwd); + }).pipe(Effect.scoped), + ); + it.effect("reports the binary as missing when the binary path does not resolve", () => Effect.gen(function* () { const snapshot = yield* checkGrokProviderStatus( diff --git a/apps/server/src/provider/ProviderSkillsLister.ts b/apps/server/src/provider/ProviderSkillsLister.ts index 6079456e610..ed39a029e68 100644 --- a/apps/server/src/provider/ProviderSkillsLister.ts +++ b/apps/server/src/provider/ProviderSkillsLister.ts @@ -75,6 +75,33 @@ function describeCodexSkillListFailure( return `Failed to list Codex skills (provider: '${input.instanceId}', cwd: '${input.cwd}').`; } +export const listCodexProviderSkillsWithTimeout = Effect.fn("listCodexProviderSkillsWithTimeout")( + function* (input: { + readonly instanceId: ProviderInstanceId; + readonly binaryPath: string; + readonly homePath?: string; + readonly cwd: string; + readonly environment: NodeJS.ProcessEnv; + }) { + return yield* listCodexProviderSkills({ + binaryPath: input.binaryPath, + ...(input.homePath ? { homePath: input.homePath } : {}), + cwd: input.cwd, + environment: input.environment, + }).pipe( + Effect.scoped, + Effect.timeout(CODEX_SKILL_LIST_TIMEOUT), + Effect.mapError( + (cause) => + new ServerProviderSkillsListError({ + message: describeCodexSkillListFailure(cause, input), + cause, + }), + ), + ); + }, +); + function requestKey(input: ProviderSkillsListInput): string { return JSON.stringify([input.instanceId, input.cwd]); } @@ -162,26 +189,13 @@ export const makeProviderSkillsLister = Effect.fn("makeProviderSkillsLister")(fu }), ), ); - const skills = yield* listCodexProviderSkills({ + const skills = yield* listCodexProviderSkillsWithTimeout({ + instanceId: input.instanceId, binaryPath: effectiveConfig.binaryPath, ...(homeLayout.effectiveHomePath ? { homePath: homeLayout.effectiveHomePath } : {}), cwd: normalizedCwd, environment: mergeProviderInstanceEnvironment(instanceConfig.environment ?? []), - }).pipe( - Effect.scoped, - Effect.timeout(CODEX_SKILL_LIST_TIMEOUT), - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), - Effect.mapError( - (cause) => - new ServerProviderSkillsListError({ - message: describeCodexSkillListFailure(cause, { - instanceId: input.instanceId, - cwd: normalizedCwd, - }), - cause, - }), - ), - ); + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner)); return { skills }; }); From 16540625bb4d686afd82984b4af7cec9eceb7027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sun, 21 Jun 2026 19:42:22 +0100 Subject: [PATCH 12/55] Stabilize provider skills error messages --- .../src/provider/ProviderSkillsLister.ts | 143 ++++++++++++------ apps/server/src/server.test.ts | 13 +- 2 files changed, 106 insertions(+), 50 deletions(-) diff --git a/apps/server/src/provider/ProviderSkillsLister.ts b/apps/server/src/provider/ProviderSkillsLister.ts index ed39a029e68..e00a16a0817 100644 --- a/apps/server/src/provider/ProviderSkillsLister.ts +++ b/apps/server/src/provider/ProviderSkillsLister.ts @@ -2,6 +2,7 @@ import { CodexSettings, ServerProviderSkillsListError, type ProviderInstanceId, + type ServerProviderSkillsListFailureReason, type ServerProviderSkillsListResult, } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; @@ -19,8 +20,9 @@ import { listCodexProviderSkills } from "./Layers/CodexProvider.ts"; import { deriveProviderInstanceConfigMap } from "./Layers/ProviderInstanceRegistryHydration.ts"; import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; +import { sanitizeErrorCause } from "../diagnostics/ErrorCause.ts"; import { ServerSettingsService } from "../serverSettings.ts"; -import { WorkspacePaths } from "../workspace/Services/WorkspacePaths.ts"; +import { WorkspacePaths } from "../workspace/WorkspacePaths.ts"; const CODEX_SKILL_LIST_TIMEOUT = Duration.seconds(15); const PROVIDER_SKILLS_CACHE_CAPACITY = 64; @@ -59,20 +61,53 @@ export const makeBoundedRequestCache = Effect.fn("makeBoundedRequestCache")(func }; }); -function describeUnknownCause(cause: unknown): string { - if (cause instanceof Error) return cause.message; - if (typeof cause === "string") return cause; - return "Unknown error"; +function optionalTrimmedNonEmptyString(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed && trimmed.length > 0 ? value : undefined; } -function describeCodexSkillListFailure( - cause: unknown, - input: { readonly instanceId: string; readonly cwd: string }, -): string { - if (Cause.isTimeoutError(cause)) { - return `Timed out listing Codex skills after ${Duration.toSeconds(CODEX_SKILL_LIST_TIMEOUT)}s (provider: '${input.instanceId}', cwd: '${input.cwd}').`; +function providerSkillsListError(input: { + readonly reason: ServerProviderSkillsListFailureReason; + readonly operation: string; + readonly message: string; + readonly instanceId?: ProviderInstanceId | undefined; + readonly cwd?: string | undefined; + readonly cause?: unknown; +}) { + const cwd = optionalTrimmedNonEmptyString(input.cwd); + return new ServerProviderSkillsListError({ + reason: input.reason, + operation: input.operation, + message: input.message, + ...(input.instanceId === undefined ? {} : { instanceId: input.instanceId }), + ...(cwd === undefined ? {} : { cwd }), + ...(input.cause === undefined ? {} : { cause: sanitizeErrorCause(input.cause) }), + }); +} + +function codexSkillListFailure(input: { + readonly cause: unknown; + readonly instanceId: ProviderInstanceId; + readonly cwd: string; +}) { + if (Cause.isTimeoutError(input.cause)) { + return providerSkillsListError({ + reason: "probe-timeout", + operation: "ProviderSkillsLister.listCodexProviderSkills", + instanceId: input.instanceId, + cwd: input.cwd, + message: `Timed out listing Codex skills after ${Duration.toSeconds(CODEX_SKILL_LIST_TIMEOUT)}s (provider: '${input.instanceId}', cwd: '${input.cwd}').`, + cause: input.cause, + }); } - return `Failed to list Codex skills (provider: '${input.instanceId}', cwd: '${input.cwd}').`; + return providerSkillsListError({ + reason: "probe-failed", + operation: "ProviderSkillsLister.listCodexProviderSkills", + instanceId: input.instanceId, + cwd: input.cwd, + message: `Failed to list Codex skills (provider: '${input.instanceId}', cwd: '${input.cwd}').`, + cause: input.cause, + }); } export const listCodexProviderSkillsWithTimeout = Effect.fn("listCodexProviderSkillsWithTimeout")( @@ -91,12 +126,12 @@ export const listCodexProviderSkillsWithTimeout = Effect.fn("listCodexProviderSk }).pipe( Effect.scoped, Effect.timeout(CODEX_SKILL_LIST_TIMEOUT), - Effect.mapError( - (cause) => - new ServerProviderSkillsListError({ - message: describeCodexSkillListFailure(cause, input), - cause, - }), + Effect.mapError((cause) => + codexSkillListFailure({ + cause, + instanceId: input.instanceId, + cwd: input.cwd, + }), ), ); }, @@ -125,7 +160,11 @@ export const makeProviderSkillsLister = Effect.fn("makeProviderSkillsLister")(fu const providers = yield* providerRegistry.getProviders; const snapshot = providers.find((provider) => provider.instanceId === input.instanceId); if (!snapshot) { - return yield* new ServerProviderSkillsListError({ + return yield* providerSkillsListError({ + reason: "provider-not-found", + operation: "ProviderSkillsLister.list", + instanceId: input.instanceId, + cwd: input.cwd, message: `Provider instance '${input.instanceId}' was not found.`, }); } @@ -134,28 +173,38 @@ export const makeProviderSkillsLister = Effect.fn("makeProviderSkillsLister")(fu } const settings = yield* serverSettings.getSettings.pipe( - Effect.mapError( - (cause) => - new ServerProviderSkillsListError({ - message: "Failed to read provider settings.", - cause, - }), + Effect.mapError((cause) => + providerSkillsListError({ + reason: "settings-read-failed", + operation: "ProviderSkillsLister.readSettings", + instanceId: input.instanceId, + cwd: input.cwd, + message: "Failed to read provider settings.", + cause, + }), ), ); const instanceConfig = deriveProviderInstanceConfigMap(settings)[input.instanceId]; if (!instanceConfig || instanceConfig.driver !== "codex") { - return yield* new ServerProviderSkillsListError({ + return yield* providerSkillsListError({ + reason: "provider-not-configured", + operation: "ProviderSkillsLister.resolveCodexInstance", + instanceId: input.instanceId, + cwd: input.cwd, message: `Codex provider instance '${input.instanceId}' is not configured.`, }); } const decodedConfig = yield* decodeCodexSettings(instanceConfig.config ?? {}).pipe( - Effect.mapError( - (cause) => - new ServerProviderSkillsListError({ - message: `Failed to decode Codex provider settings for '${input.instanceId}'.`, - cause, - }), + Effect.mapError((cause) => + providerSkillsListError({ + reason: "settings-decode-failed", + operation: "ProviderSkillsLister.decodeCodexSettings", + instanceId: input.instanceId, + cwd: input.cwd, + message: `Failed to decode Codex provider settings for '${input.instanceId}'.`, + cause, + }), ), ); const effectiveConfig = { @@ -167,12 +216,15 @@ export const makeProviderSkillsLister = Effect.fn("makeProviderSkillsLister")(fu } const normalizedCwd = yield* workspacePaths.normalizeWorkspaceRoot(input.cwd).pipe( - Effect.mapError( - (cause) => - new ServerProviderSkillsListError({ - message: `Invalid Codex skills cwd '${input.cwd}': ${cause.message}`, - cause, - }), + Effect.mapError((cause) => + providerSkillsListError({ + reason: "invalid-cwd", + operation: "ProviderSkillsLister.normalizeCwd", + instanceId: input.instanceId, + cwd: input.cwd, + message: `Invalid Codex skills cwd '${input.cwd}'.`, + cause, + }), ), ); const homeLayout = yield* resolveCodexHomeLayout(effectiveConfig).pipe( @@ -181,12 +233,15 @@ export const makeProviderSkillsLister = Effect.fn("makeProviderSkillsLister")(fu yield* materializeCodexShadowHome(homeLayout).pipe( Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(Path.Path, path), - Effect.mapError( - (cause) => - new ServerProviderSkillsListError({ - message: `Failed to prepare Codex home for '${input.instanceId}': ${describeUnknownCause(cause)}`, - cause, - }), + Effect.mapError((cause) => + providerSkillsListError({ + reason: "home-prepare-failed", + operation: "ProviderSkillsLister.prepareCodexHome", + instanceId: input.instanceId, + cwd: input.cwd, + message: `Failed to prepare Codex home for '${input.instanceId}'.`, + cause, + }), ), ); const skills = yield* listCodexProviderSkillsWithTimeout({ diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 49240d6e582..4206174a69b 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4435,14 +4435,15 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assertTrue(result._tag === "Failure"); assertTrue(result.failure._tag === "ServerProviderSkillsListError"); - assertInclude( - result.failure.message, - "Invalid Codex skills cwd '/definitely/not/a/real/workspace/path'", - ); - assertInclude( + assert.equal( result.failure.message, - "Workspace root does not exist: /definitely/not/a/real/workspace/path", + "Invalid Codex skills cwd '/definitely/not/a/real/workspace/path'.", ); + assert.notInclude(result.failure.message, "Workspace root does not exist"); + assert.property(result.failure, "cause"); + const failureCause = result.failure.cause; + assert.instanceOf(failureCause, Error); + assert.include(failureCause.message, "Workspace root does not exist"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); From f75f6cdc549240116449e1f20dc655897e3da784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sun, 21 Jun 2026 21:00:13 +0100 Subject: [PATCH 13/55] Port provider skills diagnostic details --- .../src/provider/ProviderSkillsLister.ts | 63 ++++++++++++++++- apps/server/src/server.test.ts | 68 +++++++++++++++++++ .../lib/providerWorkspaceSkillsState.test.ts | 56 +++++++++++++++ .../src/lib/providerWorkspaceSkillsState.ts | 39 ++++++++++- apps/web/src/state/query.ts | 2 + packages/contracts/src/server.ts | 9 +++ 6 files changed, 233 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/ProviderSkillsLister.ts b/apps/server/src/provider/ProviderSkillsLister.ts index e00a16a0817..ec86c27ff11 100644 --- a/apps/server/src/provider/ProviderSkillsLister.ts +++ b/apps/server/src/provider/ProviderSkillsLister.ts @@ -15,20 +15,43 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { materializeCodexShadowHome, resolveCodexHomeLayout } from "./Drivers/CodexHomeLayout.ts"; +import { + CodexShadowHomeEntryConflictError, + CodexShadowHomeFileSystemError, + CodexShadowHomePathConflictError, + CodexShadowHomePrivateEntrySymlinkError, + materializeCodexShadowHome, + resolveCodexHomeLayout, +} from "./Drivers/CodexHomeLayout.ts"; import { listCodexProviderSkills } from "./Layers/CodexProvider.ts"; import { deriveProviderInstanceConfigMap } from "./Layers/ProviderInstanceRegistryHydration.ts"; import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; import { sanitizeErrorCause } from "../diagnostics/ErrorCause.ts"; import { ServerSettingsService } from "../serverSettings.ts"; -import { WorkspacePaths } from "../workspace/WorkspacePaths.ts"; +import { + WorkspacePaths, + WorkspaceRootCreateFailedError, + WorkspaceRootNotDirectoryError, + WorkspaceRootNotExistsError, + WorkspaceRootStatFailedError, +} from "../workspace/WorkspacePaths.ts"; const CODEX_SKILL_LIST_TIMEOUT = Duration.seconds(15); const PROVIDER_SKILLS_CACHE_CAPACITY = 64; const PROVIDER_SKILLS_CACHE_TTL = Duration.seconds(1); const PROVIDER_SKILLS_MAX_CONCURRENCY = 4; const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); +const isWorkspaceRootNotExistsError = Schema.is(WorkspaceRootNotExistsError); +const isWorkspaceRootNotDirectoryError = Schema.is(WorkspaceRootNotDirectoryError); +const isWorkspaceRootCreateFailedError = Schema.is(WorkspaceRootCreateFailedError); +const isWorkspaceRootStatFailedError = Schema.is(WorkspaceRootStatFailedError); +const isCodexShadowHomePathConflictError = Schema.is(CodexShadowHomePathConflictError); +const isCodexShadowHomeEntryConflictError = Schema.is(CodexShadowHomeEntryConflictError); +const isCodexShadowHomePrivateEntrySymlinkError = Schema.is( + CodexShadowHomePrivateEntrySymlinkError, +); +const isCodexShadowHomeFileSystemError = Schema.is(CodexShadowHomeFileSystemError); export interface ProviderSkillsListInput { readonly instanceId: ProviderInstanceId; @@ -70,6 +93,7 @@ function providerSkillsListError(input: { readonly reason: ServerProviderSkillsListFailureReason; readonly operation: string; readonly message: string; + readonly detail?: string | undefined; readonly instanceId?: ProviderInstanceId | undefined; readonly cwd?: string | undefined; readonly cause?: unknown; @@ -79,12 +103,45 @@ function providerSkillsListError(input: { reason: input.reason, operation: input.operation, message: input.message, + ...(input.detail === undefined ? {} : { detail: input.detail }), ...(input.instanceId === undefined ? {} : { instanceId: input.instanceId }), ...(cwd === undefined ? {} : { cwd }), ...(input.cause === undefined ? {} : { cause: sanitizeErrorCause(input.cause) }), }); } +function workspaceCwdFailureDetail(cause: unknown): string { + if (isWorkspaceRootNotExistsError(cause)) { + return `Workspace root does not exist: ${cause.normalizedWorkspaceRoot}.`; + } + if (isWorkspaceRootNotDirectoryError(cause)) { + return `Workspace root is not a directory: ${cause.normalizedWorkspaceRoot}.`; + } + if (isWorkspaceRootCreateFailedError(cause)) { + return `Failed to create workspace root: ${cause.normalizedWorkspaceRoot}.`; + } + if (isWorkspaceRootStatFailedError(cause)) { + return `Failed to stat workspace root '${cause.normalizedWorkspaceRoot}' during '${cause.phase}'.`; + } + return "Check the requested workspace path and filesystem permissions."; +} + +function codexHomePrepareFailureDetail(cause: unknown): string { + if (isCodexShadowHomePathConflictError(cause)) { + return `Codex shadow home path '${cause.effectiveHomePath}' must be different from shared home path '${cause.sharedHomePath}'.`; + } + if (isCodexShadowHomeEntryConflictError(cause)) { + return `Codex shadow home entry '${cause.entryName}' already exists and is not a symlink.`; + } + if (isCodexShadowHomePrivateEntrySymlinkError(cause)) { + return `Codex shadow home private entry '${cause.entryName}' must be a real file.`; + } + if (isCodexShadowHomeFileSystemError(cause)) { + return `Codex shadow home filesystem operation '${cause.operation}' failed for '${cause.path}'.`; + } + return "Check the configured Codex home paths and filesystem permissions."; +} + function codexSkillListFailure(input: { readonly cause: unknown; readonly instanceId: ProviderInstanceId; @@ -223,6 +280,7 @@ export const makeProviderSkillsLister = Effect.fn("makeProviderSkillsLister")(fu instanceId: input.instanceId, cwd: input.cwd, message: `Invalid Codex skills cwd '${input.cwd}'.`, + detail: workspaceCwdFailureDetail(cause), cause, }), ), @@ -240,6 +298,7 @@ export const makeProviderSkillsLister = Effect.fn("makeProviderSkillsLister")(fu instanceId: input.instanceId, cwd: input.cwd, message: `Failed to prepare Codex home for '${input.instanceId}'.`, + detail: codexHomePrepareFailureDetail(cause), cause, }), ), diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 4206174a69b..c29eb5ea109 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4440,6 +4440,10 @@ it.layer(NodeServices.layer)("server router seam", (it) => { "Invalid Codex skills cwd '/definitely/not/a/real/workspace/path'.", ); assert.notInclude(result.failure.message, "Workspace root does not exist"); + assert.equal( + result.failure.detail, + "Workspace root does not exist: /definitely/not/a/real/workspace/path.", + ); assert.property(result.failure, "cause"); const failureCause = result.failure.cause; assert.instanceOf(failureCause, Error); @@ -4447,6 +4451,70 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc server.listProviderSkills reports Codex home details", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const instanceId = ProviderInstanceId.make("codex"); + const driver = ProviderDriverKind.make("codex"); + const workspaceDir = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-provider-skills-", + }); + const sharedHomePath = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-codex-home-", + }); + const providers = [ + makeServerProviderSnapshot({ + instanceId, + driver, + }), + ]; + + yield* buildAppUnderTest({ + layers: { + providerRegistry: { + getProviders: Effect.succeed(providers), + }, + serverSettings: { + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [instanceId]: { + driver, + enabled: true, + config: { + homePath: sharedHomePath, + shadowHomePath: sharedHomePath, + }, + }, + }, + }), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.serverListProviderSkills]({ + instanceId, + cwd: workspaceDir, + }), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "ServerProviderSkillsListError"); + assert.equal(result.failure.reason, "home-prepare-failed"); + assert.equal(result.failure.message, "Failed to prepare Codex home for 'codex'."); + assert.include(result.failure.detail ?? "", "Codex shadow home path"); + assert.include(result.failure.detail ?? "", sharedHomePath); + assert.property(result.failure, "cause"); + const failureCause = result.failure.cause; + assert.instanceOf(failureCause, Error); + assert.include(failureCause.message, "Codex shadow home path"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect( "routes websocket rpc subscribeServerLifecycle replays snapshot and streams updates", () => diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.test.ts b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts index d301f251ac2..b07f0ff17c1 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.test.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts @@ -1,7 +1,10 @@ import type { ServerProviderSkill } from "@t3tools/contracts"; +import { ServerProviderSkillsListError } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; import { describe, expect, it } from "vite-plus/test"; import { + formatProviderWorkspaceSkillsError, resolveNextProviderWorkspaceSkillsSnapshot, resolvePendingProviderWorkspaceSkills, resolveProviderWorkspaceSkills, @@ -39,6 +42,59 @@ describe("resolvePendingProviderWorkspaceSkills", () => { }); }); +describe("formatProviderWorkspaceSkillsError", () => { + it("appends structured provider skill diagnostics without putting cause text in the wrapper message", () => { + const error = new ServerProviderSkillsListError({ + reason: "invalid-cwd", + operation: "ProviderSkillsLister.normalizeCwd", + message: "Invalid Codex skills cwd '/missing'.", + detail: "Workspace root does not exist: /missing.", + cause: new Error("raw platform detail"), + }); + + expect( + formatProviderWorkspaceSkillsError({ + error: error.message, + cause: Cause.fail(error), + }), + ).toBe("Invalid Codex skills cwd '/missing'. Workspace root does not exist: /missing."); + expect(error.message).not.toContain("raw platform detail"); + }); + + it("preserves the query wrapper message when structured detail is available", () => { + const error = new ServerProviderSkillsListError({ + reason: "home-prepare-failed", + operation: "ProviderSkillsLister.prepareCodexHome", + message: "Failed to prepare Codex home for 'codex'.", + detail: "Check the configured Codex home paths and filesystem permissions.", + }); + + expect( + formatProviderWorkspaceSkillsError({ + error: "Environment request failed: Failed to prepare Codex home for 'codex'.", + cause: Cause.fail(error), + }), + ).toBe( + "Environment request failed: Failed to prepare Codex home for 'codex'. Check the configured Codex home paths and filesystem permissions.", + ); + }); + + it("leaves provider skill errors without detail unchanged", () => { + const error = new ServerProviderSkillsListError({ + reason: "probe-failed", + operation: "ProviderSkillsLister.listCodexProviderSkills", + message: "Failed to list Codex skills.", + }); + + expect( + formatProviderWorkspaceSkillsError({ + error: error.message, + cause: Cause.fail(error), + }), + ).toBe("Failed to list Codex skills."); + }); +}); + describe("resolveProviderWorkspaceSkills", () => { it("uses loaded skills as soon as workspace data is available", () => { const loadedSkills = [skill("repo-local")]; diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts index fbfd7b3584e..de433ab4b8f 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -1,4 +1,11 @@ -import type { EnvironmentId, ProviderInstanceId, ServerProviderSkill } from "@t3tools/contracts"; +import { + ServerProviderSkillsListError, + type EnvironmentId, + type ProviderInstanceId, + type ServerProviderSkill, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Schema from "effect/Schema"; import { useEffect, useMemo, useRef } from "react"; import { serverEnvironment } from "../state/server"; @@ -19,6 +26,7 @@ export interface ProviderWorkspaceSkillsState { } const EMPTY_SKILLS: ReadonlyArray = []; +const isServerProviderSkillsListError = Schema.is(ServerProviderSkillsListError); export interface ProviderWorkspaceSkillsSnapshotInput { readonly currentKey: string | null; @@ -49,6 +57,33 @@ function targetKey(target: Omit return `${target.environmentId}:${target.instanceId}:${target.cwd.trim()}`; } +function providerSkillsListErrorDetail(error: unknown): { + readonly detail: string | null; +} | null { + if (!isServerProviderSkillsListError(error)) return null; + return { + detail: + typeof error.detail === "string" && error.detail.trim().length > 0 ? error.detail : null, + }; +} + +export function formatProviderWorkspaceSkillsError(input: { + readonly error: string | null; + readonly cause: Cause.Cause | null; +}): string | null { + if (input.error === null) return null; + if (input.cause === null) return input.error; + + const providerError = providerSkillsListErrorDetail(Cause.squash(input.cause)); + if (providerError === null || providerError.detail === null) return input.error; + if (input.error.includes(providerError.detail)) return input.error; + return `${input.error} ${providerError.detail}`; +} + +export function invalidateProviderWorkspaceSkills(): void { + // Workspace skill requests are now owned by the environment query cache. +} + export function resolvePendingProviderWorkspaceSkills( input: ProviderWorkspaceSkillsSnapshotInput, ): ReadonlyArray { @@ -135,6 +170,6 @@ export function useProviderWorkspaceSkills( currentSkills: previousWorkspaceSkills?.skills ?? EMPTY_SKILLS, }), isPending: query.isPending, - error: query.error, + error: formatProviderWorkspaceSkillsError({ error: query.error, cause: query.errorCause }), }; } diff --git a/apps/web/src/state/query.ts b/apps/web/src/state/query.ts index 2610f1724a0..5ff8437f1fa 100644 --- a/apps/web/src/state/query.ts +++ b/apps/web/src/state/query.ts @@ -10,6 +10,7 @@ const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial(fals export interface EnvironmentQueryView { readonly data: A | null; readonly error: string | null; + readonly errorCause: Cause.Cause | null; readonly isPending: boolean; readonly refresh: () => void; } @@ -30,6 +31,7 @@ export function useEnvironmentQuery( return { data: Option.getOrNull(AsyncResult.value(result)), error: result._tag === "Failure" ? formatError(result.cause) : null, + errorCause: result._tag === "Failure" ? result.cause : null, isPending: atom !== null && result.waiting, refresh, }; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index ef4e3fd3bbd..25bc87e0bf0 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -106,6 +106,15 @@ export class ServerProviderSkillsListError extends Schema.TaggedErrorClass Date: Thu, 25 Jun 2026 20:13:52 +0100 Subject: [PATCH 14/55] Show workspace skill chips in chat messages --- apps/web/src/components/ChatView.tsx | 11 +++++++- .../components/chat/MessagesTimeline.test.tsx | 25 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9fb8d647b4e..05f6f4842ec 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -152,6 +152,7 @@ import { getProviderModelCapabilities, resolveSelectableProvider } from "../prov import { useEnvironmentSettings } from "../hooks/useSettings"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; +import { useProviderWorkspaceSkills } from "../lib/providerWorkspaceSkillsState"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { deriveLogicalProjectKeyFromSettings, @@ -2142,6 +2143,14 @@ function ChatViewContent(props: ChatViewProps) { const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); + const activeProviderFallbackSkills = activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS; + const activeProviderWorkspaceSkills = useProviderWorkspaceSkills({ + environmentId, + instanceId: activeProviderStatus?.instanceId ?? null, + cwd: gitCwd, + enabled: true, + fallbackSkills: activeProviderFallbackSkills, + }); const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; @@ -4787,7 +4796,7 @@ function ChatViewContent(props: ChatViewProps) { resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} workspaceRoot={activeWorkspaceRoot} - skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} + skills={activeProviderWorkspaceSkills.skills} anchorMessageId={timelineAnchorMessageId} contentInsetEndAdjustment={composerOverlayHeight} onIsAtEndChange={onIsAtEndChange} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 3008bd2ba9e..e0a5babf92f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -215,6 +215,31 @@ describe("MessagesTimeline", () => { expect(markup).toContain('data-user-message-collapsible="false"'); }); + it("renders local skill references as chips in user message bubbles", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Update Main"); + expect(markup).toContain("Piz Git Workflow"); + expect(markup).toContain('data-markdown-copy="$update-main"'); + expect(markup).toContain('data-markdown-copy="$piz-git-workflow"'); + }); + it("renders inline terminal labels with the composer chip UI", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( From 31999493e1d851c35134030b3d3465ce18505f71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 25 Jun 2026 20:14:22 +0100 Subject: [PATCH 15/55] Load workspace skills lazily in composer --- apps/web/src/components/chat/ChatComposer.tsx | 4 +- apps/web/src/composer-editor-mentions.test.ts | 17 ++++++++ apps/web/src/composer-editor-mentions.ts | 10 +++++ .../lib/providerWorkspaceSkillsState.test.ts | 39 ++++++++++++++++++- .../src/lib/providerWorkspaceSkillsState.ts | 9 ++++- 5 files changed, 75 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 4dac05c1165..e1e9e419d1f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -42,6 +42,7 @@ import { expandCollapsedComposerCursor, replaceTextRange, } from "../../composer-logic"; +import { promptHasComposerSkillReference } from "../../composer-editor-mentions"; import { deriveComposerSendState, readFileAsDataUrl } from "../ChatView.logic"; import { type ComposerImageAttachment, @@ -793,6 +794,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => getComposerPromptInjectionState(prompt), [prompt], ); + const promptHasSkillReference = useMemo(() => promptHasComposerSkillReference(prompt), [prompt]); const composerProviderState = useMemo( () => getComposerProviderState({ @@ -942,7 +944,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) environmentId, instanceId: selectedProviderStatus?.instanceId ?? null, cwd: gitCwd, - enabled: true, + enabled: composerTriggerKind === "skill" || promptHasSkillReference, fallbackSkills: selectedProviderFallbackSkills, }); diff --git a/apps/web/src/composer-editor-mentions.test.ts b/apps/web/src/composer-editor-mentions.test.ts index d79170769d5..9d704828783 100644 --- a/apps/web/src/composer-editor-mentions.test.ts +++ b/apps/web/src/composer-editor-mentions.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + promptHasComposerSkillReference, selectionTouchesMentionBoundary, splitPromptIntoComposerSegments, } from "./composer-editor-mentions"; @@ -142,6 +143,22 @@ describe("splitPromptIntoComposerSegments", () => { }); }); +describe("promptHasComposerSkillReference", () => { + it("returns true only when the prompt contains a complete skill token", () => { + expect(promptHasComposerSkillReference("Use $review-follow-up please")).toBe(true); + expect(promptHasComposerSkillReference("Use $review-follow-up")).toBe(false); + expect(promptHasComposerSkillReference("Read @AGENTS.md please")).toBe(false); + }); + + it("ignores terminal context placeholders while checking text slices", () => { + expect( + promptHasComposerSkillReference( + `Inspect ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}$review-follow-up please`, + ), + ).toBe(true); + }); +}); + describe("selectionTouchesMentionBoundary", () => { it("returns true when selection includes the whitespace after a mention", () => { expect( diff --git a/apps/web/src/composer-editor-mentions.ts b/apps/web/src/composer-editor-mentions.ts index 8b9a53808f8..d1ce659b98d 100644 --- a/apps/web/src/composer-editor-mentions.ts +++ b/apps/web/src/composer-editor-mentions.ts @@ -221,3 +221,13 @@ export function splitPromptIntoComposerSegments( return segments; } + +export function promptHasComposerSkillReference(prompt: string): boolean { + if (!prompt) { + return false; + } + + return forEachPromptTextSlice(prompt, (text) => + collectComposerInlineTokens(text).some((match) => match.type === "skill"), + ); +} diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.test.ts b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts index b07f0ff17c1..183f15d3410 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.test.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts @@ -104,8 +104,10 @@ describe("resolveProviderWorkspaceSkills", () => { nextKey: "environment:codex:/repo", nextSkills: loadedSkills, isPending: false, + error: null, currentKey: null, currentSkills: [], + fallbackSkills: [skill("provider-fallback")], }), ).toBe(loadedSkills); }); @@ -119,24 +121,29 @@ describe("resolveProviderWorkspaceSkills", () => { nextKey: "environment:codex:/repo", nextSkills: loadedSkills, isPending: true, + error: null, currentKey: "environment:codex:/repo", currentSkills, + fallbackSkills: [skill("provider-fallback")], }), ).toBe(loadedSkills); }); - it("uses an empty loaded skill list as available workspace data", () => { + it("uses fallback skills for empty loaded workspace skills", () => { const loadedSkills: ReadonlyArray = []; + const fallbackSkills = [skill("provider-fallback")]; expect( resolveProviderWorkspaceSkills({ nextKey: "environment:codex:/repo", nextSkills: loadedSkills, isPending: true, + error: null, currentKey: "environment:codex:/repo", currentSkills: [skill("repo-local")], + fallbackSkills, }), - ).toBe(loadedSkills); + ).toBe(fallbackSkills); }); it("preserves current skills while refreshing the same workspace", () => { @@ -147,8 +154,10 @@ describe("resolveProviderWorkspaceSkills", () => { nextKey: "environment:codex:/repo", nextSkills: null, isPending: true, + error: null, currentKey: "environment:codex:/repo", currentSkills, + fallbackSkills: [skill("provider-fallback")], }), ).toBe(currentSkills); }); @@ -159,8 +168,10 @@ describe("resolveProviderWorkspaceSkills", () => { nextKey: "environment:codex:/new-repo", nextSkills: null, isPending: true, + error: null, currentKey: "environment:codex:/old-repo", currentSkills: [skill("old-repo-skill")], + fallbackSkills: [skill("provider-fallback")], }), ).toEqual([]); }); @@ -174,8 +185,10 @@ describe("resolveProviderWorkspaceSkills", () => { nextKey: "environment:codex:/repo-b", nextSkills: null, isPending: true, + error: null, currentKey: "environment:codex:/repo-a", currentSkills: repoASkills, + fallbackSkills: [skill("provider-fallback")], }), ).toEqual([]); expect( @@ -183,8 +196,10 @@ describe("resolveProviderWorkspaceSkills", () => { nextKey: "environment:codex:/repo-a", nextSkills: null, isPending: true, + error: null, currentKey: "environment:codex:/repo-b", currentSkills: repoBSkills, + fallbackSkills: [skill("provider-fallback")], }), ).toEqual([]); expect( @@ -192,8 +207,10 @@ describe("resolveProviderWorkspaceSkills", () => { nextKey: "environment:codex:/repo-a", nextSkills: null, isPending: true, + error: null, currentKey: "environment:codex:/repo-a", currentSkills: repoASkills, + fallbackSkills: [skill("provider-fallback")], }), ).toBe(repoASkills); }); @@ -204,11 +221,29 @@ describe("resolveProviderWorkspaceSkills", () => { nextKey: "environment:codex:/repo", nextSkills: null, isPending: false, + error: null, currentKey: "environment:codex:/repo", currentSkills: [skill("repo-local")], + fallbackSkills: [skill("provider-fallback")], }), ).toEqual([]); }); + + it("uses fallback skills after a query error", () => { + const fallbackSkills = [skill("provider-fallback")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: null, + isPending: false, + error: "Invalid git cwd", + currentKey: "environment:codex:/repo", + currentSkills: [], + fallbackSkills, + }), + ).toBe(fallbackSkills); + }); }); describe("resolveNextProviderWorkspaceSkillsSnapshot", () => { diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts index de433ab4b8f..09c6c51274f 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -37,6 +37,8 @@ export interface ProviderWorkspaceSkillsSnapshotInput { export interface ProviderWorkspaceSkillsResolutionInput extends ProviderWorkspaceSkillsSnapshotInput { readonly nextSkills: ReadonlyArray | null; readonly isPending: boolean; + readonly error: string | null; + readonly fallbackSkills: ReadonlyArray; } export interface ProviderWorkspaceSkillsSnapshot { @@ -99,7 +101,10 @@ export function resolvePendingProviderWorkspaceSkills( export function resolveProviderWorkspaceSkills( input: ProviderWorkspaceSkillsResolutionInput, ): ReadonlyArray { - if (input.nextSkills !== null) return input.nextSkills; + if (input.nextSkills !== null) { + return input.nextSkills.length > 0 ? input.nextSkills : input.fallbackSkills; + } + if (input.error !== null) return input.fallbackSkills; if (!input.isPending) return EMPTY_SKILLS; return resolvePendingProviderWorkspaceSkills(input); } @@ -166,8 +171,10 @@ export function useProviderWorkspaceSkills( nextKey: key, nextSkills: querySkills, isPending: query.isPending, + error: query.error, currentKey: previousWorkspaceSkills?.key ?? null, currentSkills: previousWorkspaceSkills?.skills ?? EMPTY_SKILLS, + fallbackSkills: target.fallbackSkills, }), isPending: query.isPending, error: formatProviderWorkspaceSkillsError({ error: query.error, cause: query.errorCause }), From 3464e4270243e3e28d2e3d023c4ddf806e7eaaa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 25 Jun 2026 20:15:26 +0100 Subject: [PATCH 16/55] Restore provider skills failure reason contract --- packages/contracts/src/server.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 25bc87e0bf0..bed9705384d 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -102,6 +102,19 @@ export const ServerProviderSkillsListResult = Schema.Struct({ }); export type ServerProviderSkillsListResult = typeof ServerProviderSkillsListResult.Type; +export const ServerProviderSkillsListFailureReason = Schema.Literals([ + "provider-not-found", + "provider-not-configured", + "settings-read-failed", + "settings-decode-failed", + "invalid-cwd", + "home-prepare-failed", + "probe-timeout", + "probe-failed", +]); +export type ServerProviderSkillsListFailureReason = + typeof ServerProviderSkillsListFailureReason.Type; + export class ServerProviderSkillsListError extends Schema.TaggedErrorClass()( "ServerProviderSkillsListError", { From 3e2be1149131d6ecbf3dff11cf72eb88e00b124e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 25 Jun 2026 20:16:06 +0100 Subject: [PATCH 17/55] Add provider skills error cause sanitizer --- apps/server/src/diagnostics/ErrorCause.ts | 85 +++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 apps/server/src/diagnostics/ErrorCause.ts diff --git a/apps/server/src/diagnostics/ErrorCause.ts b/apps/server/src/diagnostics/ErrorCause.ts new file mode 100644 index 00000000000..e0c7be04432 --- /dev/null +++ b/apps/server/src/diagnostics/ErrorCause.ts @@ -0,0 +1,85 @@ +const MAX_DIAGNOSTIC_TEXT_LENGTH = 2_000; + +export interface SanitizedErrorCause { + readonly tag?: string; + readonly name?: string; + readonly message?: string; + readonly detail?: string; + readonly code?: string; + readonly exitCode?: number; + readonly timeoutMs?: number; + readonly truncated?: boolean; +} + +type MutableSanitizedErrorCause = { + -readonly [Key in keyof SanitizedErrorCause]?: SanitizedErrorCause[Key]; +}; + +function truncateDiagnosticText(value: string): { + readonly text: string; + readonly truncated: boolean; +} { + if (value.length <= MAX_DIAGNOSTIC_TEXT_LENGTH) { + return { text: value, truncated: false }; + } + return { + text: `${value.slice(0, MAX_DIAGNOSTIC_TEXT_LENGTH)}\n\n[truncated]`, + truncated: true, + }; +} + +function stringField(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function numberField(record: Record, key: string): number | undefined { + const value = record[key]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function addTextField( + output: MutableSanitizedErrorCause, + key: "tag" | "name" | "message" | "detail" | "code", + value: string | undefined, +) { + if (!value) return; + const truncated = truncateDiagnosticText(value); + output[key] = truncated.text; + if (truncated.truncated) output.truncated = true; +} + +export function sanitizeErrorCause(cause: unknown): SanitizedErrorCause { + if (typeof cause === "string") { + const output: MutableSanitizedErrorCause = {}; + addTextField(output, "message", cause); + return output; + } + + if (typeof cause === "object" && cause !== null) { + const record = cause as Record; + const output: MutableSanitizedErrorCause = {}; + addTextField(output, "tag", stringField(record, "_tag")); + addTextField(output, "name", stringField(record, "name")); + addTextField(output, "message", stringField(record, "message")); + addTextField(output, "detail", stringField(record, "detail")); + addTextField(output, "code", stringField(record, "code")); + + const exitCode = numberField(record, "exitCode"); + if (exitCode !== undefined) output.exitCode = exitCode; + + const timeoutMs = numberField(record, "timeoutMs"); + if (timeoutMs !== undefined) output.timeoutMs = timeoutMs; + + return Object.keys(output).length > 0 ? output : { tag: "Object" }; + } + + if (cause instanceof Error) { + const output: MutableSanitizedErrorCause = {}; + addTextField(output, "name", cause.name); + addTextField(output, "message", cause.message); + return output; + } + + return { message: "Unknown error" }; +} From 973cb5cf49fc865e063475b1655ad4d529e6d42b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 25 Jun 2026 20:16:49 +0100 Subject: [PATCH 18/55] Fix provider skills namespace imports --- apps/server/scripts/codex-skills-mock-app-server.ts | 4 ++-- apps/server/src/provider/Layers/CodexProvider.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server/scripts/codex-skills-mock-app-server.ts b/apps/server/scripts/codex-skills-mock-app-server.ts index 77cf02d3252..7d2ff4f40e0 100644 --- a/apps/server/scripts/codex-skills-mock-app-server.ts +++ b/apps/server/scripts/codex-skills-mock-app-server.ts @@ -1,13 +1,13 @@ #!/usr/bin/env node // @effect-diagnostics nodeBuiltinImport:off -import { appendFileSync } from "node:fs"; +import * as NodeFS from "node:fs"; const cwdLogPath = process.env.T3_CODEX_CWD_LOG_PATH; const exitLogPath = process.env.T3_CODEX_EXIT_LOG_PATH; const hangSkillsList = process.env.T3_CODEX_HANG_SKILLS_LIST === "1"; function appendLog(path: string | undefined, line: string): void { - if (path) appendFileSync(path, `${line}\n`, "utf8"); + if (path) NodeFS.appendFileSync(path, `${line}\n`, "utf8"); } function respond(id: number | string, result: unknown): void { diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index 52287e59bc8..bfaa00ebd5f 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -1,5 +1,5 @@ import * as NodeOS from "node:os"; -import { setTimeout as delay } from "node:timers/promises"; +import * as NodeTimersPromises from "node:timers/promises"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, expect, it } from "@effect/vitest"; @@ -51,7 +51,7 @@ const waitForFileContent = Effect.fn("waitForFileContent")(function* (filePath: for (let attempt = 0; attempt < 40; attempt += 1) { const content = yield* fileSystem.readFileString(filePath).pipe(Effect.orElseSucceed(() => "")); if (content.trim()) return content; - yield* Effect.promise(() => delay(50)); + yield* Effect.promise(() => NodeTimersPromises.setTimeout(50)); } return yield* Effect.die(`Timed out waiting for file content at ${filePath}`); }); From 89984e11f2f60458f554e0cf0c85319dbf1ccbd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 25 Jun 2026 20:20:07 +0100 Subject: [PATCH 19/55] Fix provider skill typecheck fallout --- apps/server/scripts/acp-mock-agent.ts | 2 +- .../src/provider/Layers/CursorProvider.test.ts | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 650f79db080..c0ae8c2a250 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -32,7 +32,7 @@ const permissionOptionIds = { const sessionId = "mock-session-1"; if (cwdLogPath) { - appendFileSync(cwdLogPath, `${process.cwd()}\n`, "utf8"); + NodeFS.appendFileSync(cwdLogPath, `${process.cwd()}\n`, "utf8"); } let currentModeId = "ask"; diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 66b791ce7dc..6d0492ad369 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -429,12 +429,15 @@ describe("buildCursorCapabilitiesFromConfigOptions", () => { describe("checkCursorProviderStatus", () => { it("reports the install docs when the Cursor CLI command is missing", async () => { const provider = await runNode( - checkCursorProviderStatus({ - enabled: true, - binaryPath: missingCursorBinaryPath, - apiEndpoint: "", - customModels: [], - }), + checkCursorProviderStatus( + { + enabled: true, + binaryPath: missingCursorBinaryPath, + apiEndpoint: "", + customModels: [], + }, + process.cwd(), + ), ); expect(provider).toMatchObject({ From ebe1625c9b27b60a3c368c63b61e673d51d645ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 26 Jun 2026 08:09:53 +0100 Subject: [PATCH 20/55] Normalize provider skills error cwd --- .../src/provider/ProviderSkillsLister.test.ts | 13 ++++++++++++- apps/server/src/provider/ProviderSkillsLister.ts | 4 ++-- apps/server/src/server.test.ts | 12 ++++++------ 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/ProviderSkillsLister.test.ts b/apps/server/src/provider/ProviderSkillsLister.test.ts index 38233740ea4..4a134e0318c 100644 --- a/apps/server/src/provider/ProviderSkillsLister.test.ts +++ b/apps/server/src/provider/ProviderSkillsLister.test.ts @@ -4,7 +4,18 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Ref from "effect/Ref"; -import { makeBoundedRequestCache } from "./ProviderSkillsLister.ts"; +import { makeBoundedRequestCache, optionalTrimmedNonEmptyString } from "./ProviderSkillsLister.ts"; + +describe("optionalTrimmedNonEmptyString", () => { + it("returns a trimmed value for non-empty strings", () => { + expect(optionalTrimmedNonEmptyString(" /tmp/project ")).toBe("/tmp/project"); + }); + + it("returns undefined for missing or blank strings", () => { + expect(optionalTrimmedNonEmptyString(undefined)).toBeUndefined(); + expect(optionalTrimmedNonEmptyString(" ")).toBeUndefined(); + }); +}); describe("makeBoundedRequestCache", () => { it.effect("coalesces concurrent requests for the same key", () => diff --git a/apps/server/src/provider/ProviderSkillsLister.ts b/apps/server/src/provider/ProviderSkillsLister.ts index ec86c27ff11..b530d73ca48 100644 --- a/apps/server/src/provider/ProviderSkillsLister.ts +++ b/apps/server/src/provider/ProviderSkillsLister.ts @@ -84,9 +84,9 @@ export const makeBoundedRequestCache = Effect.fn("makeBoundedRequestCache")(func }; }); -function optionalTrimmedNonEmptyString(value: string | undefined): string | undefined { +export function optionalTrimmedNonEmptyString(value: string | undefined): string | undefined { const trimmed = value?.trim(); - return trimmed && trimmed.length > 0 ? value : undefined; + return trimmed && trimmed.length > 0 ? trimmed : undefined; } function providerSkillsListError(input: { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 29806874036..20adc9f8356 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4450,25 +4450,25 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); const wsUrl = yield* getWsServerUrl("/ws"); + const missingWorkspacePath = "/definitely/not/a/real/workspace/path"; + const requestedCwd = ` ${missingWorkspacePath} `; const result = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverListProviderSkills]({ instanceId, - cwd: "/definitely/not/a/real/workspace/path", + cwd: requestedCwd, }), ).pipe(Effect.result), ); assertTrue(result._tag === "Failure"); assertTrue(result.failure._tag === "ServerProviderSkillsListError"); - assert.equal( - result.failure.message, - "Invalid Codex skills cwd '/definitely/not/a/real/workspace/path'.", - ); + assert.equal(result.failure.message, `Invalid Codex skills cwd '${missingWorkspacePath}'.`); + assert.equal(result.failure.cwd, missingWorkspacePath); assert.notInclude(result.failure.message, "Workspace root does not exist"); assert.equal( result.failure.detail, - "Workspace root does not exist: /definitely/not/a/real/workspace/path.", + `Workspace root does not exist: ${missingWorkspacePath}.`, ); assert.property(result.failure, "cause"); const failureCause = result.failure.cause; From 46a1603c248b5240a2e2760b90b46ef184ce2fbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 26 Jun 2026 10:25:16 +0100 Subject: [PATCH 21/55] Refactor route-layer dependency options in ws - Refactor ws route-layer dependency options handling - Keep route-layer wiring aligned with current server flow --- apps/server/src/ws.ts | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index b979956dc21..ded0a3f0e85 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -391,13 +391,19 @@ function toAuthAccessStreamEvent( } } -const makeWsRpcLayer = ( - currentSession: EnvironmentAuth.AuthenticatedSession, - listProviderSkills: ( +interface WsRpcLayerOptions { + readonly currentSession: EnvironmentAuth.AuthenticatedSession; + readonly listProviderSkills: ( input: ProviderSkillsListInput, - ) => Effect.Effect, - previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], -) => + ) => Effect.Effect; + readonly previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"]; +} + +const makeWsRpcLayer = ({ + currentSession, + listProviderSkills, + previewAutomationBroker, +}: WsRpcLayerOptions) => WsRpcGroup.toLayer( Effect.gen(function* () { const currentSessionId = currentSession.sessionId; @@ -1826,7 +1832,11 @@ export const websocketRpcRouteLayer = Layer.unwrap( disableTracing: true, }).pipe( Effect.provide( - makeWsRpcLayer(session, listProviderSkills, previewAutomationBroker).pipe( + makeWsRpcLayer({ + currentSession: session, + listProviderSkills, + previewAutomationBroker, + }).pipe( Layer.provideMerge(RpcSerialization.layerJson), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide( From 2bd42cd4b48d6e98be9b21b7510bc65f15774b52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Tue, 30 Jun 2026 13:40:26 +0100 Subject: [PATCH 22/55] Fix timeline live-follow cancellation - Clear anchor state when jumping to the live edge - Ignore anchor-safe controls and cancel follow on keyboard scroll --- .../chat/useTimelineScrollController.test.ts | 20 ++++++++ .../chat/useTimelineScrollController.ts | 50 ++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/chat/useTimelineScrollController.test.ts diff --git a/apps/web/src/components/chat/useTimelineScrollController.test.ts b/apps/web/src/components/chat/useTimelineScrollController.test.ts new file mode 100644 index 00000000000..3af6b375575 --- /dev/null +++ b/apps/web/src/components/chat/useTimelineScrollController.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vite-plus/test"; +import { isTimelineScrollKeyboardNavigationKey } from "./useTimelineScrollController"; + +describe("timeline scroll controller", () => { + it("recognizes keyboard keys that can move the timeline scroll position", () => { + expect(isTimelineScrollKeyboardNavigationKey("ArrowUp")).toBe(true); + expect(isTimelineScrollKeyboardNavigationKey("ArrowDown")).toBe(true); + expect(isTimelineScrollKeyboardNavigationKey("PageUp")).toBe(true); + expect(isTimelineScrollKeyboardNavigationKey("PageDown")).toBe(true); + expect(isTimelineScrollKeyboardNavigationKey("Home")).toBe(true); + expect(isTimelineScrollKeyboardNavigationKey("End")).toBe(true); + expect(isTimelineScrollKeyboardNavigationKey(" ")).toBe(true); + }); + + it("ignores non-navigation keys", () => { + expect(isTimelineScrollKeyboardNavigationKey("Enter")).toBe(false); + expect(isTimelineScrollKeyboardNavigationKey("Escape")).toBe(false); + expect(isTimelineScrollKeyboardNavigationKey("a")).toBe(false); + }); +}); diff --git a/apps/web/src/components/chat/useTimelineScrollController.ts b/apps/web/src/components/chat/useTimelineScrollController.ts index 5ee3d98d6bf..a01c3f9e5cf 100644 --- a/apps/web/src/components/chat/useTimelineScrollController.ts +++ b/apps/web/src/components/chat/useTimelineScrollController.ts @@ -5,6 +5,20 @@ import { type LegendListRef } from "@legendapp/list/react"; import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./timelineScrollAnchoring"; +const TIMELINE_SCROLL_KEYBOARD_NAVIGATION_KEYS = new Set([ + "ArrowDown", + "ArrowUp", + "End", + "Home", + "PageDown", + "PageUp", + " ", +]); + +export function isTimelineScrollKeyboardNavigationKey(key: string): boolean { + return TIMELINE_SCROLL_KEYBOARD_NAVIGATION_KEYS.has(key); +} + export interface TimelineScrollController { readonly showScrollToBottom: boolean; readonly scrollToEnd: (animated?: boolean) => void; @@ -139,8 +153,15 @@ export function useTimelineScrollController({ timelineScrollModeRef.current = "following-end"; liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; pendingTimelineAnchorRef.current = null; + positionedTimelineAnchorRef.current = null; + settledTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; + pendingAnchorScrollRestoreRef.current = null; cleanupAnchorPositioning(); + if (anchorScrollRestoreFrameRef.current !== null) { + cancelAnimationFrame(anchorScrollRestoreFrameRef.current); + anchorScrollRestoreFrameRef.current = null; + } showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); void listRef.current?.scrollToEnd?.({ animated }); @@ -155,22 +176,47 @@ export function useTimelineScrollController({ if (!scrollNode) { return; } + const isAnchorIgnoredEvent = (event: Event) => + event.target instanceof Element && + scrollNode.contains(event.target) && + event.target.closest("[data-scroll-anchor-ignore]") !== null; const handleManualNavigation = () => { cancelForManualNavigationRef.current(); }; + const handlePointerDown = (event: PointerEvent) => { + if (isAnchorIgnoredEvent(event)) { + return; + } + cancelForManualNavigationRef.current(); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.defaultPrevented || + event.metaKey || + event.ctrlKey || + event.altKey || + !isTimelineScrollKeyboardNavigationKey(event.key) || + isAnchorIgnoredEvent(event) + ) { + return; + } + cancelForManualNavigationRef.current(); + }; scrollNode.addEventListener("wheel", handleManualNavigation, { passive: true, }); scrollNode.addEventListener("touchmove", handleManualNavigation, { passive: true, }); - scrollNode.addEventListener("pointerdown", handleManualNavigation, { + scrollNode.addEventListener("pointerdown", handlePointerDown, { passive: true, }); + scrollNode.addEventListener("keydown", handleKeyDown); removeListeners = () => { scrollNode.removeEventListener("wheel", handleManualNavigation); scrollNode.removeEventListener("touchmove", handleManualNavigation); - scrollNode.removeEventListener("pointerdown", handleManualNavigation); + scrollNode.removeEventListener("pointerdown", handlePointerDown); + scrollNode.removeEventListener("keydown", handleKeyDown); }; }); From f5930d6831d56aadeb381cdac7d90fa65ed3f61c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Tue, 30 Jun 2026 14:37:58 +0100 Subject: [PATCH 23/55] Retry timeline scroll listener setup - Retry delayed manual-navigation listener setup - Cover late-mounted scroll node listener registration --- .../chat/useTimelineScrollController.test.ts | 57 ++++++- .../chat/useTimelineScrollController.ts | 153 +++++++++++------- 2 files changed, 154 insertions(+), 56 deletions(-) diff --git a/apps/web/src/components/chat/useTimelineScrollController.test.ts b/apps/web/src/components/chat/useTimelineScrollController.test.ts index 3af6b375575..4108e7ba87f 100644 --- a/apps/web/src/components/chat/useTimelineScrollController.test.ts +++ b/apps/web/src/components/chat/useTimelineScrollController.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it } from "vite-plus/test"; -import { isTimelineScrollKeyboardNavigationKey } from "./useTimelineScrollController"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { + isTimelineScrollKeyboardNavigationKey, + scheduleTimelineManualNavigationListeners, +} from "./useTimelineScrollController"; describe("timeline scroll controller", () => { it("recognizes keyboard keys that can move the timeline scroll position", () => { @@ -17,4 +20,54 @@ describe("timeline scroll controller", () => { expect(isTimelineScrollKeyboardNavigationKey("Escape")).toBe(false); expect(isTimelineScrollKeyboardNavigationKey("a")).toBe(false); }); + + it("retries manual navigation listener setup when the scroll node mounts late", () => { + const frames: FrameRequestCallback[] = []; + const listeners = new Map>(); + const onManualNavigation = vi.fn(); + let scrollNode: HTMLElement | null = null; + + const node = { + addEventListener: (type: string, listener: EventListener) => { + const typeListeners = listeners.get(type) ?? new Set(); + typeListeners.add(listener); + listeners.set(type, typeListeners); + }, + removeEventListener: (type: string, listener: EventListener) => { + listeners.get(type)?.delete(listener); + }, + } as HTMLElement; + + const cleanup = scheduleTimelineManualNavigationListeners({ + getScrollNode: () => scrollNode, + maxAttempts: 2, + onManualNavigation, + requestFrame: (callback) => { + frames.push(callback); + return frames.length; + }, + cancelFrame: () => {}, + }); + + expect(frames).toHaveLength(1); + frames.shift()?.(0); + expect(listeners.get("wheel")).toBeUndefined(); + expect(frames).toHaveLength(1); + + scrollNode = node; + frames.shift()?.(0); + expect(listeners.get("wheel")?.size).toBe(1); + expect(listeners.get("touchmove")?.size).toBe(1); + expect(listeners.get("pointerdown")?.size).toBe(1); + expect(listeners.get("keydown")?.size).toBe(1); + + listeners.get("wheel")?.forEach((listener) => listener({} as Event)); + expect(onManualNavigation).toHaveBeenCalledOnce(); + + cleanup(); + expect(listeners.get("wheel")?.size).toBe(0); + expect(listeners.get("touchmove")?.size).toBe(0); + expect(listeners.get("pointerdown")?.size).toBe(0); + expect(listeners.get("keydown")?.size).toBe(0); + }); }); diff --git a/apps/web/src/components/chat/useTimelineScrollController.ts b/apps/web/src/components/chat/useTimelineScrollController.ts index a01c3f9e5cf..e0cc72b2cf4 100644 --- a/apps/web/src/components/chat/useTimelineScrollController.ts +++ b/apps/web/src/components/chat/useTimelineScrollController.ts @@ -15,10 +15,105 @@ const TIMELINE_SCROLL_KEYBOARD_NAVIGATION_KEYS = new Set([ " ", ]); +const TIMELINE_SCROLL_LISTENER_SETUP_MAX_ATTEMPTS = 12; + export function isTimelineScrollKeyboardNavigationKey(key: string): boolean { return TIMELINE_SCROLL_KEYBOARD_NAVIGATION_KEYS.has(key); } +export function scheduleTimelineManualNavigationListeners({ + cancelFrame = cancelAnimationFrame, + getScrollNode, + maxAttempts = TIMELINE_SCROLL_LISTENER_SETUP_MAX_ATTEMPTS, + onManualNavigation, + requestFrame = requestAnimationFrame, +}: { + readonly cancelFrame?: (handle: number) => void; + readonly getScrollNode: () => HTMLElement | null; + readonly maxAttempts?: number; + readonly onManualNavigation: () => void; + readonly requestFrame?: (callback: FrameRequestCallback) => number; +}): () => void { + let frame: number | null = null; + let removeListeners: (() => void) | null = null; + let cancelled = false; + + const installListeners = (scrollNode: HTMLElement) => { + const isAnchorIgnoredEvent = (event: Event) => + event.target instanceof Element && + scrollNode.contains(event.target) && + event.target.closest("[data-scroll-anchor-ignore]") !== null; + const handleManualNavigation = () => { + onManualNavigation(); + }; + const handlePointerDown = (event: PointerEvent) => { + if (isAnchorIgnoredEvent(event)) { + return; + } + onManualNavigation(); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.defaultPrevented || + event.metaKey || + event.ctrlKey || + event.altKey || + !isTimelineScrollKeyboardNavigationKey(event.key) || + isAnchorIgnoredEvent(event) + ) { + return; + } + onManualNavigation(); + }; + scrollNode.addEventListener("wheel", handleManualNavigation, { + passive: true, + }); + scrollNode.addEventListener("touchmove", handleManualNavigation, { + passive: true, + }); + scrollNode.addEventListener("pointerdown", handlePointerDown, { + passive: true, + }); + scrollNode.addEventListener("keydown", handleKeyDown); + removeListeners = () => { + scrollNode.removeEventListener("wheel", handleManualNavigation); + scrollNode.removeEventListener("touchmove", handleManualNavigation); + scrollNode.removeEventListener("pointerdown", handlePointerDown); + scrollNode.removeEventListener("keydown", handleKeyDown); + }; + }; + + const scheduleSetup = (remainingAttempts: number) => { + frame = requestFrame(() => { + frame = null; + if (cancelled || removeListeners !== null) { + return; + } + + const scrollNode = getScrollNode(); + if (!scrollNode) { + if (remainingAttempts > 0) { + scheduleSetup(remainingAttempts - 1); + } + return; + } + + installListeners(scrollNode); + }); + }; + + scheduleSetup(maxAttempts); + + return () => { + cancelled = true; + if (frame !== null) { + cancelFrame(frame); + frame = null; + } + removeListeners?.(); + }; +} + export interface TimelineScrollController { readonly showScrollToBottom: boolean; readonly scrollToEnd: (animated?: boolean) => void; @@ -170,61 +265,11 @@ export function useTimelineScrollController({ ); useEffect(() => { - let removeListeners: (() => void) | null = null; - const frame = requestAnimationFrame(() => { - const scrollNode = listRef.current?.getScrollableNode(); - if (!scrollNode) { - return; - } - const isAnchorIgnoredEvent = (event: Event) => - event.target instanceof Element && - scrollNode.contains(event.target) && - event.target.closest("[data-scroll-anchor-ignore]") !== null; - const handleManualNavigation = () => { - cancelForManualNavigationRef.current(); - }; - const handlePointerDown = (event: PointerEvent) => { - if (isAnchorIgnoredEvent(event)) { - return; - } - cancelForManualNavigationRef.current(); - }; - const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.defaultPrevented || - event.metaKey || - event.ctrlKey || - event.altKey || - !isTimelineScrollKeyboardNavigationKey(event.key) || - isAnchorIgnoredEvent(event) - ) { - return; - } - cancelForManualNavigationRef.current(); - }; - scrollNode.addEventListener("wheel", handleManualNavigation, { - passive: true, - }); - scrollNode.addEventListener("touchmove", handleManualNavigation, { - passive: true, - }); - scrollNode.addEventListener("pointerdown", handlePointerDown, { - passive: true, - }); - scrollNode.addEventListener("keydown", handleKeyDown); - removeListeners = () => { - scrollNode.removeEventListener("wheel", handleManualNavigation); - scrollNode.removeEventListener("touchmove", handleManualNavigation); - scrollNode.removeEventListener("pointerdown", handlePointerDown); - scrollNode.removeEventListener("keydown", handleKeyDown); - }; + return scheduleTimelineManualNavigationListeners({ + getScrollNode: () => listRef.current?.getScrollableNode() ?? null, + onManualNavigation: () => cancelForManualNavigationRef.current(), }); - - return () => { - cancelAnimationFrame(frame); - removeListeners?.(); - }; - }, [activeThreadId, listRef]); + }, [activeThreadId, listRef, timelineEntries.length]); const onAnchorReady = useCallback( (messageId: MessageId, anchorIndex: number) => { From 71a2a690cc295de420af224141d76536a936ad6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Tue, 30 Jun 2026 15:39:36 +0100 Subject: [PATCH 24/55] Fix timeline scroll listener and anchor cleanup - Keep manual navigation listeners stable while entries stream - Clear pending anchor restore frames on thread resets - Retry anchor positioning until the scroll node is ready --- .../chat/useTimelineScrollController.test.ts | 23 +++++++ .../chat/useTimelineScrollController.ts | 60 ++++++++++++------- 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/chat/useTimelineScrollController.test.ts b/apps/web/src/components/chat/useTimelineScrollController.test.ts index 4108e7ba87f..811c8c0e1e6 100644 --- a/apps/web/src/components/chat/useTimelineScrollController.test.ts +++ b/apps/web/src/components/chat/useTimelineScrollController.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { + clearPendingTimelineAnchorScrollRestore, isTimelineScrollKeyboardNavigationKey, scheduleTimelineManualNavigationListeners, } from "./useTimelineScrollController"; @@ -70,4 +71,26 @@ describe("timeline scroll controller", () => { expect(listeners.get("pointerdown")?.size).toBe(0); expect(listeners.get("keydown")?.size).toBe(0); }); + + it("clears pending anchor scroll restore state and cancels the queued frame", () => { + const pendingAnchorScrollRestoreRef = { + current: { + messageId: "message-1", + offset: 42, + userScrollGeneration: 1, + }, + }; + const anchorScrollRestoreFrameRef = { current: 7 }; + const cancelFrame = vi.fn(); + + clearPendingTimelineAnchorScrollRestore({ + anchorScrollRestoreFrameRef, + cancelFrame, + pendingAnchorScrollRestoreRef, + }); + + expect(pendingAnchorScrollRestoreRef.current).toBeNull(); + expect(anchorScrollRestoreFrameRef.current).toBeNull(); + expect(cancelFrame).toHaveBeenCalledWith(7); + }); }); diff --git a/apps/web/src/components/chat/useTimelineScrollController.ts b/apps/web/src/components/chat/useTimelineScrollController.ts index e0cc72b2cf4..1335d00810a 100644 --- a/apps/web/src/components/chat/useTimelineScrollController.ts +++ b/apps/web/src/components/chat/useTimelineScrollController.ts @@ -114,6 +114,22 @@ export function scheduleTimelineManualNavigationListeners({ }; } +export function clearPendingTimelineAnchorScrollRestore({ + anchorScrollRestoreFrameRef, + cancelFrame = cancelAnimationFrame, + pendingAnchorScrollRestoreRef, +}: { + readonly anchorScrollRestoreFrameRef: { current: number | null }; + readonly cancelFrame?: (handle: number) => void; + readonly pendingAnchorScrollRestoreRef: { current: unknown | null }; +}): void { + pendingAnchorScrollRestoreRef.current = null; + if (anchorScrollRestoreFrameRef.current !== null) { + cancelFrame(anchorScrollRestoreFrameRef.current); + anchorScrollRestoreFrameRef.current = null; + } +} + export interface TimelineScrollController { readonly showScrollToBottom: boolean; readonly scrollToEnd: (animated?: boolean) => void; @@ -165,6 +181,12 @@ export function useTimelineScrollController({ anchorPositionCleanupRef.current?.(); anchorPositionCleanupRef.current = null; }, []); + const clearPendingAnchorScrollRestore = useCallback(() => { + clearPendingTimelineAnchorScrollRestore({ + anchorScrollRestoreFrameRef, + pendingAnchorScrollRestoreRef, + }); + }, []); const scheduleAnchorPositionFrame = useCallback((callback: () => void) => { const frame = requestAnimationFrame(() => { @@ -182,13 +204,9 @@ export function useTimelineScrollController({ positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; - pendingAnchorScrollRestoreRef.current = null; + clearPendingAnchorScrollRestore(); cleanupAnchorPositioning(); - if (anchorScrollRestoreFrameRef.current !== null) { - cancelAnimationFrame(anchorScrollRestoreFrameRef.current); - anchorScrollRestoreFrameRef.current = null; - } - }, [cleanupAnchorPositioning]); + }, [cleanupAnchorPositioning, clearPendingAnchorScrollRestore]); const cancelForManualNavigationRef = useRef(cancelForManualNavigation); useEffect(() => { cancelForManualNavigationRef.current = cancelForManualNavigation; @@ -251,25 +269,22 @@ export function useTimelineScrollController({ positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; - pendingAnchorScrollRestoreRef.current = null; + clearPendingAnchorScrollRestore(); cleanupAnchorPositioning(); - if (anchorScrollRestoreFrameRef.current !== null) { - cancelAnimationFrame(anchorScrollRestoreFrameRef.current); - anchorScrollRestoreFrameRef.current = null; - } showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); void listRef.current?.scrollToEnd?.({ animated }); }, - [cleanupAnchorPositioning, listRef], + [cleanupAnchorPositioning, clearPendingAnchorScrollRestore, listRef], ); + const hasTimelineEntries = timelineEntries.length > 0; useEffect(() => { return scheduleTimelineManualNavigationListeners({ getScrollNode: () => listRef.current?.getScrollableNode() ?? null, onManualNavigation: () => cancelForManualNavigationRef.current(), }); - }, [activeThreadId, listRef, timelineEntries.length]); + }, [activeThreadId, hasTimelineEntries, listRef]); const onAnchorReady = useCallback( (messageId: MessageId, anchorIndex: number) => { @@ -296,6 +311,12 @@ export function useTimelineScrollController({ return; } const scrollNode = list.getScrollableNode(); + if (!scrollNode) { + if (remainingAttempts > 0) { + positionAnchor(remainingAttempts - 1); + } + return; + } let finished = false; let cleanup: (() => void) | null = null; const finishAnimatedPositioning = () => { @@ -485,10 +506,11 @@ export function useTimelineScrollController({ positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; activeTimelineAnchorIndexRef.current = null; + clearPendingAnchorScrollRestore(); cleanupAnchorPositioning(); showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - }, [cleanupAnchorPositioning]); + }, [cleanupAnchorPositioning, clearPendingAnchorScrollRestore]); const prepareAnchorForMessage = useCallback( (messageId: MessageId) => { @@ -497,23 +519,21 @@ export function useTimelineScrollController({ liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; pendingTimelineAnchorRef.current = messageId; activeTimelineAnchorIndexRef.current = null; + clearPendingAnchorScrollRestore(); cleanupAnchorPositioning(); showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); }, - [cleanupAnchorPositioning], + [cleanupAnchorPositioning, clearPendingAnchorScrollRestore], ); useEffect( () => () => { cleanupAnchorPositioning(); showScrollDebouncer.current.cancel(); - if (anchorScrollRestoreFrameRef.current !== null) { - cancelAnimationFrame(anchorScrollRestoreFrameRef.current); - anchorScrollRestoreFrameRef.current = null; - } + clearPendingAnchorScrollRestore(); }, - [cleanupAnchorPositioning], + [cleanupAnchorPositioning, clearPendingAnchorScrollRestore], ); return { From 67a43b90725d0205cc2f5596b9668bbea3b99d2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Tue, 30 Jun 2026 16:56:06 +0100 Subject: [PATCH 25/55] Fix timeline listener retry exhaustion --- .../chat/useTimelineScrollController.test.ts | 58 +++++++++++++++++ .../chat/useTimelineScrollController.ts | 64 ++++++++++++++++++- 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/useTimelineScrollController.test.ts b/apps/web/src/components/chat/useTimelineScrollController.test.ts index 811c8c0e1e6..75410baf428 100644 --- a/apps/web/src/components/chat/useTimelineScrollController.test.ts +++ b/apps/web/src/components/chat/useTimelineScrollController.test.ts @@ -1,5 +1,7 @@ +import { type MessageId } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { + clearTimelineAnchorIfPositioningExhausted, clearPendingTimelineAnchorScrollRestore, isTimelineScrollKeyboardNavigationKey, scheduleTimelineManualNavigationListeners, @@ -26,6 +28,8 @@ describe("timeline scroll controller", () => { const frames: FrameRequestCallback[] = []; const listeners = new Map>(); const onManualNavigation = vi.fn(); + const onExhausted = vi.fn(); + const onInstalled = vi.fn(); let scrollNode: HTMLElement | null = null; const node = { @@ -42,6 +46,8 @@ describe("timeline scroll controller", () => { const cleanup = scheduleTimelineManualNavigationListeners({ getScrollNode: () => scrollNode, maxAttempts: 2, + onExhausted, + onInstalled, onManualNavigation, requestFrame: (callback) => { frames.push(callback); @@ -57,6 +63,8 @@ describe("timeline scroll controller", () => { scrollNode = node; frames.shift()?.(0); + expect(onInstalled).toHaveBeenCalledOnce(); + expect(onExhausted).not.toHaveBeenCalled(); expect(listeners.get("wheel")?.size).toBe(1); expect(listeners.get("touchmove")?.size).toBe(1); expect(listeners.get("pointerdown")?.size).toBe(1); @@ -72,6 +80,31 @@ describe("timeline scroll controller", () => { expect(listeners.get("keydown")?.size).toBe(0); }); + it("reports exhausted manual navigation listener setup without installing listeners", () => { + const frames: FrameRequestCallback[] = []; + const onExhausted = vi.fn(); + const onInstalled = vi.fn(); + + scheduleTimelineManualNavigationListeners({ + getScrollNode: () => null, + maxAttempts: 1, + onExhausted, + onInstalled, + onManualNavigation: vi.fn(), + requestFrame: (callback) => { + frames.push(callback); + return frames.length; + }, + cancelFrame: () => {}, + }); + + frames.shift()?.(0); + expect(onExhausted).not.toHaveBeenCalled(); + frames.shift()?.(0); + expect(onExhausted).toHaveBeenCalledOnce(); + expect(onInstalled).not.toHaveBeenCalled(); + }); + it("clears pending anchor scroll restore state and cancels the queued frame", () => { const pendingAnchorScrollRestoreRef = { current: { @@ -93,4 +126,29 @@ describe("timeline scroll controller", () => { expect(anchorScrollRestoreFrameRef.current).toBeNull(); expect(cancelFrame).toHaveBeenCalledWith(7); }); + + it("clears exhausted anchor positioning only for the active positioned anchor", () => { + const message1 = "message-1" as MessageId; + const message2 = "message-2" as MessageId; + const positionedTimelineAnchorRef = { current: message1 }; + const settledTimelineAnchorRef = { current: message1 }; + + clearTimelineAnchorIfPositioningExhausted({ + messageId: message2, + positionedTimelineAnchorRef, + settledTimelineAnchorRef, + }); + + expect(positionedTimelineAnchorRef.current).toBe(message1); + expect(settledTimelineAnchorRef.current).toBe(message1); + + clearTimelineAnchorIfPositioningExhausted({ + messageId: message1, + positionedTimelineAnchorRef, + settledTimelineAnchorRef, + }); + + expect(positionedTimelineAnchorRef.current).toBeNull(); + expect(settledTimelineAnchorRef.current).toBeNull(); + }); }); diff --git a/apps/web/src/components/chat/useTimelineScrollController.ts b/apps/web/src/components/chat/useTimelineScrollController.ts index 1335d00810a..1a5e233363d 100644 --- a/apps/web/src/components/chat/useTimelineScrollController.ts +++ b/apps/web/src/components/chat/useTimelineScrollController.ts @@ -25,12 +25,16 @@ export function scheduleTimelineManualNavigationListeners({ cancelFrame = cancelAnimationFrame, getScrollNode, maxAttempts = TIMELINE_SCROLL_LISTENER_SETUP_MAX_ATTEMPTS, + onExhausted, + onInstalled, onManualNavigation, requestFrame = requestAnimationFrame, }: { readonly cancelFrame?: (handle: number) => void; readonly getScrollNode: () => HTMLElement | null; readonly maxAttempts?: number; + readonly onExhausted?: () => void; + readonly onInstalled?: () => void; readonly onManualNavigation: () => void; readonly requestFrame?: (callback: FrameRequestCallback) => number; }): () => void { @@ -75,6 +79,7 @@ export function scheduleTimelineManualNavigationListeners({ passive: true, }); scrollNode.addEventListener("keydown", handleKeyDown); + onInstalled?.(); removeListeners = () => { scrollNode.removeEventListener("wheel", handleManualNavigation); scrollNode.removeEventListener("touchmove", handleManualNavigation); @@ -94,6 +99,8 @@ export function scheduleTimelineManualNavigationListeners({ if (!scrollNode) { if (remainingAttempts > 0) { scheduleSetup(remainingAttempts - 1); + } else { + onExhausted?.(); } return; } @@ -130,6 +137,22 @@ export function clearPendingTimelineAnchorScrollRestore({ } } +export function clearTimelineAnchorIfPositioningExhausted({ + messageId, + positionedTimelineAnchorRef, + settledTimelineAnchorRef, +}: { + readonly messageId: MessageId; + readonly positionedTimelineAnchorRef: { current: MessageId | null }; + readonly settledTimelineAnchorRef: { current: MessageId | null }; +}): void { + if (positionedTimelineAnchorRef.current !== messageId) { + return; + } + positionedTimelineAnchorRef.current = null; + settledTimelineAnchorRef.current = null; +} + export interface TimelineScrollController { readonly showScrollToBottom: boolean; readonly scrollToEnd: (animated?: boolean) => void; @@ -172,6 +195,9 @@ export function useTimelineScrollController({ const anchorScrollRestoreFrameRef = useRef(null); const anchorPositionFrameRefs = useRef>(new Set()); const anchorPositionCleanupRef = useRef<(() => void) | null>(null); + const manualNavigationListenersInstalledRef = useRef(false); + const previousTimelineEntryCountRef = useRef(timelineEntries.length); + const [manualNavigationListenerRetryToken, setManualNavigationListenerRetryToken] = useState(0); const cleanupAnchorPositioning = useCallback(() => { for (const frame of anchorPositionFrameRefs.current) { @@ -280,11 +306,33 @@ export function useTimelineScrollController({ const hasTimelineEntries = timelineEntries.length > 0; useEffect(() => { - return scheduleTimelineManualNavigationListeners({ + manualNavigationListenersInstalledRef.current = false; + const cleanup = scheduleTimelineManualNavigationListeners({ getScrollNode: () => listRef.current?.getScrollableNode() ?? null, + onExhausted: () => { + manualNavigationListenersInstalledRef.current = false; + }, + onInstalled: () => { + manualNavigationListenersInstalledRef.current = true; + }, onManualNavigation: () => cancelForManualNavigationRef.current(), }); - }, [activeThreadId, hasTimelineEntries, listRef]); + return () => { + manualNavigationListenersInstalledRef.current = false; + cleanup(); + }; + }, [activeThreadId, hasTimelineEntries, listRef, manualNavigationListenerRetryToken]); + useEffect(() => { + const previousTimelineEntryCount = previousTimelineEntryCountRef.current; + previousTimelineEntryCountRef.current = timelineEntries.length; + if ( + previousTimelineEntryCount > 0 && + timelineEntries.length > previousTimelineEntryCount && + !manualNavigationListenersInstalledRef.current + ) { + setManualNavigationListenerRetryToken((token) => token + 1); + } + }, [timelineEntries.length]); const onAnchorReady = useCallback( (messageId: MessageId, anchorIndex: number) => { @@ -307,6 +355,12 @@ export function useTimelineScrollController({ if (!list) { if (remainingAttempts > 0) { positionAnchor(remainingAttempts - 1); + } else { + clearTimelineAnchorIfPositioningExhausted({ + messageId, + positionedTimelineAnchorRef, + settledTimelineAnchorRef, + }); } return; } @@ -314,6 +368,12 @@ export function useTimelineScrollController({ if (!scrollNode) { if (remainingAttempts > 0) { positionAnchor(remainingAttempts - 1); + } else { + clearTimelineAnchorIfPositioningExhausted({ + messageId, + positionedTimelineAnchorRef, + settledTimelineAnchorRef, + }); } return; } From 1bc887ba968db27b52dc19e330811e40f428ec7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Tue, 30 Jun 2026 17:26:13 +0100 Subject: [PATCH 26/55] Fix exhausted timeline anchor state --- .../chat/useTimelineScrollController.test.ts | 10 ++++++++++ .../src/components/chat/useTimelineScrollController.ts | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/apps/web/src/components/chat/useTimelineScrollController.test.ts b/apps/web/src/components/chat/useTimelineScrollController.test.ts index 75410baf428..abd91de7eb2 100644 --- a/apps/web/src/components/chat/useTimelineScrollController.test.ts +++ b/apps/web/src/components/chat/useTimelineScrollController.test.ts @@ -130,25 +130,35 @@ describe("timeline scroll controller", () => { it("clears exhausted anchor positioning only for the active positioned anchor", () => { const message1 = "message-1" as MessageId; const message2 = "message-2" as MessageId; + const activeTimelineAnchorIndexRef = { current: 3 }; const positionedTimelineAnchorRef = { current: message1 }; const settledTimelineAnchorRef = { current: message1 }; + const timelineScrollModeRef = { current: "anchoring-new-turn" as const }; clearTimelineAnchorIfPositioningExhausted({ + activeTimelineAnchorIndexRef, messageId: message2, positionedTimelineAnchorRef, settledTimelineAnchorRef, + timelineScrollModeRef, }); + expect(activeTimelineAnchorIndexRef.current).toBe(3); expect(positionedTimelineAnchorRef.current).toBe(message1); expect(settledTimelineAnchorRef.current).toBe(message1); + expect(timelineScrollModeRef.current).toBe("anchoring-new-turn"); clearTimelineAnchorIfPositioningExhausted({ + activeTimelineAnchorIndexRef, messageId: message1, positionedTimelineAnchorRef, settledTimelineAnchorRef, + timelineScrollModeRef, }); + expect(activeTimelineAnchorIndexRef.current).toBeNull(); expect(positionedTimelineAnchorRef.current).toBeNull(); expect(settledTimelineAnchorRef.current).toBeNull(); + expect(timelineScrollModeRef.current).toBe("following-end"); }); }); diff --git a/apps/web/src/components/chat/useTimelineScrollController.ts b/apps/web/src/components/chat/useTimelineScrollController.ts index 1a5e233363d..74e079bb71a 100644 --- a/apps/web/src/components/chat/useTimelineScrollController.ts +++ b/apps/web/src/components/chat/useTimelineScrollController.ts @@ -138,19 +138,25 @@ export function clearPendingTimelineAnchorScrollRestore({ } export function clearTimelineAnchorIfPositioningExhausted({ + activeTimelineAnchorIndexRef, messageId, positionedTimelineAnchorRef, settledTimelineAnchorRef, + timelineScrollModeRef, }: { + readonly activeTimelineAnchorIndexRef: { current: number | null }; readonly messageId: MessageId; readonly positionedTimelineAnchorRef: { current: MessageId | null }; readonly settledTimelineAnchorRef: { current: MessageId | null }; + readonly timelineScrollModeRef: { current: TimelineScrollMode }; }): void { if (positionedTimelineAnchorRef.current !== messageId) { return; } positionedTimelineAnchorRef.current = null; settledTimelineAnchorRef.current = null; + activeTimelineAnchorIndexRef.current = null; + timelineScrollModeRef.current = "following-end"; } export interface TimelineScrollController { @@ -357,9 +363,11 @@ export function useTimelineScrollController({ positionAnchor(remainingAttempts - 1); } else { clearTimelineAnchorIfPositioningExhausted({ + activeTimelineAnchorIndexRef, messageId, positionedTimelineAnchorRef, settledTimelineAnchorRef, + timelineScrollModeRef, }); } return; @@ -370,9 +378,11 @@ export function useTimelineScrollController({ positionAnchor(remainingAttempts - 1); } else { clearTimelineAnchorIfPositioningExhausted({ + activeTimelineAnchorIndexRef, messageId, positionedTimelineAnchorRef, settledTimelineAnchorRef, + timelineScrollModeRef, }); } return; From 8bd26b0130a229a28659a8f5dcc9715555000038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Wed, 1 Jul 2026 11:33:18 +0100 Subject: [PATCH 27/55] Align workspace paths import in provider skills lister --- .../src/provider/ProviderSkillsLister.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/ProviderSkillsLister.ts b/apps/server/src/provider/ProviderSkillsLister.ts index b530d73ca48..6a8c58d7cdf 100644 --- a/apps/server/src/provider/ProviderSkillsLister.ts +++ b/apps/server/src/provider/ProviderSkillsLister.ts @@ -29,23 +29,17 @@ import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment. import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; import { sanitizeErrorCause } from "../diagnostics/ErrorCause.ts"; import { ServerSettingsService } from "../serverSettings.ts"; -import { - WorkspacePaths, - WorkspaceRootCreateFailedError, - WorkspaceRootNotDirectoryError, - WorkspaceRootNotExistsError, - WorkspaceRootStatFailedError, -} from "../workspace/WorkspacePaths.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; const CODEX_SKILL_LIST_TIMEOUT = Duration.seconds(15); const PROVIDER_SKILLS_CACHE_CAPACITY = 64; const PROVIDER_SKILLS_CACHE_TTL = Duration.seconds(1); const PROVIDER_SKILLS_MAX_CONCURRENCY = 4; const decodeCodexSettings = Schema.decodeUnknownEffect(CodexSettings); -const isWorkspaceRootNotExistsError = Schema.is(WorkspaceRootNotExistsError); -const isWorkspaceRootNotDirectoryError = Schema.is(WorkspaceRootNotDirectoryError); -const isWorkspaceRootCreateFailedError = Schema.is(WorkspaceRootCreateFailedError); -const isWorkspaceRootStatFailedError = Schema.is(WorkspaceRootStatFailedError); +const isWorkspaceRootNotExistsError = Schema.is(WorkspacePaths.WorkspaceRootNotExistsError); +const isWorkspaceRootNotDirectoryError = Schema.is(WorkspacePaths.WorkspaceRootNotDirectoryError); +const isWorkspaceRootCreateFailedError = Schema.is(WorkspacePaths.WorkspaceRootCreateFailedError); +const isWorkspaceRootStatFailedError = Schema.is(WorkspacePaths.WorkspaceRootStatFailedError); const isCodexShadowHomePathConflictError = Schema.is(CodexShadowHomePathConflictError); const isCodexShadowHomeEntryConflictError = Schema.is(CodexShadowHomeEntryConflictError); const isCodexShadowHomePrivateEntrySymlinkError = Schema.is( @@ -206,7 +200,7 @@ function parseRequestKey(key: string): ProviderSkillsListInput { export const makeProviderSkillsLister = Effect.fn("makeProviderSkillsLister")(function* () { const providerRegistry = yield* ProviderRegistry; const serverSettings = yield* ServerSettingsService; - const workspacePaths = yield* WorkspacePaths; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; From 34def14537bbec7350c5fa02cd006df231224972 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 3 Jul 2026 12:31:45 +0100 Subject: [PATCH 28/55] Remove codex skill branch pollution --- apps/mobile/src/Stack.tsx | 24 +- .../src/features/files/SourceFileSurface.tsx | 23 +- .../threads/sidebar-filter-button.tsx | 1 + .../features/threads/thread-list-items.tsx | 1 + apps/web/src/components/ChatView.tsx | 487 ++++++++++--- .../chat/useTimelineScrollController.test.ts | 164 ----- .../chat/useTimelineScrollController.ts | 619 ---------------- apps/web/src/projectScriptTerminals.test.ts | 106 --- apps/web/src/projectScriptTerminals.ts | 688 ------------------ apps/web/src/state/projectActionTerminal.ts | 18 - patches/@react-native-menu__menu@2.0.0.patch | 4 +- patches/expo-modules-jsi@56.0.10.patch | 2 +- .../react-native-gesture-handler@2.31.2.patch | 4 +- patches/react-native-screens@4.25.2.patch | 70 +- pnpm-lock.yaml | 540 +++++++------- 15 files changed, 713 insertions(+), 2038 deletions(-) delete mode 100644 apps/web/src/components/chat/useTimelineScrollController.test.ts delete mode 100644 apps/web/src/components/chat/useTimelineScrollController.ts delete mode 100644 apps/web/src/projectScriptTerminals.test.ts delete mode 100644 apps/web/src/projectScriptTerminals.ts delete mode 100644 apps/web/src/state/projectActionTerminal.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index e9f84897a9f..29cf29da90a 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -10,17 +10,13 @@ import { createNativeStackScreen, type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; -import { useLayoutEffect } from "react"; import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "react-native"; import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/AppText"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; -import { - ClerkSettingsSheetDetentProvider, - useClerkSettingsSheetDetent, -} from "./features/cloud/ClerkSettingsSheetDetent"; +import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen"; import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout"; import { HardwareKeyboardCommandProvider } from "./features/keyboard/HardwareKeyboardCommandProvider"; @@ -166,20 +162,6 @@ const SettingsSheetStack = createNativeStackNavigator({ }, }); -function SettingsSheetDetentLayout(props: { readonly children: React.ReactNode }) { - const { isExpanded } = useClerkSettingsSheetDetent(); - const navigation = useNavigation(); - - useLayoutEffect(() => { - navigation.setOptions({ - sheetAllowedDetents: isExpanded ? [0.92] : [0.7, 0.92], - sheetGrabberVisible: true, - }); - }, [isExpanded, navigation]); - - return props.children; -} - // Thread routes live FLAT in the root stack (not in a nested navigator). A nested // stack means a second UINavigationController with its own UINavigationBar, which // breaks iOS 26's shared-header morphing between Home and Thread (each pair inside @@ -422,7 +404,6 @@ export const RootStack = createNativeStackNavigator({ SettingsSheet: createNativeStackScreen({ screen: SettingsSheetStack, linking: "settings", - layout: ({ children }) => {children}, options: { gestureEnabled: true, headerShown: false, @@ -435,7 +416,6 @@ export const RootStack = createNativeStackNavigator({ screen: ConnectionsRouteScreen, linking: "connections", options: { - ...SHEET_SOLID_HEADER_OPTIONS, title: "Environments", presentation: "formSheet", sheetAllowedDetents: [0.55, 0.7], @@ -446,8 +426,6 @@ export const RootStack = createNativeStackNavigator({ screen: ConnectionsNewRouteScreen, linking: "connections/new", options: { - ...SHEET_SOLID_HEADER_OPTIONS, - title: "Add Environment", presentation: "formSheet", sheetAllowedDetents: [0.55, 0.7], sheetGrabberVisible: true, diff --git a/apps/mobile/src/features/files/SourceFileSurface.tsx b/apps/mobile/src/features/files/SourceFileSurface.tsx index cf8393a0452..9774130eb2e 100644 --- a/apps/mobile/src/features/files/SourceFileSurface.tsx +++ b/apps/mobile/src/features/files/SourceFileSurface.tsx @@ -214,22 +214,8 @@ function NativeSourceFileSurface( function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { const { codeSurface, codeWordBreak } = useAppearanceCodeSurface(); - const { onRefresh } = props; const { lines, status, targetIndex, tokens } = useSourceFileModel(props); const listRef = useRef>(null); - const [isPullRefreshing, setIsPullRefreshing] = useState(false); - - const handlePullToRefresh = useCallback(async () => { - if (!onRefresh) { - return; - } - setIsPullRefreshing(true); - try { - await onRefresh(); - } finally { - setIsPullRefreshing(false); - } - }, [onRefresh]); useEffect(() => { if (targetIndex === null) { @@ -278,12 +264,6 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { paddingTop: 8, }} renderItem={renderLine} - {...(onRefresh - ? { - refreshing: isPullRefreshing, - onRefresh: () => void handlePullToRefresh(), - } - : {})} /> ); @@ -302,9 +282,8 @@ function JavaScriptSourceFileSurface(props: SourceFileSurfaceProps) { } export function SourceFileSurface(props: SourceFileSurfaceProps) { - const { codeWordBreak } = useAppearanceCodeSurface(); const NativeView = resolveNativeReviewDiffView(); - return NativeView && !codeWordBreak ? ( + return NativeView ? ( ) : ( diff --git a/apps/mobile/src/features/threads/sidebar-filter-button.tsx b/apps/mobile/src/features/threads/sidebar-filter-button.tsx index 7b0fef6b47c..642a4a957bb 100644 --- a/apps/mobile/src/features/threads/sidebar-filter-button.tsx +++ b/apps/mobile/src/features/threads/sidebar-filter-button.tsx @@ -50,5 +50,6 @@ const styles = StyleSheet.create({ borderWidth: StyleSheet.hairlineWidth, alignItems: "center", justifyContent: "center", + cursor: "pointer", }, }); diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 91228e47a27..3952637da67 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -376,6 +376,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ? effectivePressedBackground : backgroundColor, borderRadius: SIDEBAR_ROW_RADIUS, + cursor: "pointer", minHeight: 64, justifyContent: "center", paddingHorizontal: 12, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 39e7b02a391..d837245233b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -19,6 +19,7 @@ import { ProviderInteractionMode, ProviderDriverKind, RuntimeMode, + TerminalOpenInput, } from "@t3tools/contracts"; import { connectionStatusText, @@ -35,9 +36,11 @@ import { createModelSelection, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; +import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; +import { Debouncer } from "@tanstack/react-pacer"; import { useAtomValue } from "@effect/atom-react"; import { lazy, @@ -82,7 +85,7 @@ import { isLatestTurnSettled, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; -import { useTimelineScrollController } from "./chat/useTimelineScrollController"; +import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, @@ -99,6 +102,7 @@ import { import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, + DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, type ChatMessage, type SessionPhase, @@ -144,11 +148,6 @@ import { nextProjectScriptId, projectScriptIdFromCommand, } from "~/projectScripts"; -import { - type ProjectActionTerminalReservations, - releaseProjectActionTerminalReservationsSeenRunning, - runProjectScriptInTerminal, -} from "~/projectScriptTerminals"; import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; import { useEnvironmentSettings } from "../hooks/useSettings"; @@ -191,7 +190,6 @@ import { serverEnvironment, } from "../state/server"; import { terminalEnvironment } from "../state/terminal"; -import { projectActionTerminalEnvironment } from "../state/projectActionTerminal"; import { threadEnvironment } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; @@ -333,6 +331,9 @@ function formatOutgoingPrompt(params: { const promptEffort = resolvePromptInjectedEffort(caps, params.effort); return applyClaudePromptEffortPrefix(params.text, promptEffort); } +const SCRIPT_TERMINAL_COLS = 120; +const SCRIPT_TERMINAL_ROWS = 30; + type ChatViewProps = | { environmentId: EnvironmentId; @@ -999,10 +1000,6 @@ function ChatViewContent(props: ChatViewProps) { }); const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); - const requireProjectActionTerminalReady = useAtomCommand( - projectActionTerminalEnvironment.requireInputReady, - { label: "project action terminal require input ready", reportFailure: false }, - ); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); const createThread = useAtomCommand(threadEnvironment.create, { reportFailure: false }); const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); @@ -1101,6 +1098,7 @@ function ChatViewContent(props: ChatViewProps) { const composerElementContextsRef = useRef([]); const localComposerRef = useRef(null); const composerRef = useComposerHandleContext() ?? localComposerRef; + const [showScrollToBottom, setShowScrollToBottom] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); const optimisticUserMessagesRef = useRef(optimisticUserMessages); @@ -1154,25 +1152,18 @@ function ChatViewContent(props: ChatViewProps) { const legendListRef = useRef(null); const [composerOverlayElement, setComposerOverlayElement] = useState(null); const [composerOverlayHeight, setComposerOverlayHeight] = useState(0); + const isAtEndRef = useRef(true); const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); - const projectActionTerminalLaunchReservationsByThreadRef = useRef( - new Map(), - ); const terminalUiOpenByThreadRef = useRef>({}); useLayoutEffect(() => { - if (!composerOverlayElement) { - setComposerOverlayHeight((currentHeight) => (currentHeight === 0 ? currentHeight : 0)); - return; - } + if (!composerOverlayElement) return; const updateHeight = () => { - const nextHeight = Math.max( - 0, - Math.ceil(composerOverlayElement.getBoundingClientRect().height), - ); + const nextHeight = Math.ceil(composerOverlayElement.getBoundingClientRect().height); + if (nextHeight <= 0) return; setComposerOverlayHeight((currentHeight) => currentHeight === nextHeight ? currentHeight : nextHeight, ); @@ -1296,22 +1287,6 @@ function ChatViewContent(props: ChatViewProps) { () => (activeThread ? scopeThreadRef(activeThread.environmentId, activeThread.id) : null), [activeThread], ); - const projectActionTerminalReservationsForThread = useCallback((threadRef: ScopedThreadRef) => { - const threadKey = scopedThreadKey(threadRef); - let reservations = projectActionTerminalLaunchReservationsByThreadRef.current.get(threadKey); - if (!reservations) { - reservations = new Map(); - projectActionTerminalLaunchReservationsByThreadRef.current.set(threadKey, reservations); - } - return reservations; - }, []); - useEffect(() => { - if (!activeThreadRef) return; - releaseProjectActionTerminalReservationsSeenRunning({ - runningTerminalIds, - reservedTerminalIds: projectActionTerminalReservationsForThread(activeThreadRef), - }); - }, [activeThreadRef, projectActionTerminalReservationsForThread, runningTerminalIds]); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; @@ -2487,6 +2462,11 @@ function ChatViewContent(props: ChatViewProps) { }); } const targetCwd = options?.cwd ?? gitCwd ?? activeProject.workspaceRoot; + const baseTerminalId = + terminalUiState.activeTerminalId || activeKnownTerminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; + const isBaseTerminalBusy = runningTerminalIds.includes(baseTerminalId); + const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; + const shouldCreateNewTerminal = wantsNewTerminal; const targetWorktreePath = options?.worktreePath ?? activeThread.worktreePath ?? null; setTerminalUiLaunchContext({ @@ -2507,34 +2487,55 @@ function ChatViewContent(props: ChatViewProps) { worktreePath: targetWorktreePath, ...(options?.env ? { extraEnv: options.env } : {}), }); - const runResult = await runProjectScriptInTerminal({ - script, - threadId: activeThreadId, - targetCwd, - targetWorktreePath, - runtimeEnv, - preferNewTerminal: Boolean(options?.preferNewTerminal), - knownTerminalIds: activeKnownTerminalIds, - serverTerminalIds: activeServerOrderedTerminalIds, - visibleTerminalIds: terminalUiState.terminalIds, - runningTerminalIds, - sessions: activeThreadKnownSessions, - reservedTerminalIds: projectActionTerminalReservationsForThread(activeThreadRef), - isCommandInterrupted: (result) => isAtomCommandInterrupted(result), - showTerminal: (terminalId, state) => { - if (!state.isVisibleTerminal) { - storeNewTerminal(activeThreadRef, terminalId); - } else { - storeSetActiveTerminal(activeThreadRef, terminalId); + const targetTerminalId = shouldCreateNewTerminal + ? nextTerminalId(activeKnownTerminalIds) + : baseTerminalId; + const openTerminalInput: TerminalOpenInput = shouldCreateNewTerminal + ? { + threadId: activeThreadId, + terminalId: targetTerminalId, + cwd: targetCwd, + ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), + env: runtimeEnv, + cols: SCRIPT_TERMINAL_COLS, + rows: SCRIPT_TERMINAL_ROWS, } + : { + threadId: activeThreadId, + terminalId: targetTerminalId, + cwd: targetCwd, + ...(targetWorktreePath !== null ? { worktreePath: targetWorktreePath } : {}), + env: runtimeEnv, + }; + + if (shouldCreateNewTerminal) { + storeNewTerminal(activeThreadRef, targetTerminalId); + } else { + storeSetActiveTerminal(activeThreadRef, targetTerminalId); + } + + const openResult = await openTerminal({ environmentId, input: openTerminalInput }); + if (openResult._tag === "Failure") { + if (!isAtomCommandInterrupted(openResult)) { + const error = squashAtomCommandFailure(openResult); + setThreadError( + activeThreadId, + error instanceof Error ? error.message : `Failed to run script "${script.name}".`, + ); + } + return; + } + + const writeResult = await writeTerminal({ + environmentId, + input: { + threadId: activeThreadId, + terminalId: targetTerminalId, + data: `${script.command}\r`, }, - openTerminal: (input) => openTerminal({ environmentId, input }), - writeTerminal: (input) => writeTerminal({ environmentId, input }), - waitForInputReady: (input) => requireProjectActionTerminalReady({ environmentId, input }), - requireInputReady: (input) => requireProjectActionTerminalReady({ environmentId, input }), }); - if (runResult._tag === "Failure") { - const error = squashAtomCommandFailure(runResult.result); + if (writeResult._tag === "Failure" && !isAtomCommandInterrupted(writeResult)) { + const error = squashAtomCommandFailure(writeResult); setThreadError( activeThreadId, error instanceof Error ? error.message : `Failed to run script "${script.name}".`, @@ -2552,15 +2553,11 @@ function ChatViewContent(props: ChatViewProps) { storeNewTerminal, storeSetActiveTerminal, setLastInvokedScriptByProjectId, - activeThreadKnownSessions, - activeServerOrderedTerminalIds, environmentId, openTerminal, activeKnownTerminalIds, runningTerminalIds, - projectActionTerminalReservationsForThread, - requireProjectActionTerminalReady, - terminalUiState.terminalIds, + terminalUiState.activeTerminalId, writeTerminal, ], ); @@ -3161,25 +3158,330 @@ function ChatViewContent(props: ChatViewProps) { ], ); - const { - showScrollToBottom, - scrollToEnd, - cancelForManualNavigation: cancelTimelineLiveFollowForUserNavigation, - onAnchorReady: onTimelineAnchorReady, - onAnchorSizeChanged: onTimelineAnchorSizeChanged, - onIsAtEndChange, - prepareAnchorForMessage: prepareTimelineAnchorForMessage, - resetForThread: resetTimelineScrollForThread, - } = useTimelineScrollController({ - activeThreadId, - composerOverlayHeight, - listRef: legendListRef, + // Debounce *showing* the scroll-to-bottom pill so it doesn't flash during + // thread switches. LegendList fires scroll events with isAtEnd=false while + // initialScrollAtEnd is settling; hiding is always immediate. + const showScrollDebouncer = useRef( + new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), + ); + const timelineScrollModeRef = useRef("following-end"); + const pendingTimelineAnchorRef = useRef(null); + const positionedTimelineAnchorRef = useRef(null); + const settledTimelineAnchorRef = useRef(null); + const activeTimelineAnchorIndexRef = useRef(null); + const anchorUserScrollGenerationRef = useRef(0); + const liveFollowUserScrollGenerationRef = useRef(0); + const pendingAnchorScrollRestoreRef = useRef<{ + readonly messageId: MessageId; + readonly offset: number; + readonly userScrollGeneration: number; + } | null>(null); + const anchorScrollRestoreFrameRef = useRef(null); + const cancelTimelineLiveFollowForUserNavigation = useCallback(() => { + anchorUserScrollGenerationRef.current += 1; + timelineScrollModeRef.current = "free-scrolling"; + liveFollowUserScrollGenerationRef.current = null; + pendingTimelineAnchorRef.current = null; + positionedTimelineAnchorRef.current = null; + settledTimelineAnchorRef.current = null; + activeTimelineAnchorIndexRef.current = null; + pendingAnchorScrollRestoreRef.current = null; + if (anchorScrollRestoreFrameRef.current !== null) { + cancelAnimationFrame(anchorScrollRestoreFrameRef.current); + anchorScrollRestoreFrameRef.current = null; + } + }, []); + const cancelTimelineLiveFollowForUserNavigationRef = useRef( + cancelTimelineLiveFollowForUserNavigation, + ); + useEffect(() => { + cancelTimelineLiveFollowForUserNavigationRef.current = + cancelTimelineLiveFollowForUserNavigation; + }, [cancelTimelineLiveFollowForUserNavigation]); + const getActiveTimelineTurnMetrics = useCallback( + (list?: LegendListRef | null) => { + const resolvedList = list ?? legendListRef.current; + const anchorIndex = activeTimelineAnchorIndexRef.current; + const state = resolvedList?.getState(); + if (!resolvedList || !state || anchorIndex === null) { + return null; + } + + return getAnchoredTurnMetrics({ + state, + anchorIndex, + composerOverlayHeight, + anchorOffset: CHAT_LIST_ANCHOR_OFFSET, + }); + }, + [composerOverlayHeight], + ); + const timelineRealContentOverflowsViewport = useCallback( + (list?: LegendListRef | null) => { + const resolvedList = list ?? legendListRef.current; + const state = resolvedList?.getState(); + if (!resolvedList || !state || state.data.length === 0) { + return false; + } + + const lastRowIndex = state.data.length - 1; + const lastRowTop = state.positionAtIndex(lastRowIndex); + const lastRowHeight = state.sizeAtIndex(lastRowIndex); + if ( + typeof lastRowTop !== "number" || + typeof lastRowHeight !== "number" || + !Number.isFinite(lastRowTop) || + !Number.isFinite(lastRowHeight) + ) { + return false; + } + + const realContentBottom = lastRowTop + Math.max(1, lastRowHeight); + const visibleScrollLength = Math.max( + 0, + (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_LIST_ANCHOR_OFFSET, + ); + return realContentBottom > visibleScrollLength; + }, + [composerOverlayHeight], + ); + + // Live-follow stays active after send/thread-open until an actual list scroll + // gesture opts out. + const scrollToEnd = useCallback((animated = false) => { + isAtEndRef.current = true; + timelineScrollModeRef.current = "following-end"; + liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + pendingTimelineAnchorRef.current = null; + activeTimelineAnchorIndexRef.current = null; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + void legendListRef.current?.scrollToEnd?.({ animated }); + }, []); + useEffect(() => { + let removeListeners: (() => void) | null = null; + const frame = requestAnimationFrame(() => { + const scrollNode = legendListRef.current?.getScrollableNode(); + if (!scrollNode) { + return; + } + const handleManualNavigation = () => { + cancelTimelineLiveFollowForUserNavigationRef.current(); + }; + scrollNode.addEventListener("wheel", handleManualNavigation, { + passive: true, + }); + scrollNode.addEventListener("touchmove", handleManualNavigation, { + passive: true, + }); + scrollNode.addEventListener("pointerdown", handleManualNavigation, { + passive: true, + }); + removeListeners = () => { + scrollNode.removeEventListener("wheel", handleManualNavigation); + scrollNode.removeEventListener("touchmove", handleManualNavigation); + scrollNode.removeEventListener("pointerdown", handleManualNavigation); + }; + }); + + return () => { + cancelAnimationFrame(frame); + removeListeners?.(); + }; + }, [activeThread?.id]); + + const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { + if (pendingTimelineAnchorRef.current === messageId) { + pendingTimelineAnchorRef.current = null; + } + activeTimelineAnchorIndexRef.current = anchorIndex; + if (positionedTimelineAnchorRef.current === messageId) { + return; + } + positionedTimelineAnchorRef.current = messageId; + settledTimelineAnchorRef.current = null; + const positionAnchor = (remainingAttempts: number) => { + requestAnimationFrame(() => { + if (positionedTimelineAnchorRef.current !== messageId) { + return; + } + const list = legendListRef.current; + if (!list) { + if (remainingAttempts > 0) { + positionAnchor(remainingAttempts - 1); + } + return; + } + const scrollNode = list.getScrollableNode(); + let finished = false; + const finishAnimatedPositioning = () => { + if (finished) { + return; + } + finished = true; + window.clearTimeout(fallbackTimer); + scrollNode.removeEventListener("scrollend", finishAnimatedPositioning); + if (positionedTimelineAnchorRef.current !== messageId) { + return; + } + const scrollOffset = list.getState().scroll; + void list.scrollToOffset({ offset: scrollOffset, animated: false }); + settledTimelineAnchorRef.current = messageId; + }; + const fallbackTimer = window.setTimeout(finishAnimatedPositioning, 750); + scrollNode.addEventListener("scrollend", finishAnimatedPositioning, { once: true }); + void list.scrollToIndex({ + index: anchorIndex, + animated: true, + viewPosition: 0, + viewOffset: CHAT_LIST_ANCHOR_OFFSET, + }); + }); + }; + requestAnimationFrame(() => positionAnchor(12)); + }, []); + const onTimelineAnchorSizeChanged = useCallback((messageId: MessageId) => { + if (settledTimelineAnchorRef.current !== messageId) { + return; + } + if (liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current) { + return; + } + const scrollOffset = legendListRef.current?.getState().scroll; + if (scrollOffset === undefined) { + return; + } + if (pendingAnchorScrollRestoreRef.current === null) { + pendingAnchorScrollRestoreRef.current = { + messageId, + offset: scrollOffset, + userScrollGeneration: anchorUserScrollGenerationRef.current, + }; + } + if (anchorScrollRestoreFrameRef.current !== null) { + return; + } + anchorScrollRestoreFrameRef.current = requestAnimationFrame(() => { + anchorScrollRestoreFrameRef.current = null; + const pending = pendingAnchorScrollRestoreRef.current; + pendingAnchorScrollRestoreRef.current = null; + if ( + pending && + settledTimelineAnchorRef.current === pending.messageId && + pending.userScrollGeneration === anchorUserScrollGenerationRef.current + ) { + const list = legendListRef.current; + const currentScrollOffset = list?.getState().scroll; + if ( + typeof currentScrollOffset === "number" && + Math.abs(currentScrollOffset - pending.offset) <= 2 + ) { + void list?.scrollToOffset({ offset: pending.offset, animated: false }); + } + } + }); + }, []); + + const onIsAtEndChange = useCallback((isAtEnd: boolean) => { + if ( + !isAtEnd && + liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current + ) { + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + return; + } + if (isAtEndRef.current === isAtEnd) return; + isAtEndRef.current = isAtEnd; + if (isAtEnd) { + timelineScrollModeRef.current = "following-end"; + liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); + } else { + timelineScrollModeRef.current = "free-scrolling"; + liveFollowUserScrollGenerationRef.current = null; + showScrollDebouncer.current.maybeExecute(); + } + }, []); + + useEffect(() => { + if (!activeThread?.id) { + return; + } + if (liveFollowUserScrollGenerationRef.current !== anchorUserScrollGenerationRef.current) { + return; + } + + let secondFrame: number | null = null; + const frame = requestAnimationFrame(() => { + secondFrame = requestAnimationFrame(() => { + if (liveFollowUserScrollGenerationRef.current !== anchorUserScrollGenerationRef.current) { + return; + } + if (pendingTimelineAnchorRef.current !== null) { + return; + } + if ( + positionedTimelineAnchorRef.current !== null && + settledTimelineAnchorRef.current !== positionedTimelineAnchorRef.current + ) { + return; + } + const list = legendListRef.current; + if (!list) { + return; + } + + if (timelineScrollModeRef.current === "anchoring-new-turn") { + const metrics = getActiveTimelineTurnMetrics(list); + if (!metrics) { + return; + } + if (metrics.scrollDeltaToRevealEnd <= 1) { + return; + } + + const nextOffset = list.getState().scroll + metrics.scrollDeltaToRevealEnd; + void list.scrollToOffset({ offset: nextOffset, animated: false }); + return; + } + + if (timelineScrollModeRef.current !== "following-end") { + return; + } + if (!timelineRealContentOverflowsViewport(list)) { + return; + } + + void list.scrollToEnd?.({ animated: false }); + }); + }); + + return () => { + cancelAnimationFrame(frame); + if (secondFrame !== null) { + cancelAnimationFrame(secondFrame); + } + }; + }, [ + activeThread?.id, timelineEntries, - }); + getActiveTimelineTurnMetrics, + timelineRealContentOverflowsViewport, + ]); useEffect(() => { setPullRequestDialogState(null); - resetTimelineScrollForThread(); + isAtEndRef.current = true; + timelineScrollModeRef.current = "following-end"; + liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + pendingTimelineAnchorRef.current = null; + positionedTimelineAnchorRef.current = null; + settledTimelineAnchorRef.current = null; + activeTimelineAnchorIndexRef.current = null; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); if (planSidebarOpenOnNextThreadRef.current) { planSidebarOpenOnNextThreadRef.current = false; if (activeThreadRef) { @@ -3188,7 +3490,7 @@ function ChatViewContent(props: ChatViewProps) { } planSidebarDismissedForTurnRef.current = null; // activeThreadRef resets transitively with the active thread. - }, [activeThread?.id, resetTimelineScrollForThread]); + }, [activeThread?.id]); // Auto-open the plan sidebar when plan/todo steps arrive for the current turn. // Don't auto-open for plans carried over from a previous turn (the user can open manually). @@ -3736,7 +4038,13 @@ function ChatViewContent(props: ChatViewProps) { // Sending always returns to the live edge. The new row becomes the // anchored end-space target so it lands near the top while the response // streams into the reserved space below it. - prepareTimelineAnchorForMessage(messageIdForSend); + isAtEndRef.current = true; + timelineScrollModeRef.current = "anchoring-new-turn"; + liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + pendingTimelineAnchorRef.current = messageIdForSend; + activeTimelineAnchorIndexRef.current = null; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); setTimelineAnchor({ threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), messageId: messageIdForSend, @@ -4167,7 +4475,13 @@ function ChatViewContent(props: ChatViewProps) { setThreadError(threadIdForSend, null); // Position this sent row once LegendList has measured the anchored tail. - prepareTimelineAnchorForMessage(messageIdForSend); + isAtEndRef.current = true; + timelineScrollModeRef.current = "anchoring-new-turn"; + liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + pendingTimelineAnchorRef.current = messageIdForSend; + activeTimelineAnchorIndexRef.current = null; + showScrollDebouncer.current.cancel(); + setShowScrollToBottom(false); setTimelineAnchor({ threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), messageId: messageIdForSend, @@ -4275,7 +4589,6 @@ function ChatViewContent(props: ChatViewProps) { autoOpenPlanSidebar, environmentId, composerRef, - prepareTimelineAnchorForMessage, ], ); diff --git a/apps/web/src/components/chat/useTimelineScrollController.test.ts b/apps/web/src/components/chat/useTimelineScrollController.test.ts deleted file mode 100644 index abd91de7eb2..00000000000 --- a/apps/web/src/components/chat/useTimelineScrollController.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { type MessageId } from "@t3tools/contracts"; -import { describe, expect, it, vi } from "vite-plus/test"; -import { - clearTimelineAnchorIfPositioningExhausted, - clearPendingTimelineAnchorScrollRestore, - isTimelineScrollKeyboardNavigationKey, - scheduleTimelineManualNavigationListeners, -} from "./useTimelineScrollController"; - -describe("timeline scroll controller", () => { - it("recognizes keyboard keys that can move the timeline scroll position", () => { - expect(isTimelineScrollKeyboardNavigationKey("ArrowUp")).toBe(true); - expect(isTimelineScrollKeyboardNavigationKey("ArrowDown")).toBe(true); - expect(isTimelineScrollKeyboardNavigationKey("PageUp")).toBe(true); - expect(isTimelineScrollKeyboardNavigationKey("PageDown")).toBe(true); - expect(isTimelineScrollKeyboardNavigationKey("Home")).toBe(true); - expect(isTimelineScrollKeyboardNavigationKey("End")).toBe(true); - expect(isTimelineScrollKeyboardNavigationKey(" ")).toBe(true); - }); - - it("ignores non-navigation keys", () => { - expect(isTimelineScrollKeyboardNavigationKey("Enter")).toBe(false); - expect(isTimelineScrollKeyboardNavigationKey("Escape")).toBe(false); - expect(isTimelineScrollKeyboardNavigationKey("a")).toBe(false); - }); - - it("retries manual navigation listener setup when the scroll node mounts late", () => { - const frames: FrameRequestCallback[] = []; - const listeners = new Map>(); - const onManualNavigation = vi.fn(); - const onExhausted = vi.fn(); - const onInstalled = vi.fn(); - let scrollNode: HTMLElement | null = null; - - const node = { - addEventListener: (type: string, listener: EventListener) => { - const typeListeners = listeners.get(type) ?? new Set(); - typeListeners.add(listener); - listeners.set(type, typeListeners); - }, - removeEventListener: (type: string, listener: EventListener) => { - listeners.get(type)?.delete(listener); - }, - } as HTMLElement; - - const cleanup = scheduleTimelineManualNavigationListeners({ - getScrollNode: () => scrollNode, - maxAttempts: 2, - onExhausted, - onInstalled, - onManualNavigation, - requestFrame: (callback) => { - frames.push(callback); - return frames.length; - }, - cancelFrame: () => {}, - }); - - expect(frames).toHaveLength(1); - frames.shift()?.(0); - expect(listeners.get("wheel")).toBeUndefined(); - expect(frames).toHaveLength(1); - - scrollNode = node; - frames.shift()?.(0); - expect(onInstalled).toHaveBeenCalledOnce(); - expect(onExhausted).not.toHaveBeenCalled(); - expect(listeners.get("wheel")?.size).toBe(1); - expect(listeners.get("touchmove")?.size).toBe(1); - expect(listeners.get("pointerdown")?.size).toBe(1); - expect(listeners.get("keydown")?.size).toBe(1); - - listeners.get("wheel")?.forEach((listener) => listener({} as Event)); - expect(onManualNavigation).toHaveBeenCalledOnce(); - - cleanup(); - expect(listeners.get("wheel")?.size).toBe(0); - expect(listeners.get("touchmove")?.size).toBe(0); - expect(listeners.get("pointerdown")?.size).toBe(0); - expect(listeners.get("keydown")?.size).toBe(0); - }); - - it("reports exhausted manual navigation listener setup without installing listeners", () => { - const frames: FrameRequestCallback[] = []; - const onExhausted = vi.fn(); - const onInstalled = vi.fn(); - - scheduleTimelineManualNavigationListeners({ - getScrollNode: () => null, - maxAttempts: 1, - onExhausted, - onInstalled, - onManualNavigation: vi.fn(), - requestFrame: (callback) => { - frames.push(callback); - return frames.length; - }, - cancelFrame: () => {}, - }); - - frames.shift()?.(0); - expect(onExhausted).not.toHaveBeenCalled(); - frames.shift()?.(0); - expect(onExhausted).toHaveBeenCalledOnce(); - expect(onInstalled).not.toHaveBeenCalled(); - }); - - it("clears pending anchor scroll restore state and cancels the queued frame", () => { - const pendingAnchorScrollRestoreRef = { - current: { - messageId: "message-1", - offset: 42, - userScrollGeneration: 1, - }, - }; - const anchorScrollRestoreFrameRef = { current: 7 }; - const cancelFrame = vi.fn(); - - clearPendingTimelineAnchorScrollRestore({ - anchorScrollRestoreFrameRef, - cancelFrame, - pendingAnchorScrollRestoreRef, - }); - - expect(pendingAnchorScrollRestoreRef.current).toBeNull(); - expect(anchorScrollRestoreFrameRef.current).toBeNull(); - expect(cancelFrame).toHaveBeenCalledWith(7); - }); - - it("clears exhausted anchor positioning only for the active positioned anchor", () => { - const message1 = "message-1" as MessageId; - const message2 = "message-2" as MessageId; - const activeTimelineAnchorIndexRef = { current: 3 }; - const positionedTimelineAnchorRef = { current: message1 }; - const settledTimelineAnchorRef = { current: message1 }; - const timelineScrollModeRef = { current: "anchoring-new-turn" as const }; - - clearTimelineAnchorIfPositioningExhausted({ - activeTimelineAnchorIndexRef, - messageId: message2, - positionedTimelineAnchorRef, - settledTimelineAnchorRef, - timelineScrollModeRef, - }); - - expect(activeTimelineAnchorIndexRef.current).toBe(3); - expect(positionedTimelineAnchorRef.current).toBe(message1); - expect(settledTimelineAnchorRef.current).toBe(message1); - expect(timelineScrollModeRef.current).toBe("anchoring-new-turn"); - - clearTimelineAnchorIfPositioningExhausted({ - activeTimelineAnchorIndexRef, - messageId: message1, - positionedTimelineAnchorRef, - settledTimelineAnchorRef, - timelineScrollModeRef, - }); - - expect(activeTimelineAnchorIndexRef.current).toBeNull(); - expect(positionedTimelineAnchorRef.current).toBeNull(); - expect(settledTimelineAnchorRef.current).toBeNull(); - expect(timelineScrollModeRef.current).toBe("following-end"); - }); -}); diff --git a/apps/web/src/components/chat/useTimelineScrollController.ts b/apps/web/src/components/chat/useTimelineScrollController.ts deleted file mode 100644 index 74e079bb71a..00000000000 --- a/apps/web/src/components/chat/useTimelineScrollController.ts +++ /dev/null @@ -1,619 +0,0 @@ -import { type MessageId } from "@t3tools/contracts"; -import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; -import { Debouncer } from "@tanstack/react-pacer"; -import { type LegendListRef } from "@legendapp/list/react"; -import { type RefObject, useCallback, useEffect, useRef, useState } from "react"; -import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./timelineScrollAnchoring"; - -const TIMELINE_SCROLL_KEYBOARD_NAVIGATION_KEYS = new Set([ - "ArrowDown", - "ArrowUp", - "End", - "Home", - "PageDown", - "PageUp", - " ", -]); - -const TIMELINE_SCROLL_LISTENER_SETUP_MAX_ATTEMPTS = 12; - -export function isTimelineScrollKeyboardNavigationKey(key: string): boolean { - return TIMELINE_SCROLL_KEYBOARD_NAVIGATION_KEYS.has(key); -} - -export function scheduleTimelineManualNavigationListeners({ - cancelFrame = cancelAnimationFrame, - getScrollNode, - maxAttempts = TIMELINE_SCROLL_LISTENER_SETUP_MAX_ATTEMPTS, - onExhausted, - onInstalled, - onManualNavigation, - requestFrame = requestAnimationFrame, -}: { - readonly cancelFrame?: (handle: number) => void; - readonly getScrollNode: () => HTMLElement | null; - readonly maxAttempts?: number; - readonly onExhausted?: () => void; - readonly onInstalled?: () => void; - readonly onManualNavigation: () => void; - readonly requestFrame?: (callback: FrameRequestCallback) => number; -}): () => void { - let frame: number | null = null; - let removeListeners: (() => void) | null = null; - let cancelled = false; - - const installListeners = (scrollNode: HTMLElement) => { - const isAnchorIgnoredEvent = (event: Event) => - event.target instanceof Element && - scrollNode.contains(event.target) && - event.target.closest("[data-scroll-anchor-ignore]") !== null; - const handleManualNavigation = () => { - onManualNavigation(); - }; - const handlePointerDown = (event: PointerEvent) => { - if (isAnchorIgnoredEvent(event)) { - return; - } - onManualNavigation(); - }; - const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.defaultPrevented || - event.metaKey || - event.ctrlKey || - event.altKey || - !isTimelineScrollKeyboardNavigationKey(event.key) || - isAnchorIgnoredEvent(event) - ) { - return; - } - onManualNavigation(); - }; - scrollNode.addEventListener("wheel", handleManualNavigation, { - passive: true, - }); - scrollNode.addEventListener("touchmove", handleManualNavigation, { - passive: true, - }); - scrollNode.addEventListener("pointerdown", handlePointerDown, { - passive: true, - }); - scrollNode.addEventListener("keydown", handleKeyDown); - onInstalled?.(); - removeListeners = () => { - scrollNode.removeEventListener("wheel", handleManualNavigation); - scrollNode.removeEventListener("touchmove", handleManualNavigation); - scrollNode.removeEventListener("pointerdown", handlePointerDown); - scrollNode.removeEventListener("keydown", handleKeyDown); - }; - }; - - const scheduleSetup = (remainingAttempts: number) => { - frame = requestFrame(() => { - frame = null; - if (cancelled || removeListeners !== null) { - return; - } - - const scrollNode = getScrollNode(); - if (!scrollNode) { - if (remainingAttempts > 0) { - scheduleSetup(remainingAttempts - 1); - } else { - onExhausted?.(); - } - return; - } - - installListeners(scrollNode); - }); - }; - - scheduleSetup(maxAttempts); - - return () => { - cancelled = true; - if (frame !== null) { - cancelFrame(frame); - frame = null; - } - removeListeners?.(); - }; -} - -export function clearPendingTimelineAnchorScrollRestore({ - anchorScrollRestoreFrameRef, - cancelFrame = cancelAnimationFrame, - pendingAnchorScrollRestoreRef, -}: { - readonly anchorScrollRestoreFrameRef: { current: number | null }; - readonly cancelFrame?: (handle: number) => void; - readonly pendingAnchorScrollRestoreRef: { current: unknown | null }; -}): void { - pendingAnchorScrollRestoreRef.current = null; - if (anchorScrollRestoreFrameRef.current !== null) { - cancelFrame(anchorScrollRestoreFrameRef.current); - anchorScrollRestoreFrameRef.current = null; - } -} - -export function clearTimelineAnchorIfPositioningExhausted({ - activeTimelineAnchorIndexRef, - messageId, - positionedTimelineAnchorRef, - settledTimelineAnchorRef, - timelineScrollModeRef, -}: { - readonly activeTimelineAnchorIndexRef: { current: number | null }; - readonly messageId: MessageId; - readonly positionedTimelineAnchorRef: { current: MessageId | null }; - readonly settledTimelineAnchorRef: { current: MessageId | null }; - readonly timelineScrollModeRef: { current: TimelineScrollMode }; -}): void { - if (positionedTimelineAnchorRef.current !== messageId) { - return; - } - positionedTimelineAnchorRef.current = null; - settledTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; - timelineScrollModeRef.current = "following-end"; -} - -export interface TimelineScrollController { - readonly showScrollToBottom: boolean; - readonly scrollToEnd: (animated?: boolean) => void; - readonly cancelForManualNavigation: () => void; - readonly onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; - readonly onAnchorSizeChanged: (messageId: MessageId) => void; - readonly onIsAtEndChange: (isAtEnd: boolean) => void; - readonly prepareAnchorForMessage: (messageId: MessageId) => void; - readonly resetForThread: () => void; -} - -export function useTimelineScrollController({ - activeThreadId, - composerOverlayHeight, - listRef, - timelineEntries, -}: { - readonly activeThreadId: string | null; - readonly composerOverlayHeight: number; - readonly listRef: RefObject; - readonly timelineEntries: readonly unknown[]; -}): TimelineScrollController { - const [showScrollToBottom, setShowScrollToBottom] = useState(false); - const showScrollDebouncer = useRef( - new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), - ); - const isAtEndRef = useRef(true); - const timelineScrollModeRef = useRef("following-end"); - const pendingTimelineAnchorRef = useRef(null); - const positionedTimelineAnchorRef = useRef(null); - const settledTimelineAnchorRef = useRef(null); - const activeTimelineAnchorIndexRef = useRef(null); - const anchorUserScrollGenerationRef = useRef(0); - const liveFollowUserScrollGenerationRef = useRef(0); - const pendingAnchorScrollRestoreRef = useRef<{ - readonly messageId: MessageId; - readonly offset: number; - readonly userScrollGeneration: number; - } | null>(null); - const anchorScrollRestoreFrameRef = useRef(null); - const anchorPositionFrameRefs = useRef>(new Set()); - const anchorPositionCleanupRef = useRef<(() => void) | null>(null); - const manualNavigationListenersInstalledRef = useRef(false); - const previousTimelineEntryCountRef = useRef(timelineEntries.length); - const [manualNavigationListenerRetryToken, setManualNavigationListenerRetryToken] = useState(0); - - const cleanupAnchorPositioning = useCallback(() => { - for (const frame of anchorPositionFrameRefs.current) { - cancelAnimationFrame(frame); - } - anchorPositionFrameRefs.current.clear(); - anchorPositionCleanupRef.current?.(); - anchorPositionCleanupRef.current = null; - }, []); - const clearPendingAnchorScrollRestore = useCallback(() => { - clearPendingTimelineAnchorScrollRestore({ - anchorScrollRestoreFrameRef, - pendingAnchorScrollRestoreRef, - }); - }, []); - - const scheduleAnchorPositionFrame = useCallback((callback: () => void) => { - const frame = requestAnimationFrame(() => { - anchorPositionFrameRefs.current.delete(frame); - callback(); - }); - anchorPositionFrameRefs.current.add(frame); - }, []); - - const cancelForManualNavigation = useCallback(() => { - anchorUserScrollGenerationRef.current += 1; - timelineScrollModeRef.current = "free-scrolling"; - liveFollowUserScrollGenerationRef.current = null; - pendingTimelineAnchorRef.current = null; - positionedTimelineAnchorRef.current = null; - settledTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; - clearPendingAnchorScrollRestore(); - cleanupAnchorPositioning(); - }, [cleanupAnchorPositioning, clearPendingAnchorScrollRestore]); - const cancelForManualNavigationRef = useRef(cancelForManualNavigation); - useEffect(() => { - cancelForManualNavigationRef.current = cancelForManualNavigation; - }, [cancelForManualNavigation]); - - const getActiveTimelineTurnMetrics = useCallback( - (list?: LegendListRef | null) => { - const resolvedList = list ?? listRef.current; - const anchorIndex = activeTimelineAnchorIndexRef.current; - const state = resolvedList?.getState(); - if (!resolvedList || !state || anchorIndex === null) { - return null; - } - - return getAnchoredTurnMetrics({ - state, - anchorIndex, - composerOverlayHeight, - anchorOffset: CHAT_LIST_ANCHOR_OFFSET, - }); - }, - [composerOverlayHeight, listRef], - ); - const timelineRealContentOverflowsViewport = useCallback( - (list?: LegendListRef | null) => { - const resolvedList = list ?? listRef.current; - const state = resolvedList?.getState(); - if (!resolvedList || !state || state.data.length === 0) { - return false; - } - - const lastRowIndex = state.data.length - 1; - const lastRowTop = state.positionAtIndex(lastRowIndex); - const lastRowHeight = state.sizeAtIndex(lastRowIndex); - if ( - typeof lastRowTop !== "number" || - typeof lastRowHeight !== "number" || - !Number.isFinite(lastRowTop) || - !Number.isFinite(lastRowHeight) - ) { - return false; - } - - const realContentBottom = lastRowTop + Math.max(1, lastRowHeight); - const visibleScrollLength = Math.max( - 0, - (state.scrollLength ?? 0) - composerOverlayHeight - CHAT_LIST_ANCHOR_OFFSET, - ); - return realContentBottom > visibleScrollLength; - }, - [composerOverlayHeight, listRef], - ); - - const scrollToEnd = useCallback( - (animated = false) => { - isAtEndRef.current = true; - timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - pendingTimelineAnchorRef.current = null; - positionedTimelineAnchorRef.current = null; - settledTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; - clearPendingAnchorScrollRestore(); - cleanupAnchorPositioning(); - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - void listRef.current?.scrollToEnd?.({ animated }); - }, - [cleanupAnchorPositioning, clearPendingAnchorScrollRestore, listRef], - ); - - const hasTimelineEntries = timelineEntries.length > 0; - useEffect(() => { - manualNavigationListenersInstalledRef.current = false; - const cleanup = scheduleTimelineManualNavigationListeners({ - getScrollNode: () => listRef.current?.getScrollableNode() ?? null, - onExhausted: () => { - manualNavigationListenersInstalledRef.current = false; - }, - onInstalled: () => { - manualNavigationListenersInstalledRef.current = true; - }, - onManualNavigation: () => cancelForManualNavigationRef.current(), - }); - return () => { - manualNavigationListenersInstalledRef.current = false; - cleanup(); - }; - }, [activeThreadId, hasTimelineEntries, listRef, manualNavigationListenerRetryToken]); - useEffect(() => { - const previousTimelineEntryCount = previousTimelineEntryCountRef.current; - previousTimelineEntryCountRef.current = timelineEntries.length; - if ( - previousTimelineEntryCount > 0 && - timelineEntries.length > previousTimelineEntryCount && - !manualNavigationListenersInstalledRef.current - ) { - setManualNavigationListenerRetryToken((token) => token + 1); - } - }, [timelineEntries.length]); - - const onAnchorReady = useCallback( - (messageId: MessageId, anchorIndex: number) => { - if (pendingTimelineAnchorRef.current === messageId) { - pendingTimelineAnchorRef.current = null; - } - activeTimelineAnchorIndexRef.current = anchorIndex; - if (positionedTimelineAnchorRef.current === messageId) { - return; - } - cleanupAnchorPositioning(); - positionedTimelineAnchorRef.current = messageId; - settledTimelineAnchorRef.current = null; - const positionAnchor = (remainingAttempts: number) => { - scheduleAnchorPositionFrame(() => { - if (positionedTimelineAnchorRef.current !== messageId) { - return; - } - const list = listRef.current; - if (!list) { - if (remainingAttempts > 0) { - positionAnchor(remainingAttempts - 1); - } else { - clearTimelineAnchorIfPositioningExhausted({ - activeTimelineAnchorIndexRef, - messageId, - positionedTimelineAnchorRef, - settledTimelineAnchorRef, - timelineScrollModeRef, - }); - } - return; - } - const scrollNode = list.getScrollableNode(); - if (!scrollNode) { - if (remainingAttempts > 0) { - positionAnchor(remainingAttempts - 1); - } else { - clearTimelineAnchorIfPositioningExhausted({ - activeTimelineAnchorIndexRef, - messageId, - positionedTimelineAnchorRef, - settledTimelineAnchorRef, - timelineScrollModeRef, - }); - } - return; - } - let finished = false; - let cleanup: (() => void) | null = null; - const finishAnimatedPositioning = () => { - if (finished) { - return; - } - finished = true; - window.clearTimeout(fallbackTimer); - scrollNode.removeEventListener("scrollend", finishAnimatedPositioning); - if (anchorPositionCleanupRef.current === cleanup) { - anchorPositionCleanupRef.current = null; - } - if (positionedTimelineAnchorRef.current !== messageId) { - return; - } - const scrollOffset = list.getState().scroll; - void list.scrollToOffset({ offset: scrollOffset, animated: false }); - settledTimelineAnchorRef.current = messageId; - }; - const fallbackTimer = window.setTimeout(finishAnimatedPositioning, 750); - scrollNode.addEventListener("scrollend", finishAnimatedPositioning, { once: true }); - cleanup = () => { - if (finished) { - return; - } - finished = true; - window.clearTimeout(fallbackTimer); - scrollNode.removeEventListener("scrollend", finishAnimatedPositioning); - if (anchorPositionCleanupRef.current === cleanup) { - anchorPositionCleanupRef.current = null; - } - }; - anchorPositionCleanupRef.current = cleanup; - void list.scrollToIndex({ - index: anchorIndex, - animated: true, - viewPosition: 0, - viewOffset: CHAT_LIST_ANCHOR_OFFSET, - }); - }); - }; - scheduleAnchorPositionFrame(() => positionAnchor(12)); - }, - [cleanupAnchorPositioning, listRef, scheduleAnchorPositionFrame], - ); - - const onAnchorSizeChanged = useCallback( - (messageId: MessageId) => { - if (settledTimelineAnchorRef.current !== messageId) { - return; - } - if (liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current) { - return; - } - const scrollOffset = listRef.current?.getState().scroll; - if (scrollOffset === undefined) { - return; - } - if (pendingAnchorScrollRestoreRef.current === null) { - pendingAnchorScrollRestoreRef.current = { - messageId, - offset: scrollOffset, - userScrollGeneration: anchorUserScrollGenerationRef.current, - }; - } - if (anchorScrollRestoreFrameRef.current !== null) { - return; - } - anchorScrollRestoreFrameRef.current = requestAnimationFrame(() => { - anchorScrollRestoreFrameRef.current = null; - const pending = pendingAnchorScrollRestoreRef.current; - pendingAnchorScrollRestoreRef.current = null; - if ( - pending && - settledTimelineAnchorRef.current === pending.messageId && - pending.userScrollGeneration === anchorUserScrollGenerationRef.current - ) { - const list = listRef.current; - const currentScrollOffset = list?.getState().scroll; - if ( - typeof currentScrollOffset === "number" && - Math.abs(currentScrollOffset - pending.offset) <= 2 - ) { - void list?.scrollToOffset({ offset: pending.offset, animated: false }); - } - } - }); - }, - [listRef], - ); - - const onIsAtEndChange = useCallback((isAtEnd: boolean) => { - if ( - !isAtEnd && - liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current - ) { - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - return; - } - if (isAtEndRef.current === isAtEnd) return; - isAtEndRef.current = isAtEnd; - if (isAtEnd) { - timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - } else { - timelineScrollModeRef.current = "free-scrolling"; - liveFollowUserScrollGenerationRef.current = null; - showScrollDebouncer.current.maybeExecute(); - } - }, []); - - useEffect(() => { - if (!activeThreadId) { - return; - } - if (liveFollowUserScrollGenerationRef.current !== anchorUserScrollGenerationRef.current) { - return; - } - - let secondFrame: number | null = null; - const frame = requestAnimationFrame(() => { - secondFrame = requestAnimationFrame(() => { - if (liveFollowUserScrollGenerationRef.current !== anchorUserScrollGenerationRef.current) { - return; - } - if (pendingTimelineAnchorRef.current !== null) { - return; - } - if ( - positionedTimelineAnchorRef.current !== null && - settledTimelineAnchorRef.current !== positionedTimelineAnchorRef.current - ) { - return; - } - const list = listRef.current; - if (!list) { - return; - } - - if (timelineScrollModeRef.current === "anchoring-new-turn") { - const metrics = getActiveTimelineTurnMetrics(list); - if (!metrics) { - return; - } - if (metrics.scrollDeltaToRevealEnd <= 1) { - return; - } - - const nextOffset = list.getState().scroll + metrics.scrollDeltaToRevealEnd; - void list.scrollToOffset({ offset: nextOffset, animated: false }); - return; - } - - if (timelineScrollModeRef.current !== "following-end") { - return; - } - if (!timelineRealContentOverflowsViewport(list)) { - return; - } - - void list.scrollToEnd?.({ animated: false }); - }); - }); - - return () => { - cancelAnimationFrame(frame); - if (secondFrame !== null) { - cancelAnimationFrame(secondFrame); - } - }; - }, [ - activeThreadId, - timelineEntries, - getActiveTimelineTurnMetrics, - timelineRealContentOverflowsViewport, - listRef, - ]); - - const resetForThread = useCallback(() => { - isAtEndRef.current = true; - timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - pendingTimelineAnchorRef.current = null; - positionedTimelineAnchorRef.current = null; - settledTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; - clearPendingAnchorScrollRestore(); - cleanupAnchorPositioning(); - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - }, [cleanupAnchorPositioning, clearPendingAnchorScrollRestore]); - - const prepareAnchorForMessage = useCallback( - (messageId: MessageId) => { - isAtEndRef.current = true; - timelineScrollModeRef.current = "anchoring-new-turn"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - pendingTimelineAnchorRef.current = messageId; - activeTimelineAnchorIndexRef.current = null; - clearPendingAnchorScrollRestore(); - cleanupAnchorPositioning(); - showScrollDebouncer.current.cancel(); - setShowScrollToBottom(false); - }, - [cleanupAnchorPositioning, clearPendingAnchorScrollRestore], - ); - - useEffect( - () => () => { - cleanupAnchorPositioning(); - showScrollDebouncer.current.cancel(); - clearPendingAnchorScrollRestore(); - }, - [cleanupAnchorPositioning, clearPendingAnchorScrollRestore], - ); - - return { - showScrollToBottom, - scrollToEnd, - cancelForManualNavigation, - onAnchorReady, - onAnchorSizeChanged, - onIsAtEndChange, - prepareAnchorForMessage, - resetForThread, - }; -} diff --git a/apps/web/src/projectScriptTerminals.test.ts b/apps/web/src/projectScriptTerminals.test.ts deleted file mode 100644 index 142d8322e93..00000000000 --- a/apps/web/src/projectScriptTerminals.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, it, vi } from "vite-plus/test"; -import { ThreadId } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import { AsyncResult } from "effect/unstable/reactivity"; - -import { - type ProjectActionTerminalReservations, - projectActionTerminalId, - releaseProjectActionTerminalReservationsSeenRunning, - resolveProjectActionTerminalId, - runProjectScriptInTerminal, - runningTerminalIdsWithProjectActionReservations, - terminalOutputLooksReadyForInput, -} from "./projectScriptTerminals"; - -describe("project action terminal ids", () => { - it("uses a stable action-specific terminal id", () => { - expect(projectActionTerminalId("build")).toBe("action-build"); - expect( - resolveProjectActionTerminalId({ - scriptId: "build", - terminalIds: [], - runningTerminalIds: [], - }), - ).toBe("action-build"); - }); - - it("does not allocate a fallback id that is already reserved but not known yet", () => { - expect( - resolveProjectActionTerminalId({ - scriptId: "build", - terminalIds: ["action-build"], - runningTerminalIds: runningTerminalIdsWithProjectActionReservations({ - runningTerminalIds: ["action-build"], - reservedTerminalIds: ["action-build:2"], - }), - }), - ).toBe("action-build:3"); - }); - - it("encodes script ids before adding fallback suffixes", () => { - expect(projectActionTerminalId("build:2")).toBe("action-build%3A2"); - expect(projectActionTerminalId("build:2", 2)).toBe("action-build%3A2:2"); - }); -}); - -describe("project action terminal reservations", () => { - it("releases awaiting-running reservations only after running visibility or expiry", () => { - const reservations: ProjectActionTerminalReservations = new Map([ - ["action-build", { phase: "launching", expiresAtMs: Number.POSITIVE_INFINITY }], - ["action-test", { phase: "awaiting-running", expiresAtMs: 1_500 }], - ["action-lint", { phase: "awaiting-running", expiresAtMs: 900 }], - ]); - - releaseProjectActionTerminalReservationsSeenRunning({ - runningTerminalIds: ["action-test"], - reservedTerminalIds: reservations, - nowMs: 1_000, - }); - - expect([...reservations.keys()]).toEqual(["action-build"]); - }); -}); - -describe("project action terminal readiness", () => { - it("detects common shell prompts", () => { - expect(terminalOutputLooksReadyForInput("initializing...\n$ ")).toBe(true); - expect(terminalOutputLooksReadyForInput("repo % ")).toBe(true); - expect(terminalOutputLooksReadyForInput("PS C:\\repo> ")).toBe(true); - expect(terminalOutputLooksReadyForInput("progress 100%\n")).toBe(false); - }); - - it("continues writing after advisory readiness failures", async () => { - const openTerminal = vi.fn(async () => AsyncResult.success(undefined)); - const writeTerminal = vi.fn(async () => AsyncResult.success(undefined)); - const waitForInputReady = vi.fn(async () => AsyncResult.failure(Cause.fail("not ready"))); - - const result = await runProjectScriptInTerminal({ - script: { id: "build", command: "pnpm build" }, - threadId: ThreadId.make("thread-1"), - targetCwd: "/repo", - targetWorktreePath: null, - runtimeEnv: {}, - preferNewTerminal: false, - knownTerminalIds: [], - serverTerminalIds: [], - visibleTerminalIds: [], - runningTerminalIds: [], - sessions: [], - reservedTerminalIds: new Map(), - isCommandInterrupted: () => false, - showTerminal: () => undefined, - openTerminal, - writeTerminal, - waitForInputReady, - requireInputReady: waitForInputReady, - }); - - expect(result).toEqual({ _tag: "Success" }); - expect(writeTerminal).toHaveBeenCalledWith({ - threadId: ThreadId.make("thread-1"), - terminalId: "action-build", - data: "pnpm build\r", - }); - }); -}); diff --git a/apps/web/src/projectScriptTerminals.ts b/apps/web/src/projectScriptTerminals.ts deleted file mode 100644 index 40fea90f627..00000000000 --- a/apps/web/src/projectScriptTerminals.ts +++ /dev/null @@ -1,688 +0,0 @@ -import type { - ProjectScript, - TerminalAttachStreamEvent, - TerminalOpenInput, - TerminalSummary, - TerminalWriteInput, -} from "@t3tools/contracts"; -import { WS_METHODS } from "@t3tools/contracts"; -import { subscribe } from "@t3tools/client-runtime/rpc"; -import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; -import type { KnownTerminalSession } from "@t3tools/client-runtime/state/terminal"; -import { nextTerminalId } from "@t3tools/shared/terminalLabels"; -import * as Cause from "effect/Cause"; -import * as Duration from "effect/Duration"; -import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; -import * as Result from "effect/Result"; -import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; - -const ACTION_TERMINAL_ID_PREFIX = "action-"; -const ACTION_TERMINAL_FALLBACK_SEPARATOR = ":"; -const ACTION_TERMINAL_READY_TIMEOUT_MS = 4_000; -const ACTION_TERMINAL_POST_WRITE_RESERVATION_MS = 5_000; -const SCRIPT_TERMINAL_COLS = 120; -const SCRIPT_TERMINAL_ROWS = 30; -const MAX_TERMINAL_BUFFER_TAIL = 2_000; -const PROMPT_LINE_MAX_LENGTH = 180; -const ESCAPE_CHARACTER = String.fromCharCode(27); -const ANSI_ESCAPE_PATTERN = new RegExp( - `${ESCAPE_CHARACTER}(?:[@-Z\\\\-_]|\\[[0-?]*[ -/]*[@-~])`, - "g", -); -const OSC_ESCAPE_PATTERN = new RegExp( - `${ESCAPE_CHARACTER}\\][\\s\\S]*?(?:${String.fromCharCode(7)}|${ESCAPE_CHARACTER}\\\\)`, - "g", -); -const BELL_CHARACTER = String.fromCharCode(7); -const SHELL_LABELS = new Set([ - "bash", - "zsh", - "sh", - "fish", - "csh", - "tcsh", - "pwsh", - "powershell", - "cmd", -]); -const UNIX_PERCENT_PROGRESS_SUFFIX_PATTERN = /\d\s+%\s*$/; -const UNIX_PROMPT_SUFFIX_PATTERN = /(?:[$#]|(?:^|[^\d])%)\s*$/; -const POWERSHELL_PROMPT_PATTERN = /^PS\s+\S.*>\s*$/; -const WINDOWS_PROMPT_PATTERN = /^[A-Za-z]:(?:[\\/][^<>]*)?>\s*$/; - -export class ProjectActionTerminalReadinessTimeoutError extends Schema.TaggedErrorClass()( - "ProjectActionTerminalReadinessTimeoutError", - { - threadId: Schema.String, - terminalId: Schema.String, - cwd: Schema.String, - timeoutMs: Schema.Number, - }, -) { - override get message(): string { - return `Timed out waiting for project action terminal input readiness: ${this.threadId}/${this.terminalId}.`; - } -} - -export class ProjectActionTerminalAttachError extends Schema.TaggedErrorClass()( - "ProjectActionTerminalAttachError", - { - threadId: Schema.String, - terminalId: Schema.String, - cwd: Schema.String, - detail: Schema.String, - cause: Schema.optional(Schema.Defect()), - }, -) { - override get message(): string { - return `Project action terminal attach failed: ${this.detail}`; - } -} - -export const ProjectActionTerminalReadinessError = Schema.Union([ - ProjectActionTerminalReadinessTimeoutError, - ProjectActionTerminalAttachError, -]); -export type ProjectActionTerminalReadinessError = typeof ProjectActionTerminalReadinessError.Type; - -function projectActionTerminalIdBase(scriptId: ProjectScript["id"]): string { - return `${ACTION_TERMINAL_ID_PREFIX}${encodeURIComponent(scriptId)}`; -} - -function isProjectActionTerminalFallbackId(terminalId: string, baseTerminalId: string): boolean { - const suffix = terminalId.startsWith(`${baseTerminalId}${ACTION_TERMINAL_FALLBACK_SEPARATOR}`) - ? terminalId.slice(baseTerminalId.length + ACTION_TERMINAL_FALLBACK_SEPARATOR.length) - : ""; - return /^[1-9][0-9]*$/.test(suffix); -} - -export function projectActionTerminalId(scriptId: ProjectScript["id"], suffix?: number): string { - const base = projectActionTerminalIdBase(scriptId); - return suffix === undefined ? base : `${base}${ACTION_TERMINAL_FALLBACK_SEPARATOR}${suffix}`; -} - -export function resolveProjectActionTerminalId(input: { - readonly scriptId: ProjectScript["id"]; - readonly terminalIds: ReadonlyArray; - readonly runningTerminalIds: ReadonlyArray; -}): string { - const busyTerminalIds = new Set(input.runningTerminalIds); - const baseTerminalId = projectActionTerminalId(input.scriptId); - if (!busyTerminalIds.has(baseTerminalId)) { - return baseTerminalId; - } - - const idleActionTerminal = input.terminalIds.find( - (terminalId) => - (terminalId === baseTerminalId || - isProjectActionTerminalFallbackId(terminalId, baseTerminalId)) && - !busyTerminalIds.has(terminalId), - ); - if (idleActionTerminal) { - return idleActionTerminal; - } - - const takenTerminalIds = new Set([...input.terminalIds, ...input.runningTerminalIds]); - let suffix = 2; - while (suffix < 10_000) { - const candidate = projectActionTerminalId(input.scriptId, suffix); - if (!takenTerminalIds.has(candidate)) { - return candidate; - } - suffix += 1; - } - - return projectActionTerminalId(input.scriptId, Date.now()); -} - -function stripAnsi(value: string): string { - return value.replace(OSC_ESCAPE_PATTERN, "").replace(ANSI_ESCAPE_PATTERN, ""); -} - -function stripControlCharacters(value: string): string { - let next = ""; - for (const char of value) { - const code = char.charCodeAt(0); - if (char === "\n" || char === "\r" || code >= 32) { - next += char; - } - } - return next; -} - -export function terminalOutputLooksReadyForInput(value: string): boolean { - const tail = stripControlCharacters(stripAnsi(value)) - .slice(-MAX_TERMINAL_BUFFER_TAIL) - .replaceAll(BELL_CHARACTER, ""); - const lines = tail.split(/\r?\n/); - for (let index = lines.length - 1; index >= 0; index -= 1) { - const line = lines[index]?.replace(/\r/g, "") ?? ""; - if (line.trim().length === 0) { - continue; - } - if (line.length > PROMPT_LINE_MAX_LENGTH) { - return false; - } - if (UNIX_PROMPT_SUFFIX_PATTERN.test(line) && !UNIX_PERCENT_PROGRESS_SUFFIX_PATTERN.test(line)) { - return true; - } - return POWERSHELL_PROMPT_PATTERN.test(line) || WINDOWS_PROMPT_PATTERN.test(line); - } - return false; -} - -function normalizedShellLabel(label: string): string { - const trimmed = label.trim().toLowerCase(); - const basename = trimmed.split(/[\\/]/).at(-1) ?? trimmed; - return basename.replace(/^-+/, "").replace(/\.exe$/, ""); -} - -export function terminalSessionIsReadyForProjectActionInput(input: { - readonly summary: Pick< - TerminalSummary, - "cwd" | "hasRunningSubprocess" | "label" | "status" | "worktreePath" - > | null; - readonly buffer: string; - readonly targetCwd: string; - readonly targetWorktreePath: string | null; -}): boolean { - const summary = input.summary; - if ( - !summary || - summary.status !== "running" || - summary.cwd !== input.targetCwd || - summary.worktreePath !== input.targetWorktreePath - ) { - return false; - } - if (!summary.hasRunningSubprocess || SHELL_LABELS.has(normalizedShellLabel(summary.label))) { - return terminalOutputLooksReadyForInput(input.buffer); - } - return false; -} - -export function terminalSessionShouldProbeForProjectActionInput(input: { - readonly summary: Pick< - TerminalSummary, - "cwd" | "hasRunningSubprocess" | "label" | "status" | "worktreePath" - > | null; - readonly targetCwd: string; - readonly targetWorktreePath: string | null; -}): boolean { - const summary = input.summary; - return ( - summary !== null && - summary.status === "running" && - summary.cwd === input.targetCwd && - summary.worktreePath === input.targetWorktreePath && - summary.hasRunningSubprocess && - SHELL_LABELS.has(normalizedShellLabel(summary.label)) - ); -} - -type ProjectActionTerminalCandidateSummary = Pick< - NonNullable, - "cwd" | "hasRunningSubprocess" | "label" | "status" | "worktreePath" ->; - -export interface ProjectActionTerminalCandidateSession { - readonly target: Pick; - readonly state: Pick & { - readonly summary: ProjectActionTerminalCandidateSummary | null; - }; -} - -export function classifyProjectActionTerminalCandidates(input: { - readonly sessions: ReadonlyArray; - readonly runningTerminalIds: ReadonlyArray; - readonly targetCwd: string; - readonly targetWorktreePath: string | null; -}): { - readonly sessionsById: ReadonlyMap; - readonly readyTerminalIds: ReadonlySet; - readonly probeTerminalIds: ReadonlySet; - readonly runningTerminalIdsForSelection: ReadonlyArray; -} { - const sessionsById = new Map( - input.sessions.map((session) => [session.target.terminalId, session] as const), - ); - const readyTerminalIds = new Set(); - const probeTerminalIds = new Set(); - const busyTerminalIds = new Set(input.runningTerminalIds); - for (const session of input.sessions) { - const terminalId = session.target.terminalId; - const readyForInput = terminalSessionIsReadyForProjectActionInput({ - summary: session.state.summary, - buffer: session.state.buffer, - targetCwd: input.targetCwd, - targetWorktreePath: input.targetWorktreePath, - }); - if (readyForInput) { - readyTerminalIds.add(terminalId); - busyTerminalIds.delete(terminalId); - continue; - } - if ( - terminalSessionShouldProbeForProjectActionInput({ - summary: session.state.summary, - targetCwd: input.targetCwd, - targetWorktreePath: input.targetWorktreePath, - }) - ) { - probeTerminalIds.add(terminalId); - } - } - return { - sessionsById, - readyTerminalIds, - probeTerminalIds, - runningTerminalIdsForSelection: input.runningTerminalIds.filter((terminalId) => - busyTerminalIds.has(terminalId), - ), - }; -} - -export function runningTerminalIdsWithProjectActionReservations(input: { - readonly runningTerminalIds: ReadonlyArray; - readonly reservedTerminalIds: Iterable; -}): ReadonlyArray { - const next = [...input.runningTerminalIds]; - const seen = new Set(next); - for (const terminalId of input.reservedTerminalIds) { - if (seen.has(terminalId)) continue; - seen.add(terminalId); - next.push(terminalId); - } - return next; -} - -export type ProjectActionTerminalReservationPhase = "launching" | "awaiting-running"; - -export interface ProjectActionTerminalReservation { - readonly phase: ProjectActionTerminalReservationPhase; - readonly expiresAtMs: number; -} - -export type ProjectActionTerminalReservations = Map; - -export function projectActionTerminalReservationIds( - reservedTerminalIds: ProjectActionTerminalReservations, -): ReadonlyArray { - return [...reservedTerminalIds.keys()]; -} - -export function pruneExpiredProjectActionTerminalReservations(input: { - readonly reservedTerminalIds: ProjectActionTerminalReservations; - readonly nowMs?: number; -}): void { - const nowMs = input.nowMs ?? Date.now(); - for (const [terminalId, reservation] of input.reservedTerminalIds) { - if (reservation.phase === "awaiting-running" && reservation.expiresAtMs <= nowMs) { - input.reservedTerminalIds.delete(terminalId); - } - } -} - -export function releaseProjectActionTerminalReservationsSeenRunning(input: { - readonly runningTerminalIds: Iterable; - readonly reservedTerminalIds: ProjectActionTerminalReservations; - readonly nowMs?: number; -}): void { - const runningTerminalIds = new Set(input.runningTerminalIds); - const nowMs = input.nowMs ?? Date.now(); - for (const [terminalId, reservation] of input.reservedTerminalIds) { - if (reservation.phase !== "awaiting-running") continue; - if (runningTerminalIds.has(terminalId) || reservation.expiresAtMs <= nowMs) { - input.reservedTerminalIds.delete(terminalId); - } - } -} - -export type ProjectActionTerminalCommandResult = AtomCommandResult; - -export type RunProjectScriptInTerminalResult = - | { readonly _tag: "Success" } - | { readonly _tag: "Interrupted" } - | { - readonly _tag: "Failure"; - readonly result: Extract; - }; - -function projectActionOpenInput(input: { - readonly threadId: TerminalOpenInput["threadId"]; - readonly terminalId: string; - readonly cwd: string; - readonly worktreePath: string | null; - readonly env: NonNullable; - readonly isKnownServerTerminal: boolean; -}): TerminalOpenInput { - return { - threadId: input.threadId, - terminalId: input.terminalId, - cwd: input.cwd, - ...(input.worktreePath !== null ? { worktreePath: input.worktreePath } : {}), - env: input.env, - ...(!input.isKnownServerTerminal - ? { cols: SCRIPT_TERMINAL_COLS, rows: SCRIPT_TERMINAL_ROWS } - : {}), - }; -} - -export async function runProjectScriptInTerminal(input: { - readonly script: Pick; - readonly threadId: TerminalOpenInput["threadId"]; - readonly targetCwd: string; - readonly targetWorktreePath: string | null; - readonly runtimeEnv: NonNullable; - readonly preferNewTerminal: boolean; - readonly knownTerminalIds: ReadonlyArray; - readonly serverTerminalIds: ReadonlyArray; - readonly visibleTerminalIds: ReadonlyArray; - readonly runningTerminalIds: ReadonlyArray; - readonly sessions: ReadonlyArray; - readonly reservedTerminalIds: ProjectActionTerminalReservations; - readonly isCommandInterrupted: (result: ProjectActionTerminalCommandResult) => boolean; - readonly showTerminal: ( - terminalId: string, - state: { readonly isVisibleTerminal: boolean }, - ) => void; - readonly openTerminal: (input: TerminalOpenInput) => Promise; - readonly writeTerminal: ( - input: TerminalWriteInput, - ) => Promise; - readonly waitForInputReady: ( - input: TerminalOpenInput, - ) => Promise; - readonly requireInputReady: ( - input: TerminalOpenInput, - ) => Promise; -}): Promise { - const projectActionTerminalCandidates = classifyProjectActionTerminalCandidates({ - sessions: input.sessions, - runningTerminalIds: input.runningTerminalIds, - targetCwd: input.targetCwd, - targetWorktreePath: input.targetWorktreePath, - }); - const reservedProjectActionTerminalIds = input.reservedTerminalIds; - pruneExpiredProjectActionTerminalReservations({ - reservedTerminalIds: reservedProjectActionTerminalIds, - }); - let targetTerminalId = - input.preferNewTerminal === true - ? nextTerminalId([ - ...input.knownTerminalIds, - ...projectActionTerminalReservationIds(reservedProjectActionTerminalIds), - ]) - : resolveProjectActionTerminalId({ - scriptId: input.script.id, - terminalIds: input.knownTerminalIds, - runningTerminalIds: runningTerminalIdsWithProjectActionReservations({ - runningTerminalIds: projectActionTerminalCandidates.runningTerminalIdsForSelection, - reservedTerminalIds: projectActionTerminalReservationIds( - reservedProjectActionTerminalIds, - ), - }), - }); - let isKnownServerTerminal = input.serverTerminalIds.includes(targetTerminalId); - let isVisibleTerminal = input.visibleTerminalIds.includes(targetTerminalId); - let targetSession = projectActionTerminalCandidates.sessionsById.get(targetTerminalId) ?? null; - let canWriteImmediately = projectActionTerminalCandidates.readyTerminalIds.has(targetTerminalId); - let waitAfterOpen = true; - let openTerminalInput = projectActionOpenInput({ - threadId: input.threadId, - terminalId: targetTerminalId, - cwd: input.targetCwd, - worktreePath: input.targetWorktreePath, - env: input.runtimeEnv, - isKnownServerTerminal, - }); - let reservedProjectActionTerminalId: string | null = null; - let keepReservationAfterWrite = false; - const reserveProjectActionTerminalId = (terminalId: string) => { - if (reservedProjectActionTerminalId === terminalId) return; - if (reservedProjectActionTerminalId !== null) { - reservedProjectActionTerminalIds.delete(reservedProjectActionTerminalId); - } - reservedProjectActionTerminalIds.set(terminalId, { - phase: "launching", - expiresAtMs: Number.POSITIVE_INFINITY, - }); - reservedProjectActionTerminalId = terminalId; - }; - - reserveProjectActionTerminalId(targetTerminalId); - try { - if ( - !canWriteImmediately && - projectActionTerminalCandidates.probeTerminalIds.has(targetTerminalId) - ) { - const readyResult = await input.requireInputReady(openTerminalInput); - if (readyResult._tag === "Success") { - waitAfterOpen = true; - } else { - if (input.isCommandInterrupted(readyResult)) { - return { _tag: "Interrupted" }; - } - targetTerminalId = resolveProjectActionTerminalId({ - scriptId: input.script.id, - terminalIds: input.knownTerminalIds, - runningTerminalIds: runningTerminalIdsWithProjectActionReservations({ - runningTerminalIds: input.runningTerminalIds.filter( - (terminalId) => !projectActionTerminalCandidates.readyTerminalIds.has(terminalId), - ), - reservedTerminalIds: projectActionTerminalReservationIds( - reservedProjectActionTerminalIds, - ).filter((terminalId) => terminalId !== reservedProjectActionTerminalId), - }), - }); - reserveProjectActionTerminalId(targetTerminalId); - isKnownServerTerminal = input.serverTerminalIds.includes(targetTerminalId); - isVisibleTerminal = input.visibleTerminalIds.includes(targetTerminalId); - targetSession = projectActionTerminalCandidates.sessionsById.get(targetTerminalId) ?? null; - canWriteImmediately = terminalSessionIsReadyForProjectActionInput({ - summary: targetSession?.state.summary ?? null, - buffer: targetSession?.state.buffer ?? "", - targetCwd: input.targetCwd, - targetWorktreePath: input.targetWorktreePath, - }); - waitAfterOpen = true; - openTerminalInput = projectActionOpenInput({ - threadId: input.threadId, - terminalId: targetTerminalId, - cwd: input.targetCwd, - worktreePath: input.targetWorktreePath, - env: input.runtimeEnv, - isKnownServerTerminal, - }); - } - } - - input.showTerminal(targetTerminalId, { isVisibleTerminal }); - - const openResult = await input.openTerminal(openTerminalInput); - if (openResult._tag === "Failure") { - if (!input.isCommandInterrupted(openResult)) { - return { _tag: "Failure", result: openResult }; - } - return { _tag: "Interrupted" }; - } - - if (waitAfterOpen) { - const readyResult = await input.waitForInputReady(openTerminalInput); - if (readyResult._tag === "Failure") { - if (input.isCommandInterrupted(readyResult)) { - return { _tag: "Interrupted" }; - } - } - } - - const writeResult = await input.writeTerminal({ - threadId: input.threadId, - terminalId: targetTerminalId, - data: `${input.script.command}\r`, - }); - if (writeResult._tag === "Failure") { - if (!input.isCommandInterrupted(writeResult)) { - return { _tag: "Failure", result: writeResult }; - } - return { _tag: "Interrupted" }; - } - reservedProjectActionTerminalIds.set(targetTerminalId, { - phase: "awaiting-running", - expiresAtMs: Date.now() + ACTION_TERMINAL_POST_WRITE_RESERVATION_MS, - }); - keepReservationAfterWrite = true; - return { _tag: "Success" }; - } finally { - if (reservedProjectActionTerminalId !== null && !keepReservationAfterWrite) { - reservedProjectActionTerminalIds.delete(reservedProjectActionTerminalId); - } - } -} - -function terminalAttachInputFromOpenInput(input: TerminalOpenInput) { - return { - threadId: input.threadId, - terminalId: input.terminalId, - cwd: input.cwd, - ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), - ...(input.cols !== undefined ? { cols: input.cols } : {}), - ...(input.rows !== undefined ? { rows: input.rows } : {}), - ...(input.env !== undefined ? { env: input.env } : {}), - restartIfNotRunning: true, - }; -} - -function terminalAttachEventCompletesReadyWait( - event: TerminalAttachStreamEvent, - appendAndCheck: (data: string) => boolean, -): boolean { - if (event.type === "snapshot") { - return appendAndCheck(event.snapshot.history); - } - if (event.type === "output") { - return appendAndCheck(event.data); - } - return event.type === "error"; -} - -export function projectActionTerminalReadinessFailureFromEvent( - input: TerminalOpenInput, - event: TerminalAttachStreamEvent, -): ProjectActionTerminalAttachError | null { - if (event.type === "error") { - return new ProjectActionTerminalAttachError({ - threadId: input.threadId, - terminalId: input.terminalId, - cwd: input.cwd, - detail: event.message, - }); - } - if (event.type === "closed") { - return new ProjectActionTerminalAttachError({ - threadId: input.threadId, - terminalId: input.terminalId, - cwd: input.cwd, - detail: "Terminal closed before it was ready for input.", - }); - } - if (event.type === "exited") { - return new ProjectActionTerminalAttachError({ - threadId: input.threadId, - terminalId: input.terminalId, - cwd: input.cwd, - detail: "Terminal process exited before it was ready for input.", - }); - } - return null; -} - -function projectActionTerminalAttachErrorFromCause( - input: TerminalOpenInput, - cause: Cause.Cause, -): ProjectActionTerminalAttachError { - return new ProjectActionTerminalAttachError({ - threadId: input.threadId, - terminalId: input.terminalId, - cwd: input.cwd, - detail: "Terminal attach failed before it was ready for input.", - cause, - }); -} - -function projectActionTerminalReadinessTimeoutError( - input: TerminalOpenInput, - timeoutMs: number, -): ProjectActionTerminalReadinessTimeoutError { - return new ProjectActionTerminalReadinessTimeoutError({ - threadId: input.threadId, - terminalId: input.terminalId, - cwd: input.cwd, - timeoutMs, - }); -} - -type ProjectActionTerminalReadinessOutcome = - | { readonly _tag: "ready" } - | { readonly _tag: "failed"; readonly error: ProjectActionTerminalReadinessError }; - -export function waitForProjectActionTerminalInputReadyStrict( - input: TerminalOpenInput, - timeoutMs = ACTION_TERMINAL_READY_TIMEOUT_MS, -) { - let bufferTail = ""; - const appendAndCheck = (data: string) => { - bufferTail = `${bufferTail}${data}`.slice(-MAX_TERMINAL_BUFFER_TAIL); - return terminalOutputLooksReadyForInput(bufferTail); - }; - - return Stream.suspend(() => - subscribe(WS_METHODS.terminalAttach, terminalAttachInputFromOpenInput(input)), - ).pipe( - Stream.catchCause((cause) => - Stream.fail(projectActionTerminalAttachErrorFromCause(input, cause)), - ), - Stream.filterMap((event) => { - const error = projectActionTerminalReadinessFailureFromEvent(input, event); - if (error) { - return Result.succeed({ - _tag: "failed", - error, - }); - } - return terminalAttachEventCompletesReadyWait(event, appendAndCheck) - ? Result.succeed({ _tag: "ready" }) - : Result.failVoid; - }), - Stream.runHead, - Effect.timeoutOption(Duration.millis(timeoutMs)), - Effect.flatMap((result) => { - if (Option.isNone(result)) { - return Effect.fail(projectActionTerminalReadinessTimeoutError(input, timeoutMs)); - } - if (Option.isNone(result.value)) { - return Effect.fail( - new ProjectActionTerminalAttachError({ - threadId: input.threadId, - terminalId: input.terminalId, - cwd: input.cwd, - detail: "Terminal attach stream ended before it was ready for input.", - }), - ); - } - const outcome = result.value.value; - return outcome._tag === "failed" ? Effect.fail(outcome.error) : Effect.void; - }), - ); -} - -export function waitForProjectActionTerminalInputReady( - input: TerminalOpenInput, - timeoutMs = ACTION_TERMINAL_READY_TIMEOUT_MS, -) { - return waitForProjectActionTerminalInputReadyStrict(input, timeoutMs).pipe( - Effect.catchCause(() => Effect.void), - ); -} diff --git a/apps/web/src/state/projectActionTerminal.ts b/apps/web/src/state/projectActionTerminal.ts deleted file mode 100644 index 98a4e3bcc6e..00000000000 --- a/apps/web/src/state/projectActionTerminal.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createEnvironmentCommand } from "@t3tools/client-runtime/state/runtime"; - -import { connectionAtomRuntime } from "../connection/runtime"; -import { - waitForProjectActionTerminalInputReady, - waitForProjectActionTerminalInputReadyStrict, -} from "../projectScriptTerminals"; - -export const projectActionTerminalEnvironment = { - waitForInputReady: createEnvironmentCommand(connectionAtomRuntime, { - label: "environment-data:project-action-terminal:wait-for-input-ready", - execute: waitForProjectActionTerminalInputReady, - }), - requireInputReady: createEnvironmentCommand(connectionAtomRuntime, { - label: "environment-data:project-action-terminal:require-input-ready", - execute: waitForProjectActionTerminalInputReadyStrict, - }), -}; diff --git a/patches/@react-native-menu__menu@2.0.0.patch b/patches/@react-native-menu__menu@2.0.0.patch index a1416706999..f03ef60bb5b 100644 --- a/patches/@react-native-menu__menu@2.0.0.patch +++ b/patches/@react-native-menu__menu@2.0.0.patch @@ -3,7 +3,7 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e7 --- a/ios/Shared/MenuViewImplementation.swift +++ b/ios/Shared/MenuViewImplementation.swift @@ -88,6 +88,41 @@ public class MenuViewImplementation: UIButton { - + self.menu = menu self.showsMenuAsPrimaryAction = !shouldOpenOnLongPress + // In long-press mode the button must not intercept touches: as a @@ -42,5 +42,5 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..ea19f2eec02dd78fbc78cc455c91b4e7 + longPressInteraction = nil + } } - + public override func reactSetFrame(_ frame: CGRect) { diff --git a/patches/expo-modules-jsi@56.0.10.patch b/patches/expo-modules-jsi@56.0.10.patch index d87997332bc..afb1da5caaa 100644 --- a/patches/expo-modules-jsi@56.0.10.patch +++ b/patches/expo-modules-jsi@56.0.10.patch @@ -17,5 +17,5 @@ index a0e3e24d6070c5ec0a220af3cb56fe3373fe1968..d610cc38dd8dee363ea2f1822aea0c4a - context, getter, set == nil ? nil : setter, propertyNamesGetter, deallocate) + context, getter, setter, propertyNamesGetter, deallocate) let hostObject = expo.HostObject.makeObject(pointee, consume callbacks) - + return JavaScriptObject(self, hostObject) diff --git a/patches/react-native-gesture-handler@2.31.2.patch b/patches/react-native-gesture-handler@2.31.2.patch index ecec3db083d..a4f345e9e33 100644 --- a/patches/react-native-gesture-handler@2.31.2.patch +++ b/patches/react-native-gesture-handler@2.31.2.patch @@ -87,7 +87,7 @@ index b6134c908624adc590ebd264e90e558b88e235d5..41535348e937ad7762bd531d5b63ba6e @@ -537,6 +538,10 @@ const Swipeable = (props: SwipeableProps) => { dragStarted.value = false; }); - + + if (failOffsetY !== undefined) { + pan.failOffsetY(failOffsetY); + } @@ -110,7 +110,7 @@ index 0c0e517e0d340faf50ff78c3d48e7a2bbcf808ec..d4b6ff508077728c8be83762923d866d @@ -77,6 +77,14 @@ export interface SwipeableProps { */ dragOffsetFromRightEdge?: number; - + + /** + * Range along the Y axis (in points) that, once exceeded before the swipe + * activates, makes the pan fail. Lets vertically-dominant pans (list diff --git a/patches/react-native-screens@4.25.2.patch b/patches/react-native-screens@4.25.2.patch index 851cd3772f3..48457c10a0a 100644 --- a/patches/react-native-screens@4.25.2.patch +++ b/patches/react-native-screens@4.25.2.patch @@ -5,7 +5,7 @@ index ea5325ea8d17b1ddfa790ff8dab48ce83142d4d3..acd2fd7ceb7162ed300abc4d3c9f0c24 @@ -13,4 +13,8 @@ typedef void (^RNSBarButtonMenuItemAction)(NSString *menuId); menuAction:(RNSBarButtonMenuItemAction)menuAction imageLoader:(RCTImageLoader *)imageLoader; - + ++ (UIMenu *)initUIMenuWithDict:(NSDictionary *)dict + menuAction:(RNSBarButtonMenuItemAction)menuAction + imageLoader:(RCTImageLoader *)imageLoader; @@ -71,7 +71,7 @@ index 0eb1f09dee82edd99e3e614938233db5cdeaebfe..73298f10807520970f625e166d7f5dd1 + } + } +#endif - + #if !TARGET_OS_TV || __TV_OS_VERSION_MAX_ALLOWED >= 170000 if (@available(tvOS 17.0, *)) { diff --git a/ios/RNSScreenStackHeaderConfig.h b/ios/RNSScreenStackHeaderConfig.h @@ -80,7 +80,7 @@ index 919b984edc9f91ee9ac26faf257d8a721e26457c..5bb0cd6736ed6bc51db57e2a9326f758 +++ b/ios/RNSScreenStackHeaderConfig.h @@ -21,6 +21,8 @@ NS_ASSUME_NONNULL_BEGIN - + @property (nonatomic, retain) NSString *title; +@property (nonatomic, retain) NSString *subtitle; +@property (nonatomic, retain) NSString *largeSubtitle; @@ -98,7 +98,7 @@ index 919b984edc9f91ee9ac26faf257d8a721e26457c..5bb0cd6736ed6bc51db57e2a9326f758 +@property (nonatomic, copy, nullable) NSArray *> *headerCenterBarButtonItems; +@property (nonatomic, copy, nullable) NSArray *> *headerToolbarItems; @property (nonatomic, readwrite) BOOL synchronousShadowStateUpdatesEnabled; - + NS_ASSUME_NONNULL_END diff --git a/ios/RNSScreenStackHeaderConfig.mm b/ios/RNSScreenStackHeaderConfig.mm index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d652cbb83 100644 @@ -107,7 +107,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d @@ -30,6 +30,20 @@ static const NSNumber *const DEFAULT_TITLE_FONT_SIZE = @17; static const NSNumber *const DEFAULT_TITLE_LARGE_FONT_SIZE = @34; - + +static NSInteger navigationItemStyleFromCppEquivalent( + react::RNSScreenStackHeaderConfigNavigationItemStyle navigationItemStyle) +{ @@ -127,13 +127,13 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d @end @@ -47,6 +61,9 @@ + (BOOL)rnscreens_isBlankOrNull:(NSString *)string @end - + @interface RNSScreenStackHeaderConfig () +#if !TARGET_OS_TV +- (NSArray *)barButtonItemGroupsFromItems:(NSArray *)items; +#endif @end - + @implementation RNSScreenStackHeaderConfig { @@ -81,6 +98,7 @@ - (void)initProps self.hidden = YES; @@ -142,16 +142,16 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d + _navigationItemStyle = UINavigationItemStyleNavigator; _blurEffect = RNSBlurEffectStyleNone; } - + @@ -496,6 +514,10 @@ + (void)updateViewController:(UIViewController *)vc - + if (shouldHide) { navitem.title = config.title; + if (@available(iOS 26.0, *)) { + navitem.subtitle = config.subtitle; + navitem.largeSubtitle = config.largeSubtitle; + } - + // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items. [navctr setNavigationBarHidden:YES animated:animated]; @@ -512,11 +534,19 @@ + (void)updateViewController:(UIViewController *)vc @@ -163,7 +163,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d + navitem.style = (UINavigationItemStyle)config.navigationItemStyle; + } #endif - + UINavigationBarAppearance *appearance = [self buildAppearance:vc withConfig:config]; navitem.standardAppearance = appearance; navitem.compactAppearance = appearance; @@ -171,7 +171,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d + navitem.subtitle = config.subtitle; + navitem.largeSubtitle = config.largeSubtitle; + } - + // appearance does not apply to the tvOS so we need to use lagacy customization #if TARGET_OS_TV @@ -637,10 +667,286 @@ + (void)updateViewController:(UIViewController *)vc @@ -462,11 +462,11 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d + vc.toolbarItems = nil; + [navctr setToolbarHidden:YES animated:animated]; + } - + // Setting navigation bar visibility is split to mitigate iOS 26 bug with bar button items // (setting nav bar visibility should be done after `navitem.*BarButtonItems`). @@ -773,6 +1079,7 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * - + - (NSArray *)barButtonItemsFromConfigs:(NSArray *> *)dicts withCurrentItems:(NSArray *)currentItems + navigationItem:(UINavigationItem *)navitem @@ -695,7 +695,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d @@ -825,6 +1326,47 @@ - (void)applySemanticContentAttributeIfNeededToNavCtrl:(UINavigationController * return items; } - + +#if !TARGET_OS_TV +- (NSArray *)barButtonItemGroupsFromItems:(NSArray *)items +{ @@ -742,7 +742,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d { @@ -1013,6 +1555,8 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: } - + _title = RCTNSStringFromStringNilIfEmpty(newScreenProps.title); + _subtitle = RCTNSStringFromStringNilIfEmpty(newScreenProps.subtitle); + _largeSubtitle = RCTNSStringFromStringNilIfEmpty(newScreenProps.largeSubtitle); @@ -754,13 +754,13 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d _backButtonDisplayMode = [RNSConvert UINavigationItemBackButtonDisplayModeFromCppEquivalent:newScreenProps.backButtonDisplayMode]; + _navigationItemStyle = navigationItemStyleFromCppEquivalent(newScreenProps.navigationItemStyle); - + if (newScreenProps.userInterfaceStyle != oldScreenProps.userInterfaceStyle) { _userInterfaceStyle = [RNSConvert UIUserInterfaceStyleFromCppEquivalent:newScreenProps.userInterfaceStyle]; @@ -1084,6 +1629,30 @@ - (void)updateProps:(react::Props::Shared const &)props oldProps:(react::Props:: _headerRightBarButtonItems = array; } - + + if (newScreenProps.headerCenterBarButtonItems != oldScreenProps.headerCenterBarButtonItems) { + const auto &vec = newScreenProps.headerCenterBarButtonItems; + NSMutableArray *> *array = [NSMutableArray arrayWithCapacity:vec.size()]; @@ -786,7 +786,7 @@ index 5970e3e3a624b9498b8dedfc16831df03a274d0c..073a72a68688369f39e54b61d1381b8d + } + [self updateViewControllerIfNeeded]; - + if (needsNavigationControllerLayout) { diff --git a/lib/commonjs/components/ScreenStackHeaderConfig.js b/lib/commonjs/components/ScreenStackHeaderConfig.js index 16b979bb3dfb41ff247403f1632c300a9a60d549..01415d4cba66086a8d8e5af728f809aee5190d91 100644 @@ -807,7 +807,7 @@ index 16b979bb3dfb41ff247403f1632c300a9a60d549..01415d4cba66086a8d8e5af728f809ae + const preparedHeaderCenterBarButtonItems = headerCenterBarButtonItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerCenterBarButtonItems, 'right') : undefined; + const preparedHeaderToolbarItems = headerToolbarItems && _utils.isHeaderBarButtonsAvailableForCurrentPlatform ? (0, _prepareHeaderBarButtonItems.prepareHeaderBarButtonItems)(headerToolbarItems, 'right') : undefined; + const hasHeaderBarButtonItems = _utils.isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length || preparedHeaderCenterBarButtonItems?.length || preparedHeaderToolbarItems?.length); - + // Handle bar button item presses const onPressHeaderBarButtonItem = hasHeaderBarButtonItems ? event => { - const pressedItem = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])].find(item => item && 'buttonId' in item && item.buttonId === event.nativeEvent.buttonId); @@ -836,11 +836,11 @@ index 16b979bb3dfb41ff247403f1632c300a9a60d549..01415d4cba66086a8d8e5af728f809ae + } + } } : undefined; - + // Handle bar button menu item presses by deep-searching nested menus @@ -56,7 +80,7 @@ const ScreenStackHeaderConfig = exports.ScreenStackHeaderConfig = /*#__PURE__*/_ }; - + // Check each bar-button item with a menu - const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])]; + const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])]; @@ -928,7 +928,7 @@ index cf15f36f25e51d95c896fbc3a31ccba90156a5b6..dd66167c4ab0e5640ad27400e08dcfb4 + const preparedHeaderCenterBarButtonItems = headerCenterBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerCenterBarButtonItems, 'right') : undefined; + const preparedHeaderToolbarItems = headerToolbarItems && isHeaderBarButtonsAvailableForCurrentPlatform ? prepareHeaderBarButtonItems(headerToolbarItems, 'right') : undefined; + const hasHeaderBarButtonItems = isHeaderBarButtonsAvailableForCurrentPlatform && (preparedHeaderLeftBarButtonItems?.length || preparedHeaderRightBarButtonItems?.length || preparedHeaderCenterBarButtonItems?.length || preparedHeaderToolbarItems?.length); - + // Handle bar button item presses const onPressHeaderBarButtonItem = hasHeaderBarButtonItems ? event => { - const pressedItem = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])].find(item => item && 'buttonId' in item && item.buttonId === event.nativeEvent.buttonId); @@ -957,11 +957,11 @@ index cf15f36f25e51d95c896fbc3a31ccba90156a5b6..dd66167c4ab0e5640ad27400e08dcfb4 + } + } } : undefined; - + // Handle bar button menu item presses by deep-searching nested menus @@ -52,7 +76,7 @@ export const ScreenStackHeaderConfig = /*#__PURE__*/React.forwardRef((props, ref }; - + // Check each bar-button item with a menu - const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? [])]; + const allItems = [...(preparedHeaderLeftBarButtonItems ?? []), ...(preparedHeaderRightBarButtonItems ?? []), ...(preparedHeaderCenterBarButtonItems ?? []), ...(preparedHeaderToolbarItems ?? [])]; @@ -1199,7 +1199,7 @@ index 421b3c2545426ae957271bd6515bcf827437541c..0ca6f1d7f324eaf257891a3bbb0aab21 @@ -39,7 +39,12 @@ export const ScreenStackHeaderConfig = React.forwardRef< props.disableTopInsetApplication ?? false, ); - + - const { headerLeftBarButtonItems, headerRightBarButtonItems } = props; + const { + headerLeftBarButtonItems, @@ -1207,7 +1207,7 @@ index 421b3c2545426ae957271bd6515bcf827437541c..0ca6f1d7f324eaf257891a3bbb0aab21 + headerCenterBarButtonItems, + headerToolbarItems, + } = props; - + const preparedHeaderLeftBarButtonItems = headerLeftBarButtonItems && isHeaderBarButtonsAvailableForCurrentPlatform @@ -49,22 +54,36 @@ export const ScreenStackHeaderConfig = React.forwardRef< @@ -1229,7 +1229,7 @@ index 421b3c2545426ae957271bd6515bcf827437541c..0ca6f1d7f324eaf257891a3bbb0aab21 + preparedHeaderRightBarButtonItems?.length || + preparedHeaderCenterBarButtonItems?.length || + preparedHeaderToolbarItems?.length); - + // Handle bar button item presses const onPressHeaderBarButtonItem = hasHeaderBarButtonItems ? (event: NativeSyntheticEvent<{ buttonId: string }>) => { @@ -1282,7 +1282,7 @@ index 421b3c2545426ae957271bd6515bcf827437541c..0ca6f1d7f324eaf257891a3bbb0aab21 + } } : undefined; - + @@ -102,6 +146,8 @@ export const ScreenStackHeaderConfig = React.forwardRef< const allItems = [ ...(preparedHeaderLeftBarButtonItems ?? []), @@ -1325,7 +1325,7 @@ index be2c24dfee77f5883b5ab1d7473be80933b5dc6f..0b038d2005b2d51bbb2ab1d7dba7eacc +++ b/src/components/helpers/prepareHeaderBarButtonItems.ts @@ -50,13 +50,43 @@ const prepareMenu = ( }; - + export const prepareHeaderBarButtonItems = ( - barButtonItems: HeaderBarButtonItem[], + barButtonItems: @@ -1375,10 +1375,10 @@ index ba2479de0f49c112470f78c5084059ddd9e2f3e8..8677583a8f8daa4839340467466bfab8 +++ b/src/fabric/ScreenStackHeaderConfigNativeComponent.ts @@ -14,6 +14,7 @@ type OnPressHeaderBarButtonItemEvent = Readonly<{ buttonId: string }>; type OnPressHeaderBarButtonMenuItemEvent = Readonly<{ menuId: string }>; - + type BackButtonDisplayMode = 'minimal' | 'default' | 'generic'; +type NavigationItemStyle = 'navigator' | 'browser' | 'editor'; - + type BlurEffect = | 'none' @@ -61,12 +62,15 @@ export interface NativeProps extends ViewProps { @@ -1412,7 +1412,7 @@ index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e13 +++ b/src/types.tsx @@ -26,6 +26,7 @@ export type SearchBarCommands = { }; - + export type BackButtonDisplayMode = 'default' | 'generic' | 'minimal'; +export type NavigationItemStyle = 'navigator' | 'browser' | 'editor'; export type StackPresentationTypes = @@ -1530,7 +1530,7 @@ index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e13 + useFallbackSearchField?: boolean | undefined; + width?: number | undefined; } - + export type HeaderBarButtonItem = | HeaderBarButtonItemWithAction | HeaderBarButtonItemWithMenu @@ -1538,5 +1538,5 @@ index 76a83f3acb6fd3f0af7f027798848b7124100286..9e4499f076f9988e3266df4be7201e13 + | HeaderBarButtonSearchFieldItem + | HeaderBarButtonSearchBarPlacementItem | HeaderBarButtonItemSpacing; - + /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cf0aeb1e1d..e54d6056d48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -207,10 +207,10 @@ importers: dependencies: '@callstack/liquid-glass': specifier: ^0.7.1 - version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@clerk/expo': specifier: 3.6.2 - version: 3.6.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.6.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@effect/atom-react': specifier: 4.0.0-beta.78 version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0) @@ -219,13 +219,13 @@ importers: version: 0.4.2 '@expo/metro-runtime': specifier: ~56.0.15 - version: 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/ui': specifier: ~56.0.18 - version: 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) + version: 56.0.18(19413efe5eaad64848598eedfe3a0fd3) '@legendapp/list': specifier: 3.2.0 - version: 3.2.0(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 3.2.0(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@noble/curves': specifier: 'catalog:' version: 1.9.1 @@ -237,16 +237,16 @@ importers: version: 1.3.0-beta.5(patch_hash=7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-native-menu/menu': specifier: ^2.0.0 - version: 2.0.0(patch_hash=5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 2.0.0(patch_hash=5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/elements': specifier: 2.9.26 - version: 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) + version: 2.9.26(@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(@react-navigation/native@7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/native': specifier: 7.3.4 - version: 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/native-stack': specifier: 7.17.6 - version: 7.17.6(patch_hash=2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca)(3070ef8171da54a09b9d2c464be5d26b) + version: 7.17.6(patch_hash=2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca)(889fbbb381dfeb79ac64b7284ebf7fa4) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -267,7 +267,7 @@ importers: version: link:../../packages/contracts '@t3tools/mobile-markdown-text': specifier: file:./modules/t3-markdown-text - version: file:apps/mobile/modules/t3-markdown-text(ed3009b8f2424467288a00b38bef28fe) + version: file:apps/mobile/modules/t3-markdown-text(a6e4bd1e9376530ea93dbb7d8a53d32d) '@t3tools/mobile-review-diff-native': specifier: file:./modules/t3-review-diff version: file:apps/mobile/modules/t3-review-diff @@ -288,40 +288,40 @@ importers: version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) expo: specifier: ~56.0.12 - version: 56.0.12(8895228379997a2a064f9644cda56ed0) + version: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-asset: specifier: ~56.0.17 - version: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + version: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-auth-session: specifier: ~56.0.14 - version: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-build-properties: specifier: ~56.0.19 version: 56.0.19(expo@56.0.12) expo-camera: specifier: ~56.0.8 - version: 56.0.8(@types/emscripten@1.41.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.8(@types/emscripten@1.41.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-clipboard: specifier: ~56.0.4 - version: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-constants: specifier: ~56.0.18 - version: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-crypto: specifier: ~56.0.4 version: 56.0.4(expo@56.0.12) expo-dev-client: specifier: ~56.0.20 - version: 56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-file-system: specifier: ~56.0.8 - version: 56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-font: specifier: ~56.0.7 - version: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-glass-effect: specifier: ~56.0.4 - version: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-haptics: specifier: ~56.0.3 version: 56.0.3(expo@56.0.12) @@ -330,16 +330,16 @@ importers: version: 56.0.18(expo@56.0.12) expo-linking: specifier: ~56.0.14 - version: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-network: specifier: ~56.0.5 version: 56.0.5(expo@56.0.12)(react@19.2.3) expo-notifications: specifier: ~56.0.18 - version: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + version: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-paste-input: specifier: ^0.1.15 - version: 0.1.15(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.1.15(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-secure-store: specifier: ~56.0.4 version: 56.0.4(expo@56.0.12) @@ -348,16 +348,16 @@ importers: version: 56.0.10(expo@56.0.12)(typescript@6.0.3) expo-symbols: specifier: ~56.0.6 - version: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-updates: specifier: ~56.0.19 - version: 56.0.19(expo-dev-client@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 56.0.19(expo-dev-client@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-web-browser: specifier: ~56.0.5 - version: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + version: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-widgets: specifier: ~56.0.19 - version: 56.0.19(3cdc0dde9f93166d952f1e1bd0cb25c0) + version: 56.0.19(19413efe5eaad64848598eedfe3a0fd3) punycode: specifier: ^2.3.1 version: 2.3.1 @@ -369,43 +369,43 @@ importers: version: 19.2.3(react@19.2.3) react-native: specifier: 0.85.3 - version: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + version: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-native-gesture-handler: specifier: ~2.31.1 - version: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-image-viewing: specifier: ^0.2.2 - version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-keyboard-controller: specifier: 1.21.6 - version: 1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-markdown: specifier: ^0.5.0 - version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-modules: specifier: 0.35.9 - version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-reanimated: specifier: 4.3.1 - version: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-safe-area-context: specifier: ~5.7.0 - version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-screens: specifier: 4.25.2 - version: 4.25.2(patch_hash=e1b49230abc20a663115cc3f34ca2f63764a873485ccb67f2bd43b85ce2a94dc)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 4.25.2(patch_hash=e1b49230abc20a663115cc3f34ca2f63764a873485ccb67f2bd43b85ce2a94dc)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-shiki-engine: specifier: ^0.3.12 - version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-svg: specifier: 15.15.4 - version: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-webview: specifier: ^13.16.1 - version: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-worklets: specifier: 0.8.3 - version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + version: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) shiki: specifier: 4.2.0 version: 4.2.0 @@ -414,7 +414,7 @@ importers: version: 3.6.0 uniwind: specifier: ^1.6.2 - version: 1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0) + version: 1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0) devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 @@ -11305,10 +11305,10 @@ snapshots: '@bruits/satteri-win32-x64-msvc@0.9.3': optional: true - '@callstack/liquid-glass@0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@callstack/liquid-glass@0.7.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@capsizecss/unpack@4.0.1': dependencies: @@ -11412,23 +11412,23 @@ snapshots: electron-store: 8.2.0 react-dom: 19.2.6(react@19.2.6) - '@clerk/expo@3.6.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@clerk/expo@3.6.2(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: '@clerk/clerk-js': 6.22.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@clerk/react': 6.11.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@clerk/shared': 4.22.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) base-64: 1.0.0 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-url-polyfill: 2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) tslib: 2.8.1 optionalDependencies: - expo-auth-session: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-auth-session: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-crypto: 56.0.4(expo@56.0.12) expo-secure-store: 56.0.4(expo@56.0.12) - expo-web-browser: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-web-browser: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react-dom: 19.2.3(react@19.2.3) '@clerk/react@6.11.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': @@ -12011,7 +12011,7 @@ snapshots: '@expo-google-fonts/material-symbols@0.4.38': {} - '@expo/cli@56.1.16(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)': + '@expo/cli@56.1.16(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6)': dependencies: '@expo/code-signing-certificates': 0.0.6 '@expo/config': 56.0.9(typescript@6.0.3) @@ -12021,7 +12021,7 @@ snapshots: '@expo/image-utils': 0.10.1(typescript@6.0.3) '@expo/inline-modules': 0.0.12(typescript@6.0.3) '@expo/json-file': 10.2.0 - '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@expo/metro-config': 56.0.14(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.12)(typescript@6.0.3)(utf-8-validate@6.0.6) '@expo/metro-file-map': 56.0.3 @@ -12046,7 +12046,7 @@ snapshots: connect: 3.7.0 debug: 4.4.3 dnssd-advertise: 1.1.4 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-server: 56.0.5 fetch-nodeshim: 0.4.10 getenv: 2.0.0 @@ -12072,8 +12072,8 @@ snapshots: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) zod: 3.25.76 optionalDependencies: - expo-router: 56.2.11(9f4026bc3652892fe782f80a88dfbe4a) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo-router: 56.2.11(3af01b44e50aae5a8ae60852f3cf7e3f) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@expo/dom-webview' - '@expo/metro-runtime' @@ -12156,18 +12156,18 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/devtools@56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/devtools@56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: chalk: 4.1.2 optionalDependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - '@expo/dom-webview@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/dom-webview@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@expo/env@2.3.0': dependencies: @@ -12228,13 +12228,13 @@ snapshots: - supports-color - typescript - '@expo/log-box@56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/log-box@56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@expo/dom-webview': 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/dom-webview': 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) anser: 1.4.10 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 '@expo/metro-config@56.0.14(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.12)(typescript@6.0.3)(utf-8-validate@6.0.6)': @@ -12263,7 +12263,7 @@ snapshots: postcss: 8.5.15 resolve-from: 5.0.0 optionalDependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - supports-color @@ -12281,14 +12281,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/metro-runtime@56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@expo/metro-runtime@56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) anser: 1.4.10 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) pretty-format: 29.7.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) stacktrace-parser: 0.1.11 whatwg-fetch: 3.6.20 optionalDependencies: @@ -12363,14 +12363,14 @@ snapshots: '@expo/router-server@56.0.14(@expo/metro-runtime@56.0.15)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo-server@56.0.5)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: debug: 4.4.3 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-server: 56.0.5 react: 19.2.3 optionalDependencies: - '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-router: 56.2.11(9f4026bc3652892fe782f80a88dfbe4a) + '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-router: 56.2.11(3af01b44e50aae5a8ae60852f3cf7e3f) react-dom: 19.2.3(react@19.2.3) transitivePeerDependencies: - supports-color @@ -12385,18 +12385,18 @@ snapshots: '@expo/sudo-prompt@9.3.2': {} - '@expo/ui@56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0)': + '@expo/ui@56.0.18(19413efe5eaad64848598eedfe3a0fd3)': dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) optionalDependencies: '@babel/core': 7.29.7 react-dom: 19.2.3(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@types/react' - '@types/react-dom' @@ -12659,13 +12659,13 @@ snapshots: dependencies: jsbi: 4.3.2 - '@legendapp/list@3.2.0(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@legendapp/list@3.2.0(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 use-sync-external-store: 1.6.0(react@19.2.3) optionalDependencies: react-dom: 19.2.3(react@19.2.3) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@legendapp/list@3.2.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: @@ -13493,16 +13493,16 @@ snapshots: prompts: 2.4.2 tinyexec: 1.2.4 - '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) optional: true - '@react-native-menu/menu@2.0.0(patch_hash=5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native-menu/menu@2.0.0(patch_hash=5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) '@react-native/assets-registry@0.85.3': {} @@ -13562,7 +13562,7 @@ snapshots: tinyglobby: 0.2.17 yargs: 17.7.2 - '@react-native/community-cli-plugin@0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6)': + '@react-native/community-cli-plugin@0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(bufferutil@4.1.0)(utf-8-validate@6.0.6)': dependencies: '@react-native/dev-middleware': 0.85.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) debug: 4.4.3 @@ -13572,7 +13572,7 @@ snapshots: metro-core: 0.84.4 semver: 7.8.5 optionalDependencies: - '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@react-native/metro-config': 0.85.3(@babel/core@7.29.7) transitivePeerDependencies: - bufferutil - supports-color @@ -13620,7 +13620,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6)': + '@react-native/metro-config@0.85.3(@babel/core@7.29.7)': dependencies: '@react-native/js-polyfills': 0.85.3 '@react-native/metro-babel-transformer': 0.85.3(@babel/core@7.29.7) @@ -13628,18 +13628,16 @@ snapshots: metro-runtime: 0.84.4 transitivePeerDependencies: - '@babel/core' - - bufferutil - supports-color - - utf-8-validate '@react-native/normalize-colors@0.85.3': {} - '@react-native/virtualized-lists@0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-native/virtualized-lists@0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: invariant: 2.2.4 nullthrows: 1.1.1 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) optionalDependencies: '@types/react': 19.2.16 @@ -13655,40 +13653,40 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.3) use-sync-external-store: 1.6.0(react@19.2.3) - '@react-navigation/elements@2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4)': + '@react-navigation/elements@2.9.26(@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(@react-navigation/native@7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: - '@react-navigation/native': 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@react-navigation/native': 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) color: 4.2.3 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) use-latest-callback: 0.2.6(react@19.2.3) use-sync-external-store: 1.6.0(react@19.2.3) optionalDependencies: - '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@react-navigation/native-stack@7.17.6(patch_hash=2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca)(3070ef8171da54a09b9d2c464be5d26b)': + '@react-navigation/native-stack@7.17.6(patch_hash=2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca)(889fbbb381dfeb79ac64b7284ebf7fa4)': dependencies: - '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) - '@react-navigation/native': 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@react-navigation/elements': 2.9.26(@react-native-masked-view/masked-view@0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(@react-navigation/native@7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@react-navigation/native': 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) color: 4.2.3 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.25.2(patch_hash=e1b49230abc20a663115cc3f34ca2f63764a873485ccb67f2bd43b85ce2a94dc)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.25.2(patch_hash=e1b49230abc20a663115cc3f34ca2f63764a873485ccb67f2bd43b85ce2a94dc)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) sf-symbols-typescript: 2.2.0 warn-once: 0.1.1 transitivePeerDependencies: - '@react-native-masked-view/masked-view' - '@react-navigation/native@7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': + '@react-navigation/native@7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)': dependencies: '@react-navigation/core': 7.21.2(react@19.2.3) escape-string-regexp: 4.0.0 fast-deep-equal: 3.1.3 nanoid: 3.3.12 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) standard-navigation: 0.0.7 use-latest-callback: 0.2.6(react@19.2.3) @@ -14077,15 +14075,15 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(ed3009b8f2424467288a00b38bef28fe)': + '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(a6e4bd1e9376530ea93dbb7d8a53d32d)': dependencies: - expo-asset: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) - expo-clipboard: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-asset: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + expo-clipboard: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-haptics: 56.0.3(expo@56.0.12) - expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-nitro-markdown: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@t3tools/mobile-review-diff-native@file:apps/mobile/modules/t3-review-diff': {} @@ -15201,8 +15199,8 @@ snapshots: react-refresh: 0.14.2 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-widgets: 56.0.19(3cdc0dde9f93166d952f1e1bd0cb25c0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-widgets: 56.0.19(19413efe5eaad64848598eedfe3a0fd3) transitivePeerDependencies: - '@babel/core' - supports-color @@ -15254,8 +15252,8 @@ snapshots: react-refresh: 0.14.2 optionalDependencies: '@babel/runtime': 7.29.7 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-widgets: 56.0.19(3cdc0dde9f93166d952f1e1bd0cb25c0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-widgets: 56.0.19(19413efe5eaad64848598eedfe3a0fd3) transitivePeerDependencies: - '@babel/core' - supports-color @@ -16155,29 +16153,29 @@ snapshots: expo-application@56.0.3(expo@56.0.12): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-asset@56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): + expo-asset@56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color - typescript - expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: expo-application: 56.0.3(expo@56.0.12) - expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-crypto: 56.0.4(expo@56.0.12) - expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-web-browser: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-web-browser: 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - expo - supports-color @@ -16185,119 +16183,119 @@ snapshots: expo-build-properties@56.0.19(expo@56.0.12): dependencies: '@expo/schema-utils': 56.0.1 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) resolve-from: 5.0.0 semver: 7.8.5 - expo-camera@56.0.8(@types/emscripten@1.41.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-camera@56.0.8(@types/emscripten@1.41.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: barcode-detector: 3.2.0(@types/emscripten@1.41.5) - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@types/emscripten' - expo-clipboard@56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-clipboard@56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-constants@56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-constants@56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: '@expo/env': 2.3.0 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color expo-crypto@56.0.4(expo@56.0.12): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-dev-client@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-dev-client@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-dev-launcher: 56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-dev-menu: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-dev-launcher: 56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-dev-menu: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-dev-menu-interface: 56.0.1(expo@56.0.12) expo-manifests: 56.0.4(expo@56.0.12) expo-updates-interface: 56.0.2(expo@56.0.12) transitivePeerDependencies: - react-native - expo-dev-launcher@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-dev-launcher@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: '@expo/schema-utils': 56.0.1 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-dev-menu: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-dev-menu: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) expo-manifests: 56.0.4(expo@56.0.12) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-dev-menu-interface@56.0.1(expo@56.0.12): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-dev-menu@56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-dev-menu@56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-dev-menu-interface: 56.0.1(expo@56.0.12) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-eas-client@56.0.1: {} - expo-file-system@56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-file-system@56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-font@56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-font@56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) fontfaceobserver: 2.3.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-glass-effect@56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-glass-effect@56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-haptics@56.0.3(expo@56.0.12): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-image-loader@56.0.3(expo@56.0.12): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-image-picker@56.0.18(expo@56.0.12): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-image-loader: 56.0.3(expo@56.0.12) expo-json-utils@56.0.0: {} expo-keep-awake@56.0.3(expo@56.0.12)(react@19.2.3): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - expo-linking@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-linking@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - expo - supports-color expo-manifests@56.0.4(expo@56.0.12): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-json-utils: 56.0.0 expo-modules-autolinking@56.0.16(typescript@6.0.3): @@ -16310,66 +16308,66 @@ snapshots: - supports-color - typescript - expo-modules-core@56.0.17(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-modules-core@56.0.17(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo/expo-modules-macros-plugin': 0.2.2 - expo-modules-jsi: 56.0.10(patch_hash=9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-modules-jsi: 56.0.10(patch_hash=9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) optionalDependencies: - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-modules-jsi@56.0.10(patch_hash=9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-modules-jsi@56.0.10(patch_hash=9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) expo-network@56.0.5(expo@56.0.12)(react@19.2.3): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - expo-notifications@56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): + expo-notifications@56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3): dependencies: '@expo/image-utils': 0.10.1(typescript@6.0.3) abort-controller: 3.0.0 badgin: 1.2.3 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-application: 56.0.3(expo@56.0.12) - expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - supports-color - typescript - expo-paste-input@0.1.15(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-paste-input@0.1.15(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-router@56.2.11(9f4026bc3652892fe782f80a88dfbe4a): + expo-router@56.2.11(3af01b44e50aae5a8ae60852f3cf7e3f): dependencies: - '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/schema-utils': 56.0.1 - '@expo/ui': 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) + '@expo/ui': 56.0.18(19413efe5eaad64848598eedfe3a0fd3) '@radix-ui/react-slot': 1.2.4(@types/react@19.2.16)(react@19.2.3) '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@testing-library/jest-dom': 6.9.1 '@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1) client-only: 0.0.1 color: 4.2.3 debug: 4.4.3 escape-string-regexp: 4.0.0 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-glass-effect: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-linking: 56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-server: 56.0.5 - expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-symbols: 56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) fast-deep-equal: 3.1.3 invariant: 2.2.4 nanoid: 3.3.12 @@ -16377,10 +16375,10 @@ snapshots: react: 19.2.3 react-fast-compare: 3.2.2 react-is: 19.2.7 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-drawer-layout: 4.2.4(05364bd849de538917a7364cc7dee3f5) - react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-screens: 4.25.2(patch_hash=e1b49230abc20a663115cc3f34ca2f63764a873485ccb67f2bd43b85ce2a94dc)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-drawer-layout: 4.2.4(react-native-gesture-handler@2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-safe-area-context: 5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-screens: 4.25.2(patch_hash=e1b49230abc20a663115cc3f34ca2f63764a873485ccb67f2bd43b85ce2a94dc)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) server-only: 0.0.1 sf-symbols-typescript: 2.2.0 shallowequal: 1.1.0 @@ -16388,8 +16386,8 @@ snapshots: vaul: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) optionalDependencies: react-dom: 19.2.3(react@19.2.3) - react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@babel/core' - '@testing-library/dom' @@ -16402,7 +16400,7 @@ snapshots: expo-secure-store@56.0.4(expo@56.0.12): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-server@56.0.5: {} @@ -16410,7 +16408,7 @@ snapshots: dependencies: '@expo/config-plugins': 56.0.8(typescript@6.0.3) '@expo/image-utils': 0.10.1(typescript@6.0.3) - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) xml2js: 0.6.0 transitivePeerDependencies: - supports-color @@ -16418,20 +16416,20 @@ snapshots: expo-structured-headers@56.0.0: {} - expo-symbols@56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-symbols@56.0.6(expo-font@56.0.7)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo-google-fonts/material-symbols': 0.4.38 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) sf-symbols-typescript: 2.2.0 expo-updates-interface@56.0.2(expo@56.0.12): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) - expo-updates@56.0.19(expo-dev-client@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + expo-updates@56.0.19(expo-dev-client@56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@expo/code-signing-certificates': 0.0.6 '@expo/plist': 0.7.0 @@ -16439,7 +16437,7 @@ snapshots: arg: 4.1.3 chalk: 4.1.2 debug: 4.4.3 - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) expo-eas-client: 56.0.1 expo-manifests: 56.0.4(expo@56.0.12) expo-structured-headers: 56.0.0 @@ -16449,25 +16447,25 @@ snapshots: ignore: 5.3.2 nullthrows: 1.1.1 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) resolve-from: 5.0.0 optionalDependencies: - expo-dev-client: 56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-dev-client: 56.0.20(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) transitivePeerDependencies: - supports-color - expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - expo-widgets@56.0.19(3cdc0dde9f93166d952f1e1bd0cb25c0): + expo-widgets@56.0.19(19413efe5eaad64848598eedfe3a0fd3): dependencies: '@expo/plist': 0.7.0 - '@expo/ui': 56.0.18(3cdc0dde9f93166d952f1e1bd0cb25c0) - expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + '@expo/ui': 56.0.18(19413efe5eaad64848598eedfe3a0fd3) + expo: 56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) transitivePeerDependencies: - '@babel/core' - '@types/react' @@ -16476,37 +16474,37 @@ snapshots: - react-native-reanimated - react-native-worklets - expo@56.0.12(8895228379997a2a064f9644cda56ed0): + expo@56.0.12(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-router@56.2.11)(expo-widgets@56.0.19)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6): dependencies: '@babel/runtime': 7.29.7 - '@expo/cli': 56.1.16(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) + '@expo/cli': 56.1.16(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.15)(bufferutil@4.1.0)(expo-constants@56.0.18)(expo-font@56.0.7)(expo-router@56.2.11)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3)(utf-8-validate@6.0.6) '@expo/config': 56.0.9(typescript@6.0.3) '@expo/config-plugins': 56.0.9(typescript@6.0.3) - '@expo/devtools': 56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/devtools': 56.0.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/fingerprint': 0.19.4 '@expo/local-build-cache-provider': 56.0.8(typescript@6.0.3) - '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/log-box': 56.0.13(@expo/dom-webview@56.0.5)(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@expo/metro': 56.0.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@expo/metro-config': 56.0.14(patch_hash=8cb08b5bb7051ed9d2dbe46a2c293c5a1e17f1bd6ddf30de27909e18c921ff46)(bufferutil@4.1.0)(expo@56.0.12)(typescript@6.0.3)(utf-8-validate@6.0.6) '@ungap/structured-clone': 1.3.1 babel-preset-expo: 56.0.15(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo-widgets@56.0.19)(expo@56.0.12)(react-refresh@0.14.2) - expo-asset: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) - expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-file-system: 56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) - expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-asset: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + expo-constants: 56.0.18(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-file-system: 56.0.8(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) + expo-font: 56.0.7(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-keep-awake: 56.0.3(expo@56.0.12)(react@19.2.3) expo-modules-autolinking: 56.0.16(typescript@6.0.3) - expo-modules-core: 56.0.17(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + expo-modules-core: 56.0.17(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) pretty-format: 29.7.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) react-refresh: 0.14.2 whatwg-url-minimum: 0.1.2 optionalDependencies: - '@expo/dom-webview': 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/dom-webview': 56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@expo/metro-runtime': 56.0.15(@expo/log-box@56.0.13)(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-dom: 19.2.3(react@19.2.3) - react-native-webview: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-webview: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) transitivePeerDependencies: - '@babel/core' - bufferutil @@ -18930,103 +18928,103 @@ snapshots: transitivePeerDependencies: - supports-color - react-native-drawer-layout@4.2.4(05364bd849de538917a7364cc7dee3f5): - dependencies: + ? react-native-drawer-layout@4.2.4(react-native-gesture-handler@2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + : dependencies: color: 4.2.3 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-gesture-handler: 2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) use-latest-callback: 0.2.6(react@19.2.3) optional: true - react-native-gesture-handler@2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-gesture-handler@2.31.2(patch_hash=808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@egjs/hammerjs': 2.0.17 '@types/react-test-renderer': 19.1.0 hoist-non-react-statics: 3.3.2 invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-image-viewing@0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-image-viewing@0.2.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-is-edge-to-edge@1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-is-edge-to-edge@1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-keyboard-controller@1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-keyboard-controller@1.21.6(react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-reanimated: 4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-nitro-markdown@0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-nitro-modules: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-nitro-modules: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) optionalDependencies: - react-native-svg: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-svg: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-reanimated@4.3.1(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-is-edge-to-edge: 1.3.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) semver: 7.8.5 - react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-safe-area-context@5.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-screens@4.25.2(patch_hash=e1b49230abc20a663115cc3f34ca2f63764a873485ccb67f2bd43b85ce2a94dc)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-screens@4.25.2(patch_hash=e1b49230abc20a663115cc3f34ca2f63764a873485ccb67f2bd43b85ce2a94dc)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: react: 19.2.3 react-freeze: 1.0.4(react@19.2.3) - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-shiki-engine@0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-shiki-engine@0.3.12(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@shikijs/types': 4.3.0 '@shikijs/vscode-textmate': 10.0.2 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: css-select: 5.2.2 css-tree: 1.1.3 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) warn-once: 0.1.1 - react-native-url-polyfill@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + react-native-url-polyfill@2.0.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): dependencies: - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) whatwg-url-without-unicode: 8.0.0-3 - react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: escape-string-regexp: 4.0.0 invariant: 2.2.4 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) - react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): + react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: '@babel/core': 7.29.7 '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) @@ -19038,23 +19036,23 @@ snapshots: '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7) '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) - '@react-native/metro-config': 0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@react-native/metro-config': 0.85.3(@babel/core@7.29.7) convert-source-map: 2.0.0 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) semver: 7.8.5 transitivePeerDependencies: - supports-color - react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6): + react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6): dependencies: '@react-native/assets-registry': 0.85.3 '@react-native/codegen': 0.85.3(@babel/core@7.29.7) - '@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(utf-8-validate@6.0.6) + '@react-native/community-cli-plugin': 0.85.3(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@react-native/gradle-plugin': 0.85.3 '@react-native/js-polyfills': 0.85.3 '@react-native/normalize-colors': 0.85.3 - '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + '@react-native/virtualized-lists': 0.85.3(@types/react@19.2.16)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) abort-controller: 3.0.0 anser: 1.4.10 ansi-regex: 5.0.1 @@ -20112,14 +20110,14 @@ snapshots: universalify@2.0.1: {} - uniwind@1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0): + uniwind@1.7.0(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0): dependencies: '@tailwindcss/node': 4.2.1 '@tailwindcss/oxide': 4.2.1 culori: 4.0.2 lightningcss: 1.30.1 react: 19.2.3 - react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) tailwindcss: 4.3.0 unpipe@1.0.0: {} From 0b571ea3270252d5fb2fff33c70764a8bdcdf209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 10 Jul 2026 18:48:38 +0100 Subject: [PATCH 29/55] Hide publish-only links from Connect discovery - Filter user environment discovery to managed-tunnel links - Add coverage for active managed-link filtering --- .../src/environments/EnvironmentLinks.test.ts | 36 +++++++++++++++++++ .../src/environments/EnvironmentLinks.ts | 1 + 2 files changed, 37 insertions(+) diff --git a/infra/relay/src/environments/EnvironmentLinks.test.ts b/infra/relay/src/environments/EnvironmentLinks.test.ts index dccb9e39f60..3994183b2e3 100644 --- a/infra/relay/src/environments/EnvironmentLinks.test.ts +++ b/infra/relay/src/environments/EnvironmentLinks.test.ts @@ -116,6 +116,42 @@ describe("EnvironmentLinks", () => { ); }); + it.effect("lists only active managed links for client discovery", () => { + const whereConditions: Array = []; + const fakeDb = { + select: (selection: unknown) => { + expect(selection).toBeDefined(); + return { + from: (table: unknown) => { + expect(table).toBe(relayEnvironmentLinks); + return { + where: (condition: unknown) => { + whereConditions.push(condition); + return Effect.succeed([]); + }, + }; + }, + }; + }, + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const links = yield* EnvironmentLinks.EnvironmentLinks; + expect(yield* links.listForUser({ userId: "user-1" })).toEqual([]); + expect(whereConditions).toHaveLength(1); + + const query = new PgDialect().sqlToQuery(whereConditions[0] as never); + expect(query.sql).toContain('"relay_environment_links"."user_id" = $1'); + expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null'); + expect(query.sql).toContain('"relay_environment_links"."managed_tunnels_enabled" = $2'); + expect(query.params).toEqual(["user-1", true]); + }).pipe( + Effect.provide( + EnvironmentLinks.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), + ), + ); + }); + it.effect("revokes only the active link owned by the requesting user", () => { const updateValues: Array> = []; const whereConditions: Array = []; diff --git a/infra/relay/src/environments/EnvironmentLinks.ts b/infra/relay/src/environments/EnvironmentLinks.ts index 6630af0a11b..3ef9b954df6 100644 --- a/infra/relay/src/environments/EnvironmentLinks.ts +++ b/infra/relay/src/environments/EnvironmentLinks.ts @@ -314,6 +314,7 @@ const make = Effect.gen(function* () { and( eq(relayEnvironmentLinks.userId, input.userId), isNull(relayEnvironmentLinks.revokedAt), + eq(relayEnvironmentLinks.managedTunnelsEnabled, true), ), ) .pipe( From 33581b82882383e08997fb9c6d0f7fbd3644bac8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 10 Jul 2026 19:03:07 +0100 Subject: [PATCH 30/55] Harden relay activity delivery maintenance - Prune terminal rows in bounded lock-skipping batches - Compare aggregate snapshots with canonical JSON - Verify APNs JWT signatures with Node crypto --- .../src/agentActivity/AgentActivityRows.ts | 29 ++++++++++++++----- .../relay/src/agentActivity/ApnsDeliveries.ts | 5 +++- .../agentActivity/ApnsProviderTokens.test.ts | 24 ++++++++++++++- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/infra/relay/src/agentActivity/AgentActivityRows.ts b/infra/relay/src/agentActivity/AgentActivityRows.ts index 63868ad2d72..f4eabe6ebcd 100644 --- a/infra/relay/src/agentActivity/AgentActivityRows.ts +++ b/infra/relay/src/agentActivity/AgentActivityRows.ts @@ -7,7 +7,7 @@ import * as Function from "effect/Function"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { and, desc, eq, isNull, lt, sql } from "drizzle-orm"; +import { and, desc, eq, isNull, sql } from "drizzle-orm"; import * as RelayDb from "../db.ts"; import { relayAgentActivityRows, relayEnvironmentLinks } from "../persistence/schema.ts"; @@ -97,6 +97,8 @@ const decodeRelayAgentActivityStateJson = Schema.decodeUnknownOption( Schema.fromJsonString(RelayAgentActivityStateSchema), ); +const TERMINAL_PRUNE_BATCH_SIZE = 500; + export const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; @@ -181,15 +183,26 @@ export const make = Effect.gen(function* () { pruneTerminal: Effect.fn("relay.agent_activity_rows.prune_terminal")(function* (input) { yield* Effect.annotateCurrentSpan({ "relay.agent_activity_prune.before": input.updatedBefore, + "relay.agent_activity_prune.batch_size": TERMINAL_PRUNE_BATCH_SIZE, }); + // Bound each cron invocation so a large backlog cannot hold row locks in + // one long transaction. SKIP LOCKED also lets multiple worker instances + // prune disjoint batches without waiting on one another. yield* db - .delete(relayAgentActivityRows) - .where( - and( - sql`${relayAgentActivityRows.stateJson} ->> 'phase' IN ('completed', 'failed')`, - lt(relayAgentActivityRows.updatedAt, input.updatedBefore), - ), - ) + .execute(sql` + WITH terminal_rows AS ( + SELECT ctid + FROM ${relayAgentActivityRows} + WHERE ${relayAgentActivityRows.stateJson} ->> 'phase' IN ('completed', 'failed') + AND ${relayAgentActivityRows.updatedAt} < ${input.updatedBefore} + ORDER BY ${relayAgentActivityRows.updatedAt} + LIMIT ${TERMINAL_PRUNE_BATCH_SIZE} + FOR UPDATE SKIP LOCKED + ) + DELETE FROM ${relayAgentActivityRows} + USING terminal_rows + WHERE ${relayAgentActivityRows}.ctid = terminal_rows.ctid + `) .pipe( Effect.mapError( (cause) => diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index 9b86ed46417..b4976c6eb25 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -9,6 +9,7 @@ import { RelayAgentAwarenessPreferences as RelayAgentAwarenessPreferencesSchema, RelayDeliveryKind as RelayDeliveryKindSchema, } from "@t3tools/contracts/relay"; +import { stableStringify } from "@t3tools/shared/relaySigning"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -297,7 +298,9 @@ function shouldUpdateLiveActivity(input: { if (!input.previousAggregate) { return true; } - if (JSON.stringify(input.previousAggregate) === JSON.stringify(input.nextAggregate)) { + // PostgreSQL jsonb does not preserve object key order, so compare canonical + // encodings instead of treating insertion order as an aggregate change. + if (stableStringify(input.previousAggregate) === stableStringify(input.nextAggregate)) { return false; } if (input.previousAggregate.activeCount !== input.nextAggregate.activeCount) { diff --git a/infra/relay/src/agentActivity/ApnsProviderTokens.test.ts b/infra/relay/src/agentActivity/ApnsProviderTokens.test.ts index d98b9f920be..8cfb2726877 100644 --- a/infra/relay/src/agentActivity/ApnsProviderTokens.test.ts +++ b/infra/relay/src/agentActivity/ApnsProviderTokens.test.ts @@ -7,7 +7,7 @@ import * as Schema from "effect/Schema"; import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; -const { privateKey } = NodeCrypto.generateKeyPairSync("ec", { +const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", { namedCurve: "prime256v1", privateKeyEncoding: { type: "pkcs8", format: "pem" }, publicKeyEncoding: { type: "spki", format: "pem" }, @@ -60,6 +60,28 @@ describe("ApnsProviderTokens", () => { }).pipe(Effect.provide(ApnsProviderTokens.layer)); }); + it.effect("produces an APNs-compatible ES256 signature", () => { + ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); + return Effect.gen(function* () { + const tokens = yield* ApnsProviderTokens.ApnsProviderTokens; + const jwt = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: WINDOW + 10 }); + const [header, payload, signature] = jwt.split("."); + + expect(header).toBeDefined(); + expect(payload).toBeDefined(); + expect(signature).toBeDefined(); + expect( + NodeCrypto.verify( + "sha256", + Buffer.from(`${header}.${payload}`), + { key: publicKey, dsaEncoding: "ieee-p1363" }, + Buffer.from(signature!, "base64url"), + ), + ).toBe(true); + ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); + }).pipe(Effect.provide(ApnsProviderTokens.layer)); + }); + it.effect("serves repeat pushes from the isolate cache without re-signing", () => { ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); return Effect.gen(function* () { From bc17d9515e870b880abfcb84f3a18e2544046fd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 13 Jul 2026 13:11:35 +0100 Subject: [PATCH 31/55] Share workspace-aware provider skills with mobile - Extract provider workspace-skill policy into client runtime - Use workspace skills in mobile threads and new-task drafts - Surface mobile skill loading and structured errors - Cover refresh, fallback, ranking, and worktree selection --- .../threads/ComposerCommandPopover.tsx | 10 +- .../src/features/threads/ThreadComposer.tsx | 104 ++---------- .../features/threads/ThreadDetailScreen.tsx | 16 +- .../threads/new-task-flow-provider.tsx | 25 ++- .../threads/new-task-provider-skills.test.ts | 49 ++++++ .../threads/new-task-provider-skills.ts | 26 +++ .../thread-composer-skill-items.test.ts | 52 ++++++ .../threads/thread-composer-skill-items.ts | 94 +++++++++++ .../src/state/providerWorkspaceSkillsState.ts | 75 +++++++++ apps/mobile/src/state/query.ts | 2 + .../lib/providerWorkspaceSkillsState.test.ts | 7 +- .../src/lib/providerWorkspaceSkillsState.ts | 125 ++------------ packages/client-runtime/package.json | 4 + .../src/state/providerWorkspaceSkills.test.ts | 153 ++++++++++++++++++ .../src/state/providerWorkspaceSkills.ts | 119 ++++++++++++++ 15 files changed, 650 insertions(+), 211 deletions(-) create mode 100644 apps/mobile/src/features/threads/new-task-provider-skills.test.ts create mode 100644 apps/mobile/src/features/threads/new-task-provider-skills.ts create mode 100644 apps/mobile/src/features/threads/thread-composer-skill-items.test.ts create mode 100644 apps/mobile/src/features/threads/thread-composer-skill-items.ts create mode 100644 apps/mobile/src/state/providerWorkspaceSkillsState.ts create mode 100644 packages/client-runtime/src/state/providerWorkspaceSkills.test.ts create mode 100644 packages/client-runtime/src/state/providerWorkspaceSkills.ts diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index ccf6a307122..cb2d72ccdc6 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -42,6 +42,7 @@ interface ComposerCommandPopoverProps { readonly items: ReadonlyArray; readonly triggerKind: ComposerTriggerKind | null; readonly isLoading: boolean; + readonly error: string | null; readonly onSelect: (item: ComposerCommandItem) => void; } @@ -181,6 +182,13 @@ export const ComposerCommandPopover = memo(function ComposerCommandPopover( ) : null} + {props.error ? ( + + + {props.error} + + + ) : null} {props.items.length > 0 ? ( ))} - ) : ( + ) : props.error ? null : ( {emptyText(props.triggerKind, props.isLoading)} diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index e712664d30a..5e3b59aaa5b 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -7,6 +7,7 @@ import type { ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig, + ServerProviderSkill, } from "@t3tools/contracts"; import { detectComposerTrigger, @@ -50,11 +51,6 @@ import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import type { RemoteClientConnectionState } from "../../lib/connection"; -import { - insertRankedSearchResult, - normalizeSearchQuery, - scoreQueryMatch, -} from "@t3tools/shared/searchRanking"; import { applyProviderOptionMenuEvent, buildProviderOptionMenuActions, @@ -63,6 +59,7 @@ import { } from "../../lib/providerOptions"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; +import { buildComposerSkillItems } from "./thread-composer-skill-items"; /** * Height of the collapsed composer (pill + vertical padding, excluding safe-area inset). @@ -93,6 +90,9 @@ export interface ThreadComposerProps { readonly threadSyncPhase?: "loading" | "syncing" | null; readonly selectedThread: OrchestrationThreadShell; readonly serverConfig: T3ServerConfig | null; + readonly workspaceSkills: ReadonlyArray; + readonly workspaceSkillsIsPending: boolean; + readonly workspaceSkillsError: string | null; readonly queueCount: number; readonly activeThreadBusy: boolean; readonly environmentId: EnvironmentId; @@ -401,86 +401,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } if (composerTrigger.kind === "skill") { - const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled); - const normalizedQuery = normalizeSearchQuery(composerTrigger.query, { - trimLeadingPattern: /^\$+/, - }); - - if (!normalizedQuery) { - return enabledSkills.slice(0, 20).map((skill) => ({ - id: `skill:${skill.name}`, - type: "skill" as const, - skill, - label: skill.displayName ?? skill.name, - description: skill.shortDescription ?? skill.description ?? "", - })); - } - - const ranked: Array<{ - item: (typeof enabledSkills)[number]; - score: number; - tieBreaker: string; - }> = []; - for (const skill of enabledSkills) { - const displayLabel = (skill.displayName ?? skill.name).toLowerCase(); - const scores = [ - scoreQueryMatch({ - value: skill.name.toLowerCase(), - query: normalizedQuery, - exactBase: 0, - prefixBase: 2, - boundaryBase: 4, - includesBase: 6, - fuzzyBase: 100, - boundaryMarkers: ["-", "_", "/"], - }), - scoreQueryMatch({ - value: displayLabel, - query: normalizedQuery, - exactBase: 1, - prefixBase: 3, - boundaryBase: 5, - includesBase: 7, - fuzzyBase: 110, - }), - scoreQueryMatch({ - value: skill.shortDescription?.toLowerCase() ?? "", - query: normalizedQuery, - exactBase: 20, - prefixBase: 22, - boundaryBase: 24, - includesBase: 26, - }), - scoreQueryMatch({ - value: skill.description?.toLowerCase() ?? "", - query: normalizedQuery, - exactBase: 30, - prefixBase: 32, - boundaryBase: 34, - includesBase: 36, - }), - ].filter((s): s is number => s !== null); - - if (scores.length > 0) { - insertRankedSearchResult( - ranked, - { - item: skill, - score: Math.min(...scores), - tieBreaker: `${displayLabel}\u0000${skill.name}`, - }, - 20, - ); - } - } - - return ranked.map(({ item: skill }) => ({ - id: `skill:${skill.name}`, - type: "skill" as const, - skill, - label: skill.displayName ?? skill.name, - description: skill.shortDescription ?? skill.description ?? "", - })); + return buildComposerSkillItems(props.workspaceSkills, composerTrigger.query); } if (composerTrigger.kind === "path") { @@ -498,7 +419,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } return []; - }, [composerTrigger, pathSearch.entries, selectedProviderStatus]); + }, [composerTrigger, pathSearch.entries, props.workspaceSkills, selectedProviderStatus]); // ── Handle command selection ────────────────────────────── const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; @@ -710,12 +631,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer layout={COMPOSER_LAYOUT_TRANSITION} style={{ maxWidth: props.contentMaxWidth }} > - {composerTrigger && composerMenuItems.length > 0 ? ( + {composerTrigger && (composerMenuItems.length > 0 || composerTrigger.kind === "skill") ? ( @@ -769,7 +695,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ref={inputRef} multiline value={props.draftMessage} - skills={selectedProviderStatus?.skills ?? []} + skills={props.workspaceSkills} selection={composerSelection} onChangeText={props.onChangeDraftMessage} onSelectionChange={handleSelectionChange} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 31ad5660983..b916aa17932 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -12,6 +12,7 @@ import type { ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig, + ServerProviderSkill, ThreadId, } from "@t3tools/contracts"; import { formatElapsed } from "@t3tools/shared/orchestrationTiming"; @@ -43,6 +44,7 @@ import { } from "./ThreadComposer"; import { ThreadFeed } from "./ThreadFeed"; import type { ThreadContentPresentation } from "./threadContentPresentation"; +import { useProviderWorkspaceSkills } from "../../state/providerWorkspaceSkillsState"; export interface ThreadDetailScreenProps { readonly selectedThread: OrchestrationThreadShell; @@ -270,12 +272,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const contentMaxWidth = isSplitLayout ? CHAT_CONTENT_MAX_WIDTH : undefined; const selectedInstanceId = props.selectedThread.modelSelection.instanceId; useStreamingHaptics(props.selectedThread.id, props.selectedThreadFeed); - const selectedProviderSkills = useMemo( + const selectedProviderFallbackSkills = useMemo>( () => props.serverConfig?.providers.find((provider) => provider.instanceId === selectedInstanceId) ?.skills ?? [], [props.serverConfig, selectedInstanceId], ); + const selectedProviderWorkspaceSkills = useProviderWorkspaceSkills({ + environmentId: props.environmentId, + instanceId: selectedInstanceId, + cwd: props.threadCwd ?? props.projectWorkspaceRoot, + enabled: true, + fallbackSkills: selectedProviderFallbackSkills, + }); useLayoutEffect(() => { selectedThreadKeyRef.current = selectedThreadKey; @@ -414,7 +423,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread layoutVariant={layoutVariant} usesAutomaticContentInsets={props.usesAutomaticContentInsets} onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} - skills={selectedProviderSkills} + skills={selectedProviderWorkspaceSkills.skills} /> ) : ( @@ -480,6 +489,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread threadSyncPhase={threadSyncPhase} selectedThread={props.selectedThread} serverConfig={props.serverConfig} + workspaceSkills={selectedProviderWorkspaceSkills.skills} + workspaceSkillsIsPending={selectedProviderWorkspaceSkills.isPending} + workspaceSkillsError={selectedProviderWorkspaceSkills.error} queueCount={props.selectedThreadQueueCount} activeThreadBusy={props.activeThreadBusy} environmentId={props.environmentId} diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index ee2bde9d971..a08912e2de2 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -53,8 +53,13 @@ import { setPendingConnectionError, useSavedRemoteConnections, } from "../../state/use-remote-environment-registry"; +import { useProviderWorkspaceSkills } from "../../state/providerWorkspaceSkillsState"; import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { type VcsRef } from "@t3tools/client-runtime/state/vcs"; +import { + promptHasNewTaskProviderSkillReference, + resolveNewTaskProviderSkillsCwd, +} from "./new-task-provider-skills"; type WorkspaceMode = "local" | "worktree"; @@ -381,13 +386,31 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { option.selection.instanceId === selectedModel.instanceId && option.selection.model === selectedModel.model, ) ?? null; - const selectedProviderSkills = useMemo( + const selectedProviderFallbackSkills = useMemo( () => selectedEnvironmentServerConfig?.providers.find( (provider) => provider.instanceId === selectedModel?.instanceId, )?.skills ?? [], [selectedEnvironmentServerConfig, selectedModel?.instanceId], ); + const promptNeedsWorkspaceSkills = useMemo( + () => promptHasNewTaskProviderSkillReference(prompt), + [prompt], + ); + const selectedProviderSkills = useProviderWorkspaceSkills({ + environmentId: selectedProject?.environmentId ?? null, + instanceId: selectedModel?.instanceId ?? null, + // A new-worktree draft has no target directory yet. Do not inspect the + // selected base branch's checkout because its uncommitted skills may not + // exist in the worktree that task creation will materialize. + cwd: resolveNewTaskProviderSkillsCwd({ + workspaceMode, + selectedWorktreePath, + projectWorkspaceRoot: selectedProject?.workspaceRoot ?? null, + }), + enabled: promptNeedsWorkspaceSkills, + fallbackSkills: selectedProviderFallbackSkills, + }).skills; const setSelectedModelKey = useCallback( (key: string | null) => { if (!key || !selectedProjectDraftKey) { diff --git a/apps/mobile/src/features/threads/new-task-provider-skills.test.ts b/apps/mobile/src/features/threads/new-task-provider-skills.test.ts new file mode 100644 index 00000000000..9311a80c5ce --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-provider-skills.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + promptHasNewTaskProviderSkillReference, + resolveNewTaskProviderSkillsCwd, +} from "./new-task-provider-skills"; + +describe("promptHasNewTaskProviderSkillReference", () => { + it("loads workspace metadata only after a skill reference is complete", () => { + expect(promptHasNewTaskProviderSkillReference("Use $review-follow-up")).toBe(false); + expect(promptHasNewTaskProviderSkillReference("Use $review-follow-up next")).toBe(true); + }); + + it("ignores non-skill composer tokens", () => { + expect(promptHasNewTaskProviderSkillReference("Read @AGENTS.md next")).toBe(false); + }); +}); + +describe("resolveNewTaskProviderSkillsCwd", () => { + it("uses the selected checkout only for local tasks", () => { + expect( + resolveNewTaskProviderSkillsCwd({ + workspaceMode: "local", + selectedWorktreePath: "/repo/worktrees/feature", + projectWorkspaceRoot: "/repo", + }), + ).toBe("/repo/worktrees/feature"); + }); + + it("uses the project root when a local task has no alternate checkout", () => { + expect( + resolveNewTaskProviderSkillsCwd({ + workspaceMode: "local", + selectedWorktreePath: null, + projectWorkspaceRoot: "/repo", + }), + ).toBe("/repo"); + }); + + it("uses provider fallback while a future worktree has no cwd", () => { + expect( + resolveNewTaskProviderSkillsCwd({ + workspaceMode: "worktree", + selectedWorktreePath: "/repo/worktrees/existing-feature", + projectWorkspaceRoot: "/repo", + }), + ).toBeNull(); + }); +}); diff --git a/apps/mobile/src/features/threads/new-task-provider-skills.ts b/apps/mobile/src/features/threads/new-task-provider-skills.ts new file mode 100644 index 00000000000..fc0e4e5bb89 --- /dev/null +++ b/apps/mobile/src/features/threads/new-task-provider-skills.ts @@ -0,0 +1,26 @@ +import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; + +export type NewTaskWorkspaceMode = "local" | "worktree"; + +export function promptHasNewTaskProviderSkillReference(prompt: string): boolean { + return collectComposerInlineTokens(prompt).some((token) => token.type === "skill"); +} + +/** + * Resolves the only workspace path whose skills are safe to expose before a + * task exists. A future worktree has no materialized directory yet, so using a + * checkout for its selected base branch could leak uncommitted skills that the + * new worktree will not contain. Returning null keeps the provider snapshot as + * the fallback until the task has a real cwd. + */ +export function resolveNewTaskProviderSkillsCwd(input: { + readonly workspaceMode: NewTaskWorkspaceMode; + readonly selectedWorktreePath: string | null; + readonly projectWorkspaceRoot: string | null; +}): string | null { + if (input.workspaceMode === "worktree") { + return null; + } + + return input.selectedWorktreePath ?? input.projectWorkspaceRoot; +} diff --git a/apps/mobile/src/features/threads/thread-composer-skill-items.test.ts b/apps/mobile/src/features/threads/thread-composer-skill-items.test.ts new file mode 100644 index 00000000000..28f7bb67d70 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-composer-skill-items.test.ts @@ -0,0 +1,52 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildComposerSkillItems } from "./thread-composer-skill-items"; + +function skill( + name: string, + input: Partial> = {}, +): ServerProviderSkill { + return { + name, + path: `/skills/${name}/SKILL.md`, + enabled: true, + ...input, + }; +} + +describe("buildComposerSkillItems", () => { + it("exposes enabled workspace skills and excludes disabled snapshot entries", () => { + const repoLocal = skill("update-worktrees", { displayName: "Update Worktrees" }); + + expect( + buildComposerSkillItems([repoLocal, skill("disabled", { enabled: false })], "$"), + ).toEqual([ + { + id: "skill:update-worktrees", + type: "skill", + skill: repoLocal, + label: "Update Worktrees", + description: "", + }, + ]); + }); + + it("searches workspace skill names, labels, and descriptions", () => { + const releaseNotes = skill("release-notes", { + displayName: "Find Release Notes", + shortDescription: "Assess changelog entries", + }); + const updateWorktrees = skill("update-worktrees", { + displayName: "Update Worktrees", + description: "Refresh active workspaces", + }); + + expect(buildComposerSkillItems([updateWorktrees, releaseNotes], "$release")).toEqual([ + expect.objectContaining({ skill: releaseNotes }), + ]); + expect(buildComposerSkillItems([releaseNotes, updateWorktrees], "workspaces")).toEqual([ + expect.objectContaining({ skill: updateWorktrees }), + ]); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-composer-skill-items.ts b/apps/mobile/src/features/threads/thread-composer-skill-items.ts new file mode 100644 index 00000000000..8e70ca37528 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-composer-skill-items.ts @@ -0,0 +1,94 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; +import { + insertRankedSearchResult, + normalizeSearchQuery, + scoreQueryMatch, +} from "@t3tools/shared/searchRanking"; + +import type { ComposerCommandItem } from "./ComposerCommandPopover"; + +type ComposerSkillCommandItem = Extract; + +function skillCommandItem(skill: ServerProviderSkill): ComposerSkillCommandItem { + return { + id: `skill:${skill.name}`, + type: "skill", + skill, + label: skill.displayName ?? skill.name, + description: skill.shortDescription ?? skill.description ?? "", + }; +} + +export function buildComposerSkillItems( + skills: ReadonlyArray, + query: string, +): Array { + const enabledSkills = skills.filter((skill) => skill.enabled); + const normalizedQuery = normalizeSearchQuery(query, { + trimLeadingPattern: /^\$+/, + }); + + if (!normalizedQuery) { + return enabledSkills.slice(0, 20).map(skillCommandItem); + } + + const ranked: Array<{ + item: ServerProviderSkill; + score: number; + tieBreaker: string; + }> = []; + for (const skill of enabledSkills) { + const displayLabel = (skill.displayName ?? skill.name).toLowerCase(); + const scores = [ + scoreQueryMatch({ + value: skill.name.toLowerCase(), + query: normalizedQuery, + exactBase: 0, + prefixBase: 2, + boundaryBase: 4, + includesBase: 6, + fuzzyBase: 100, + boundaryMarkers: ["-", "_", "/"], + }), + scoreQueryMatch({ + value: displayLabel, + query: normalizedQuery, + exactBase: 1, + prefixBase: 3, + boundaryBase: 5, + includesBase: 7, + fuzzyBase: 110, + }), + scoreQueryMatch({ + value: skill.shortDescription?.toLowerCase() ?? "", + query: normalizedQuery, + exactBase: 20, + prefixBase: 22, + boundaryBase: 24, + includesBase: 26, + }), + scoreQueryMatch({ + value: skill.description?.toLowerCase() ?? "", + query: normalizedQuery, + exactBase: 30, + prefixBase: 32, + boundaryBase: 34, + includesBase: 36, + }), + ].filter((score): score is number => score !== null); + + if (scores.length > 0) { + insertRankedSearchResult( + ranked, + { + item: skill, + score: Math.min(...scores), + tieBreaker: `${displayLabel}\u0000${skill.name}`, + }, + 20, + ); + } + } + + return ranked.map(({ item }) => skillCommandItem(item)); +} diff --git a/apps/mobile/src/state/providerWorkspaceSkillsState.ts b/apps/mobile/src/state/providerWorkspaceSkillsState.ts new file mode 100644 index 00000000000..93d025a654a --- /dev/null +++ b/apps/mobile/src/state/providerWorkspaceSkillsState.ts @@ -0,0 +1,75 @@ +import { + EMPTY_PROVIDER_WORKSPACE_SKILLS, + formatProviderWorkspaceSkillsError, + providerWorkspaceSkillsTargetKey, + resolveNextProviderWorkspaceSkillsSnapshot, + resolveProviderWorkspaceSkills, + type ProviderWorkspaceSkillsSnapshot, + type ProviderWorkspaceSkillsState, + type ProviderWorkspaceSkillsTarget, +} from "@t3tools/client-runtime/state/provider-workspace-skills"; +import { useEffect, useMemo, useRef } from "react"; + +import { useEnvironmentQuery } from "./query"; +import { serverEnvironment } from "./server"; + +export function useProviderWorkspaceSkills( + target: ProviderWorkspaceSkillsTarget, +): ProviderWorkspaceSkillsState { + const stableTarget = useMemo( + () => ({ + environmentId: target.environmentId, + instanceId: target.instanceId, + cwd: target.cwd?.trim() || null, + enabled: target.enabled, + }), + [target.cwd, target.enabled, target.environmentId, target.instanceId], + ); + const key = providerWorkspaceSkillsTargetKey(stableTarget); + const query = useEnvironmentQuery( + key !== null && stableTarget.environmentId !== null && stableTarget.instanceId !== null + ? serverEnvironment.providerSkills({ + environmentId: stableTarget.environmentId, + input: { + instanceId: stableTarget.instanceId, + cwd: stableTarget.cwd!, + }, + }) + : null, + ); + + const previousFallbackSkillsRef = useRef(target.fallbackSkills); + useEffect(() => { + if (previousFallbackSkillsRef.current === target.fallbackSkills) return; + previousFallbackSkillsRef.current = target.fallbackSkills; + if (key !== null) query.refresh(); + }, [key, query, target.fallbackSkills]); + const previousWorkspaceSkillsRef = useRef(null); + const querySkills = query.data?.skills ?? null; + useEffect(() => { + previousWorkspaceSkillsRef.current = resolveNextProviderWorkspaceSkillsSnapshot({ + key, + skills: querySkills, + isPending: query.isPending, + current: previousWorkspaceSkillsRef.current, + }); + }, [key, query.isPending, querySkills]); + + if (key === null) { + return { skills: target.fallbackSkills, isPending: false, error: null }; + } + const previousWorkspaceSkills = previousWorkspaceSkillsRef.current; + return { + skills: resolveProviderWorkspaceSkills({ + nextKey: key, + nextSkills: querySkills, + isPending: query.isPending, + error: query.error, + currentKey: previousWorkspaceSkills?.key ?? null, + currentSkills: previousWorkspaceSkills?.skills ?? EMPTY_PROVIDER_WORKSPACE_SKILLS, + fallbackSkills: target.fallbackSkills, + }), + isPending: query.isPending, + error: formatProviderWorkspaceSkillsError({ error: query.error, cause: query.errorCause }), + }; +} diff --git a/apps/mobile/src/state/query.ts b/apps/mobile/src/state/query.ts index c29d01d397b..a8164b3332f 100644 --- a/apps/mobile/src/state/query.ts +++ b/apps/mobile/src/state/query.ts @@ -10,6 +10,7 @@ const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial(fals export interface EnvironmentQueryView { readonly data: A | null; readonly error: string | null; + readonly errorCause: Cause.Cause | null; readonly isPending: boolean; readonly refresh: () => void; } @@ -30,6 +31,7 @@ export function useEnvironmentQuery( return { data: Option.getOrNull(AsyncResult.value(result)), error: result._tag === "Failure" ? formatError(result.cause) : null, + errorCause: result._tag === "Failure" ? result.cause : null, isPending: atom !== null && result.waiting, refresh, }; diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.test.ts b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts index 183f15d3410..b08c7385b20 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.test.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts @@ -1,14 +1,13 @@ import type { ServerProviderSkill } from "@t3tools/contracts"; import { ServerProviderSkillsListError } from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import { describe, expect, it } from "vite-plus/test"; - import { formatProviderWorkspaceSkillsError, resolveNextProviderWorkspaceSkillsSnapshot, resolvePendingProviderWorkspaceSkills, resolveProviderWorkspaceSkills, -} from "./providerWorkspaceSkillsState"; +} from "@t3tools/client-runtime/state/provider-workspace-skills"; +import * as Cause from "effect/Cause"; +import { describe, expect, it } from "vite-plus/test"; function skill(name: string): ServerProviderSkill { return { diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts index 09c6c51274f..a8eb99bb9c7 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -1,125 +1,22 @@ import { - ServerProviderSkillsListError, - type EnvironmentId, - type ProviderInstanceId, - type ServerProviderSkill, -} from "@t3tools/contracts"; -import * as Cause from "effect/Cause"; -import * as Schema from "effect/Schema"; + EMPTY_PROVIDER_WORKSPACE_SKILLS, + formatProviderWorkspaceSkillsError, + providerWorkspaceSkillsTargetKey, + resolveNextProviderWorkspaceSkillsSnapshot, + resolveProviderWorkspaceSkills, + type ProviderWorkspaceSkillsSnapshot, + type ProviderWorkspaceSkillsState, + type ProviderWorkspaceSkillsTarget, +} from "@t3tools/client-runtime/state/provider-workspace-skills"; import { useEffect, useMemo, useRef } from "react"; import { serverEnvironment } from "../state/server"; import { useEnvironmentQuery } from "../state/query"; -export interface ProviderWorkspaceSkillsTarget { - readonly environmentId: EnvironmentId | null; - readonly instanceId: ProviderInstanceId | null; - readonly cwd: string | null; - readonly enabled: boolean; - readonly fallbackSkills: ReadonlyArray; -} - -export interface ProviderWorkspaceSkillsState { - readonly skills: ReadonlyArray; - readonly isPending: boolean; - readonly error: string | null; -} - -const EMPTY_SKILLS: ReadonlyArray = []; -const isServerProviderSkillsListError = Schema.is(ServerProviderSkillsListError); - -export interface ProviderWorkspaceSkillsSnapshotInput { - readonly currentKey: string | null; - readonly nextKey: string; - readonly currentSkills: ReadonlyArray; -} - -export interface ProviderWorkspaceSkillsResolutionInput extends ProviderWorkspaceSkillsSnapshotInput { - readonly nextSkills: ReadonlyArray | null; - readonly isPending: boolean; - readonly error: string | null; - readonly fallbackSkills: ReadonlyArray; -} - -export interface ProviderWorkspaceSkillsSnapshot { - readonly key: string; - readonly skills: ReadonlyArray; -} - -function targetKey(target: Omit): string | null { - if ( - !target.enabled || - target.environmentId === null || - target.instanceId === null || - target.cwd === null || - target.cwd.trim().length === 0 - ) { - return null; - } - return `${target.environmentId}:${target.instanceId}:${target.cwd.trim()}`; -} - -function providerSkillsListErrorDetail(error: unknown): { - readonly detail: string | null; -} | null { - if (!isServerProviderSkillsListError(error)) return null; - return { - detail: - typeof error.detail === "string" && error.detail.trim().length > 0 ? error.detail : null, - }; -} - -export function formatProviderWorkspaceSkillsError(input: { - readonly error: string | null; - readonly cause: Cause.Cause | null; -}): string | null { - if (input.error === null) return null; - if (input.cause === null) return input.error; - - const providerError = providerSkillsListErrorDetail(Cause.squash(input.cause)); - if (providerError === null || providerError.detail === null) return input.error; - if (input.error.includes(providerError.detail)) return input.error; - return `${input.error} ${providerError.detail}`; -} - export function invalidateProviderWorkspaceSkills(): void { // Workspace skill requests are now owned by the environment query cache. } -export function resolvePendingProviderWorkspaceSkills( - input: ProviderWorkspaceSkillsSnapshotInput, -): ReadonlyArray { - return input.currentKey === input.nextKey && input.currentSkills.length > 0 - ? input.currentSkills - : EMPTY_SKILLS; -} - -/** - * Query result arrays are readonly cache values, so these helpers preserve references - * and rely on callers to keep them immutable. - */ -export function resolveProviderWorkspaceSkills( - input: ProviderWorkspaceSkillsResolutionInput, -): ReadonlyArray { - if (input.nextSkills !== null) { - return input.nextSkills.length > 0 ? input.nextSkills : input.fallbackSkills; - } - if (input.error !== null) return input.fallbackSkills; - if (!input.isPending) return EMPTY_SKILLS; - return resolvePendingProviderWorkspaceSkills(input); -} - -export function resolveNextProviderWorkspaceSkillsSnapshot(input: { - readonly key: string | null; - readonly skills: ReadonlyArray | null; - readonly isPending: boolean; - readonly current: ProviderWorkspaceSkillsSnapshot | null; -}): ProviderWorkspaceSkillsSnapshot | null { - if (input.key === null) return null; - if (input.skills === null) return input.isPending ? input.current : null; - return input.isPending ? input.current : { key: input.key, skills: input.skills }; -} - export function useProviderWorkspaceSkills( target: ProviderWorkspaceSkillsTarget, ): ProviderWorkspaceSkillsState { @@ -132,7 +29,7 @@ export function useProviderWorkspaceSkills( }), [target.cwd, target.enabled, target.environmentId, target.instanceId], ); - const key = targetKey(stableTarget); + const key = providerWorkspaceSkillsTargetKey(stableTarget); const query = useEnvironmentQuery( key !== null && stableTarget.environmentId !== null && stableTarget.instanceId !== null ? serverEnvironment.providerSkills({ @@ -173,7 +70,7 @@ export function useProviderWorkspaceSkills( isPending: query.isPending, error: query.error, currentKey: previousWorkspaceSkills?.key ?? null, - currentSkills: previousWorkspaceSkills?.skills ?? EMPTY_SKILLS, + currentSkills: previousWorkspaceSkills?.skills ?? EMPTY_PROVIDER_WORKSPACE_SKILLS, fallbackSkills: target.fallbackSkills, }), isPending: query.isPending, diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d9e19889721..20d31e14b9a 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -79,6 +79,10 @@ "types": "./src/state/preview.ts", "default": "./src/state/preview.ts" }, + "./state/provider-workspace-skills": { + "types": "./src/state/providerWorkspaceSkills.ts", + "default": "./src/state/providerWorkspaceSkills.ts" + }, "./state/projects": { "types": "./src/state/projects.ts", "default": "./src/state/projects.ts" diff --git a/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts b/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts new file mode 100644 index 00000000000..97ec912ac49 --- /dev/null +++ b/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts @@ -0,0 +1,153 @@ +import { + EnvironmentId, + ProviderInstanceId, + ServerProviderSkillsListError, + type ServerProviderSkill, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import { describe, expect, it } from "vite-plus/test"; + +import { + formatProviderWorkspaceSkillsError, + providerWorkspaceSkillsTargetKey, + resolveNextProviderWorkspaceSkillsSnapshot, + resolveProviderWorkspaceSkills, +} from "./providerWorkspaceSkills.ts"; + +function skill(name: string): ServerProviderSkill { + return { + name, + path: `/skills/${name}/SKILL.md`, + enabled: true, + }; +} + +describe("providerWorkspaceSkillsTargetKey", () => { + it("normalizes an enabled environment, provider, and cwd target", () => { + expect( + providerWorkspaceSkillsTargetKey({ + environmentId: EnvironmentId.make("local"), + instanceId: ProviderInstanceId.make("codex"), + cwd: " /repo/worktree ", + enabled: true, + }), + ).toBe("local:codex:/repo/worktree"); + }); + + it("disables workspace queries without a usable cwd", () => { + expect( + providerWorkspaceSkillsTargetKey({ + environmentId: EnvironmentId.make("local"), + instanceId: ProviderInstanceId.make("codex"), + cwd: " ", + enabled: true, + }), + ).toBeNull(); + }); +}); + +describe("resolveProviderWorkspaceSkills", () => { + it("preserves loaded skills while the same workspace refreshes", () => { + const currentSkills = [skill("repo-local")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "local:codex:/repo", + nextSkills: null, + isPending: true, + error: null, + currentKey: "local:codex:/repo", + currentSkills, + fallbackSkills: [skill("provider-fallback")], + }), + ).toBe(currentSkills); + }); + + it("does not leak skills across workspace switches", () => { + expect( + resolveProviderWorkspaceSkills({ + nextKey: "local:codex:/repo-b", + nextSkills: null, + isPending: true, + error: null, + currentKey: "local:codex:/repo-a", + currentSkills: [skill("repo-a")], + fallbackSkills: [skill("provider-fallback")], + }), + ).toEqual([]); + }); + + it("uses provider snapshot skills for empty and failed workspace responses", () => { + const fallbackSkills = [skill("provider-fallback")]; + const base = { + nextKey: "local:codex:/repo", + currentKey: null, + currentSkills: [], + fallbackSkills, + } as const; + + expect( + resolveProviderWorkspaceSkills({ + ...base, + nextSkills: [], + isPending: false, + error: null, + }), + ).toBe(fallbackSkills); + expect( + resolveProviderWorkspaceSkills({ + ...base, + // Effect AsyncResult failures retain the previous success, so query + // consumers can receive stale data and an error at the same time. + nextSkills: [skill("stale-workspace-skill")], + isPending: false, + error: "Failed to list skills.", + }), + ).toBe(fallbackSkills); + }); +}); + +describe("resolveNextProviderWorkspaceSkillsSnapshot", () => { + it("keeps the settled snapshot during refresh and clears it when disabled", () => { + const current = { + key: "local:codex:/repo", + skills: [skill("repo-local")], + }; + + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: current.key, + skills: [skill("fresh-repo-local")], + isPending: true, + current, + }), + ).toBe(current); + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: null, + skills: current.skills, + isPending: false, + current, + }), + ).toBeNull(); + }); +}); + +describe("formatProviderWorkspaceSkillsError", () => { + it("adds bounded structured detail without exposing a raw cause", () => { + const error = new ServerProviderSkillsListError({ + reason: "invalid-cwd", + operation: "ProviderSkillsLister.normalizeCwd", + message: "Invalid Codex skills cwd '/missing'.", + detail: "Workspace root does not exist: /missing.", + cause: new Error("raw platform detail"), + }); + + expect( + formatProviderWorkspaceSkillsError({ + error: error.message, + cause: Cause.fail(error), + }), + ).toBe("Invalid Codex skills cwd '/missing'. Workspace root does not exist: /missing."); + }); +}); diff --git a/packages/client-runtime/src/state/providerWorkspaceSkills.ts b/packages/client-runtime/src/state/providerWorkspaceSkills.ts new file mode 100644 index 00000000000..a73c1d51ec1 --- /dev/null +++ b/packages/client-runtime/src/state/providerWorkspaceSkills.ts @@ -0,0 +1,119 @@ +import { + ServerProviderSkillsListError, + type EnvironmentId, + type ProviderInstanceId, + type ServerProviderSkill, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Schema from "effect/Schema"; + +export interface ProviderWorkspaceSkillsTarget { + readonly environmentId: EnvironmentId | null; + readonly instanceId: ProviderInstanceId | null; + readonly cwd: string | null; + readonly enabled: boolean; + readonly fallbackSkills: ReadonlyArray; +} + +export interface ProviderWorkspaceSkillsState { + readonly skills: ReadonlyArray; + readonly isPending: boolean; + readonly error: string | null; +} + +export interface ProviderWorkspaceSkillsSnapshotInput { + readonly currentKey: string | null; + readonly nextKey: string; + readonly currentSkills: ReadonlyArray; +} + +export interface ProviderWorkspaceSkillsResolutionInput extends ProviderWorkspaceSkillsSnapshotInput { + readonly nextSkills: ReadonlyArray | null; + readonly isPending: boolean; + readonly error: string | null; + readonly fallbackSkills: ReadonlyArray; +} + +export interface ProviderWorkspaceSkillsSnapshot { + readonly key: string; + readonly skills: ReadonlyArray; +} + +export const EMPTY_PROVIDER_WORKSPACE_SKILLS: ReadonlyArray = []; + +const isServerProviderSkillsListError = Schema.is(ServerProviderSkillsListError); + +export function providerWorkspaceSkillsTargetKey( + target: Omit, +): string | null { + if ( + !target.enabled || + target.environmentId === null || + target.instanceId === null || + target.cwd === null || + target.cwd.trim().length === 0 + ) { + return null; + } + return `${target.environmentId}:${target.instanceId}:${target.cwd.trim()}`; +} + +function providerSkillsListErrorDetail(error: unknown): { + readonly detail: string | null; +} | null { + if (!isServerProviderSkillsListError(error)) return null; + return { + detail: + typeof error.detail === "string" && error.detail.trim().length > 0 ? error.detail : null, + }; +} + +export function formatProviderWorkspaceSkillsError(input: { + readonly error: string | null; + readonly cause: Cause.Cause | null; +}): string | null { + if (input.error === null) return null; + if (input.cause === null) return input.error; + + const providerError = providerSkillsListErrorDetail(Cause.squash(input.cause)); + if (providerError === null || providerError.detail === null) return input.error; + if (input.error.includes(providerError.detail)) return input.error; + return `${input.error} ${providerError.detail}`; +} + +export function resolvePendingProviderWorkspaceSkills( + input: ProviderWorkspaceSkillsSnapshotInput, +): ReadonlyArray { + return input.currentKey === input.nextKey && input.currentSkills.length > 0 + ? input.currentSkills + : EMPTY_PROVIDER_WORKSPACE_SKILLS; +} + +/** + * Query result arrays are readonly cache values, so these helpers preserve references + * and rely on callers to keep them immutable. + */ +export function resolveProviderWorkspaceSkills( + input: ProviderWorkspaceSkillsResolutionInput, +): ReadonlyArray { + // AsyncResult failures can retain a previous success for stale-while-revalidate. + // A failed workspace refresh must still fall back to the provider snapshot rather + // than keeping a stale workspace's skills selectable. + if (input.error !== null) return input.fallbackSkills; + if (input.nextSkills !== null) { + return input.nextSkills.length > 0 ? input.nextSkills : input.fallbackSkills; + } + if (!input.isPending) return EMPTY_PROVIDER_WORKSPACE_SKILLS; + return resolvePendingProviderWorkspaceSkills(input); +} + +export function resolveNextProviderWorkspaceSkillsSnapshot(input: { + readonly key: string | null; + readonly skills: ReadonlyArray | null; + readonly isPending: boolean; + readonly current: ProviderWorkspaceSkillsSnapshot | null; +}): ProviderWorkspaceSkillsSnapshot | null { + if (input.key === null) return null; + if (input.skills === null) return input.isPending ? input.current : null; + return input.isPending ? input.current : { key: input.key, skills: input.skills }; +} From 57fc6cf0d7b696c0306ac94ae5364817fe935e46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Wed, 15 Jul 2026 21:48:52 +0100 Subject: [PATCH 32/55] Surface workspace skill errors with fallbacks - Show lookup failures even when provider snapshot skills remain - Cover the web command menu fallback-and-error rendering path --- apps/web/src/components/chat/ChatComposer.tsx | 1 + .../chat/ComposerCommandMenu.test.tsx | 41 +++++++++++++++++++ .../components/chat/ComposerCommandMenu.tsx | 8 +++- 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/chat/ComposerCommandMenu.test.tsx diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 581f234bae0..24ada1b6ccc 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2270,6 +2270,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerTrigger.query.trim().length === 0 } emptyStateText={composerMenuEmptyState} + errorText={composerTriggerKind === "skill" ? providerWorkspaceSkills.error : null} activeItemId={activeComposerMenuItem?.id ?? null} onHighlightedItemChange={onComposerMenuItemHighlighted} onSelect={onSelectComposerItem} diff --git a/apps/web/src/components/chat/ComposerCommandMenu.test.tsx b/apps/web/src/components/chat/ComposerCommandMenu.test.tsx new file mode 100644 index 00000000000..0d642cfc227 --- /dev/null +++ b/apps/web/src/components/chat/ComposerCommandMenu.test.tsx @@ -0,0 +1,41 @@ +import { ProviderDriverKind, type ServerProviderSkill } from "@t3tools/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vite-plus/test"; + +import { ComposerCommandMenu, type ComposerCommandItem } from "./ComposerCommandMenu"; + +const fallbackSkill: ServerProviderSkill = { + name: "provider-fallback", + path: "/skills/provider-fallback/SKILL.md", + enabled: true, +}; + +const fallbackSkillItem: ComposerCommandItem = { + id: "skill:provider-fallback", + type: "skill", + provider: ProviderDriverKind.make("codex"), + skill: fallbackSkill, + label: "provider-fallback", + description: "Provider snapshot skill", +}; + +describe("ComposerCommandMenu", () => { + it("surfaces workspace skill errors alongside fallback skills", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain('role="alert"'); + expect(markup).toContain("Failed to load workspace skills."); + expect(markup).toContain("provider-fallback"); + }); +}); diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 8354412ce11..3ef2e9625f7 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -110,6 +110,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { triggerKind: ComposerTriggerKind | null; groupSlashCommandSections?: boolean; emptyStateText?: string; + errorText?: string | null; activeItemId: string | null; onHighlightedItemChange: (itemId: string | null) => void; onSelect: (item: ComposerCommandItem) => void; @@ -143,6 +144,11 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { ref={listRef} className="relative w-full overflow-hidden rounded-[20px] border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs" > + {props.errorText ? ( +

+ {props.errorText} +

+ ) : null} {props.items.length > 0 ? ( {groups.map((group, groupIndex) => ( @@ -168,7 +174,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { ))} - ) : ( + ) : props.errorText ? null : (
{props.triggerKind === "skill" ? ( From fa0563c9a2299b2c5d47faa24df89a7ccfd0e86a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 16 Jul 2026 22:43:24 +0100 Subject: [PATCH 33/55] Remove remaining codex skills branch pollution --- apps/server/scripts/acp-mock-agent.ts | 5 -- .../src/provider/Drivers/CursorDriver.ts | 7 +- .../server/src/provider/Drivers/GrokDriver.ts | 7 +- .../provider/Layers/CursorProvider.test.ts | 83 ++++--------------- .../src/provider/Layers/CursorProvider.ts | 16 ++-- .../src/provider/Layers/GrokProvider.test.ts | 48 ----------- .../src/provider/Layers/GrokProvider.ts | 6 +- apps/server/src/ws.ts | 1 + .../src/agentActivity/AgentActivityRows.ts | 29 ++----- .../relay/src/agentActivity/ApnsDeliveries.ts | 5 +- .../agentActivity/ApnsProviderTokens.test.ts | 24 +----- .../src/environments/EnvironmentLinks.test.ts | 36 -------- .../src/environments/EnvironmentLinks.ts | 1 - 13 files changed, 38 insertions(+), 230 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 6255259e739..bc7828dd854 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -13,7 +13,6 @@ import type * as AcpSchema from "effect-acp/schema"; const requestLogPath = process.env.T3_ACP_REQUEST_LOG_PATH; const exitLogPath = process.env.T3_ACP_EXIT_LOG_PATH; -const cwdLogPath = process.env.T3_ACP_CWD_LOG_PATH; const emitToolCalls = process.env.T3_ACP_EMIT_TOOL_CALLS === "1"; const emitInterleavedAssistantToolCalls = process.env.T3_ACP_EMIT_INTERLEAVED_ASSISTANT_TOOL_CALLS === "1"; @@ -48,10 +47,6 @@ const permissionOptionIds = { }; const sessionId = "mock-session-1"; -if (cwdLogPath) { - NodeFS.appendFileSync(cwdLogPath, `${process.cwd()}\n`, "utf8"); -} - let currentModeId = "ask"; let currentModelId = "default"; let parameterizedModelPicker = false; diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index a5df2de9eb2..c394a7d1b43 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -106,7 +106,6 @@ export const CursorDriver: ProviderDriver = { const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; - const serverConfig = yield* ServerConfig; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -131,11 +130,7 @@ export const CursorDriver: ProviderDriver = { }); const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkCursorProviderStatus( - effectiveConfig, - serverConfig.cwd, - processEnv, - ).pipe( + const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe( Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(FileSystem.FileSystem, fileSystem), diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 3ba0396642c..d855d1a4515 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -88,7 +88,6 @@ export const GrokDriver: ProviderDriver = { const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; const eventLoggers = yield* ProviderEventLoggers; - const serverConfig = yield* ServerConfig; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -113,11 +112,7 @@ export const GrokDriver: ProviderDriver = { }); const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkGrokProviderStatus( - effectiveConfig, - serverConfig.cwd, - processEnv, - ).pipe( + const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv).pipe( Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ); diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 6d0492ad369..78f62ac2123 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -429,15 +429,12 @@ describe("buildCursorCapabilitiesFromConfigOptions", () => { describe("checkCursorProviderStatus", () => { it("reports the install docs when the Cursor CLI command is missing", async () => { const provider = await runNode( - checkCursorProviderStatus( - { - enabled: true, - binaryPath: missingCursorBinaryPath, - apiEndpoint: "", - customModels: [], - }, - process.cwd(), - ), + checkCursorProviderStatus({ + enabled: true, + binaryPath: missingCursorBinaryPath, + apiEndpoint: "", + customModels: [], + }), ); expect(provider).toMatchObject({ @@ -459,7 +456,6 @@ describe("checkCursorProviderStatus", () => { apiEndpoint: "", customModels: [], }, - process.cwd(), { ...process.env, T3_ACP_REQUEST_LOG_PATH: requestLogPath, @@ -478,56 +474,16 @@ describe("checkCursorProviderStatus", () => { }); describe("discoverCursorModelsViaAcp", () => { - it("starts the ACP process in the configured cwd", async () => { - const fixture = await runNode( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fileSystem.makeTempDirectory({ - directory: NodeOS.tmpdir(), - prefix: "cursor-provider-cwd-", - }); - const workspaceDirectory = yield* fileSystem.makeTempDirectory({ - directory: NodeOS.tmpdir(), - prefix: "cursor-provider-workspace-", - }); - return { - cwd: yield* fileSystem.realPath(workspaceDirectory), - cwdLogPath: path.join(directory, "cwd.log"), - wrapperPath: yield* makeMockAgentWrapper(), - }; - }), - ); - - await runNode( - discoverCursorModelsViaAcp( - { - enabled: true, - binaryPath: fixture.wrapperPath, - apiEndpoint: "", - customModels: [], - }, - fixture.cwd, - { ...process.env, T3_ACP_CWD_LOG_PATH: fixture.cwdLogPath }, - ).pipe(Effect.provide(NodeServices.layer), Effect.scoped), - ); - - await expect(runNode(waitForFileContent(fixture.cwdLogPath))).resolves.toBe(`${fixture.cwd}\n`); - }); - it("keeps the ACP probe runtime alive long enough to discover models", async () => { const wrapperPath = await runNode(makeMockAgentWrapper()); const models = await runNode( - discoverCursorModelsViaAcp( - { - enabled: true, - binaryPath: wrapperPath, - apiEndpoint: "", - customModels: [], - }, - process.cwd(), - ).pipe(Effect.scoped), + discoverCursorModelsViaAcp({ + enabled: true, + binaryPath: wrapperPath, + apiEndpoint: "", + customModels: [], + }).pipe(Effect.scoped), ); expect(models.map((model) => model.slug)).toEqual([ @@ -544,15 +500,12 @@ describe("discoverCursorModelsViaAcp", () => { ); await runNode( - discoverCursorModelsViaAcp( - { - enabled: true, - binaryPath: wrapperPath, - apiEndpoint: "", - customModels: [], - }, - process.cwd(), - ), + discoverCursorModelsViaAcp({ + enabled: true, + binaryPath: wrapperPath, + apiEndpoint: "", + customModels: [], + }), ); const exitLog = await runNode(waitForFileContent(exitLogPath)); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 54b2eb920af..cd9b93a4734 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -403,7 +403,6 @@ function buildCursorDiscoveredModelsFromAvailableModelsResponse( const makeCursorAcpProbeRuntime = ( cursorSettings: CursorSettings, - cwd: string, environment?: NodeJS.ProcessEnv, ) => Effect.gen(function* () { @@ -416,10 +415,10 @@ const makeCursorAcpProbeRuntime = ( ...(cursorSettings.apiEndpoint ? (["-e", cursorSettings.apiEndpoint] as const) : []), "acp", ], - cwd, + cwd: process.cwd(), ...(environment ? { env: environment } : {}), }, - cwd, + cwd: process.cwd(), clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, authMethodId: "cursor_login", clientCapabilities: CURSOR_PARAMETERIZED_MODEL_PICKER_CAPABILITIES, @@ -432,11 +431,10 @@ const makeCursorAcpProbeRuntime = ( const withCursorAcpProbeRuntime = ( cursorSettings: CursorSettings, - cwd: string, useRuntime: (acp: AcpSessionRuntime.AcpSessionRuntime["Service"]) => Effect.Effect, environment?: NodeJS.ProcessEnv, ) => - makeCursorAcpProbeRuntime(cursorSettings, cwd, environment).pipe( + makeCursorAcpProbeRuntime(cursorSettings, environment).pipe( Effect.flatMap(useRuntime), Effect.scoped, ); @@ -555,12 +553,10 @@ export function resolveCursorAcpConfigUpdates( const discoverCursorModelsViaListAvailableModels = ( cursorSettings: CursorSettings, - cwd: string, environment?: NodeJS.ProcessEnv, ) => withCursorAcpProbeRuntime( cursorSettings, - cwd, (acp) => Effect.gen(function* () { yield* acp.start(); @@ -573,9 +569,8 @@ const discoverCursorModelsViaListAvailableModels = ( export const discoverCursorModelsViaAcp = ( cursorSettings: CursorSettings, - cwd: string, environment?: NodeJS.ProcessEnv, -) => discoverCursorModelsViaListAvailableModels(cursorSettings, cwd, environment); +) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment); export function getCursorFallbackModels( cursorSettings: Pick, @@ -993,7 +988,6 @@ const runCursorAboutCommand = (cursorSettings: CursorSettings, environment?: Nod export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( cursorSettings: CursorSettings, - cwd: string, environment?: NodeJS.ProcessEnv, ): Effect.fn.Return< ServerProviderDraft, @@ -1092,7 +1086,7 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( let discoveryWarning: string | undefined; if (parsed.auth.status !== "unauthenticated") { const discoveryExit = yield* Effect.exit( - discoverCursorModelsViaAcp(cursorSettings, cwd, environment).pipe( + discoverCursorModelsViaAcp(cursorSettings, environment).pipe( Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS), ), ); diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 87f45f01923..000243869c9 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -1,5 +1,3 @@ -import * as NodeOS from "node:os"; - import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; @@ -39,49 +37,6 @@ describe("buildInitialGrokProviderSnapshot", () => { }); it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { - it.effect("starts ACP model discovery in the configured cwd", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-grok-cwd-" }); - const workspaceDirectory = yield* fileSystem.makeTempDirectory({ - directory: NodeOS.tmpdir(), - prefix: "t3code-grok-workspace-", - }); - const cwd = yield* fileSystem.realPath(workspaceDirectory); - const cwdLogPath = path.join(directory, "cwd.log"); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const grokPath = path.join(directory, "grok"); - const command = [process.execPath, mockAgentPath] - .map((argument) => JSON.stringify(argument)) - .join(" "); - yield* fileSystem.writeFileString( - grokPath, - [ - "#!/bin/sh", - 'if [ "$1" = "--version" ]; then', - ' printf "grok-cli 0.0.99\\n"', - " exit 0", - "fi", - `exec ${command} "$@"`, - "", - ].join("\n"), - ); - yield* fileSystem.chmod(grokPath, 0o755); - - const snapshot = yield* checkGrokProviderStatus( - decodeGrokSettings({ enabled: true, binaryPath: grokPath }), - cwd, - { ...process.env, T3_ACP_CWD_LOG_PATH: cwdLogPath }, - ); - - expect(snapshot.status).toBe("ready"); - expect((yield* fileSystem.readFileString(cwdLogPath)).trim()).toBe(cwd); - }).pipe(Effect.scoped), - ); - it.effect("reports the binary as missing when the binary path does not resolve", () => Effect.gen(function* () { const snapshot = yield* checkGrokProviderStatus( @@ -89,7 +44,6 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { enabled: true, binaryPath: "/definitely/not/installed/grok-binary", }), - process.cwd(), ); expect(snapshot.enabled).toBe(true); expect(snapshot.installed).toBe(false); @@ -115,7 +69,6 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { return yield* checkGrokProviderStatus( decodeGrokSettings({ enabled: true, binaryPath: grokPath }), - process.cwd(), ); }), ); @@ -144,7 +97,6 @@ it.layer(NodeServices.layer)("checkGrokProviderStatus", (it) => { return yield* checkGrokProviderStatus( decodeGrokSettings({ enabled: true, binaryPath: grokPath }), - process.cwd(), ); }), ); diff --git a/apps/server/src/provider/Layers/GrokProvider.ts b/apps/server/src/provider/Layers/GrokProvider.ts index 8fcd47a3e03..cf5d5ad9c8d 100644 --- a/apps/server/src/provider/Layers/GrokProvider.ts +++ b/apps/server/src/provider/Layers/GrokProvider.ts @@ -131,7 +131,6 @@ function buildGrokDiscoveredModelsFromSessionModelState( const discoverGrokModelsViaAcp = ( grokSettings: GrokSettings, - cwd: string, environment: NodeJS.ProcessEnv = process.env, ) => Effect.gen(function* () { @@ -140,7 +139,7 @@ const discoverGrokModelsViaAcp = ( grokSettings, environment, childProcessSpawner, - cwd, + cwd: process.cwd(), clientInfo: { name: "t3-code-provider-probe", version: "0.0.0" }, }); const started = yield* acp.start(); @@ -167,7 +166,6 @@ const runGrokVersionCommand = ( export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(function* ( grokSettings: GrokSettings, - cwd: string, environment: NodeJS.ProcessEnv = process.env, ): Effect.fn.Return { const checkedAt = DateTime.formatIso(yield* DateTime.now); @@ -255,7 +253,7 @@ export const checkGrokProviderStatus = Effect.fn("checkGrokProviderStatus")(func }); } - const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, cwd, environment).pipe( + const discoveryExit = yield* discoverGrokModelsViaAcp(grokSettings, environment).pipe( Effect.timeoutOption(GROK_ACP_MODEL_DISCOVERY_TIMEOUT_MS), Effect.exit, ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 53cec8d5702..270a9db35cf 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -121,6 +121,7 @@ import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); + const nowIso = Effect.map(DateTime.now, DateTime.formatIso); function unexpectedCompatibilityError(error: never): never { diff --git a/infra/relay/src/agentActivity/AgentActivityRows.ts b/infra/relay/src/agentActivity/AgentActivityRows.ts index ad184166044..14245075e10 100644 --- a/infra/relay/src/agentActivity/AgentActivityRows.ts +++ b/infra/relay/src/agentActivity/AgentActivityRows.ts @@ -7,7 +7,7 @@ import * as Function from "effect/Function"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { and, desc, eq, isNull, sql } from "drizzle-orm"; +import { and, desc, eq, isNull, lt, sql } from "drizzle-orm"; import * as RelayDb from "../db.ts"; import { relayAgentActivityRows, relayEnvironmentLinks } from "../persistence/schema.ts"; @@ -102,8 +102,6 @@ const decodeRelayAgentActivityStateJson = Schema.decodeUnknownOption( Schema.fromJsonString(RelayAgentActivityStateSchema), ); -const TERMINAL_PRUNE_BATCH_SIZE = 500; - export const make = Effect.gen(function* () { const db = yield* RelayDb.RelayDb; @@ -188,26 +186,15 @@ export const make = Effect.gen(function* () { pruneTerminal: Effect.fn("relay.agent_activity_rows.prune_terminal")(function* (input) { yield* Effect.annotateCurrentSpan({ "relay.agent_activity_prune.before": input.updatedBefore, - "relay.agent_activity_prune.batch_size": TERMINAL_PRUNE_BATCH_SIZE, }); - // Bound each cron invocation so a large backlog cannot hold row locks in - // one long transaction. SKIP LOCKED also lets multiple worker instances - // prune disjoint batches without waiting on one another. yield* db - .execute(sql` - WITH terminal_rows AS ( - SELECT ctid - FROM ${relayAgentActivityRows} - WHERE ${relayAgentActivityRows.stateJson} ->> 'phase' IN ('completed', 'failed') - AND ${relayAgentActivityRows.updatedAt} < ${input.updatedBefore} - ORDER BY ${relayAgentActivityRows.updatedAt} - LIMIT ${TERMINAL_PRUNE_BATCH_SIZE} - FOR UPDATE SKIP LOCKED - ) - DELETE FROM ${relayAgentActivityRows} - USING terminal_rows - WHERE ${relayAgentActivityRows}.ctid = terminal_rows.ctid - `) + .delete(relayAgentActivityRows) + .where( + and( + sql`${relayAgentActivityRows.stateJson} ->> 'phase' IN ('completed', 'failed')`, + lt(relayAgentActivityRows.updatedAt, input.updatedBefore), + ), + ) .pipe( Effect.mapError( (cause) => diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts index 298bc45c70a..9db9be3f168 100644 --- a/infra/relay/src/agentActivity/ApnsDeliveries.ts +++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts @@ -9,7 +9,6 @@ import { RelayAgentAwarenessPreferences as RelayAgentAwarenessPreferencesSchema, RelayDeliveryKind as RelayDeliveryKindSchema, } from "@t3tools/contracts/relay"; -import { stableStringify } from "@t3tools/shared/relaySigning"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -298,9 +297,7 @@ function shouldUpdateLiveActivity(input: { if (!input.previousAggregate) { return true; } - // PostgreSQL jsonb does not preserve object key order, so compare canonical - // encodings instead of treating insertion order as an aggregate change. - if (stableStringify(input.previousAggregate) === stableStringify(input.nextAggregate)) { + if (JSON.stringify(input.previousAggregate) === JSON.stringify(input.nextAggregate)) { return false; } if (input.previousAggregate.activeCount !== input.nextAggregate.activeCount) { diff --git a/infra/relay/src/agentActivity/ApnsProviderTokens.test.ts b/infra/relay/src/agentActivity/ApnsProviderTokens.test.ts index 8cfb2726877..d98b9f920be 100644 --- a/infra/relay/src/agentActivity/ApnsProviderTokens.test.ts +++ b/infra/relay/src/agentActivity/ApnsProviderTokens.test.ts @@ -7,7 +7,7 @@ import * as Schema from "effect/Schema"; import * as ApnsProviderTokens from "./ApnsProviderTokens.ts"; -const { privateKey, publicKey } = NodeCrypto.generateKeyPairSync("ec", { +const { privateKey } = NodeCrypto.generateKeyPairSync("ec", { namedCurve: "prime256v1", privateKeyEncoding: { type: "pkcs8", format: "pem" }, publicKeyEncoding: { type: "spki", format: "pem" }, @@ -60,28 +60,6 @@ describe("ApnsProviderTokens", () => { }).pipe(Effect.provide(ApnsProviderTokens.layer)); }); - it.effect("produces an APNs-compatible ES256 signature", () => { - ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); - return Effect.gen(function* () { - const tokens = yield* ApnsProviderTokens.ApnsProviderTokens; - const jwt = yield* tokens.getJwt({ ...signingInput, issuedAtUnixSeconds: WINDOW + 10 }); - const [header, payload, signature] = jwt.split("."); - - expect(header).toBeDefined(); - expect(payload).toBeDefined(); - expect(signature).toBeDefined(); - expect( - NodeCrypto.verify( - "sha256", - Buffer.from(`${header}.${payload}`), - { key: publicKey, dsaEncoding: "ieee-p1363" }, - Buffer.from(signature!, "base64url"), - ), - ).toBe(true); - ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); - }).pipe(Effect.provide(ApnsProviderTokens.layer)); - }); - it.effect("serves repeat pushes from the isolate cache without re-signing", () => { ApnsProviderTokens.__resetApnsProviderTokenCacheForTest(); return Effect.gen(function* () { diff --git a/infra/relay/src/environments/EnvironmentLinks.test.ts b/infra/relay/src/environments/EnvironmentLinks.test.ts index 3994183b2e3..dccb9e39f60 100644 --- a/infra/relay/src/environments/EnvironmentLinks.test.ts +++ b/infra/relay/src/environments/EnvironmentLinks.test.ts @@ -116,42 +116,6 @@ describe("EnvironmentLinks", () => { ); }); - it.effect("lists only active managed links for client discovery", () => { - const whereConditions: Array = []; - const fakeDb = { - select: (selection: unknown) => { - expect(selection).toBeDefined(); - return { - from: (table: unknown) => { - expect(table).toBe(relayEnvironmentLinks); - return { - where: (condition: unknown) => { - whereConditions.push(condition); - return Effect.succeed([]); - }, - }; - }, - }; - }, - } as unknown as RelayDb.RelayDb["Service"]; - - return Effect.gen(function* () { - const links = yield* EnvironmentLinks.EnvironmentLinks; - expect(yield* links.listForUser({ userId: "user-1" })).toEqual([]); - expect(whereConditions).toHaveLength(1); - - const query = new PgDialect().sqlToQuery(whereConditions[0] as never); - expect(query.sql).toContain('"relay_environment_links"."user_id" = $1'); - expect(query.sql).toContain('"relay_environment_links"."revoked_at" is null'); - expect(query.sql).toContain('"relay_environment_links"."managed_tunnels_enabled" = $2'); - expect(query.params).toEqual(["user-1", true]); - }).pipe( - Effect.provide( - EnvironmentLinks.layer.pipe(Layer.provide(Layer.succeed(RelayDb.RelayDb, fakeDb))), - ), - ); - }); - it.effect("revokes only the active link owned by the requesting user", () => { const updateValues: Array> = []; const whereConditions: Array = []; diff --git a/infra/relay/src/environments/EnvironmentLinks.ts b/infra/relay/src/environments/EnvironmentLinks.ts index 3ef9b954df6..6630af0a11b 100644 --- a/infra/relay/src/environments/EnvironmentLinks.ts +++ b/infra/relay/src/environments/EnvironmentLinks.ts @@ -314,7 +314,6 @@ const make = Effect.gen(function* () { and( eq(relayEnvironmentLinks.userId, input.userId), isNull(relayEnvironmentLinks.revokedAt), - eq(relayEnvironmentLinks.managedTunnelsEnabled, true), ), ) .pipe( From 0f8bc68edd62f554d8e4dfa0a275360357cc6338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 17 Jul 2026 19:29:58 +0100 Subject: [PATCH 34/55] Add Codex skill loading branch details - Document expected workspace-aware skill behavior - Record primary files, focused tests, and development ports --- BRANCH_DETAILS.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 BRANCH_DETAILS.md diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md new file mode 100644 index 00000000000..d6f3e81bd82 --- /dev/null +++ b/BRANCH_DETAILS.md @@ -0,0 +1,61 @@ +# Codex Workspace Skill Loading + +Fix Codex repo-local skill discovery in the composer by resolving skills for the active project/worktree cwd, instead of relying on the global provider status snapshot. + +Expected behavior: + +- Repo-local Codex skills for the active workspace appear in the `$` skill picker. +- The server exposes a workspace-aware `server.listProviderSkills` path and validates enabled Codex skill-listing requests against the requested cwd. +- The server routes skill listing through a bounded request lister that coalesces concurrent requests for the same provider/cwd, limits cross-workspace concurrency, and applies a short TTL so reconnects or repeated composer renders do not repeatedly spawn Codex app-server probes. +- The Codex provider requests `skills/list` with the current workspace cwd, times out hung app-server probes, and terminates the probe process when a timeout occurs. +- Provider skill-list failures preserve structured reason, operation, provider instance, normalized cwd, and bounded cause diagnostics for missing providers, invalid cwd, settings failures, Codex home preparation, probe timeouts, and probe failures while keeping stable user-facing messages. Raw thrown values are not sent directly to clients; the server keeps a small plain diagnostic shape so file paths, process output, and unexpected objects do not expand the wire payload. +- Non-Codex or disabled providers keep returning provider snapshot skills instead of failing workspace skill search. +- The client runtime keys provider-skill query state by environment, provider instance, and cwd, with a bounded stale window so reconnects refresh workspace-local skills without reusing another workspace's snapshot. +- The composer loads workspace skills lazily: it starts workspace skill discovery when the `$` skill menu is active or the prompt already contains a complete `$skill` token, rather than probing on every empty composer mount. It preserves already loaded repo-local skills while refreshing the same workspace, falls back to provider snapshot skills when a settled workspace lookup returns no skills or errors, keeps structured lookup errors visible alongside those fallback skills on web and mobile, and clears stale repo-local skills during workspace switches or settled no-data states. +- The conversation timeline renders sent user prompts against the same workspace-aware skill list as the composer, so repo-local `$skill-name` references display with the same skill chip treatment as user-level skills. +- The shared client-runtime policy also drives mobile thread composers and feeds. Mobile shows loading and structured error feedback, refreshes an already-open `$` menu when skills arrive, decorates complete skill references, and prevents stale successful results from surviving a failed refresh. +- New-task drafts request workspace skills lazily only after a complete `$skill` reference. Local drafts resolve the selected checkout or project root, while future-worktree drafts deliberately have no cwd and use provider snapshots until the target directory exists. + +Primary files: + +- `apps/server/src/ws.ts` +- `apps/server/src/provider/ProviderSkillsLister.ts` +- `apps/server/src/provider/Layers/CodexProvider.ts` +- `apps/web/src/components/ChatView.tsx` +- `apps/web/src/components/chat/ChatComposer.tsx` +- `apps/web/src/components/chat/MessagesTimeline.tsx` +- `apps/web/src/lib/providerWorkspaceSkillsState.ts` +- `apps/mobile/src/state/providerWorkspaceSkillsState.ts` +- `apps/mobile/src/features/threads/new-task-provider-skills.ts` +- `apps/mobile/src/features/threads/thread-composer-skill-items.ts` +- `packages/client-runtime/src/state/providerWorkspaceSkills.ts` +- `packages/contracts/src/server.ts` +- `packages/client-runtime/src/state/server.ts` + +Relevant tests live in: + +- `apps/server/src/server.test.ts` +- `apps/server/src/provider/ProviderSkillsLister.test.ts` +- `apps/server/src/provider/Layers/CodexProvider.test.ts` +- `apps/server/src/provider/Layers/CursorProvider.test.ts` +- `apps/server/src/provider/Layers/GrokProvider.test.ts` +- `apps/web/src/components/chat/MessagesTimeline.test.tsx` +- `apps/web/src/lib/providerWorkspaceSkillsState.test.ts` +- `apps/mobile/src/features/threads/new-task-provider-skills.test.ts` +- `apps/mobile/src/features/threads/thread-composer-skill-items.test.ts` +- `packages/client-runtime/src/state/providerWorkspaceSkills.test.ts` +- `packages/client-runtime/src/state/runtime.test.ts` + +Useful focused commands: + +```sh +(cd apps/server && pnpm exec vp test run --passWithNoTests src/provider/ProviderSkillsLister.test.ts src/provider/Layers/CodexProvider.test.ts src/provider/Layers/CursorProvider.test.ts src/provider/Layers/GrokProvider.test.ts) +(cd apps/web && pnpm exec vp test run --passWithNoTests --project unit src/lib/providerWorkspaceSkillsState.test.ts) +(cd apps/mobile && pnpm exec vp test run --passWithNoTests src/features/threads/new-task-provider-skills.test.ts src/features/threads/thread-composer-skill-items.test.ts) +(cd packages/client-runtime && pnpm exec vp test run --passWithNoTests src/state/providerWorkspaceSkills.test.ts) +``` + +## Development Ports + +- Web: `5735` +- Server/WebSocket: `13775` From aa4201352a322dc23502eb7cdfbe5f2354dfa875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 18 Jul 2026 23:42:44 +0100 Subject: [PATCH 35/55] Gate timeline skill loading on sent skill references - Avoid workspace skill probes for empty drafts and unrelated messages - Add focused coverage for complete user skill tokens - Refresh branch details after merging upstream main --- BRANCH_DETAILS.md | 18 ++++++++++++++- .../web/src/components/ChatView.logic.test.ts | 22 +++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 9 ++++++++ apps/web/src/components/ChatView.tsx | 7 +++++- 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index d6f3e81bd82..c15972f1c80 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -1,5 +1,12 @@ # Codex Workspace Skill Loading +Generated from: + +- Branch: `fix/codex-skills` at `6b04a4cd0731c7f114d85d9d94ed2fb99787d3af`. +- Upstream: `upstream/main` at `1735e27d9e5106bbb35d5b1dd10363604a54b69e`. +- Ahead/behind upstream: `55` / `0` commits. +- Branch diff against upstream: `42 files`, `2,407 insertions`, `144 deletions` (`upstream/main...HEAD`, including this branch-details refresh). + Fix Codex repo-local skill discovery in the composer by resolving skills for the active project/worktree cwd, instead of relying on the global provider status snapshot. Expected behavior: @@ -12,7 +19,7 @@ Expected behavior: - Non-Codex or disabled providers keep returning provider snapshot skills instead of failing workspace skill search. - The client runtime keys provider-skill query state by environment, provider instance, and cwd, with a bounded stale window so reconnects refresh workspace-local skills without reusing another workspace's snapshot. - The composer loads workspace skills lazily: it starts workspace skill discovery when the `$` skill menu is active or the prompt already contains a complete `$skill` token, rather than probing on every empty composer mount. It preserves already loaded repo-local skills while refreshing the same workspace, falls back to provider snapshot skills when a settled workspace lookup returns no skills or errors, keeps structured lookup errors visible alongside those fallback skills on web and mobile, and clears stale repo-local skills during workspace switches or settled no-data states. -- The conversation timeline renders sent user prompts against the same workspace-aware skill list as the composer, so repo-local `$skill-name` references display with the same skill chip treatment as user-level skills. +- The conversation timeline renders sent user prompts against the same workspace-aware skill list as the composer, so repo-local `$skill-name` references display with the same skill chip treatment as user-level skills. Timeline lookup stays disabled until a sent user prompt contains a complete skill token, so an empty draft does not probe Codex merely to decorate nonexistent messages. - The shared client-runtime policy also drives mobile thread composers and feeds. Mobile shows loading and structured error feedback, refreshes an already-open `$` menu when skills arrive, decorates complete skill references, and prevents stale successful results from surviving a failed refresh. - New-task drafts request workspace skills lazily only after a complete `$skill` reference. Local drafts resolve the selected checkout or project root, while future-worktree drafts deliberately have no cwd and use provider snapshots until the target directory exists. @@ -46,6 +53,15 @@ Relevant tests live in: - `packages/client-runtime/src/state/providerWorkspaceSkills.test.ts` - `packages/client-runtime/src/state/runtime.test.ts` +Latest upstream merge compatibility: + +- The merge of `upstream/main` at `1735e27d9e5106bbb35d5b1dd10363604a54b69e` completed without conflicts; the merge itself introduced no conflict-only fork customization and none of the workspace-skill behavior was retired. +- `apps/server/src/provider/Drivers/CodexHomeLayout.ts` now shares `mcp-oauth-locks` across Codex shadow homes. Workspace skill probes continue to use the prepared Codex home, so they inherit the shared OAuth lock without a separate branch-only path. +- `apps/server/src/ws.ts` now buffers live thread events before snapshot/replay reads. The workspace-aware `server.listProviderSkills` route remains independently wired through `ProviderSkillsLister`. +- `apps/web/src/components/ChatView.tsx`, `apps/web/src/components/chat/ChatComposer.tsx`, and `apps/web/src/components/chat/MessagesTimeline.tsx` now include the upstream draft hero, active-turn send, project-selection, paste, and empty-timeline behavior while retaining workspace-skill query state, skill-token decoration, and timeline skill chips. +- The upstream draft hero mounts `ChatView` for empty drafts; timeline skill discovery is therefore gated by complete skill references in sent user messages, while opening the composer `$` menu still starts its independent workspace lookup. +- Upstream mobile composer and branding changes auto-merged around the shared client-runtime skill policy; mobile workspace-skill loading, feedback, and token decoration remain branch-owned behavior. + Useful focused commands: ```sh diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index bc7487cee29..34e274ef2c2 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -22,6 +22,7 @@ import { reconcileRetainedMountedThreadIds, resolveSendEnvMode, shouldWriteThreadErrorToCurrentServerThread, + timelineMessagesHaveComposerSkillReference, } from "./ChatView.logic"; const environmentId = EnvironmentId.make("environment-local"); @@ -101,6 +102,27 @@ describe("buildThreadTurnInterruptInput", () => { }); }); +describe("timelineMessagesHaveComposerSkillReference", () => { + it("keeps empty drafts and unrelated messages from requesting workspace skills", () => { + expect(timelineMessagesHaveComposerSkillReference([])).toBe(false); + expect( + timelineMessagesHaveComposerSkillReference([ + { role: "assistant", text: "Try $repo-skill next." }, + { role: "user", text: "Inspect @AGENTS.md" }, + { role: "user", text: "Maybe use $repo-skill" }, + ]), + ).toBe(false); + }); + + it("requests workspace skills when a sent user prompt contains a complete skill token", () => { + expect( + timelineMessagesHaveComposerSkillReference([ + { role: "user", text: "Use $repo-skill to inspect this." }, + ]), + ).toBe(true); + }); +}); + describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { const state = deriveComposerSendState({ diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 325f9afa90a..9a1fceea9e2 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -20,6 +20,7 @@ import { type TerminalContextDraft, } from "../lib/terminalContext"; import type { DraftThreadEnvMode } from "../composerDraftStore"; +import { promptHasComposerSkillReference } from "../composer-editor-mentions"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; @@ -164,6 +165,14 @@ export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[ return previewUrls; } +export function timelineMessagesHaveComposerSkillReference( + messages: ReadonlyArray>, +): boolean { + return messages.some( + (message) => message.role === "user" && promptHasComposerSkillReference(message.text ?? ""), + ); +} + export interface PullRequestDialogState { initialReference: string | null; key: number; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4fdfe93902c..3b540e9b056 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -244,6 +244,7 @@ import { resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, + timelineMessagesHaveComposerSkillReference, waitForStartedServerThread, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; @@ -2291,11 +2292,15 @@ function ChatViewContent(props: ChatViewProps) { return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); const activeProviderFallbackSkills = activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS; + const timelineHasSkillReference = useMemo( + () => timelineMessagesHaveComposerSkillReference(timelineMessages), + [timelineMessages], + ); const activeProviderWorkspaceSkills = useProviderWorkspaceSkills({ environmentId, instanceId: activeProviderStatus?.instanceId ?? null, cwd: gitCwd, - enabled: true, + enabled: timelineHasSkillReference, fallbackSkills: activeProviderFallbackSkills, }); const activeProjectCwd = activeProject?.workspaceRoot ?? null; From 218303202f9369a331e3d812247799a95c4cbf53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sat, 18 Jul 2026 23:55:39 +0100 Subject: [PATCH 36/55] Recognize trailing skills in timeline loading - Share sent-message skill parsing with timeline rendering - Cover skill references at the end of sent prompts --- BRANCH_DETAILS.md | 2 +- .../web/src/components/ChatView.logic.test.ts | 5 +++- apps/web/src/components/ChatView.logic.ts | 4 ++-- .../src/components/chat/SkillInlineText.tsx | 9 ++------ .../src/components/chat/skillInlineTokens.ts | 23 +++++++++++++++++++ 5 files changed, 32 insertions(+), 11 deletions(-) create mode 100644 apps/web/src/components/chat/skillInlineTokens.ts diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index c15972f1c80..434cd9a67f6 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -19,7 +19,7 @@ Expected behavior: - Non-Codex or disabled providers keep returning provider snapshot skills instead of failing workspace skill search. - The client runtime keys provider-skill query state by environment, provider instance, and cwd, with a bounded stale window so reconnects refresh workspace-local skills without reusing another workspace's snapshot. - The composer loads workspace skills lazily: it starts workspace skill discovery when the `$` skill menu is active or the prompt already contains a complete `$skill` token, rather than probing on every empty composer mount. It preserves already loaded repo-local skills while refreshing the same workspace, falls back to provider snapshot skills when a settled workspace lookup returns no skills or errors, keeps structured lookup errors visible alongside those fallback skills on web and mobile, and clears stale repo-local skills during workspace switches or settled no-data states. -- The conversation timeline renders sent user prompts against the same workspace-aware skill list as the composer, so repo-local `$skill-name` references display with the same skill chip treatment as user-level skills. Timeline lookup stays disabled until a sent user prompt contains a complete skill token, so an empty draft does not probe Codex merely to decorate nonexistent messages. +- The conversation timeline renders sent user prompts against the same workspace-aware skill list as the composer, so repo-local `$skill-name` references display with the same skill chip treatment as user-level skills. Timeline lookup stays disabled until a sent user prompt contains a complete skill token, including one at the end of the message, so an empty draft does not probe Codex merely to decorate nonexistent messages. - The shared client-runtime policy also drives mobile thread composers and feeds. Mobile shows loading and structured error feedback, refreshes an already-open `$` menu when skills arrive, decorates complete skill references, and prevents stale successful results from surviving a failed refresh. - New-task drafts request workspace skills lazily only after a complete `$skill` reference. Local drafts resolve the selected checkout or project root, while future-worktree drafts deliberately have no cwd and use provider snapshots until the target directory exists. diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 34e274ef2c2..44015542910 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -109,7 +109,7 @@ describe("timelineMessagesHaveComposerSkillReference", () => { timelineMessagesHaveComposerSkillReference([ { role: "assistant", text: "Try $repo-skill next." }, { role: "user", text: "Inspect @AGENTS.md" }, - { role: "user", text: "Maybe use $repo-skill" }, + { role: "user", text: "Maybe use $repo-skill?" }, ]), ).toBe(false); }); @@ -120,6 +120,9 @@ describe("timelineMessagesHaveComposerSkillReference", () => { { role: "user", text: "Use $repo-skill to inspect this." }, ]), ).toBe(true); + expect( + timelineMessagesHaveComposerSkillReference([{ role: "user", text: "Use $repo-skill" }]), + ).toBe(true); }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 9a1fceea9e2..4bb6aac29ac 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -20,7 +20,7 @@ import { type TerminalContextDraft, } from "../lib/terminalContext"; import type { DraftThreadEnvMode } from "../composerDraftStore"; -import { promptHasComposerSkillReference } from "../composer-editor-mentions"; +import { parseInlineSkillTokens } from "./chat/skillInlineTokens"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; @@ -169,7 +169,7 @@ export function timelineMessagesHaveComposerSkillReference( messages: ReadonlyArray>, ): boolean { return messages.some( - (message) => message.role === "user" && promptHasComposerSkillReference(message.text ?? ""), + (message) => message.role === "user" && parseInlineSkillTokens(message.text ?? "").length > 0, ); } diff --git a/apps/web/src/components/chat/SkillInlineText.tsx b/apps/web/src/components/chat/SkillInlineText.tsx index 444f35b425c..04f931b5f51 100644 --- a/apps/web/src/components/chat/SkillInlineText.tsx +++ b/apps/web/src/components/chat/SkillInlineText.tsx @@ -9,8 +9,7 @@ import { SKILL_CHIP_ICON_SVG, } from "../composerInlineChip"; import { cn } from "~/lib/utils"; - -const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; +import { parseInlineSkillTokens } from "./skillInlineTokens"; type InlineSkill = Pick; @@ -18,11 +17,7 @@ export function SkillInlineText(props: { text: string; skills: ReadonlyArray candidate.name === name); if (!skill) { continue; diff --git a/apps/web/src/components/chat/skillInlineTokens.ts b/apps/web/src/components/chat/skillInlineTokens.ts new file mode 100644 index 00000000000..9d03af88aab --- /dev/null +++ b/apps/web/src/components/chat/skillInlineTokens.ts @@ -0,0 +1,23 @@ +const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; + +export interface InlineSkillToken { + name: string; + rawText: string; + start: number; +} + +export function parseInlineSkillTokens(text: string): InlineSkillToken[] { + const tokens: InlineSkillToken[] = []; + + for (const match of text.matchAll(SKILL_TOKEN_REGEX)) { + const prefix = match[1] ?? ""; + const name = match[2] ?? ""; + tokens.push({ + name, + rawText: `$${name}`, + start: (match.index ?? 0) + prefix.length, + }); + } + + return tokens; +} From b1af7c38eb0abcc034439bec4181c588834e5e9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sun, 19 Jul 2026 00:04:16 +0100 Subject: [PATCH 37/55] Improve timeline skill reference detection - Recognize skill tokens followed by punctuation - Cache sent-message matches and avoid token-array allocation - Remove stale branch snapshot metadata --- BRANCH_DETAILS.md | 16 ----------- .../web/src/components/ChatView.logic.test.ts | 27 +++++++++++++------ apps/web/src/components/ChatView.logic.ts | 16 +++++++---- apps/web/src/components/ChatView.tsx | 9 +++++-- .../components/chat/MessagesTimeline.test.tsx | 2 +- .../src/components/chat/skillInlineTokens.ts | 11 ++++++-- 6 files changed, 47 insertions(+), 34 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 434cd9a67f6..31f7a251f9a 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -1,12 +1,5 @@ # Codex Workspace Skill Loading -Generated from: - -- Branch: `fix/codex-skills` at `6b04a4cd0731c7f114d85d9d94ed2fb99787d3af`. -- Upstream: `upstream/main` at `1735e27d9e5106bbb35d5b1dd10363604a54b69e`. -- Ahead/behind upstream: `55` / `0` commits. -- Branch diff against upstream: `42 files`, `2,407 insertions`, `144 deletions` (`upstream/main...HEAD`, including this branch-details refresh). - Fix Codex repo-local skill discovery in the composer by resolving skills for the active project/worktree cwd, instead of relying on the global provider status snapshot. Expected behavior: @@ -53,15 +46,6 @@ Relevant tests live in: - `packages/client-runtime/src/state/providerWorkspaceSkills.test.ts` - `packages/client-runtime/src/state/runtime.test.ts` -Latest upstream merge compatibility: - -- The merge of `upstream/main` at `1735e27d9e5106bbb35d5b1dd10363604a54b69e` completed without conflicts; the merge itself introduced no conflict-only fork customization and none of the workspace-skill behavior was retired. -- `apps/server/src/provider/Drivers/CodexHomeLayout.ts` now shares `mcp-oauth-locks` across Codex shadow homes. Workspace skill probes continue to use the prepared Codex home, so they inherit the shared OAuth lock without a separate branch-only path. -- `apps/server/src/ws.ts` now buffers live thread events before snapshot/replay reads. The workspace-aware `server.listProviderSkills` route remains independently wired through `ProviderSkillsLister`. -- `apps/web/src/components/ChatView.tsx`, `apps/web/src/components/chat/ChatComposer.tsx`, and `apps/web/src/components/chat/MessagesTimeline.tsx` now include the upstream draft hero, active-turn send, project-selection, paste, and empty-timeline behavior while retaining workspace-skill query state, skill-token decoration, and timeline skill chips. -- The upstream draft hero mounts `ChatView` for empty drafts; timeline skill discovery is therefore gated by complete skill references in sent user messages, while opening the composer `$` menu still starts its independent workspace lookup. -- Upstream mobile composer and branding changes auto-merged around the shared client-runtime skill policy; mobile workspace-skill loading, feedback, and token decoration remain branch-owned behavior. - Useful focused commands: ```sh diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 44015542910..61befcf28e8 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -22,7 +22,7 @@ import { reconcileRetainedMountedThreadIds, resolveSendEnvMode, shouldWriteThreadErrorToCurrentServerThread, - timelineMessagesHaveComposerSkillReference, + timelineMessagesHaveCompleteSkillReference, } from "./ChatView.logic"; const environmentId = EnvironmentId.make("environment-local"); @@ -102,26 +102,37 @@ describe("buildThreadTurnInterruptInput", () => { }); }); -describe("timelineMessagesHaveComposerSkillReference", () => { +describe("timelineMessagesHaveCompleteSkillReference", () => { it("keeps empty drafts and unrelated messages from requesting workspace skills", () => { - expect(timelineMessagesHaveComposerSkillReference([])).toBe(false); + expect(timelineMessagesHaveCompleteSkillReference([])).toBe(false); expect( - timelineMessagesHaveComposerSkillReference([ - { role: "assistant", text: "Try $repo-skill next." }, + timelineMessagesHaveCompleteSkillReference([ { role: "user", text: "Inspect @AGENTS.md" }, - { role: "user", text: "Maybe use $repo-skill?" }, + { role: "user", text: "$" }, + { role: "user", text: "$123invalid" }, + ]), + ).toBe(false); + }); + + it("ignores complete skill references in assistant messages", () => { + expect( + timelineMessagesHaveCompleteSkillReference([ + { role: "assistant", text: "Try $repo-skill next." }, ]), ).toBe(false); }); it("requests workspace skills when a sent user prompt contains a complete skill token", () => { expect( - timelineMessagesHaveComposerSkillReference([ + timelineMessagesHaveCompleteSkillReference([ { role: "user", text: "Use $repo-skill to inspect this." }, ]), ).toBe(true); expect( - timelineMessagesHaveComposerSkillReference([{ role: "user", text: "Use $repo-skill" }]), + timelineMessagesHaveCompleteSkillReference([{ role: "user", text: "Use $repo-skill" }]), + ).toBe(true); + expect( + timelineMessagesHaveCompleteSkillReference([{ role: "user", text: "Use $repo-skill?" }]), ).toBe(true); }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 4bb6aac29ac..ed882c91e88 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -20,7 +20,7 @@ import { type TerminalContextDraft, } from "../lib/terminalContext"; import type { DraftThreadEnvMode } from "../composerDraftStore"; -import { parseInlineSkillTokens } from "./chat/skillInlineTokens"; +import { hasInlineSkillToken } from "./chat/skillInlineTokens"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; @@ -165,12 +165,18 @@ export function collectUserMessageBlobPreviewUrls(message: ChatMessage): string[ return previewUrls; } -export function timelineMessagesHaveComposerSkillReference( +export function timelineMessagesHaveCompleteSkillReference( messages: ReadonlyArray>, + cache?: WeakMap, ): boolean { - return messages.some( - (message) => message.role === "user" && parseInlineSkillTokens(message.text ?? "").length > 0, - ); + return messages.some((message) => { + if (message.role !== "user") return false; + const cached = cache?.get(message); + if (cached !== undefined) return cached; + const hasSkillReference = hasInlineSkillToken(message.text); + cache?.set(message, hasSkillReference); + return hasSkillReference; + }); } export interface PullRequestDialogState { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3b540e9b056..f9587832091 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -244,7 +244,7 @@ import { resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, - timelineMessagesHaveComposerSkillReference, + timelineMessagesHaveCompleteSkillReference, waitForStartedServerThread, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; @@ -2292,8 +2292,13 @@ function ChatViewContent(props: ChatViewProps) { return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); const activeProviderFallbackSkills = activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS; + const timelineSkillReferenceCacheRef = useRef(new WeakMap()); const timelineHasSkillReference = useMemo( - () => timelineMessagesHaveComposerSkillReference(timelineMessages), + () => + timelineMessagesHaveCompleteSkillReference( + timelineMessages, + timelineSkillReferenceCacheRef.current, + ), [timelineMessages], ); const activeProviderWorkspaceSkills = useProviderWorkspaceSkills({ diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 1ca6132ffe2..12daf4d9f99 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -343,7 +343,7 @@ describe("MessagesTimeline", () => { const markup = renderToStaticMarkup( Date: Sun, 19 Jul 2026 00:17:43 +0100 Subject: [PATCH 38/55] Unify sent skill token boundaries - Share punctuation-aware parsing across web and mobile - Reject path and statement continuations when gating skill loading - Cover sentence boundaries and code-variable continuations --- .../modules/t3-markdown-text/package.json | 1 + .../src/nativeMarkdownText.ts | 10 +++--- .../mobile/src/lib/nativeMarkdownText.test.ts | 33 +++++++++++++++++ .../web/src/components/ChatView.logic.test.ts | 2 ++ .../src/components/chat/skillInlineTokens.ts | 35 +++---------------- packages/shared/package.json | 4 +++ packages/shared/src/skillInlineTokens.test.ts | 26 ++++++++++++++ packages/shared/src/skillInlineTokens.ts | 30 ++++++++++++++++ 8 files changed, 105 insertions(+), 36 deletions(-) create mode 100644 packages/shared/src/skillInlineTokens.test.ts create mode 100644 packages/shared/src/skillInlineTokens.ts diff --git a/apps/mobile/modules/t3-markdown-text/package.json b/apps/mobile/modules/t3-markdown-text/package.json index d51b6c5d9ff..a60e67a2935 100644 --- a/apps/mobile/modules/t3-markdown-text/package.json +++ b/apps/mobile/modules/t3-markdown-text/package.json @@ -26,6 +26,7 @@ "./types": "./src/SelectableMarkdownText.types.ts" }, "peerDependencies": { + "@t3tools/shared": "workspace:*", "expo-asset": "*", "expo-clipboard": "*", "expo-haptics": "*", diff --git a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts index dc84755cbbd..7ec1fc5068c 100644 --- a/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts +++ b/apps/mobile/modules/t3-markdown-text/src/nativeMarkdownText.ts @@ -1,4 +1,5 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless"; +import { parseInlineSkillTokens } from "@t3tools/shared/skillInlineTokens"; import type { SelectableMarkdownSkill } from "./SelectableMarkdownText.types"; import { resolveMarkdownLinkPresentation, type MarkdownFileIcon } from "./markdownLinks"; @@ -184,8 +185,6 @@ function appendRun( return runs; } -const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s|$)/g; - function formatSkillLabel(skill: SelectableMarkdownSkill): string { const displayName = skill.displayName?.trim(); if (displayName) { @@ -216,14 +215,13 @@ function decorateSkillRuns( let cursor = 0; let matched = false; - for (const match of run.text.matchAll(SKILL_TOKEN_REGEX)) { - const prefix = match[1] ?? ""; - const name = match[2] ?? ""; + for (const match of parseInlineSkillTokens(run.text)) { + const { name } = match; const skill = skillByName.get(name); if (!skill) { continue; } - const start = (match.index ?? 0) + prefix.length; + const { start } = match; const end = start + name.length + 1; if (start > cursor) { decorated.push({ ...run, text: run.text.slice(cursor, start) }); diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 6e41f2243a9..86ef6164523 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -173,6 +173,39 @@ describe("nativeMarkdownDocumentRuns", () => { ]); }); + it("uses sent-message boundaries for skill references", () => { + const node: MarkdownNode = { + type: "document", + children: [ + { + type: "paragraph", + children: [ + { + type: "text", + content: "$update-main? echo $HOME/.codex and use PHP $value;", + }, + ], + }, + ], + }; + + expect( + nativeMarkdownDocumentRuns(node, [ + { name: "update-main", displayName: "Update Main" }, + { name: "HOME", displayName: "Home" }, + { name: "value", displayName: "Value" }, + ]), + ).toEqual([ + { + text: "$update-main", + role: "body", + skillName: "update-main", + skillLabel: "Update Main", + }, + { text: "? echo $HOME/.codex and use PHP $value;", role: "body" }, + ]); + }); + it("leaves unknown skill-like text unchanged", () => { const node: MarkdownNode = { type: "document", diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 61befcf28e8..9d9503e8a45 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -110,6 +110,8 @@ describe("timelineMessagesHaveCompleteSkillReference", () => { { role: "user", text: "Inspect @AGENTS.md" }, { role: "user", text: "$" }, { role: "user", text: "$123invalid" }, + { role: "user", text: "echo $HOME/.codex" }, + { role: "user", text: "use PHP $value;" }, ]), ).toBe(false); }); diff --git a/apps/web/src/components/chat/skillInlineTokens.ts b/apps/web/src/components/chat/skillInlineTokens.ts index 1184cc922a2..f6513e95a13 100644 --- a/apps/web/src/components/chat/skillInlineTokens.ts +++ b/apps/web/src/components/chat/skillInlineTokens.ts @@ -1,30 +1,5 @@ -const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?![a-zA-Z0-9:_-])/g; -const SKILL_TOKEN_TEST_REGEX = /(^|\s)\$[a-zA-Z][a-zA-Z0-9:_-]*(?![a-zA-Z0-9:_-])/; - -export interface InlineSkillToken { - name: string; - rawText: string; - start: number; -} - -export function parseInlineSkillTokens(text: string): InlineSkillToken[] { - const tokens: InlineSkillToken[] = []; - - // matchAll clones the global RegExp, so repeated calls do not share lastIndex state. - for (const match of text.matchAll(SKILL_TOKEN_REGEX)) { - const prefix = match[1] ?? ""; - const name = match[2]; - if (!name) continue; - tokens.push({ - name, - rawText: `$${name}`, - start: (match.index ?? 0) + prefix.length, - }); - } - - return tokens; -} - -export function hasInlineSkillToken(text: string): boolean { - return SKILL_TOKEN_TEST_REGEX.test(text); -} +export { + hasInlineSkillToken, + parseInlineSkillTokens, + type InlineSkillToken, +} from "@t3tools/shared/skillInlineTokens"; diff --git a/packages/shared/package.json b/packages/shared/package.json index bf68ce766ef..6636ed0e0f9 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -151,6 +151,10 @@ "types": "./src/composerInlineTokens.ts", "import": "./src/composerInlineTokens.ts" }, + "./skillInlineTokens": { + "types": "./src/skillInlineTokens.ts", + "import": "./src/skillInlineTokens.ts" + }, "./terminalLabels": { "types": "./src/terminalLabels.ts", "import": "./src/terminalLabels.ts" diff --git a/packages/shared/src/skillInlineTokens.test.ts b/packages/shared/src/skillInlineTokens.test.ts new file mode 100644 index 00000000000..e5347c8863b --- /dev/null +++ b/packages/shared/src/skillInlineTokens.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { hasInlineSkillToken, parseInlineSkillTokens } from "./skillInlineTokens.ts"; + +describe("parseInlineSkillTokens", () => { + it("recognizes sent skill references at sentence boundaries", () => { + expect(parseInlineSkillTokens("Use $update-main? Then run $commit.")).toEqual([ + { + name: "update-main", + rawText: "$update-main", + start: 4, + }, + { + name: "commit", + rawText: "$commit", + start: 27, + }, + ]); + }); + + it("rejects code-variable continuations", () => { + expect(parseInlineSkillTokens("echo $HOME/.codex or use PHP $value;")).toEqual([]); + expect(hasInlineSkillToken("echo $HOME/.codex")).toBe(false); + expect(hasInlineSkillToken("use PHP $value;")).toBe(false); + }); +}); diff --git a/packages/shared/src/skillInlineTokens.ts b/packages/shared/src/skillInlineTokens.ts new file mode 100644 index 00000000000..149e470a55e --- /dev/null +++ b/packages/shared/src/skillInlineTokens.ts @@ -0,0 +1,30 @@ +const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=$|\s|[,.!?])/g; +const SKILL_TOKEN_TEST_REGEX = /(^|\s)\$[a-zA-Z][a-zA-Z0-9:_-]*(?=$|\s|[,.!?])/; + +export interface InlineSkillToken { + readonly name: string; + readonly rawText: string; + readonly start: number; +} + +export function parseInlineSkillTokens(text: string): ReadonlyArray { + const tokens: InlineSkillToken[] = []; + + // matchAll clones the global RegExp, so repeated calls do not share lastIndex state. + for (const match of text.matchAll(SKILL_TOKEN_REGEX)) { + const prefix = match[1] ?? ""; + const name = match[2]; + if (!name) continue; + tokens.push({ + name, + rawText: `$${name}`, + start: (match.index ?? 0) + prefix.length, + }); + } + + return tokens; +} + +export function hasInlineSkillToken(text: string): boolean { + return SKILL_TOKEN_TEST_REGEX.test(text); +} From 78833da553c73f3c67eadf5a59f1fabdbccb7b59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sun, 19 Jul 2026 00:22:18 +0100 Subject: [PATCH 39/55] Update mobile markdown lockfile dependencies - Add the shared workspace package to peer dependencies - Refresh the mobile markdown package snapshot hash --- pnpm-lock.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0271c378ae..658f4f7bd10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -250,7 +250,7 @@ importers: version: link:../../packages/contracts '@t3tools/mobile-markdown-text': specifier: file:./modules/t3-markdown-text - version: file:apps/mobile/modules/t3-markdown-text(ed3009b8f2424467288a00b38bef28fe) + version: file:apps/mobile/modules/t3-markdown-text(beb74b814261aa4192920293ac865a8e) '@t3tools/mobile-review-diff-native': specifier: file:./modules/t3-review-diff version: file:apps/mobile/modules/t3-review-diff @@ -4515,6 +4515,7 @@ packages: '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text': resolution: {directory: apps/mobile/modules/t3-markdown-text, type: directory} peerDependencies: + '@t3tools/shared': workspace:* expo-asset: '*' expo-clipboard: '*' expo-haptics: '*' @@ -14558,8 +14559,9 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(ed3009b8f2424467288a00b38bef28fe)': + '@t3tools/mobile-markdown-text@file:apps/mobile/modules/t3-markdown-text(beb74b814261aa4192920293ac865a8e)': dependencies: + '@t3tools/shared': link:packages/shared expo-asset: 56.0.17(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) expo-clipboard: 56.0.4(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) expo-haptics: 56.0.3(expo@56.0.12) From 823202b83860bb9d4ad5db6eccd176f17f3e0d72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 20 Jul 2026 00:18:10 +0100 Subject: [PATCH 40/55] Enable pnpm global virtual store - Update Effect Vitest peer dependency wiring for the patched beta release - Refresh lockfile metadata and Alchemy dependency resolution --- pnpm-lock.yaml | 103 ++++++++++++++++++++++++++++++++++++-------- pnpm-workspace.yaml | 8 +++- 2 files changed, 90 insertions(+), 21 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9fc9b2fef7..016662eff23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,7 +66,7 @@ overrides: vite: npm:@voidzero-dev/vite-plus-core@0.2.2 yaml: ^2.9.0 -packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE= +packageExtensionsChecksum: sha256-dL4kgB3oS88+usby/QZU7EK4kxNoz9EGcSH5yDZBXZM= patchedDependencies: '@effect/vitest@4.0.0-beta.78': 42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f @@ -153,7 +153,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -419,7 +419,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -471,7 +471,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts @@ -616,7 +616,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@rolldown/plugin-babel': specifier: ^0.2.0 version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.3) @@ -682,7 +682,7 @@ importers: version: link:../../packages/shared alchemy: specifier: https://pkg.ing/alchemy/078ff00 - version: https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39) + version: https://pkg.ing/alchemy/078ff00(23ff62b65d6c8c4c39b8fd62a02be58c) drizzle-orm: specifier: 1.0.0-rc.3 version: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) @@ -698,7 +698,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -726,7 +726,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -745,7 +745,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -758,7 +758,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -777,7 +777,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -799,7 +799,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -833,7 +833,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -858,7 +858,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -880,7 +880,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -911,7 +911,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@types/pngjs': specifier: 6.0.5 version: 6.0.5 @@ -2012,6 +2012,10 @@ packages: resolution: {integrity: sha512-5KQsQYrQ/o7mfOVAxRtNnfD9M0W4OI6yQd0n/m2N7OOLxTdX4FwN4s/X4obykBC7ZEwH+bzMrFJiB4pq9lrQKQ==} peerDependencies: effect: 4.0.0-beta.78 + vitest: '*' + peerDependenciesMeta: + vitest: + optional: true '@egjs/hammerjs@2.0.17': resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} @@ -11762,9 +11766,43 @@ snapshots: '@effect/tsgo-win32-arm64': 0.13.2 '@effect/tsgo-win32-x64': 0.13.2 - '@effect/vitest@4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': + '@effect/vitest@4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0)': dependencies: effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) + vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + optionalDependencies: + vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) + transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' + - '@types/node' + - '@vitejs/devtools' + - '@vitest/browser-playwright' + - '@vitest/browser-webdriverio' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' + - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - msw + - publint + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun + - utf-8-validate + - yaml '@egjs/hammerjs@2.0.17': dependencies: @@ -15304,7 +15342,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39): + alchemy@https://pkg.ing/alchemy/078ff00(23ff62b65d6c8c4c39b8fd62a02be58c): dependencies: '@alchemy.run/node-utils': 0.0.4 '@aws-sdk/credential-providers': 3.1062.0 @@ -15318,7 +15356,7 @@ snapshots: '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@distilled.cloud/neon': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@distilled.cloud/planetscale': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - '@effect/vitest': 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@effect/vitest': 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@octokit/rest': 22.0.1 '@smithy/node-config-provider': 4.4.6 @@ -15351,12 +15389,39 @@ snapshots: vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: + - '@arethetypeswrong/core' + - '@edge-runtime/vm' + - '@opentelemetry/api' - '@types/node' - '@types/react' + - '@vitejs/devtools' + - '@vitest/browser-playwright' + - '@vitest/browser-webdriverio' + - '@vitest/coverage-istanbul' + - '@vitest/coverage-v8' + - '@vitest/ui' - bufferutil + - esbuild + - happy-dom + - jiti + - jsdom + - less + - msw - pg-native + - publint - react-devtools-core + - sass + - sass-embedded + - stylus + - sugarss + - svelte + - terser + - tsx + - typescript + - unplugin-unused + - unrun - utf-8-validate + - vitest - workerd alien-signals@2.0.6: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 22757702fc8..5b02480a65d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,8 @@ packages: - packages/* - scripts +enableGlobalVirtualStore: true + # Successor to onlyBuiltDependencies/ignoredBuiltDependencies (pnpm 11). # true = allowed to run build scripts; false mirrors the pnpm 10 behavior # where anything outside onlyBuiltDependencies was silently not built. @@ -95,9 +97,11 @@ packageExtensions: "@clerk/expo@*": dependencies: "@expo/config-plugins": 56.0.9 - "@effect/vitest@*": + # Wildcard semver excludes prereleases. + "@effect/vitest@4.0.0-beta.78": dependencies: - vite-plus: "catalog:" + # The patched package imports vite-plus inside the shared graph. + vite-plus: 0.2.2 peerDependenciesMeta: vitest: optional: true From 5798b8106029bb8f87d17ba33c866bb4ea8f4883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 20 Jul 2026 15:47:23 +0100 Subject: [PATCH 41/55] Stop fallback changes from refreshing skill probes - Remove fallback-driven workspace query refreshes on web and mobile - Drop the unused no-op web invalidation helper - Document the corrected workspace query behavior --- BRANCH_DETAILS.md | 2 +- apps/mobile/src/state/providerWorkspaceSkillsState.ts | 6 ------ apps/web/src/lib/providerWorkspaceSkillsState.ts | 10 ---------- 3 files changed, 1 insertion(+), 17 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 31f7a251f9a..d5ff11bc610 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -10,7 +10,7 @@ Expected behavior: - The Codex provider requests `skills/list` with the current workspace cwd, times out hung app-server probes, and terminates the probe process when a timeout occurs. - Provider skill-list failures preserve structured reason, operation, provider instance, normalized cwd, and bounded cause diagnostics for missing providers, invalid cwd, settings failures, Codex home preparation, probe timeouts, and probe failures while keeping stable user-facing messages. Raw thrown values are not sent directly to clients; the server keeps a small plain diagnostic shape so file paths, process output, and unexpected objects do not expand the wire payload. - Non-Codex or disabled providers keep returning provider snapshot skills instead of failing workspace skill search. -- The client runtime keys provider-skill query state by environment, provider instance, and cwd, with a bounded stale window so reconnects refresh workspace-local skills without reusing another workspace's snapshot. +- The client runtime keys provider-skill query state by environment, provider instance, and cwd, with a bounded stale window so reconnects refresh workspace-local skills without reusing another workspace's snapshot. Client-side fallback skill changes do not refresh the workspace query or respawn Codex skill probes. - The composer loads workspace skills lazily: it starts workspace skill discovery when the `$` skill menu is active or the prompt already contains a complete `$skill` token, rather than probing on every empty composer mount. It preserves already loaded repo-local skills while refreshing the same workspace, falls back to provider snapshot skills when a settled workspace lookup returns no skills or errors, keeps structured lookup errors visible alongside those fallback skills on web and mobile, and clears stale repo-local skills during workspace switches or settled no-data states. - The conversation timeline renders sent user prompts against the same workspace-aware skill list as the composer, so repo-local `$skill-name` references display with the same skill chip treatment as user-level skills. Timeline lookup stays disabled until a sent user prompt contains a complete skill token, including one at the end of the message, so an empty draft does not probe Codex merely to decorate nonexistent messages. - The shared client-runtime policy also drives mobile thread composers and feeds. Mobile shows loading and structured error feedback, refreshes an already-open `$` menu when skills arrive, decorates complete skill references, and prevents stale successful results from surviving a failed refresh. diff --git a/apps/mobile/src/state/providerWorkspaceSkillsState.ts b/apps/mobile/src/state/providerWorkspaceSkillsState.ts index 93d025a654a..5e30e5996ad 100644 --- a/apps/mobile/src/state/providerWorkspaceSkillsState.ts +++ b/apps/mobile/src/state/providerWorkspaceSkillsState.ts @@ -38,12 +38,6 @@ export function useProviderWorkspaceSkills( : null, ); - const previousFallbackSkillsRef = useRef(target.fallbackSkills); - useEffect(() => { - if (previousFallbackSkillsRef.current === target.fallbackSkills) return; - previousFallbackSkillsRef.current = target.fallbackSkills; - if (key !== null) query.refresh(); - }, [key, query, target.fallbackSkills]); const previousWorkspaceSkillsRef = useRef(null); const querySkills = query.data?.skills ?? null; useEffect(() => { diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts index a8eb99bb9c7..6cffdf65645 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -13,10 +13,6 @@ import { useEffect, useMemo, useRef } from "react"; import { serverEnvironment } from "../state/server"; import { useEnvironmentQuery } from "../state/query"; -export function invalidateProviderWorkspaceSkills(): void { - // Workspace skill requests are now owned by the environment query cache. -} - export function useProviderWorkspaceSkills( target: ProviderWorkspaceSkillsTarget, ): ProviderWorkspaceSkillsState { @@ -42,12 +38,6 @@ export function useProviderWorkspaceSkills( : null, ); - const previousFallbackSkillsRef = useRef(target.fallbackSkills); - useEffect(() => { - if (previousFallbackSkillsRef.current === target.fallbackSkills) return; - previousFallbackSkillsRef.current = target.fallbackSkills; - if (key !== null) query.refresh(); - }, [key, query, target.fallbackSkills]); const previousWorkspaceSkillsRef = useRef(null); const querySkills = query.data?.skills ?? null; useEffect(() => { From 55b7c235e52310f116caaaa05a9e1dc6bbacaa97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 20 Jul 2026 17:08:57 +0100 Subject: [PATCH 42/55] Fix Codex skill probe and refresh state - Forward configured launch arguments to workspace skill probes - Clear retained workspace skills after failed refreshes - Add focused probe and snapshot regressions --- .../src/state/providerWorkspaceSkillsState.ts | 3 ++- .../scripts/codex-skills-mock-app-server.ts | 2 ++ .../src/provider/Layers/CodexProvider.test.ts | 8 ++++++++ .../src/provider/Layers/CodexProvider.ts | 13 +++++++++---- .../src/provider/ProviderSkillsLister.ts | 7 ++++++- .../lib/providerWorkspaceSkillsState.test.ts | 19 +++++++++++++++++++ .../src/lib/providerWorkspaceSkillsState.ts | 3 ++- .../src/state/providerWorkspaceSkills.test.ts | 17 +++++++++++++++++ .../src/state/providerWorkspaceSkills.ts | 3 ++- 9 files changed, 67 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/state/providerWorkspaceSkillsState.ts b/apps/mobile/src/state/providerWorkspaceSkillsState.ts index 5e30e5996ad..a70326164f7 100644 --- a/apps/mobile/src/state/providerWorkspaceSkillsState.ts +++ b/apps/mobile/src/state/providerWorkspaceSkillsState.ts @@ -45,9 +45,10 @@ export function useProviderWorkspaceSkills( key, skills: querySkills, isPending: query.isPending, + error: query.error, current: previousWorkspaceSkillsRef.current, }); - }, [key, query.isPending, querySkills]); + }, [key, query.error, query.isPending, querySkills]); if (key === null) { return { skills: target.fallbackSkills, isPending: false, error: null }; diff --git a/apps/server/scripts/codex-skills-mock-app-server.ts b/apps/server/scripts/codex-skills-mock-app-server.ts index 7d2ff4f40e0..1f4900dee8f 100644 --- a/apps/server/scripts/codex-skills-mock-app-server.ts +++ b/apps/server/scripts/codex-skills-mock-app-server.ts @@ -3,6 +3,7 @@ import * as NodeFS from "node:fs"; const cwdLogPath = process.env.T3_CODEX_CWD_LOG_PATH; +const argsLogPath = process.env.T3_CODEX_ARGS_LOG_PATH; const exitLogPath = process.env.T3_CODEX_EXIT_LOG_PATH; const hangSkillsList = process.env.T3_CODEX_HANG_SKILLS_LIST === "1"; @@ -38,6 +39,7 @@ process.stdin.on("data", (chunk) => { switch (message.method) { case "initialize": appendLog(cwdLogPath, process.cwd()); + appendLog(argsLogPath, JSON.stringify(process.argv.slice(2))); respond(id, { userAgent: "t3code-codex-skills-test", codexHome: process.cwd(), diff --git a/apps/server/src/provider/Layers/CodexProvider.test.ts b/apps/server/src/provider/Layers/CodexProvider.test.ts index bfaa00ebd5f..61d161eae15 100644 --- a/apps/server/src/provider/Layers/CodexProvider.test.ts +++ b/apps/server/src/provider/Layers/CodexProvider.test.ts @@ -41,6 +41,7 @@ const makeMockAppServer = Effect.fn("makeMockAppServer")(function* () { return { binaryPath, cwd: yield* fileSystem.realPath(workspaceDirectory), + argsLogPath: path.join(directory, "args.log"), cwdLogPath: path.join(directory, "cwd.log"), exitLogPath: path.join(directory, "exit.log"), }; @@ -163,9 +164,11 @@ describe("listCodexProviderSkills", () => { const fixture = yield* makeMockAppServer(); const skills = yield* listCodexProviderSkills({ binaryPath: fixture.binaryPath, + launchArgs: "--enable workspace-skill-test", cwd: fixture.cwd, environment: { ...process.env, + T3_CODEX_ARGS_LOG_PATH: fixture.argsLogPath, T3_CODEX_CWD_LOG_PATH: fixture.cwdLogPath, }, }).pipe(Effect.scoped); @@ -180,6 +183,11 @@ describe("listCodexProviderSkills", () => { enabled: true, }, ]); + expect(JSON.parse((yield* waitForFileContent(fixture.argsLogPath)).trim())).toEqual([ + "app-server", + "--enable", + "workspace-skill-test", + ]); expect((yield* waitForFileContent(fixture.cwdLogPath)).trim()).toBe(fixture.cwd); }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index e7c3aea2f00..fa34065ecc4 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -277,6 +277,7 @@ const requestAllCodexModels = Effect.fn("requestAllCodexModels")(function* ( export const listCodexProviderSkills = Effect.fn("listCodexProviderSkills")(function* (input: { readonly binaryPath: string; readonly homePath?: string; + readonly launchArgs?: string; readonly cwd: string; readonly environment: NodeJS.ProcessEnv; }) { @@ -286,10 +287,14 @@ export const listCodexProviderSkills = Effect.fn("listCodexProviderSkills")(func ...input.environment, ...(resolvedHomePath ? { CODEX_HOME: resolvedHomePath } : {}), }; - const spawnCommand = yield* resolveSpawnCommand(input.binaryPath, ["app-server"], { - env: environment, - extendEnv: true, - }); + const spawnCommand = yield* resolveSpawnCommand( + input.binaryPath, + codexAppServerArgs(input.launchArgs), + { + env: environment, + extendEnv: true, + }, + ); const child = yield* spawner .spawn( ChildProcess.make(spawnCommand.command, spawnCommand.args, { diff --git a/apps/server/src/provider/ProviderSkillsLister.ts b/apps/server/src/provider/ProviderSkillsLister.ts index 6a8c58d7cdf..93daca1fff5 100644 --- a/apps/server/src/provider/ProviderSkillsLister.ts +++ b/apps/server/src/provider/ProviderSkillsLister.ts @@ -24,6 +24,7 @@ import { resolveCodexHomeLayout, } from "./Drivers/CodexHomeLayout.ts"; import { listCodexProviderSkills } from "./Layers/CodexProvider.ts"; +import { resolveCodexLaunchArgs } from "./Layers/codexLaunchArgs.ts"; import { deriveProviderInstanceConfigMap } from "./Layers/ProviderInstanceRegistryHydration.ts"; import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; import { ProviderRegistry } from "./Services/ProviderRegistry.ts"; @@ -166,12 +167,14 @@ export const listCodexProviderSkillsWithTimeout = Effect.fn("listCodexProviderSk readonly instanceId: ProviderInstanceId; readonly binaryPath: string; readonly homePath?: string; + readonly launchArgs?: string; readonly cwd: string; readonly environment: NodeJS.ProcessEnv; }) { return yield* listCodexProviderSkills({ binaryPath: input.binaryPath, ...(input.homePath ? { homePath: input.homePath } : {}), + ...(input.launchArgs ? { launchArgs: input.launchArgs } : {}), cwd: input.cwd, environment: input.environment, }).pipe( @@ -297,12 +300,14 @@ export const makeProviderSkillsLister = Effect.fn("makeProviderSkillsLister")(fu }), ), ); + const environment = mergeProviderInstanceEnvironment(instanceConfig.environment ?? []); const skills = yield* listCodexProviderSkillsWithTimeout({ instanceId: input.instanceId, binaryPath: effectiveConfig.binaryPath, ...(homeLayout.effectiveHomePath ? { homePath: homeLayout.effectiveHomePath } : {}), + launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, environment), cwd: normalizedCwd, - environment: mergeProviderInstanceEnvironment(instanceConfig.environment ?? []), + environment, }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner)); return { skills }; }); diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.test.ts b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts index b08c7385b20..4949325866b 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.test.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts @@ -254,6 +254,7 @@ describe("resolveNextProviderWorkspaceSkillsSnapshot", () => { key: "environment:codex:/repo", skills: loadedSkills, isPending: false, + error: null, current: null, }), ).toEqual({ @@ -273,6 +274,7 @@ describe("resolveNextProviderWorkspaceSkillsSnapshot", () => { key: "environment:codex:/repo", skills: [skill("fresh-repo-local")], isPending: true, + error: null, current, }), ).toBe(current); @@ -284,6 +286,7 @@ describe("resolveNextProviderWorkspaceSkillsSnapshot", () => { key: null, skills: [skill("repo-local")], isPending: false, + error: null, current: { key: "environment:codex:/repo", skills: [skill("repo-local")], @@ -298,6 +301,22 @@ describe("resolveNextProviderWorkspaceSkillsSnapshot", () => { key: "environment:codex:/repo", skills: null, isPending: false, + error: null, + current: { + key: "environment:codex:/repo", + skills: [skill("repo-local")], + }, + }), + ).toBeNull(); + }); + + it("clears stale workspace skills after a failed refresh", () => { + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "environment:codex:/repo", + skills: [skill("stale-repo-local")], + isPending: false, + error: "Failed to list skills.", current: { key: "environment:codex:/repo", skills: [skill("repo-local")], diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts index 6cffdf65645..c0e54ebf490 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -45,9 +45,10 @@ export function useProviderWorkspaceSkills( key, skills: querySkills, isPending: query.isPending, + error: query.error, current: previousWorkspaceSkillsRef.current, }); - }, [key, query.isPending, querySkills]); + }, [key, query.error, query.isPending, querySkills]); if (key === null) { return { skills: target.fallbackSkills, isPending: false, error: null }; diff --git a/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts b/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts index 97ec912ac49..e5d189abf29 100644 --- a/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts +++ b/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts @@ -119,6 +119,7 @@ describe("resolveNextProviderWorkspaceSkillsSnapshot", () => { key: current.key, skills: [skill("fresh-repo-local")], isPending: true, + error: null, current, }), ).toBe(current); @@ -127,10 +128,26 @@ describe("resolveNextProviderWorkspaceSkillsSnapshot", () => { key: null, skills: current.skills, isPending: false, + error: null, current, }), ).toBeNull(); }); + + it("clears a stale snapshot after a failed refresh", () => { + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "local:codex:/repo", + skills: [skill("stale-repo-local")], + isPending: false, + error: "Failed to list skills.", + current: { + key: "local:codex:/repo", + skills: [skill("repo-local")], + }, + }), + ).toBeNull(); + }); }); describe("formatProviderWorkspaceSkillsError", () => { diff --git a/packages/client-runtime/src/state/providerWorkspaceSkills.ts b/packages/client-runtime/src/state/providerWorkspaceSkills.ts index a73c1d51ec1..9aee0e380c9 100644 --- a/packages/client-runtime/src/state/providerWorkspaceSkills.ts +++ b/packages/client-runtime/src/state/providerWorkspaceSkills.ts @@ -111,9 +111,10 @@ export function resolveNextProviderWorkspaceSkillsSnapshot(input: { readonly key: string | null; readonly skills: ReadonlyArray | null; readonly isPending: boolean; + readonly error: string | null; readonly current: ProviderWorkspaceSkillsSnapshot | null; }): ProviderWorkspaceSkillsSnapshot | null { - if (input.key === null) return null; + if (input.key === null || input.error !== null) return null; if (input.skills === null) return input.isPending ? input.current : null; return input.isPending ? input.current : { key: input.key, skills: input.skills }; } From eb0650de55e3c0d15c26f22995de627cb68d179b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Mon, 20 Jul 2026 17:18:41 +0100 Subject: [PATCH 43/55] Document pending workspace skill isolation - Explain why provider snapshots stay hidden during pending lookups --- packages/client-runtime/src/state/providerWorkspaceSkills.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/client-runtime/src/state/providerWorkspaceSkills.ts b/packages/client-runtime/src/state/providerWorkspaceSkills.ts index 9aee0e380c9..d59395f15f8 100644 --- a/packages/client-runtime/src/state/providerWorkspaceSkills.ts +++ b/packages/client-runtime/src/state/providerWorkspaceSkills.ts @@ -104,6 +104,9 @@ export function resolveProviderWorkspaceSkills( return input.nextSkills.length > 0 ? input.nextSkills : input.fallbackSkills; } if (!input.isPending) return EMPTY_PROVIDER_WORKSPACE_SKILLS; + // Do not use the provider-wide fallback while the workspace lookup is pending: + // it can contain repo-local skills discovered for a different cwd. This also + // keeps disconnected clients from presenting unverified workspace metadata. return resolvePendingProviderWorkspaceSkills(input); } From a9b4a6ac538476f6361cdb4abe824d058079c04c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Tue, 21 Jul 2026 19:45:59 +0100 Subject: [PATCH 44/55] Avoid probing skills for future worktrees - Pass a null skill cwd until a draft worktree materializes - Keep local checkout skill queries and path searches unchanged - Cover local, materialized, and future worktree resolution --- .../web/src/components/ChatView.logic.test.ts | 36 +++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 17 +++++++++ apps/web/src/components/ChatView.tsx | 10 +++++- apps/web/src/components/chat/ChatComposer.tsx | 4 ++- 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 9d9503e8a45..8b27d915273 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -20,6 +20,7 @@ import { hasServerAcknowledgedLocalDispatch, reconcileMountedTerminalThreadIds, reconcileRetainedMountedThreadIds, + resolveProviderSkillsCwd, resolveSendEnvMode, shouldWriteThreadErrorToCurrentServerThread, timelineMessagesHaveCompleteSkillReference, @@ -139,6 +140,41 @@ describe("timelineMessagesHaveCompleteSkillReference", () => { }); }); +describe("resolveProviderSkillsCwd", () => { + it("uses the selected checkout for local drafts", () => { + expect( + resolveProviderSkillsCwd({ + gitCwd: "/repo/worktrees/existing-feature", + isLocalDraftThread: true, + draftThreadEnvMode: "local", + worktreePath: "/repo/worktrees/existing-feature", + }), + ).toBe("/repo/worktrees/existing-feature"); + }); + + it("uses a materialized worktree for worktree drafts", () => { + expect( + resolveProviderSkillsCwd({ + gitCwd: "/repo/worktrees/new-feature", + isLocalDraftThread: true, + draftThreadEnvMode: "worktree", + worktreePath: "/repo/worktrees/new-feature", + }), + ).toBe("/repo/worktrees/new-feature"); + }); + + it("does not probe the base checkout for a future worktree draft", () => { + expect( + resolveProviderSkillsCwd({ + gitCwd: "/repo", + isLocalDraftThread: true, + draftThreadEnvMode: "worktree", + worktreePath: null, + }), + ).toBeNull(); + }); +}); + describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { const state = deriveComposerSendState({ diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index ed882c91e88..017698d688b 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -179,6 +179,23 @@ export function timelineMessagesHaveCompleteSkillReference( }); } +export function resolveProviderSkillsCwd(input: { + readonly gitCwd: string | null; + readonly isLocalDraftThread: boolean; + readonly draftThreadEnvMode: DraftThreadEnvMode | undefined; + readonly worktreePath: string | null; +}): string | null { + if ( + input.isLocalDraftThread && + input.draftThreadEnvMode === "worktree" && + input.worktreePath === null + ) { + return null; + } + + return input.gitCwd; +} + export interface PullRequestDialogState { initialReference: string | null; key: number; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b7336aede8d..34b153b90ab 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -242,6 +242,7 @@ import { deriveLockedProvider, readFileAsDataUrl, reconcileMountedTerminalThreadIds, + resolveProviderSkillsCwd, resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, @@ -2259,6 +2260,12 @@ function ChatViewContent(props: ChatViewProps) { worktreePath: activeThread?.worktreePath ?? null, }) : null; + const providerSkillsCwd = resolveProviderSkillsCwd({ + gitCwd, + isLocalDraftThread, + draftThreadEnvMode: draftThread?.envMode, + worktreePath: activeThread?.worktreePath ?? null, + }); const gitStatusCwd = activeThread?.worktreePath ?? gitCwd; const gitStatusQuery = useEnvironmentQuery( gitStatusCwd === null @@ -2305,7 +2312,7 @@ function ChatViewContent(props: ChatViewProps) { const activeProviderWorkspaceSkills = useProviderWorkspaceSkills({ environmentId, instanceId: activeProviderStatus?.instanceId ?? null, - cwd: gitCwd, + cwd: providerSkillsCwd, enabled: timelineHasSkillReference, fallbackSkills: activeProviderFallbackSkills, }); @@ -5429,6 +5436,7 @@ function ChatViewContent(props: ChatViewProps) { keybindings={keybindings} terminalOpen={Boolean(terminalUiState.terminalOpen)} gitCwd={gitCwd} + providerSkillsCwd={providerSkillsCwd} promptRef={promptRef} composerImagesRef={composerImagesRef} composerTerminalContextsRef={composerTerminalContextsRef} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 19b6dee6476..79e2000d798 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -516,6 +516,7 @@ export interface ChatComposerProps { keybindings: ResolvedKeybindingsConfig; terminalOpen: boolean; gitCwd: string | null; + providerSkillsCwd: string | null; // Refs the parent needs kept in sync promptRef: React.RefObject; @@ -606,6 +607,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) keybindings, terminalOpen, gitCwd, + providerSkillsCwd, promptRef, composerRef, composerImagesRef, @@ -963,7 +965,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const providerWorkspaceSkills = useProviderWorkspaceSkills({ environmentId, instanceId: selectedProviderStatus?.instanceId ?? null, - cwd: gitCwd, + cwd: providerSkillsCwd, enabled: composerTriggerKind === "skill" || promptHasSkillReference, fallbackSkills: selectedProviderFallbackSkills, }); From c39f371fdd4b93929cc2635c9f3406653e529c15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Wed, 22 Jul 2026 11:51:16 +0100 Subject: [PATCH 45/55] Handle workspace skills while disconnected --- apps/web/src/components/ChatView.tsx | 4 ++ apps/web/src/components/chat/ChatComposer.tsx | 3 + .../lib/providerWorkspaceSkillsState.test.ts | 58 +++++++++++++++++ .../src/lib/providerWorkspaceSkillsState.ts | 26 ++++++-- .../src/state/providerWorkspaceSkills.test.ts | 62 +++++++++++++++++++ .../src/state/providerWorkspaceSkills.ts | 15 ++++- 6 files changed, 162 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 34b153b90ab..0617a9ec996 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1134,6 +1134,8 @@ function ChatViewContent(props: ChatViewProps) { () => new Map(environments.map((environment) => [environment.environmentId, environment])), [environments], ); + const providerSkillsConnectionAvailable = + environmentById.get(environmentId)?.connection.phase === "connected"; const composerDraftTarget: ScopedThreadRef | DraftId = routeKind === "server" ? routeThreadRef : props.draftId; const serverThread = useThread(routeThreadRef); @@ -2314,6 +2316,7 @@ function ChatViewContent(props: ChatViewProps) { instanceId: activeProviderStatus?.instanceId ?? null, cwd: providerSkillsCwd, enabled: timelineHasSkillReference, + connectionAvailable: providerSkillsConnectionAvailable, fallbackSkills: activeProviderFallbackSkills, }); const activeProjectCwd = activeProject?.workspaceRoot ?? null; @@ -5437,6 +5440,7 @@ function ChatViewContent(props: ChatViewProps) { terminalOpen={Boolean(terminalUiState.terminalOpen)} gitCwd={gitCwd} providerSkillsCwd={providerSkillsCwd} + providerSkillsConnectionAvailable={providerSkillsConnectionAvailable} promptRef={promptRef} composerImagesRef={composerImagesRef} composerTerminalContextsRef={composerTerminalContextsRef} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 2f42d571726..2560215af19 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -517,6 +517,7 @@ export interface ChatComposerProps { terminalOpen: boolean; gitCwd: string | null; providerSkillsCwd: string | null; + providerSkillsConnectionAvailable: boolean; // Refs the parent needs kept in sync promptRef: React.RefObject; @@ -608,6 +609,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) terminalOpen, gitCwd, providerSkillsCwd, + providerSkillsConnectionAvailable, promptRef, composerRef, composerImagesRef, @@ -967,6 +969,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) instanceId: selectedProviderStatus?.instanceId ?? null, cwd: providerSkillsCwd, enabled: composerTriggerKind === "skill" || promptHasSkillReference, + connectionAvailable: providerSkillsConnectionAvailable, fallbackSkills: selectedProviderFallbackSkills, }); diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.test.ts b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts index 4949325866b..4219767b631 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.test.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts @@ -175,6 +175,36 @@ describe("resolveProviderWorkspaceSkills", () => { ).toEqual([]); }); + it("settles unavailable lookups with same-workspace skills or provider fallback", () => { + const currentSkills = [skill("repo-local")]; + const fallbackSkills = [skill("provider-fallback")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/repo", + nextSkills: null, + isPending: false, + error: null, + unavailable: true, + currentKey: "environment:codex:/repo", + currentSkills, + fallbackSkills, + }), + ).toBe(currentSkills); + expect( + resolveProviderWorkspaceSkills({ + nextKey: "environment:codex:/other-repo", + nextSkills: null, + isPending: false, + error: null, + unavailable: true, + currentKey: "environment:codex:/repo", + currentSkills, + fallbackSkills, + }), + ).toBe(fallbackSkills); + }); + it("does not leak skills during rapid workspace switches", () => { const repoASkills = [skill("repo-a")]; const repoBSkills = [skill("repo-b")]; @@ -324,4 +354,32 @@ describe("resolveNextProviderWorkspaceSkillsSnapshot", () => { }), ).toBeNull(); }); + + it("preserves only the active workspace snapshot while unavailable", () => { + const current = { + key: "environment:codex:/repo", + skills: [skill("repo-local")], + }; + + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: current.key, + skills: null, + isPending: false, + error: null, + unavailable: true, + current, + }), + ).toBe(current); + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "environment:codex:/other-repo", + skills: null, + isPending: false, + error: null, + unavailable: true, + current, + }), + ).toBeNull(); + }); }); diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts index c0e54ebf490..e4a597c436e 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -1,6 +1,7 @@ import { EMPTY_PROVIDER_WORKSPACE_SKILLS, formatProviderWorkspaceSkillsError, + PROVIDER_WORKSPACE_SKILLS_UNAVAILABLE_MESSAGE, providerWorkspaceSkillsTargetKey, resolveNextProviderWorkspaceSkillsSnapshot, resolveProviderWorkspaceSkills, @@ -22,12 +23,23 @@ export function useProviderWorkspaceSkills( instanceId: target.instanceId, cwd: target.cwd?.trim() || null, enabled: target.enabled, + connectionAvailable: target.connectionAvailable !== false, }), - [target.cwd, target.enabled, target.environmentId, target.instanceId], + [ + target.connectionAvailable, + target.cwd, + target.enabled, + target.environmentId, + target.instanceId, + ], ); const key = providerWorkspaceSkillsTargetKey(stableTarget); + const unavailable = key !== null && !stableTarget.connectionAvailable; const query = useEnvironmentQuery( - key !== null && stableTarget.environmentId !== null && stableTarget.instanceId !== null + key !== null && + !unavailable && + stableTarget.environmentId !== null && + stableTarget.instanceId !== null ? serverEnvironment.providerSkills({ environmentId: stableTarget.environmentId, input: { @@ -46,9 +58,10 @@ export function useProviderWorkspaceSkills( skills: querySkills, isPending: query.isPending, error: query.error, + unavailable, current: previousWorkspaceSkillsRef.current, }); - }, [key, query.error, query.isPending, querySkills]); + }, [key, query.error, query.isPending, querySkills, unavailable]); if (key === null) { return { skills: target.fallbackSkills, isPending: false, error: null }; @@ -60,11 +73,14 @@ export function useProviderWorkspaceSkills( nextSkills: querySkills, isPending: query.isPending, error: query.error, + unavailable, currentKey: previousWorkspaceSkills?.key ?? null, currentSkills: previousWorkspaceSkills?.skills ?? EMPTY_PROVIDER_WORKSPACE_SKILLS, fallbackSkills: target.fallbackSkills, }), - isPending: query.isPending, - error: formatProviderWorkspaceSkillsError({ error: query.error, cause: query.errorCause }), + isPending: unavailable ? false : query.isPending, + error: unavailable + ? PROVIDER_WORKSPACE_SKILLS_UNAVAILABLE_MESSAGE + : formatProviderWorkspaceSkillsError({ error: query.error, cause: query.errorCause }), }; } diff --git a/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts b/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts index e5d189abf29..c4ed0c1f7b4 100644 --- a/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts +++ b/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts @@ -77,6 +77,40 @@ describe("resolveProviderWorkspaceSkills", () => { ).toEqual([]); }); + it("preserves verified same-workspace skills while the environment is unavailable", () => { + const currentSkills = [skill("repo-local")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "local:codex:/repo", + nextSkills: null, + isPending: false, + error: null, + unavailable: true, + currentKey: "local:codex:/repo", + currentSkills, + fallbackSkills: [skill("provider-fallback")], + }), + ).toBe(currentSkills); + }); + + it("uses provider fallback skills when a different workspace is unavailable", () => { + const fallbackSkills = [skill("provider-fallback")]; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "local:codex:/repo-b", + nextSkills: null, + isPending: false, + error: null, + unavailable: true, + currentKey: "local:codex:/repo-a", + currentSkills: [skill("repo-a")], + fallbackSkills, + }), + ).toBe(fallbackSkills); + }); + it("uses provider snapshot skills for empty and failed workspace responses", () => { const fallbackSkills = [skill("provider-fallback")]; const base = { @@ -148,6 +182,34 @@ describe("resolveNextProviderWorkspaceSkillsSnapshot", () => { }), ).toBeNull(); }); + + it("retains only a same-workspace snapshot while unavailable", () => { + const current = { + key: "local:codex:/repo-a", + skills: [skill("repo-a")], + }; + + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: current.key, + skills: null, + isPending: false, + error: null, + unavailable: true, + current, + }), + ).toBe(current); + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "local:codex:/repo-b", + skills: null, + isPending: false, + error: null, + unavailable: true, + current, + }), + ).toBeNull(); + }); }); describe("formatProviderWorkspaceSkillsError", () => { diff --git a/packages/client-runtime/src/state/providerWorkspaceSkills.ts b/packages/client-runtime/src/state/providerWorkspaceSkills.ts index d59395f15f8..96b71ba8664 100644 --- a/packages/client-runtime/src/state/providerWorkspaceSkills.ts +++ b/packages/client-runtime/src/state/providerWorkspaceSkills.ts @@ -12,6 +12,7 @@ export interface ProviderWorkspaceSkillsTarget { readonly instanceId: ProviderInstanceId | null; readonly cwd: string | null; readonly enabled: boolean; + readonly connectionAvailable?: boolean; readonly fallbackSkills: ReadonlyArray; } @@ -31,6 +32,7 @@ export interface ProviderWorkspaceSkillsResolutionInput extends ProviderWorkspac readonly nextSkills: ReadonlyArray | null; readonly isPending: boolean; readonly error: string | null; + readonly unavailable?: boolean; readonly fallbackSkills: ReadonlyArray; } @@ -40,6 +42,8 @@ export interface ProviderWorkspaceSkillsSnapshot { } export const EMPTY_PROVIDER_WORKSPACE_SKILLS: ReadonlyArray = []; +export const PROVIDER_WORKSPACE_SKILLS_UNAVAILABLE_MESSAGE = + "Reconnect this environment to refresh workspace skills."; const isServerProviderSkillsListError = Schema.is(ServerProviderSkillsListError); @@ -96,6 +100,10 @@ export function resolvePendingProviderWorkspaceSkills( export function resolveProviderWorkspaceSkills( input: ProviderWorkspaceSkillsResolutionInput, ): ReadonlyArray { + if (input.unavailable === true) { + const currentSkills = resolvePendingProviderWorkspaceSkills(input); + return currentSkills.length > 0 ? currentSkills : input.fallbackSkills; + } // AsyncResult failures can retain a previous success for stale-while-revalidate. // A failed workspace refresh must still fall back to the provider snapshot rather // than keeping a stale workspace's skills selectable. @@ -115,9 +123,14 @@ export function resolveNextProviderWorkspaceSkillsSnapshot(input: { readonly skills: ReadonlyArray | null; readonly isPending: boolean; readonly error: string | null; + readonly unavailable?: boolean; readonly current: ProviderWorkspaceSkillsSnapshot | null; }): ProviderWorkspaceSkillsSnapshot | null { - if (input.key === null || input.error !== null) return null; + if (input.key === null) return null; + if (input.unavailable === true) { + return input.current?.key === input.key ? input.current : null; + } + if (input.error !== null) return null; if (input.skills === null) return input.isPending ? input.current : null; return input.isPending ? input.current : { key: input.key, skills: input.skills }; } From 3c596ca4e2e4ddcb4e1e6245bc6ce5ad90c21560 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Wed, 22 Jul 2026 11:52:27 +0100 Subject: [PATCH 46/55] Document disconnected workspace skill behavior --- BRANCH_DETAILS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index d5ff11bc610..b5b71e03518 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -11,6 +11,7 @@ Expected behavior: - Provider skill-list failures preserve structured reason, operation, provider instance, normalized cwd, and bounded cause diagnostics for missing providers, invalid cwd, settings failures, Codex home preparation, probe timeouts, and probe failures while keeping stable user-facing messages. Raw thrown values are not sent directly to clients; the server keeps a small plain diagnostic shape so file paths, process output, and unexpected objects do not expand the wire payload. - Non-Codex or disabled providers keep returning provider snapshot skills instead of failing workspace skill search. - The client runtime keys provider-skill query state by environment, provider instance, and cwd, with a bounded stale window so reconnects refresh workspace-local skills without reusing another workspace's snapshot. Client-side fallback skill changes do not refresh the workspace query or respawn Codex skill probes. +- Web workspace-skill lookup follows the route environment's connection state. While disconnected, it does not start an RPC, retains only verified skills for the same environment/provider/cwd, otherwise falls back to provider snapshot skills with a non-pending reconnect error, and resumes the workspace refresh when the environment reconnects. - The composer loads workspace skills lazily: it starts workspace skill discovery when the `$` skill menu is active or the prompt already contains a complete `$skill` token, rather than probing on every empty composer mount. It preserves already loaded repo-local skills while refreshing the same workspace, falls back to provider snapshot skills when a settled workspace lookup returns no skills or errors, keeps structured lookup errors visible alongside those fallback skills on web and mobile, and clears stale repo-local skills during workspace switches or settled no-data states. - The conversation timeline renders sent user prompts against the same workspace-aware skill list as the composer, so repo-local `$skill-name` references display with the same skill chip treatment as user-level skills. Timeline lookup stays disabled until a sent user prompt contains a complete skill token, including one at the end of the message, so an empty draft does not probe Codex merely to decorate nonexistent messages. - The shared client-runtime policy also drives mobile thread composers and feeds. Mobile shows loading and structured error feedback, refreshes an already-open `$` menu when skills arrive, decorates complete skill references, and prevents stale successful results from surviving a failed refresh. From a8b5e558d5d2fca9c40af06fd44dad84b4d8e56d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Wed, 22 Jul 2026 11:59:44 +0100 Subject: [PATCH 47/55] Preserve empty disconnected skill snapshots - Keep matching workspace snapshots even when they contain no skills - Cover empty disconnected snapshots with a regression test --- .../src/state/providerWorkspaceSkills.test.ts | 17 +++++++++++++++++ .../src/state/providerWorkspaceSkills.ts | 3 +-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts b/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts index c4ed0c1f7b4..4861b46a6ac 100644 --- a/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts +++ b/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts @@ -94,6 +94,23 @@ describe("resolveProviderWorkspaceSkills", () => { ).toBe(currentSkills); }); + it("preserves an empty same-workspace snapshot while the environment is unavailable", () => { + const currentSkills: ReadonlyArray = []; + + expect( + resolveProviderWorkspaceSkills({ + nextKey: "local:codex:/repo", + nextSkills: null, + isPending: false, + error: null, + unavailable: true, + currentKey: "local:codex:/repo", + currentSkills, + fallbackSkills: [skill("provider-fallback")], + }), + ).toBe(currentSkills); + }); + it("uses provider fallback skills when a different workspace is unavailable", () => { const fallbackSkills = [skill("provider-fallback")]; diff --git a/packages/client-runtime/src/state/providerWorkspaceSkills.ts b/packages/client-runtime/src/state/providerWorkspaceSkills.ts index 96b71ba8664..5b73854b8e0 100644 --- a/packages/client-runtime/src/state/providerWorkspaceSkills.ts +++ b/packages/client-runtime/src/state/providerWorkspaceSkills.ts @@ -101,8 +101,7 @@ export function resolveProviderWorkspaceSkills( input: ProviderWorkspaceSkillsResolutionInput, ): ReadonlyArray { if (input.unavailable === true) { - const currentSkills = resolvePendingProviderWorkspaceSkills(input); - return currentSkills.length > 0 ? currentSkills : input.fallbackSkills; + return input.currentKey === input.nextKey ? input.currentSkills : input.fallbackSkills; } // AsyncResult failures can retain a previous success for stale-while-revalidate. // A failed workspace refresh must still fall back to the provider snapshot rather From 52e5cf97933f5a18396ce87d68fffe381d6a3685 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Wed, 22 Jul 2026 12:08:52 +0100 Subject: [PATCH 48/55] Preserve skills across inactive lookups - Keep snapshots keyed while lazy skill menus close - Clear retained data when the workspace target changes --- BRANCH_DETAILS.md | 2 +- .../lib/providerWorkspaceSkillsState.test.ts | 28 +++++++++++++++++++ .../src/lib/providerWorkspaceSkillsState.ts | 8 ++++-- .../src/state/providerWorkspaceSkills.test.ts | 28 +++++++++++++++++++ .../src/state/providerWorkspaceSkills.ts | 4 +++ 5 files changed, 66 insertions(+), 4 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index b5b71e03518..a0c70ac33ad 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -11,7 +11,7 @@ Expected behavior: - Provider skill-list failures preserve structured reason, operation, provider instance, normalized cwd, and bounded cause diagnostics for missing providers, invalid cwd, settings failures, Codex home preparation, probe timeouts, and probe failures while keeping stable user-facing messages. Raw thrown values are not sent directly to clients; the server keeps a small plain diagnostic shape so file paths, process output, and unexpected objects do not expand the wire payload. - Non-Codex or disabled providers keep returning provider snapshot skills instead of failing workspace skill search. - The client runtime keys provider-skill query state by environment, provider instance, and cwd, with a bounded stale window so reconnects refresh workspace-local skills without reusing another workspace's snapshot. Client-side fallback skill changes do not refresh the workspace query or respawn Codex skill probes. -- Web workspace-skill lookup follows the route environment's connection state. While disconnected, it does not start an RPC, retains only verified skills for the same environment/provider/cwd, otherwise falls back to provider snapshot skills with a non-pending reconnect error, and resumes the workspace refresh when the environment reconnects. +- Web workspace-skill lookup follows the route environment's connection state. While disconnected, it does not start an RPC, retains only verified skills for the same environment/provider/cwd across lazy menu close/reopen cycles, otherwise falls back to provider snapshot skills with a non-pending reconnect error, and resumes the workspace refresh when the environment reconnects. - The composer loads workspace skills lazily: it starts workspace skill discovery when the `$` skill menu is active or the prompt already contains a complete `$skill` token, rather than probing on every empty composer mount. It preserves already loaded repo-local skills while refreshing the same workspace, falls back to provider snapshot skills when a settled workspace lookup returns no skills or errors, keeps structured lookup errors visible alongside those fallback skills on web and mobile, and clears stale repo-local skills during workspace switches or settled no-data states. - The conversation timeline renders sent user prompts against the same workspace-aware skill list as the composer, so repo-local `$skill-name` references display with the same skill chip treatment as user-level skills. Timeline lookup stays disabled until a sent user prompt contains a complete skill token, including one at the end of the message, so an empty draft does not probe Codex merely to decorate nonexistent messages. - The shared client-runtime policy also drives mobile thread composers and feeds. Mobile shows loading and structured error feedback, refreshes an already-open `$` menu when skills arrive, decorates complete skill references, and prevents stale successful results from surviving a failed refresh. diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.test.ts b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts index 4219767b631..a35940edebe 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.test.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.test.ts @@ -325,6 +325,34 @@ describe("resolveNextProviderWorkspaceSkillsSnapshot", () => { ).toBeNull(); }); + it("preserves only the same target snapshot while lookup is inactive", () => { + const current = { + key: "environment:codex:/repo", + skills: [skill("repo-local")], + }; + + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: current.key, + skills: null, + isPending: false, + error: null, + inactive: true, + current, + }), + ).toBe(current); + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "environment:codex:/other-repo", + skills: null, + isPending: false, + error: null, + inactive: true, + current, + }), + ).toBeNull(); + }); + it("clears the snapshot after a settled query without data", () => { expect( resolveNextProviderWorkspaceSkillsSnapshot({ diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts index e4a597c436e..ead957b1697 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -33,7 +33,8 @@ export function useProviderWorkspaceSkills( target.instanceId, ], ); - const key = providerWorkspaceSkillsTargetKey(stableTarget); + const targetKey = providerWorkspaceSkillsTargetKey({ ...stableTarget, enabled: true }); + const key = stableTarget.enabled ? targetKey : null; const unavailable = key !== null && !stableTarget.connectionAvailable; const query = useEnvironmentQuery( key !== null && @@ -54,14 +55,15 @@ export function useProviderWorkspaceSkills( const querySkills = query.data?.skills ?? null; useEffect(() => { previousWorkspaceSkillsRef.current = resolveNextProviderWorkspaceSkillsSnapshot({ - key, + key: targetKey, skills: querySkills, isPending: query.isPending, error: query.error, + inactive: key === null, unavailable, current: previousWorkspaceSkillsRef.current, }); - }, [key, query.error, query.isPending, querySkills, unavailable]); + }, [key, query.error, query.isPending, querySkills, targetKey, unavailable]); if (key === null) { return { skills: target.fallbackSkills, isPending: false, error: null }; diff --git a/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts b/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts index 4861b46a6ac..139a3d514b9 100644 --- a/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts +++ b/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts @@ -159,6 +159,34 @@ describe("resolveProviderWorkspaceSkills", () => { }); describe("resolveNextProviderWorkspaceSkillsSnapshot", () => { + it("preserves only the same target snapshot while lookup is inactive", () => { + const current = { + key: "local:codex:/repo", + skills: [skill("repo-local")], + }; + + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: current.key, + skills: null, + isPending: false, + error: null, + inactive: true, + current, + }), + ).toBe(current); + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "local:codex:/other-repo", + skills: null, + isPending: false, + error: null, + inactive: true, + current, + }), + ).toBeNull(); + }); + it("keeps the settled snapshot during refresh and clears it when disabled", () => { const current = { key: "local:codex:/repo", diff --git a/packages/client-runtime/src/state/providerWorkspaceSkills.ts b/packages/client-runtime/src/state/providerWorkspaceSkills.ts index 5b73854b8e0..30546430e41 100644 --- a/packages/client-runtime/src/state/providerWorkspaceSkills.ts +++ b/packages/client-runtime/src/state/providerWorkspaceSkills.ts @@ -122,10 +122,14 @@ export function resolveNextProviderWorkspaceSkillsSnapshot(input: { readonly skills: ReadonlyArray | null; readonly isPending: boolean; readonly error: string | null; + readonly inactive?: boolean; readonly unavailable?: boolean; readonly current: ProviderWorkspaceSkillsSnapshot | null; }): ProviderWorkspaceSkillsSnapshot | null { if (input.key === null) return null; + if (input.inactive === true) { + return input.current?.key === input.key ? input.current : null; + } if (input.unavailable === true) { return input.current?.key === input.key ? input.current : null; } From 1ea2f6728341ab92028eb198131a5c75cc8a89ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Wed, 22 Jul 2026 12:13:12 +0100 Subject: [PATCH 49/55] Keep disconnected empty-skill fallback --- .../src/state/providerWorkspaceSkills.test.ts | 10 +++++----- .../src/state/providerWorkspaceSkills.ts | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts b/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts index 139a3d514b9..d273cb20c8b 100644 --- a/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts +++ b/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts @@ -94,8 +94,8 @@ describe("resolveProviderWorkspaceSkills", () => { ).toBe(currentSkills); }); - it("preserves an empty same-workspace snapshot while the environment is unavailable", () => { - const currentSkills: ReadonlyArray = []; + it("uses provider fallback for an empty same-workspace snapshot while unavailable", () => { + const fallbackSkills = [skill("provider-fallback")]; expect( resolveProviderWorkspaceSkills({ @@ -105,10 +105,10 @@ describe("resolveProviderWorkspaceSkills", () => { error: null, unavailable: true, currentKey: "local:codex:/repo", - currentSkills, - fallbackSkills: [skill("provider-fallback")], + currentSkills: [], + fallbackSkills, }), - ).toBe(currentSkills); + ).toBe(fallbackSkills); }); it("uses provider fallback skills when a different workspace is unavailable", () => { diff --git a/packages/client-runtime/src/state/providerWorkspaceSkills.ts b/packages/client-runtime/src/state/providerWorkspaceSkills.ts index 30546430e41..c4c6907dcbc 100644 --- a/packages/client-runtime/src/state/providerWorkspaceSkills.ts +++ b/packages/client-runtime/src/state/providerWorkspaceSkills.ts @@ -101,7 +101,8 @@ export function resolveProviderWorkspaceSkills( input: ProviderWorkspaceSkillsResolutionInput, ): ReadonlyArray { if (input.unavailable === true) { - return input.currentKey === input.nextKey ? input.currentSkills : input.fallbackSkills; + const currentSkills = resolvePendingProviderWorkspaceSkills(input); + return currentSkills.length > 0 ? currentSkills : input.fallbackSkills; } // AsyncResult failures can retain a previous success for stale-while-revalidate. // A failed workspace refresh must still fall back to the provider snapshot rather From 8b52e81f2ebbfb5bb7cf8e52a1e468b2b2ca61c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 23 Jul 2026 20:31:56 +0100 Subject: [PATCH 50/55] Fix mobile workspace skill state handling - Follow mobile environment connectivity and lazy lookup state - Clear retained skill snapshots across workspace switches --- BRANCH_DETAILS.md | 2 +- .../features/threads/ThreadDetailScreen.tsx | 1 + .../threads/new-task-flow-provider.tsx | 10 ++++++ .../src/state/providerWorkspaceSkillsState.ts | 32 +++++++++++++++---- .../src/state/providerWorkspaceSkills.test.ts | 15 +++++++++ .../src/state/providerWorkspaceSkills.ts | 9 +++--- 6 files changed, 57 insertions(+), 12 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index a0c70ac33ad..aeda7872ba5 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -14,7 +14,7 @@ Expected behavior: - Web workspace-skill lookup follows the route environment's connection state. While disconnected, it does not start an RPC, retains only verified skills for the same environment/provider/cwd across lazy menu close/reopen cycles, otherwise falls back to provider snapshot skills with a non-pending reconnect error, and resumes the workspace refresh when the environment reconnects. - The composer loads workspace skills lazily: it starts workspace skill discovery when the `$` skill menu is active or the prompt already contains a complete `$skill` token, rather than probing on every empty composer mount. It preserves already loaded repo-local skills while refreshing the same workspace, falls back to provider snapshot skills when a settled workspace lookup returns no skills or errors, keeps structured lookup errors visible alongside those fallback skills on web and mobile, and clears stale repo-local skills during workspace switches or settled no-data states. - The conversation timeline renders sent user prompts against the same workspace-aware skill list as the composer, so repo-local `$skill-name` references display with the same skill chip treatment as user-level skills. Timeline lookup stays disabled until a sent user prompt contains a complete skill token, including one at the end of the message, so an empty draft does not probe Codex merely to decorate nonexistent messages. -- The shared client-runtime policy also drives mobile thread composers and feeds. Mobile shows loading and structured error feedback, refreshes an already-open `$` menu when skills arrive, decorates complete skill references, and prevents stale successful results from surviving a failed refresh. +- The shared client-runtime policy also drives mobile thread composers and feeds. Mobile follows the selected environment's connection state, retains only verified same-workspace skills while disconnected or across lazy lookup close/reopen cycles, shows loading and structured reconnect/error feedback, refreshes an already-open `$` menu when skills arrive, decorates complete skill references, and prevents stale successful results from surviving failed refreshes or workspace switches. - New-task drafts request workspace skills lazily only after a complete `$skill` reference. Local drafts resolve the selected checkout or project root, while future-worktree drafts deliberately have no cwd and use provider snapshots until the target directory exists. Primary files: diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 2a32e5760d5..fce21651248 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -238,6 +238,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread instanceId: selectedInstanceId, cwd: props.threadCwd ?? props.projectWorkspaceRoot, enabled: true, + connectionAvailable: props.connectionStateLabel === "connected", fallbackSkills: selectedProviderFallbackSkills, }); diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 3dc094192d2..28a227f14cf 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -51,6 +51,7 @@ import { } from "../../state/use-thread-outbox"; import { setPendingConnectionError, + useRemoteConnectionStatus, useSavedRemoteConnections, } from "../../state/use-remote-environment-registry"; import { useProviderWorkspaceSkills } from "../../state/providerWorkspaceSkillsState"; @@ -175,6 +176,7 @@ const NewTaskFlowContext = React.createContext(n export function NewTaskFlowProvider(props: React.PropsWithChildren) { const projects = useProjects(); const threads = useThreadShells(); + const { connectedEnvironments } = useRemoteConnectionStatus(); const { savedConnectionsById } = useSavedRemoteConnections(); const repositoryGroups = useMemo( @@ -283,6 +285,13 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { scopedProjectKey(editingPendingProject.environmentId, editingPendingProject.id) ? editingPendingProject : (projectsForEnvironment[0] ?? null)); + const providerSkillsConnectionAvailable = + selectedProject !== null && + connectedEnvironments.some( + (environment) => + environment.environmentId === selectedProject.environmentId && + environment.connectionState === "connected", + ); // Only offer machines that actually host the currently selected repository, so // switching computers moves the same repo across machines instead of jumping to @@ -419,6 +428,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { projectWorkspaceRoot: selectedProject?.workspaceRoot ?? null, }), enabled: promptNeedsWorkspaceSkills, + connectionAvailable: providerSkillsConnectionAvailable, fallbackSkills: selectedProviderFallbackSkills, }).skills; const setSelectedModelKey = useCallback( diff --git a/apps/mobile/src/state/providerWorkspaceSkillsState.ts b/apps/mobile/src/state/providerWorkspaceSkillsState.ts index a70326164f7..2efd810717c 100644 --- a/apps/mobile/src/state/providerWorkspaceSkillsState.ts +++ b/apps/mobile/src/state/providerWorkspaceSkillsState.ts @@ -1,6 +1,7 @@ import { EMPTY_PROVIDER_WORKSPACE_SKILLS, formatProviderWorkspaceSkillsError, + PROVIDER_WORKSPACE_SKILLS_UNAVAILABLE_MESSAGE, providerWorkspaceSkillsTargetKey, resolveNextProviderWorkspaceSkillsSnapshot, resolveProviderWorkspaceSkills, @@ -22,12 +23,24 @@ export function useProviderWorkspaceSkills( instanceId: target.instanceId, cwd: target.cwd?.trim() || null, enabled: target.enabled, + connectionAvailable: target.connectionAvailable !== false, }), - [target.cwd, target.enabled, target.environmentId, target.instanceId], + [ + target.connectionAvailable, + target.cwd, + target.enabled, + target.environmentId, + target.instanceId, + ], ); - const key = providerWorkspaceSkillsTargetKey(stableTarget); + const targetKey = providerWorkspaceSkillsTargetKey({ ...stableTarget, enabled: true }); + const key = stableTarget.enabled ? targetKey : null; + const unavailable = key !== null && !stableTarget.connectionAvailable; const query = useEnvironmentQuery( - key !== null && stableTarget.environmentId !== null && stableTarget.instanceId !== null + key !== null && + !unavailable && + stableTarget.environmentId !== null && + stableTarget.instanceId !== null ? serverEnvironment.providerSkills({ environmentId: stableTarget.environmentId, input: { @@ -42,13 +55,15 @@ export function useProviderWorkspaceSkills( const querySkills = query.data?.skills ?? null; useEffect(() => { previousWorkspaceSkillsRef.current = resolveNextProviderWorkspaceSkillsSnapshot({ - key, + key: targetKey, skills: querySkills, isPending: query.isPending, error: query.error, + inactive: key === null, + unavailable, current: previousWorkspaceSkillsRef.current, }); - }, [key, query.error, query.isPending, querySkills]); + }, [key, query.error, query.isPending, querySkills, targetKey, unavailable]); if (key === null) { return { skills: target.fallbackSkills, isPending: false, error: null }; @@ -60,11 +75,14 @@ export function useProviderWorkspaceSkills( nextSkills: querySkills, isPending: query.isPending, error: query.error, + unavailable, currentKey: previousWorkspaceSkills?.key ?? null, currentSkills: previousWorkspaceSkills?.skills ?? EMPTY_PROVIDER_WORKSPACE_SKILLS, fallbackSkills: target.fallbackSkills, }), - isPending: query.isPending, - error: formatProviderWorkspaceSkillsError({ error: query.error, cause: query.errorCause }), + isPending: unavailable ? false : query.isPending, + error: unavailable + ? PROVIDER_WORKSPACE_SKILLS_UNAVAILABLE_MESSAGE + : formatProviderWorkspaceSkillsError({ error: query.error, cause: query.errorCause }), }; } diff --git a/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts b/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts index d273cb20c8b..0f20227a714 100644 --- a/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts +++ b/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts @@ -213,6 +213,21 @@ describe("resolveNextProviderWorkspaceSkillsSnapshot", () => { ).toBeNull(); }); + it("clears a different workspace snapshot while the next lookup is pending", () => { + expect( + resolveNextProviderWorkspaceSkillsSnapshot({ + key: "local:codex:/repo-b", + skills: null, + isPending: true, + error: null, + current: { + key: "local:codex:/repo-a", + skills: [skill("repo-a")], + }, + }), + ).toBeNull(); + }); + it("clears a stale snapshot after a failed refresh", () => { expect( resolveNextProviderWorkspaceSkillsSnapshot({ diff --git a/packages/client-runtime/src/state/providerWorkspaceSkills.ts b/packages/client-runtime/src/state/providerWorkspaceSkills.ts index c4c6907dcbc..f682e57cd2d 100644 --- a/packages/client-runtime/src/state/providerWorkspaceSkills.ts +++ b/packages/client-runtime/src/state/providerWorkspaceSkills.ts @@ -128,13 +128,14 @@ export function resolveNextProviderWorkspaceSkillsSnapshot(input: { readonly current: ProviderWorkspaceSkillsSnapshot | null; }): ProviderWorkspaceSkillsSnapshot | null { if (input.key === null) return null; + const current = input.current?.key === input.key ? input.current : null; if (input.inactive === true) { - return input.current?.key === input.key ? input.current : null; + return current; } if (input.unavailable === true) { - return input.current?.key === input.key ? input.current : null; + return current; } if (input.error !== null) return null; - if (input.skills === null) return input.isPending ? input.current : null; - return input.isPending ? input.current : { key: input.key, skills: input.skills }; + if (input.skills === null) return input.isPending ? current : null; + return input.isPending ? current : { key: input.key, skills: input.skills }; } From bb00f1d4fc97e1de16065e54ca2118a789637671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Thu, 23 Jul 2026 23:44:20 +0100 Subject: [PATCH 51/55] Retry failed provider skill lookups immediately - Cache successful workspace skill probes for the configured TTL - Expire failed probes immediately and cover retry behavior - Document the success-only cache policy in branch details --- BRANCH_DETAILS.md | 2 +- .../src/provider/ProviderSkillsLister.test.ts | 21 +++++++++++++++++++ .../src/provider/ProviderSkillsLister.ts | 6 +++--- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index aeda7872ba5..003cf14a03d 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -6,7 +6,7 @@ Expected behavior: - Repo-local Codex skills for the active workspace appear in the `$` skill picker. - The server exposes a workspace-aware `server.listProviderSkills` path and validates enabled Codex skill-listing requests against the requested cwd. -- The server routes skill listing through a bounded request lister that coalesces concurrent requests for the same provider/cwd, limits cross-workspace concurrency, and applies a short TTL so reconnects or repeated composer renders do not repeatedly spawn Codex app-server probes. +- The server routes skill listing through a bounded request lister that coalesces concurrent requests for the same provider/cwd, limits cross-workspace concurrency, and applies a short TTL only to successful lookups so reconnects or repeated composer renders do not repeatedly spawn Codex app-server probes while transient failures remain immediately retryable. - The Codex provider requests `skills/list` with the current workspace cwd, times out hung app-server probes, and terminates the probe process when a timeout occurs. - Provider skill-list failures preserve structured reason, operation, provider instance, normalized cwd, and bounded cause diagnostics for missing providers, invalid cwd, settings failures, Codex home preparation, probe timeouts, and probe failures while keeping stable user-facing messages. Raw thrown values are not sent directly to clients; the server keeps a small plain diagnostic shape so file paths, process output, and unexpected objects do not expand the wire payload. - Non-Codex or disabled providers keep returning provider snapshot skills instead of failing workspace skill search. diff --git a/apps/server/src/provider/ProviderSkillsLister.test.ts b/apps/server/src/provider/ProviderSkillsLister.test.ts index 4a134e0318c..b7249269cbc 100644 --- a/apps/server/src/provider/ProviderSkillsLister.test.ts +++ b/apps/server/src/provider/ProviderSkillsLister.test.ts @@ -75,4 +75,25 @@ describe("makeBoundedRequestCache", () => { expect(yield* Fiber.join(fibers)).toEqual(["one", "two", "three"]); }), ); + + it.effect("retries immediately after a failed lookup", () => + Effect.gen(function* () { + const calls = yield* Ref.make(0); + const requests = yield* makeBoundedRequestCache({ + capacity: 8, + concurrency: 2, + timeToLive: "1 second", + lookup: (key: string) => + Ref.getAndUpdate(calls, (count) => count + 1).pipe( + Effect.flatMap((count) => + count === 0 ? Effect.fail("transient") : Effect.succeed(`value:${key}`), + ), + ), + }); + + expect(yield* Effect.flip(requests.get("repo"))).toBe("transient"); + expect(yield* requests.get("repo")).toBe("value:repo"); + expect(yield* Ref.get(calls)).toBe(2); + }), + ); }); diff --git a/apps/server/src/provider/ProviderSkillsLister.ts b/apps/server/src/provider/ProviderSkillsLister.ts index 93daca1fff5..d02509b1c2a 100644 --- a/apps/server/src/provider/ProviderSkillsLister.ts +++ b/apps/server/src/provider/ProviderSkillsLister.ts @@ -9,6 +9,7 @@ import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; @@ -69,10 +70,9 @@ export const makeBoundedRequestCache = Effect.fn("makeBoundedRequestCache")(func readonly lookup: (key: Key) => Effect.Effect; }): Effect.fn.Return, never, R> { const semaphore = yield* Semaphore.make(options.concurrency); - const cache = yield* Cache.make({ + const cache = yield* Cache.makeWith((key: Key) => semaphore.withPermits(1)(options.lookup(key)), { capacity: options.capacity, - timeToLive: options.timeToLive, - lookup: (key: Key) => semaphore.withPermits(1)(options.lookup(key)), + timeToLive: (exit) => (Exit.isSuccess(exit) ? options.timeToLive : Duration.zero), }); return { get: (key) => Cache.get(cache, key), From 9388e7fe99df4d642d9ef7a0ba53fd358ca4b4e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 24 Jul 2026 08:28:23 +0100 Subject: [PATCH 52/55] Make mobile thread skill lookup lazy - Activate lookups from the skill menu, draft, or user feed - Cover empty threads and non-user skill references --- BRANCH_DETAILS.md | 1 + .../src/features/threads/ThreadComposer.tsx | 7 +++ .../features/threads/ThreadDetailScreen.tsx | 30 +++++++++- .../threads/thread-provider-skills.test.ts | 59 +++++++++++++++++++ .../threads/thread-provider-skills.ts | 31 ++++++++++ 5 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/features/threads/thread-provider-skills.test.ts create mode 100644 apps/mobile/src/features/threads/thread-provider-skills.ts diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 003cf14a03d..3023b55b403 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -15,6 +15,7 @@ Expected behavior: - The composer loads workspace skills lazily: it starts workspace skill discovery when the `$` skill menu is active or the prompt already contains a complete `$skill` token, rather than probing on every empty composer mount. It preserves already loaded repo-local skills while refreshing the same workspace, falls back to provider snapshot skills when a settled workspace lookup returns no skills or errors, keeps structured lookup errors visible alongside those fallback skills on web and mobile, and clears stale repo-local skills during workspace switches or settled no-data states. - The conversation timeline renders sent user prompts against the same workspace-aware skill list as the composer, so repo-local `$skill-name` references display with the same skill chip treatment as user-level skills. Timeline lookup stays disabled until a sent user prompt contains a complete skill token, including one at the end of the message, so an empty draft does not probe Codex merely to decorate nonexistent messages. - The shared client-runtime policy also drives mobile thread composers and feeds. Mobile follows the selected environment's connection state, retains only verified same-workspace skills while disconnected or across lazy lookup close/reopen cycles, shows loading and structured reconnect/error feedback, refreshes an already-open `$` menu when skills arrive, decorates complete skill references, and prevents stale successful results from surviving failed refreshes or workspace switches. +- Mobile thread detail keeps workspace lookup lazy: it activates only while the composer `$` menu is active, when the draft contains a complete skill token, or when a visible sent user prompt contains a complete skill reference. - New-task drafts request workspace skills lazily only after a complete `$skill` reference. Local drafts resolve the selected checkout or project root, while future-worktree drafts deliberately have no cwd and use provider snapshots until the target directory exists. Primary files: diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 0d2f88eeb21..eb5005fa92b 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -115,6 +115,7 @@ export interface ThreadComposerProps { readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; readonly onReconnectEnvironment: () => void; readonly onExpandedChange?: (expanded: boolean) => void; + readonly onWorkspaceSkillsLookupActiveChange?: (active: boolean) => void; } /** @@ -360,6 +361,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } return detectComposerTrigger(props.draftMessage, composerSelection.end); }, [composerSelection, props.draftMessage]); + const workspaceSkillsLookupActive = composerTrigger?.kind === "skill"; + const { onWorkspaceSkillsLookupActiveChange } = props; + useEffect(() => { + onWorkspaceSkillsLookupActiveChange?.(workspaceSkillsLookupActive); + return () => onWorkspaceSkillsLookupActiveChange?.(false); + }, [onWorkspaceSkillsLookupActiveChange, workspaceSkillsLookupActive]); const pathSearch = useComposerPathSearch({ environmentId: props.environmentId, cwd: composerTrigger?.kind === "path" ? props.projectCwd : null, diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index fce21651248..fdfbefbc0ee 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -42,6 +42,7 @@ import { } from "./ThreadComposer"; import { ThreadFeed } from "./ThreadFeed"; import type { ThreadContentPresentation } from "./threadContentPresentation"; +import { shouldLoadThreadProviderWorkspaceSkills } from "./thread-provider-skills"; import { useProviderWorkspaceSkills } from "../../state/providerWorkspaceSkillsState"; export interface ThreadDetailScreenProps { @@ -182,6 +183,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const selectedThreadKeyRef = useRef(selectedThreadKey); const lastScrolledAnchorMessageIdRef = useRef(null); const [composerExpanded, setComposerExpanded] = useState(false); + const [composerSkillMenuTargetKey, setComposerSkillMenuTargetKey] = useState(null); const [anchorMessageId, setAnchorMessageId] = useState(null); const composerBottomInset = composerExpanded ? 0 : Math.max(insets.bottom, 12); const contentPresentationKind = props.contentPresentation.kind; @@ -233,11 +235,36 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread ?.skills ?? [], [props.serverConfig, selectedInstanceId], ); + const handleWorkspaceSkillsLookupActiveChange = useCallback( + (active: boolean) => { + const nextTargetKey = active ? selectedThreadKey : null; + setComposerSkillMenuTargetKey((current) => + current === nextTargetKey ? current : nextTargetKey, + ); + }, + [selectedThreadKey], + ); + const providerWorkspaceSkillsLookupEnabled = useMemo( + () => + showContent && + shouldLoadThreadProviderWorkspaceSkills({ + composerSkillMenuActive: composerSkillMenuTargetKey === selectedThreadKey, + draftMessage: props.draftMessage, + feed: selectedThreadFeed, + }), + [ + composerSkillMenuTargetKey, + props.draftMessage, + selectedThreadFeed, + selectedThreadKey, + showContent, + ], + ); const selectedProviderWorkspaceSkills = useProviderWorkspaceSkills({ environmentId: props.environmentId, instanceId: selectedInstanceId, cwd: props.threadCwd ?? props.projectWorkspaceRoot, - enabled: true, + enabled: providerWorkspaceSkillsLookupEnabled, connectionAvailable: props.connectionStateLabel === "connected", fallbackSkills: selectedProviderFallbackSkills, }); @@ -457,6 +484,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onUpdateRuntimeMode={props.onUpdateThreadRuntimeMode} onUpdateInteractionMode={props.onUpdateThreadInteractionMode} onExpandedChange={setComposerExpanded} + onWorkspaceSkillsLookupActiveChange={handleWorkspaceSkillsLookupActiveChange} /> diff --git a/apps/mobile/src/features/threads/thread-provider-skills.test.ts b/apps/mobile/src/features/threads/thread-provider-skills.test.ts new file mode 100644 index 00000000000..9a7a9deca08 --- /dev/null +++ b/apps/mobile/src/features/threads/thread-provider-skills.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { shouldLoadThreadProviderWorkspaceSkills } from "./thread-provider-skills"; + +function feedMessage(role: string, text: string) { + return { + type: "message", + message: { role, text }, + }; +} + +describe("shouldLoadThreadProviderWorkspaceSkills", () => { + it("keeps an empty thread composer lazy", () => { + expect( + shouldLoadThreadProviderWorkspaceSkills({ + composerSkillMenuActive: false, + draftMessage: "", + feed: [], + }), + ).toBe(false); + }); + + it("loads while the composer skill menu is active", () => { + expect( + shouldLoadThreadProviderWorkspaceSkills({ + composerSkillMenuActive: true, + draftMessage: "$review-follow-up", + feed: [], + }), + ).toBe(true); + }); + + it("loads for complete draft and sent user skill references", () => { + expect( + shouldLoadThreadProviderWorkspaceSkills({ + composerSkillMenuActive: false, + draftMessage: "Use $review-follow-up next", + feed: [], + }), + ).toBe(true); + expect( + shouldLoadThreadProviderWorkspaceSkills({ + composerSkillMenuActive: false, + draftMessage: "", + feed: [feedMessage("user", "Use $review-follow-up")], + }), + ).toBe(true); + }); + + it("ignores assistant references and incomplete inactive drafts", () => { + expect( + shouldLoadThreadProviderWorkspaceSkills({ + composerSkillMenuActive: false, + draftMessage: "Use $review-follow-up", + feed: [feedMessage("assistant", "Try $review-follow-up")], + }), + ).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-provider-skills.ts b/apps/mobile/src/features/threads/thread-provider-skills.ts new file mode 100644 index 00000000000..71c545378fe --- /dev/null +++ b/apps/mobile/src/features/threads/thread-provider-skills.ts @@ -0,0 +1,31 @@ +import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; +import { hasInlineSkillToken } from "@t3tools/shared/skillInlineTokens"; + +interface ThreadProviderSkillFeedEntry { + readonly type: string; + readonly message?: { + readonly role: string; + readonly text: string; + }; +} + +export function shouldLoadThreadProviderWorkspaceSkills(input: { + readonly composerSkillMenuActive: boolean; + readonly draftMessage: string; + readonly feed: ReadonlyArray; +}): boolean { + if (input.composerSkillMenuActive) { + return true; + } + + if (collectComposerInlineTokens(input.draftMessage).some((token) => token.type === "skill")) { + return true; + } + + return input.feed.some( + (entry) => + entry.type === "message" && + entry.message?.role === "user" && + hasInlineSkillToken(entry.message.text), + ); +} From cfe3942c070fbc052f2ea914a4f8567529be7f46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Fri, 24 Jul 2026 17:49:24 +0100 Subject: [PATCH 53/55] Harden workspace skill request handling - Normalize cache keys before bounded provider-skill lookups - Share workspace query policy across web and mobile adapters - Remove dead sanitizer logic and test bounded error projection --- BRANCH_DETAILS.md | 4 +- .../src/state/providerWorkspaceSkillsState.ts | 75 +++--------- .../server/src/diagnostics/ErrorCause.test.ts | 29 +++++ apps/server/src/diagnostics/ErrorCause.ts | 7 -- .../src/provider/ProviderSkillsLister.test.ts | 37 +++++- .../src/provider/ProviderSkillsLister.ts | 9 +- apps/server/src/workspace/WorkspacePaths.ts | 6 +- .../src/lib/providerWorkspaceSkillsState.ts | 75 +++--------- .../src/state/providerWorkspaceSkills.test.ts | 74 ++++++++++++ .../src/state/providerWorkspaceSkills.ts | 109 +++++++++++++++++- 10 files changed, 290 insertions(+), 135 deletions(-) create mode 100644 apps/server/src/diagnostics/ErrorCause.test.ts diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index 3023b55b403..f0b755fedcd 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -6,7 +6,7 @@ Expected behavior: - Repo-local Codex skills for the active workspace appear in the `$` skill picker. - The server exposes a workspace-aware `server.listProviderSkills` path and validates enabled Codex skill-listing requests against the requested cwd. -- The server routes skill listing through a bounded request lister that coalesces concurrent requests for the same provider/cwd, limits cross-workspace concurrency, and applies a short TTL only to successful lookups so reconnects or repeated composer renders do not repeatedly spawn Codex app-server probes while transient failures remain immediately retryable. +- The server routes skill listing through a bounded request lister that keys equivalent requests by normalized workspace root, coalesces concurrent requests for the same provider/cwd, limits cross-workspace concurrency, and applies a short TTL only to successful lookups so reconnects or repeated composer renders do not repeatedly spawn Codex app-server probes while transient failures remain immediately retryable. - The Codex provider requests `skills/list` with the current workspace cwd, times out hung app-server probes, and terminates the probe process when a timeout occurs. - Provider skill-list failures preserve structured reason, operation, provider instance, normalized cwd, and bounded cause diagnostics for missing providers, invalid cwd, settings failures, Codex home preparation, probe timeouts, and probe failures while keeping stable user-facing messages. Raw thrown values are not sent directly to clients; the server keeps a small plain diagnostic shape so file paths, process output, and unexpected objects do not expand the wire payload. - Non-Codex or disabled providers keep returning provider snapshot skills instead of failing workspace skill search. @@ -14,7 +14,7 @@ Expected behavior: - Web workspace-skill lookup follows the route environment's connection state. While disconnected, it does not start an RPC, retains only verified skills for the same environment/provider/cwd across lazy menu close/reopen cycles, otherwise falls back to provider snapshot skills with a non-pending reconnect error, and resumes the workspace refresh when the environment reconnects. - The composer loads workspace skills lazily: it starts workspace skill discovery when the `$` skill menu is active or the prompt already contains a complete `$skill` token, rather than probing on every empty composer mount. It preserves already loaded repo-local skills while refreshing the same workspace, falls back to provider snapshot skills when a settled workspace lookup returns no skills or errors, keeps structured lookup errors visible alongside those fallback skills on web and mobile, and clears stale repo-local skills during workspace switches or settled no-data states. - The conversation timeline renders sent user prompts against the same workspace-aware skill list as the composer, so repo-local `$skill-name` references display with the same skill chip treatment as user-level skills. Timeline lookup stays disabled until a sent user prompt contains a complete skill token, including one at the end of the message, so an empty draft does not probe Codex merely to decorate nonexistent messages. -- The shared client-runtime policy also drives mobile thread composers and feeds. Mobile follows the selected environment's connection state, retains only verified same-workspace skills while disconnected or across lazy lookup close/reopen cycles, shows loading and structured reconnect/error feedback, refreshes an already-open `$` menu when skills arrive, decorates complete skill references, and prevents stale successful results from surviving failed refreshes or workspace switches. +- The shared client-runtime policy, including target preparation, snapshot transitions, fallback selection, and error formatting, drives both web and mobile adapters. Mobile follows the selected environment's connection state, retains only verified same-workspace skills while disconnected or across lazy lookup close/reopen cycles, shows loading and structured reconnect/error feedback, refreshes an already-open `$` menu when skills arrive, decorates complete skill references, and prevents stale successful results from surviving failed refreshes or workspace switches. - Mobile thread detail keeps workspace lookup lazy: it activates only while the composer `$` menu is active, when the draft contains a complete skill token, or when a visible sent user prompt contains a complete skill reference. - New-task drafts request workspace skills lazily only after a complete `$skill` reference. Local drafts resolve the selected checkout or project root, while future-worktree drafts deliberately have no cwd and use provider snapshots until the target directory exists. diff --git a/apps/mobile/src/state/providerWorkspaceSkillsState.ts b/apps/mobile/src/state/providerWorkspaceSkillsState.ts index 2efd810717c..edf26441266 100644 --- a/apps/mobile/src/state/providerWorkspaceSkillsState.ts +++ b/apps/mobile/src/state/providerWorkspaceSkillsState.ts @@ -1,10 +1,6 @@ import { - EMPTY_PROVIDER_WORKSPACE_SKILLS, - formatProviderWorkspaceSkillsError, - PROVIDER_WORKSPACE_SKILLS_UNAVAILABLE_MESSAGE, - providerWorkspaceSkillsTargetKey, - resolveNextProviderWorkspaceSkillsSnapshot, - resolveProviderWorkspaceSkills, + prepareProviderWorkspaceSkillsTarget, + resolveProviderWorkspaceSkillsQuery, type ProviderWorkspaceSkillsSnapshot, type ProviderWorkspaceSkillsState, type ProviderWorkspaceSkillsTarget, @@ -17,14 +13,8 @@ import { serverEnvironment } from "./server"; export function useProviderWorkspaceSkills( target: ProviderWorkspaceSkillsTarget, ): ProviderWorkspaceSkillsState { - const stableTarget = useMemo( - () => ({ - environmentId: target.environmentId, - instanceId: target.instanceId, - cwd: target.cwd?.trim() || null, - enabled: target.enabled, - connectionAvailable: target.connectionAvailable !== false, - }), + const preparedTarget = useMemo( + () => prepareProviderWorkspaceSkillsTarget(target), [ target.connectionAvailable, target.cwd, @@ -33,56 +23,21 @@ export function useProviderWorkspaceSkills( target.instanceId, ], ); - const targetKey = providerWorkspaceSkillsTargetKey({ ...stableTarget, enabled: true }); - const key = stableTarget.enabled ? targetKey : null; - const unavailable = key !== null && !stableTarget.connectionAvailable; const query = useEnvironmentQuery( - key !== null && - !unavailable && - stableTarget.environmentId !== null && - stableTarget.instanceId !== null - ? serverEnvironment.providerSkills({ - environmentId: stableTarget.environmentId, - input: { - instanceId: stableTarget.instanceId, - cwd: stableTarget.cwd!, - }, - }) + preparedTarget.queryTarget !== null + ? serverEnvironment.providerSkills(preparedTarget.queryTarget) : null, ); const previousWorkspaceSkillsRef = useRef(null); - const querySkills = query.data?.skills ?? null; + const resolution = resolveProviderWorkspaceSkillsQuery({ + target: preparedTarget, + query, + fallbackSkills: target.fallbackSkills, + current: previousWorkspaceSkillsRef.current, + }); useEffect(() => { - previousWorkspaceSkillsRef.current = resolveNextProviderWorkspaceSkillsSnapshot({ - key: targetKey, - skills: querySkills, - isPending: query.isPending, - error: query.error, - inactive: key === null, - unavailable, - current: previousWorkspaceSkillsRef.current, - }); - }, [key, query.error, query.isPending, querySkills, targetKey, unavailable]); - - if (key === null) { - return { skills: target.fallbackSkills, isPending: false, error: null }; - } - const previousWorkspaceSkills = previousWorkspaceSkillsRef.current; - return { - skills: resolveProviderWorkspaceSkills({ - nextKey: key, - nextSkills: querySkills, - isPending: query.isPending, - error: query.error, - unavailable, - currentKey: previousWorkspaceSkills?.key ?? null, - currentSkills: previousWorkspaceSkills?.skills ?? EMPTY_PROVIDER_WORKSPACE_SKILLS, - fallbackSkills: target.fallbackSkills, - }), - isPending: unavailable ? false : query.isPending, - error: unavailable - ? PROVIDER_WORKSPACE_SKILLS_UNAVAILABLE_MESSAGE - : formatProviderWorkspaceSkillsError({ error: query.error, cause: query.errorCause }), - }; + previousWorkspaceSkillsRef.current = resolution.snapshot; + }, [resolution.snapshot]); + return resolution.state; } diff --git a/apps/server/src/diagnostics/ErrorCause.test.ts b/apps/server/src/diagnostics/ErrorCause.test.ts new file mode 100644 index 00000000000..0cbb3f20ee7 --- /dev/null +++ b/apps/server/src/diagnostics/ErrorCause.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { sanitizeErrorCause } from "./ErrorCause.ts"; + +describe("sanitizeErrorCause", () => { + it("sanitizes Error values through the bounded record projection", () => { + const cause = Object.assign(new Error("probe failed"), { + code: "E_PROBE", + detail: "bounded detail", + }); + + expect(sanitizeErrorCause(cause)).toEqual({ + name: "Error", + message: "probe failed", + detail: "bounded detail", + code: "E_PROBE", + }); + }); + + it("does not expose unknown object fields", () => { + expect( + sanitizeErrorCause({ + message: "safe message", + stdout: "private process output", + nested: { path: "/private/workspace" }, + }), + ).toEqual({ message: "safe message" }); + }); +}); diff --git a/apps/server/src/diagnostics/ErrorCause.ts b/apps/server/src/diagnostics/ErrorCause.ts index e0c7be04432..af5d636e839 100644 --- a/apps/server/src/diagnostics/ErrorCause.ts +++ b/apps/server/src/diagnostics/ErrorCause.ts @@ -74,12 +74,5 @@ export function sanitizeErrorCause(cause: unknown): SanitizedErrorCause { return Object.keys(output).length > 0 ? output : { tag: "Object" }; } - if (cause instanceof Error) { - const output: MutableSanitizedErrorCause = {}; - addTextField(output, "name", cause.name); - addTextField(output, "message", cause.message); - return output; - } - return { message: "Unknown error" }; } diff --git a/apps/server/src/provider/ProviderSkillsLister.test.ts b/apps/server/src/provider/ProviderSkillsLister.test.ts index b7249269cbc..d2118b60eec 100644 --- a/apps/server/src/provider/ProviderSkillsLister.test.ts +++ b/apps/server/src/provider/ProviderSkillsLister.test.ts @@ -1,10 +1,17 @@ +import * as NodePath from "@effect/platform-node/NodePath"; import { describe, expect, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; -import { makeBoundedRequestCache, optionalTrimmedNonEmptyString } from "./ProviderSkillsLister.ts"; +import { + makeBoundedRequestCache, + optionalTrimmedNonEmptyString, + providerSkillsRequestKey, +} from "./ProviderSkillsLister.ts"; describe("optionalTrimmedNonEmptyString", () => { it("returns a trimmed value for non-empty strings", () => { @@ -97,3 +104,31 @@ describe("makeBoundedRequestCache", () => { }), ); }); + +describe("providerSkillsRequestKey", () => { + it.effect("uses the normalized cwd for equivalent workspace requests", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("codex"); + const absoluteCwd = path.resolve("workspace"); + + expect( + providerSkillsRequestKey( + { + instanceId, + cwd: "workspace/./", + }, + path, + ), + ).toBe( + providerSkillsRequestKey( + { + instanceId, + cwd: absoluteCwd, + }, + path, + ), + ); + }).pipe(Effect.provide(NodePath.layer)), + ); +}); diff --git a/apps/server/src/provider/ProviderSkillsLister.ts b/apps/server/src/provider/ProviderSkillsLister.ts index d02509b1c2a..ca967393d83 100644 --- a/apps/server/src/provider/ProviderSkillsLister.ts +++ b/apps/server/src/provider/ProviderSkillsLister.ts @@ -191,8 +191,11 @@ export const listCodexProviderSkillsWithTimeout = Effect.fn("listCodexProviderSk }, ); -function requestKey(input: ProviderSkillsListInput): string { - return JSON.stringify([input.instanceId, input.cwd]); +export function providerSkillsRequestKey(input: ProviderSkillsListInput, path: Path.Path): string { + return JSON.stringify([ + input.instanceId, + WorkspacePaths.normalizeWorkspaceRootPath(input.cwd, path), + ]); } function parseRequestKey(key: string): ProviderSkillsListInput { @@ -320,6 +323,6 @@ export const makeProviderSkillsLister = Effect.fn("makeProviderSkillsLister")(fu }); return Effect.fn("ProviderSkillsLister.list")(function* (input: ProviderSkillsListInput) { - return yield* requests.get(requestKey(input)); + return yield* requests.get(providerSkillsRequestKey(input, path)); }); }); diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts index 5acf6677cde..926ba6ee916 100644 --- a/apps/server/src/workspace/WorkspacePaths.ts +++ b/apps/server/src/workspace/WorkspacePaths.ts @@ -131,6 +131,10 @@ function expandHomePath(input: string, path: Path.Path): string { return input; } +export function normalizeWorkspaceRootPath(workspaceRoot: string, path: Path.Path): string { + return path.resolve(expandHomePath(workspaceRoot.trim(), path)); +} + export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -161,7 +165,7 @@ export const make = Effect.gen(function* () { const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn( "WorkspacePaths.normalizeWorkspaceRoot", )(function* (workspaceRoot, options) { - const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path)); + const normalizedWorkspaceRoot = normalizeWorkspaceRootPath(workspaceRoot, path); let workspaceStat = yield* statWorkspaceRoot( workspaceRoot, normalizedWorkspaceRoot, diff --git a/apps/web/src/lib/providerWorkspaceSkillsState.ts b/apps/web/src/lib/providerWorkspaceSkillsState.ts index ead957b1697..c2fb93d40b0 100644 --- a/apps/web/src/lib/providerWorkspaceSkillsState.ts +++ b/apps/web/src/lib/providerWorkspaceSkillsState.ts @@ -1,10 +1,6 @@ import { - EMPTY_PROVIDER_WORKSPACE_SKILLS, - formatProviderWorkspaceSkillsError, - PROVIDER_WORKSPACE_SKILLS_UNAVAILABLE_MESSAGE, - providerWorkspaceSkillsTargetKey, - resolveNextProviderWorkspaceSkillsSnapshot, - resolveProviderWorkspaceSkills, + prepareProviderWorkspaceSkillsTarget, + resolveProviderWorkspaceSkillsQuery, type ProviderWorkspaceSkillsSnapshot, type ProviderWorkspaceSkillsState, type ProviderWorkspaceSkillsTarget, @@ -17,14 +13,8 @@ import { useEnvironmentQuery } from "../state/query"; export function useProviderWorkspaceSkills( target: ProviderWorkspaceSkillsTarget, ): ProviderWorkspaceSkillsState { - const stableTarget = useMemo( - () => ({ - environmentId: target.environmentId, - instanceId: target.instanceId, - cwd: target.cwd?.trim() || null, - enabled: target.enabled, - connectionAvailable: target.connectionAvailable !== false, - }), + const preparedTarget = useMemo( + () => prepareProviderWorkspaceSkillsTarget(target), [ target.connectionAvailable, target.cwd, @@ -33,56 +23,21 @@ export function useProviderWorkspaceSkills( target.instanceId, ], ); - const targetKey = providerWorkspaceSkillsTargetKey({ ...stableTarget, enabled: true }); - const key = stableTarget.enabled ? targetKey : null; - const unavailable = key !== null && !stableTarget.connectionAvailable; const query = useEnvironmentQuery( - key !== null && - !unavailable && - stableTarget.environmentId !== null && - stableTarget.instanceId !== null - ? serverEnvironment.providerSkills({ - environmentId: stableTarget.environmentId, - input: { - instanceId: stableTarget.instanceId, - cwd: stableTarget.cwd!, - }, - }) + preparedTarget.queryTarget !== null + ? serverEnvironment.providerSkills(preparedTarget.queryTarget) : null, ); const previousWorkspaceSkillsRef = useRef(null); - const querySkills = query.data?.skills ?? null; + const resolution = resolveProviderWorkspaceSkillsQuery({ + target: preparedTarget, + query, + fallbackSkills: target.fallbackSkills, + current: previousWorkspaceSkillsRef.current, + }); useEffect(() => { - previousWorkspaceSkillsRef.current = resolveNextProviderWorkspaceSkillsSnapshot({ - key: targetKey, - skills: querySkills, - isPending: query.isPending, - error: query.error, - inactive: key === null, - unavailable, - current: previousWorkspaceSkillsRef.current, - }); - }, [key, query.error, query.isPending, querySkills, targetKey, unavailable]); - - if (key === null) { - return { skills: target.fallbackSkills, isPending: false, error: null }; - } - const previousWorkspaceSkills = previousWorkspaceSkillsRef.current; - return { - skills: resolveProviderWorkspaceSkills({ - nextKey: key, - nextSkills: querySkills, - isPending: query.isPending, - error: query.error, - unavailable, - currentKey: previousWorkspaceSkills?.key ?? null, - currentSkills: previousWorkspaceSkills?.skills ?? EMPTY_PROVIDER_WORKSPACE_SKILLS, - fallbackSkills: target.fallbackSkills, - }), - isPending: unavailable ? false : query.isPending, - error: unavailable - ? PROVIDER_WORKSPACE_SKILLS_UNAVAILABLE_MESSAGE - : formatProviderWorkspaceSkillsError({ error: query.error, cause: query.errorCause }), - }; + previousWorkspaceSkillsRef.current = resolution.snapshot; + }, [resolution.snapshot]); + return resolution.state; } diff --git a/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts b/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts index 0f20227a714..cd21acee6ab 100644 --- a/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts +++ b/packages/client-runtime/src/state/providerWorkspaceSkills.test.ts @@ -9,9 +9,11 @@ import { describe, expect, it } from "vite-plus/test"; import { formatProviderWorkspaceSkillsError, + prepareProviderWorkspaceSkillsTarget, providerWorkspaceSkillsTargetKey, resolveNextProviderWorkspaceSkillsSnapshot, resolveProviderWorkspaceSkills, + resolveProviderWorkspaceSkillsQuery, } from "./providerWorkspaceSkills.ts"; function skill(name: string): ServerProviderSkill { @@ -46,6 +48,41 @@ describe("providerWorkspaceSkillsTargetKey", () => { }); }); +describe("prepareProviderWorkspaceSkillsTarget", () => { + it("centralizes lazy and connection-aware query preparation", () => { + const unavailable = prepareProviderWorkspaceSkillsTarget({ + environmentId: EnvironmentId.make("local"), + instanceId: ProviderInstanceId.make("codex"), + cwd: " /repo/worktree ", + enabled: true, + connectionAvailable: false, + fallbackSkills: [], + }); + + expect(unavailable).toEqual({ + targetKey: "local:codex:/repo/worktree", + key: "local:codex:/repo/worktree", + unavailable: true, + queryTarget: null, + }); + + expect( + prepareProviderWorkspaceSkillsTarget({ + environmentId: EnvironmentId.make("local"), + instanceId: ProviderInstanceId.make("codex"), + cwd: "/repo/worktree", + enabled: false, + fallbackSkills: [], + }), + ).toEqual({ + targetKey: "local:codex:/repo/worktree", + key: null, + unavailable: false, + queryTarget: null, + }); + }); +}); + describe("resolveProviderWorkspaceSkills", () => { it("preserves loaded skills while the same workspace refreshes", () => { const currentSkills = [skill("repo-local")]; @@ -272,6 +309,43 @@ describe("resolveNextProviderWorkspaceSkillsSnapshot", () => { }); }); +describe("resolveProviderWorkspaceSkillsQuery", () => { + it("centralizes snapshot and visible-state resolution for client adapters", () => { + const loadedSkills = [skill("repo-local")]; + const target = prepareProviderWorkspaceSkillsTarget({ + environmentId: EnvironmentId.make("local"), + instanceId: ProviderInstanceId.make("codex"), + cwd: "/repo", + enabled: true, + fallbackSkills: [skill("provider-fallback")], + }); + + expect( + resolveProviderWorkspaceSkillsQuery({ + target, + query: { + data: { skills: loadedSkills }, + error: null, + errorCause: null, + isPending: false, + }, + fallbackSkills: [skill("provider-fallback")], + current: null, + }), + ).toEqual({ + snapshot: { + key: "local:codex:/repo", + skills: loadedSkills, + }, + state: { + skills: loadedSkills, + isPending: false, + error: null, + }, + }); + }); +}); + describe("formatProviderWorkspaceSkillsError", () => { it("adds bounded structured detail without exposing a raw cause", () => { const error = new ServerProviderSkillsListError({ diff --git a/packages/client-runtime/src/state/providerWorkspaceSkills.ts b/packages/client-runtime/src/state/providerWorkspaceSkills.ts index f682e57cd2d..995030b07ee 100644 --- a/packages/client-runtime/src/state/providerWorkspaceSkills.ts +++ b/packages/client-runtime/src/state/providerWorkspaceSkills.ts @@ -3,6 +3,8 @@ import { type EnvironmentId, type ProviderInstanceId, type ServerProviderSkill, + type ServerProviderSkillsListInput, + type ServerProviderSkillsListResult, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; @@ -22,6 +24,28 @@ export interface ProviderWorkspaceSkillsState { readonly error: string | null; } +export interface PreparedProviderWorkspaceSkillsTarget { + readonly targetKey: string | null; + readonly key: string | null; + readonly unavailable: boolean; + readonly queryTarget: { + readonly environmentId: EnvironmentId; + readonly input: ServerProviderSkillsListInput; + } | null; +} + +export interface ProviderWorkspaceSkillsQueryView { + readonly data: ServerProviderSkillsListResult | null; + readonly error: string | null; + readonly errorCause: Cause.Cause | null; + readonly isPending: boolean; +} + +export interface ProviderWorkspaceSkillsQueryResolution { + readonly snapshot: ProviderWorkspaceSkillsSnapshot | null; + readonly state: ProviderWorkspaceSkillsState; +} + export interface ProviderWorkspaceSkillsSnapshotInput { readonly currentKey: string | null; readonly nextKey: string; @@ -62,6 +86,36 @@ export function providerWorkspaceSkillsTargetKey( return `${target.environmentId}:${target.instanceId}:${target.cwd.trim()}`; } +export function prepareProviderWorkspaceSkillsTarget( + target: ProviderWorkspaceSkillsTarget, +): PreparedProviderWorkspaceSkillsTarget { + const cwd = target.cwd?.trim() || null; + const targetKey = providerWorkspaceSkillsTargetKey({ + environmentId: target.environmentId, + instanceId: target.instanceId, + cwd, + enabled: true, + connectionAvailable: target.connectionAvailable, + }); + const key = target.enabled ? targetKey : null; + const unavailable = key !== null && target.connectionAvailable === false; + const queryTarget = + key !== null && + !unavailable && + target.environmentId !== null && + target.instanceId !== null && + cwd !== null + ? { + environmentId: target.environmentId, + input: { + instanceId: target.instanceId, + cwd, + }, + } + : null; + return { targetKey, key, unavailable, queryTarget }; +} + function providerSkillsListErrorDetail(error: unknown): { readonly detail: string | null; } | null { @@ -137,5 +191,58 @@ export function resolveNextProviderWorkspaceSkillsSnapshot(input: { } if (input.error !== null) return null; if (input.skills === null) return input.isPending ? current : null; - return input.isPending ? current : { key: input.key, skills: input.skills }; + if (input.isPending) return current; + return current?.skills === input.skills ? current : { key: input.key, skills: input.skills }; +} + +export function resolveProviderWorkspaceSkillsQuery(input: { + readonly target: PreparedProviderWorkspaceSkillsTarget; + readonly query: ProviderWorkspaceSkillsQueryView; + readonly fallbackSkills: ReadonlyArray; + readonly current: ProviderWorkspaceSkillsSnapshot | null; +}): ProviderWorkspaceSkillsQueryResolution { + const querySkills = input.query.data?.skills ?? null; + const snapshot = resolveNextProviderWorkspaceSkillsSnapshot({ + key: input.target.targetKey, + skills: querySkills, + isPending: input.query.isPending, + error: input.query.error, + inactive: input.target.key === null, + unavailable: input.target.unavailable, + current: input.current, + }); + + if (input.target.key === null) { + return { + snapshot, + state: { + skills: input.fallbackSkills, + isPending: false, + error: null, + }, + }; + } + + return { + snapshot, + state: { + skills: resolveProviderWorkspaceSkills({ + nextKey: input.target.key, + nextSkills: querySkills, + isPending: input.query.isPending, + error: input.query.error, + unavailable: input.target.unavailable, + currentKey: input.current?.key ?? null, + currentSkills: input.current?.skills ?? EMPTY_PROVIDER_WORKSPACE_SKILLS, + fallbackSkills: input.fallbackSkills, + }), + isPending: input.target.unavailable ? false : input.query.isPending, + error: input.target.unavailable + ? PROVIDER_WORKSPACE_SKILLS_UNAVAILABLE_MESSAGE + : formatProviderWorkspaceSkillsError({ + error: input.query.error, + cause: input.query.errorCause, + }), + }, + }; } From 6b7baa3c1bea32285ea91ec5349fb1fa6c0b1854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sun, 26 Jul 2026 15:34:42 +0100 Subject: [PATCH 54/55] Align timeline and composer skill providers - Share enabled and available provider-instance resolution - Keep timeline workspace skills on the composer's fallback instance - Cover disabled and unavailable persisted selections --- BRANCH_DETAILS.md | 3 + apps/web/src/components/ChatView.tsx | 46 +++++++- apps/web/src/components/chat/ChatComposer.tsx | 101 ++++-------------- apps/web/src/providerInstances.test.ts | 66 ++++++++++++ apps/web/src/providerInstances.ts | 66 +++++++++++- 5 files changed, 197 insertions(+), 85 deletions(-) diff --git a/BRANCH_DETAILS.md b/BRANCH_DETAILS.md index f0b755fedcd..7ae18001f5c 100644 --- a/BRANCH_DETAILS.md +++ b/BRANCH_DETAILS.md @@ -14,6 +14,7 @@ Expected behavior: - Web workspace-skill lookup follows the route environment's connection state. While disconnected, it does not start an RPC, retains only verified skills for the same environment/provider/cwd across lazy menu close/reopen cycles, otherwise falls back to provider snapshot skills with a non-pending reconnect error, and resumes the workspace refresh when the environment reconnects. - The composer loads workspace skills lazily: it starts workspace skill discovery when the `$` skill menu is active or the prompt already contains a complete `$skill` token, rather than probing on every empty composer mount. It preserves already loaded repo-local skills while refreshing the same workspace, falls back to provider snapshot skills when a settled workspace lookup returns no skills or errors, keeps structured lookup errors visible alongside those fallback skills on web and mobile, and clears stale repo-local skills during workspace switches or settled no-data states. - The conversation timeline renders sent user prompts against the same workspace-aware skill list as the composer, so repo-local `$skill-name` references display with the same skill chip treatment as user-level skills. Timeline lookup stays disabled until a sent user prompt contains a complete skill token, including one at the end of the message, so an empty draft does not probe Codex merely to decorate nonexistent messages. +- Web composer and timeline skill lookup share the same provider-instance resolver, including settings-adjusted enabled state, availability, provider locks, continuation groups, and deterministic fallback, so disabled or unavailable persisted selections cannot make the two surfaces query different providers. - The shared client-runtime policy, including target preparation, snapshot transitions, fallback selection, and error formatting, drives both web and mobile adapters. Mobile follows the selected environment's connection state, retains only verified same-workspace skills while disconnected or across lazy lookup close/reopen cycles, shows loading and structured reconnect/error feedback, refreshes an already-open `$` menu when skills arrive, decorates complete skill references, and prevents stale successful results from surviving failed refreshes or workspace switches. - Mobile thread detail keeps workspace lookup lazy: it activates only while the composer `$` menu is active, when the draft contains a complete skill token, or when a visible sent user prompt contains a complete skill reference. - New-task drafts request workspace skills lazily only after a complete `$skill` reference. Local drafts resolve the selected checkout or project root, while future-worktree drafts deliberately have no cwd and use provider snapshots until the target directory exists. @@ -27,6 +28,7 @@ Primary files: - `apps/web/src/components/chat/ChatComposer.tsx` - `apps/web/src/components/chat/MessagesTimeline.tsx` - `apps/web/src/lib/providerWorkspaceSkillsState.ts` +- `apps/web/src/providerInstances.ts` - `apps/mobile/src/state/providerWorkspaceSkillsState.ts` - `apps/mobile/src/features/threads/new-task-provider-skills.ts` - `apps/mobile/src/features/threads/thread-composer-skill-items.ts` @@ -43,6 +45,7 @@ Relevant tests live in: - `apps/server/src/provider/Layers/GrokProvider.test.ts` - `apps/web/src/components/chat/MessagesTimeline.test.tsx` - `apps/web/src/lib/providerWorkspaceSkillsState.test.ts` +- `apps/web/src/providerInstances.test.ts` - `apps/mobile/src/features/threads/new-task-provider-skills.test.ts` - `apps/mobile/src/features/threads/thread-composer-skill-items.test.ts` - `packages/client-runtime/src/state/providerWorkspaceSkills.test.ts` diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c6f89ce1f0a..9b6eba6d0b6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -160,7 +160,13 @@ import { } from "~/projectScripts"; import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; import { getProviderModelCapabilities, resolveSelectableProvider } from "../providerModels"; -import { NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + NO_PROVIDER_MODEL_SELECTION, + resolveProviderInstanceSelection, + sortProviderInstanceEntries, +} from "../providerInstances"; import { useClientSettings, useEnvironmentSettings } from "../hooks/useSettings"; import { useNowMinute } from "../hooks/useNowMinute"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; @@ -2365,7 +2371,39 @@ function ChatViewContent(props: ChatViewProps) { const defaultInstanceId = defaultInstanceIdForDriver(selectedProvider); return providerStatuses.find((status) => status.instanceId === defaultInstanceId) ?? null; }, [activeProviderInstanceId, providerStatuses, selectedProvider]); - const activeProviderFallbackSkills = activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS; + const timelineProviderInstanceEntries = useMemo( + () => + sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(providerStatuses), settings), + ), + [providerStatuses, settings], + ); + const timelineProviderStatus = useMemo( + () => + resolveProviderInstanceSelection({ + entries: timelineProviderInstanceEntries, + preferredInstanceIds: [ + selectedProviderByThreadId, + activeThread?.session?.providerInstanceId, + activeThread?.modelSelection.instanceId, + activeProject?.defaultModelSelection?.instanceId, + ], + lockedDriverKind: lockedProvider, + lockedInstanceId: + activeThread?.session?.providerInstanceId ?? + activeThread?.modelSelection.instanceId ?? + null, + }).entry?.snapshot ?? null, + [ + activeProject?.defaultModelSelection?.instanceId, + activeThread?.modelSelection.instanceId, + activeThread?.session?.providerInstanceId, + lockedProvider, + selectedProviderByThreadId, + timelineProviderInstanceEntries, + ], + ); + const timelineProviderFallbackSkills = timelineProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS; const timelineSkillReferenceCacheRef = useRef(new WeakMap()); const timelineHasSkillReference = useMemo( () => @@ -2377,11 +2415,11 @@ function ChatViewContent(props: ChatViewProps) { ); const activeProviderWorkspaceSkills = useProviderWorkspaceSkills({ environmentId, - instanceId: activeProviderStatus?.instanceId ?? null, + instanceId: timelineProviderStatus?.instanceId ?? null, cwd: providerSkillsCwd, enabled: timelineHasSkillReference, connectionAvailable: providerSkillsConnectionAvailable, - fallbackSkills: activeProviderFallbackSkills, + fallbackSkills: timelineProviderFallbackSkills, }); const providerStatusBannerKey = getProviderStatusBannerKey(activeProviderStatus); const [dismissedProviderStatusBannerKey, setDismissedProviderStatusBannerKey] = useState< diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d26267e8153..06070e801a8 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -174,8 +174,7 @@ import { applyProviderInstanceSettings, deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, - resolveProviderDriverKindForInstanceSelection, - resolveSelectableProviderInstanceEntry, + resolveProviderInstanceSelection, sortProviderInstanceEntries, type ProviderInstanceEntry, } from "../../providerInstances"; @@ -748,38 +747,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ), [providerStatuses, settings], ); - const selectedProviderByThreadId = composerDraft.activeProvider ?? null; - const threadProvider = - activeThread?.session?.providerInstanceId ?? - activeThreadModelSelection?.instanceId ?? - activeProjectDefaultModelSelection?.instanceId ?? - null; - const explicitSelectedInstanceId = selectedProviderByThreadId ?? threadProvider; - - const unlockedSelectedProvider = - resolveProviderDriverKindForInstanceSelection( - providerInstanceEntries, - providerStatuses, - explicitSelectedInstanceId, - ) ?? - providerInstanceEntries[0]?.driverKind ?? - ProviderDriverKind.make("unconfigured"); - const requestedDriverKind: ProviderDriverKind = lockedProvider ?? unlockedSelectedProvider; - const lockedContinuationGroupKey = useMemo((): string | null => { - if (!lockedProvider || !activeThread) return null; - const lockedInstanceId = - activeThread.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId; - if (!lockedInstanceId) return null; - return ( - providerInstanceEntries.find((entry) => entry.instanceId === lockedInstanceId) - ?.continuationGroupKey ?? null - ); - }, [ - activeThread, - activeThreadModelSelection?.instanceId, - lockedProvider, - providerInstanceEntries, - ]); // Resolve which configured instance the composer is currently targeting. // Priority: @@ -791,68 +758,40 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // 4. First enabled entry matching the current driver kind. // 5. First enabled entry overall / default instance for the kind. // - const selectedInstanceId = useMemo(() => { - const candidates: Array = [ - composerDraft.activeProvider, - activeThread?.session?.providerInstanceId, - activeThreadModelSelection?.instanceId, - activeProjectDefaultModelSelection?.instanceId, - ]; - for (const candidate of candidates) { - if (!candidate) continue; - const match = providerInstanceEntries.find( - (entry) => entry.instanceId === candidate && entry.enabled && entry.isAvailable, - ); - if (match) { - // When locked to a specific driver kind, ignore persisted instance - // ids from a different kind or continuation group. - if (lockedProvider && match.driverKind !== lockedProvider) continue; - if ( - lockedContinuationGroupKey && - match.continuationGroupKey !== lockedContinuationGroupKey - ) { - continue; - } - return match.instanceId; - } - } - const compatibleEntries = providerInstanceEntries.filter( - (entry) => - (!lockedProvider || entry.driverKind === lockedProvider) && - (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey), - ); - const requestedDriverEntries = compatibleEntries.filter( - (entry) => entry.driverKind === requestedDriverKind, - ); - return ( - resolveSelectableProviderInstanceEntry(requestedDriverEntries, undefined)?.instanceId ?? - resolveSelectableProviderInstanceEntry(compatibleEntries, undefined)?.instanceId ?? - NO_PROVIDER_MODEL_SELECTION.instanceId - ); + const providerInstanceSelection = useMemo(() => { + return resolveProviderInstanceSelection({ + entries: providerInstanceEntries, + preferredInstanceIds: [ + composerDraft.activeProvider, + activeThread?.session?.providerInstanceId, + activeThreadModelSelection?.instanceId, + activeProjectDefaultModelSelection?.instanceId, + ], + lockedDriverKind: lockedProvider, + lockedInstanceId: + activeThread?.session?.providerInstanceId ?? activeThreadModelSelection?.instanceId ?? null, + }); }, [ activeProjectDefaultModelSelection?.instanceId, activeThread?.session?.providerInstanceId, activeThreadModelSelection?.instanceId, composerDraft.activeProvider, - lockedContinuationGroupKey, lockedProvider, providerInstanceEntries, - requestedDriverKind, ]); + const selectedProviderEntry = providerInstanceSelection.entry; + const selectedInstanceId = + selectedProviderEntry?.instanceId ?? NO_PROVIDER_MODEL_SELECTION.instanceId; // Resolve the active instance's snapshot by `instanceId` so a custom // instance gets its own slash commands, skills, and model list — not // the first snapshot for the same driver kind. - const selectedProviderEntry = useMemo( - () => providerInstanceEntries.find((entry) => entry.instanceId === selectedInstanceId), - [providerInstanceEntries, selectedInstanceId], - ); const noProviderAvailable = selectedProviderEntry === undefined; // The driver kind follows the instance that will actually run the turn, // which can differ from the persisted selection when that selection is // disabled. const selectedProvider: ProviderDriverKind = - selectedProviderEntry?.driverKind ?? requestedDriverKind; + selectedProviderEntry?.driverKind ?? providerInstanceSelection.requestedDriverKind; const { modelOptions: composerModelOptions, selectedModel } = useEffectiveComposerModelState({ threadRef: composerDraftTarget, @@ -2676,7 +2615,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeInstanceId={selectedInstanceId} model={selectedModelForPickerWithCustomFallback} lockedProvider={lockedProvider} - lockedContinuationGroupKey={lockedContinuationGroupKey} + lockedContinuationGroupKey={ + providerInstanceSelection.lockedContinuationGroupKey + } instanceEntries={providerInstanceEntries} keybindings={keybindings} modelOptionsByInstance={modelOptionsByInstance} diff --git a/apps/web/src/providerInstances.test.ts b/apps/web/src/providerInstances.test.ts index 2d0d7bb0213..ff7c0c191f8 100644 --- a/apps/web/src/providerInstances.test.ts +++ b/apps/web/src/providerInstances.test.ts @@ -7,6 +7,7 @@ import { isProviderInstancePickerReady, isProviderInstancePickerVisible, resolveDefaultProviderModelSelection, + resolveProviderInstanceSelection, resolveSelectableProviderInstance, resolveProviderDriverKindForInstanceSelection, } from "./providerInstances"; @@ -242,6 +243,71 @@ describe("resolveSelectableProviderInstance", () => { }); }); +describe("resolveProviderInstanceSelection", () => { + it("falls back from a disabled preferred instance to the next selectable provider", () => { + const disabled = ProviderInstanceId.make("codex_personal"); + const fallback = ProviderInstanceId.make("claudeAgent"); + const entries = applyProviderInstanceSettings( + deriveProviderInstanceEntries([ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: disabled, + enabled: true, + }), + provider({ + provider: ProviderDriverKind.make("claudeAgent"), + instanceId: fallback, + }), + ]), + { + providerInstances: { + [disabled]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + }, + }, + providers: { + [ProviderDriverKind.make("claudeAgent")]: { enabled: true }, + } as never, + }, + ); + + expect( + resolveProviderInstanceSelection({ + entries, + preferredInstanceIds: [disabled], + lockedDriverKind: null, + lockedInstanceId: null, + }).entry?.instanceId, + ).toBe(fallback); + }); + + it("falls back from an unavailable preferred instance within the requested driver", () => { + const unavailable = ProviderInstanceId.make("codex_personal"); + const fallback = ProviderInstanceId.make("codex"); + const entries = deriveProviderInstanceEntries([ + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: unavailable, + availability: "unavailable", + }), + provider({ + provider: ProviderDriverKind.make("codex"), + instanceId: fallback, + }), + ]); + + expect( + resolveProviderInstanceSelection({ + entries, + preferredInstanceIds: [unavailable], + lockedDriverKind: null, + lockedInstanceId: null, + }).entry?.instanceId, + ).toBe(fallback); + }); +}); + describe("resolveProviderDriverKindForInstanceSelection", () => { it("maps custom provider instance ids back to their driver kind", () => { const providers = [ diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 337e68d44d0..450b0826cc6 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -17,7 +17,7 @@ import { defaultInstanceIdForDriver, PROVIDER_DISPLAY_NAMES, type ModelSelection, - type ProviderDriverKind, + ProviderDriverKind, ProviderInstanceId, type ServerProvider, type ServerProviderModel, @@ -312,6 +312,70 @@ export function resolveSelectableProviderInstanceEntry( ); } +export interface ProviderInstanceSelectionResolution { + readonly requestedDriverKind: ProviderDriverKind; + readonly lockedContinuationGroupKey: string | null; + readonly entry: ProviderInstanceEntry | undefined; +} + +/** + * Resolve the exact provider instance targeted by the composer and any UI + * surfaces that must describe the same pending turn. + * + * Preferred ids are checked in caller-defined priority order, but disabled or + * unavailable entries are skipped. Provider and continuation locks constrain + * both preferred ids and deterministic fallbacks. + */ +export function resolveProviderInstanceSelection(input: { + readonly entries: ReadonlyArray; + readonly preferredInstanceIds: ReadonlyArray; + readonly lockedDriverKind: ProviderDriverKind | null; + readonly lockedInstanceId: ProviderInstanceId | null; +}): ProviderInstanceSelectionResolution { + const explicitInstanceId = input.preferredInstanceIds.find( + (instanceId): instanceId is ProviderInstanceId => + instanceId !== null && instanceId !== undefined, + ); + const requestedDriverKind = + input.lockedDriverKind ?? + input.entries.find((entry) => entry.instanceId === explicitInstanceId)?.driverKind ?? + input.entries[0]?.driverKind ?? + ProviderDriverKind.make("unconfigured"); + const lockedContinuationGroupKey = + input.lockedDriverKind && input.lockedInstanceId + ? (input.entries.find((entry) => entry.instanceId === input.lockedInstanceId) + ?.continuationGroupKey ?? null) + : null; + const isCompatible = (entry: ProviderInstanceEntry): boolean => + (!input.lockedDriverKind || entry.driverKind === input.lockedDriverKind) && + (!lockedContinuationGroupKey || entry.continuationGroupKey === lockedContinuationGroupKey); + + for (const instanceId of input.preferredInstanceIds) { + if (!instanceId) continue; + const entry = input.entries.find( + (candidate) => + candidate.instanceId === instanceId && + isSelectableProviderInstanceEntry(candidate) && + isCompatible(candidate), + ); + if (entry) { + return { requestedDriverKind, lockedContinuationGroupKey, entry }; + } + } + + const compatibleEntries = input.entries.filter(isCompatible); + const requestedDriverEntries = compatibleEntries.filter( + (entry) => entry.driverKind === requestedDriverKind, + ); + return { + requestedDriverKind, + lockedContinuationGroupKey, + entry: + resolveSelectableProviderInstanceEntry(requestedDriverEntries, undefined) ?? + resolveSelectableProviderInstanceEntry(compatibleEntries, undefined), + }; +} + /** * Resolve the routing key for a selection that may reference an instance * id that no longer exists (e.g. a persisted thread selection after the From 413da05173284045f4e44ffdfedf71008efd609f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Miguel?= Date: Sun, 26 Jul 2026 17:18:52 +0100 Subject: [PATCH 55/55] Remove main-only pnpm global store pollution --- pnpm-lock.yaml | 103 ++++++++------------------------------------ pnpm-workspace.yaml | 8 +--- 2 files changed, 21 insertions(+), 90 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a4c1d10564..a6c8937cbec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,7 +66,7 @@ overrides: vite: npm:@voidzero-dev/vite-plus-core@0.2.2 yaml: ^2.9.0 -packageExtensionsChecksum: sha256-dL4kgB3oS88+usby/QZU7EK4kxNoz9EGcSH5yDZBXZM= +packageExtensionsChecksum: sha256-CUzzeefpj3gNFrCKNBhV9FOaniNbrLdKyIhWQyXuaiE= patchedDependencies: '@effect/vitest@4.0.0-beta.78': 42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f @@ -153,7 +153,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -422,7 +422,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -477,7 +477,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts @@ -622,7 +622,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@rolldown/plugin-babel': specifier: ^0.2.0 version: 0.2.3(@babel/core@7.29.7)(@babel/plugin-transform-runtime@7.29.7(@babel/core@7.29.7))(@babel/runtime@7.29.7)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.1.3) @@ -688,7 +688,7 @@ importers: version: link:../../packages/shared alchemy: specifier: https://pkg.ing/alchemy/078ff00 - version: https://pkg.ing/alchemy/078ff00(23ff62b65d6c8c4c39b8fd62a02be58c) + version: https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39) drizzle-orm: specifier: 1.0.0-rc.3 version: 1.0.0-rc.3(@cloudflare/workers-types@4.20260604.1)(@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@libsql/client@0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6))(bun-types@1.3.14)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(expo-sqlite@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.6)(utf-8-validate@6.0.6))(react@19.2.6))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) @@ -704,7 +704,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -732,7 +732,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -751,7 +751,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -764,7 +764,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) vite-plus: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -783,7 +783,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -805,7 +805,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -839,7 +839,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -864,7 +864,7 @@ importers: version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(ioredis@5.11.0)(utf-8-validate@6.0.6) '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -886,7 +886,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -917,7 +917,7 @@ importers: devDependencies: '@effect/vitest': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@types/pngjs': specifier: 6.0.5 version: 6.0.5 @@ -2021,10 +2021,6 @@ packages: resolution: {integrity: sha512-5KQsQYrQ/o7mfOVAxRtNnfD9M0W4OI6yQd0n/m2N7OOLxTdX4FwN4s/X4obykBC7ZEwH+bzMrFJiB4pq9lrQKQ==} peerDependencies: effect: 4.0.0-beta.78 - vitest: '*' - peerDependenciesMeta: - vitest: - optional: true '@egjs/hammerjs@2.0.17': resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} @@ -11761,43 +11757,9 @@ snapshots: '@effect/tsgo-win32-arm64': 0.13.2 '@effect/tsgo-win32-x64': 0.13.2 - '@effect/vitest@4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0)': + '@effect/vitest@4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': dependencies: effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) - vite-plus: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) - optionalDependencies: - vitest: 4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)) - transitivePeerDependencies: - - '@arethetypeswrong/core' - - '@edge-runtime/vm' - - '@opentelemetry/api' - - '@types/node' - - '@vitejs/devtools' - - '@vitest/browser-playwright' - - '@vitest/browser-webdriverio' - - '@vitest/coverage-istanbul' - - '@vitest/coverage-v8' - - '@vitest/ui' - - bufferutil - - esbuild - - happy-dom - - jiti - - jsdom - - less - - msw - - publint - - sass - - sass-embedded - - stylus - - sugarss - - svelte - - terser - - tsx - - typescript - - unplugin-unused - - unrun - - utf-8-validate - - yaml '@egjs/hammerjs@2.0.17': dependencies: @@ -15337,7 +15299,7 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@https://pkg.ing/alchemy/078ff00(23ff62b65d6c8c4c39b8fd62a02be58c): + alchemy@https://pkg.ing/alchemy/078ff00(2403b7b35608124e7556b92b6489da39): dependencies: '@alchemy.run/node-utils': 0.0.4 '@aws-sdk/credential-providers': 3.1062.0 @@ -15351,7 +15313,7 @@ snapshots: '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@distilled.cloud/neon': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@distilled.cloud/planetscale': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) - '@effect/vitest': 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(@types/node@24.12.4)(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(vitest@4.1.9(@types/node@24.12.4)(@vitest/browser-preview@4.1.9)(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3)))(yaml@2.9.0) + '@effect/vitest': 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@libsql/client': 0.17.3(bufferutil@4.1.0)(utf-8-validate@6.0.6) '@octokit/rest': 22.0.1 '@smithy/node-config-provider': 4.4.6 @@ -15384,39 +15346,12 @@ snapshots: vite: '@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - - '@arethetypeswrong/core' - - '@edge-runtime/vm' - - '@opentelemetry/api' - '@types/node' - '@types/react' - - '@vitejs/devtools' - - '@vitest/browser-playwright' - - '@vitest/browser-webdriverio' - - '@vitest/coverage-istanbul' - - '@vitest/coverage-v8' - - '@vitest/ui' - bufferutil - - esbuild - - happy-dom - - jiti - - jsdom - - less - - msw - pg-native - - publint - react-devtools-core - - sass - - sass-embedded - - stylus - - sugarss - - svelte - - terser - - tsx - - typescript - - unplugin-unused - - unrun - utf-8-validate - - vitest - workerd alien-signals@2.0.6: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3b4a7aaa975..ff840659efc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,8 +5,6 @@ packages: - packages/* - scripts -enableGlobalVirtualStore: true - # Successor to onlyBuiltDependencies/ignoredBuiltDependencies (pnpm 11). # true = allowed to run build scripts; false mirrors the pnpm 10 behavior # where anything outside onlyBuiltDependencies was silently not built. @@ -97,11 +95,9 @@ packageExtensions: "@clerk/expo@*": dependencies: "@expo/config-plugins": 56.0.9 - # Wildcard semver excludes prereleases. - "@effect/vitest@4.0.0-beta.78": + "@effect/vitest@*": dependencies: - # The patched package imports vite-plus inside the shared graph. - vite-plus: 0.2.2 + vite-plus: "catalog:" peerDependenciesMeta: vitest: optional: true