From 8f9911f79dcfa6890c6f14f9c712c236e8453cea Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 16 Jun 2026 10:53:25 -0700 Subject: [PATCH 1/8] Add DELETE-based MCP session termination (#3113) --- apps/server/src/mcp/McpHttpServer.test.ts | 56 ++++- patches/effect@4.0.0-beta.78.patch | 31 +++ pnpm-lock.yaml | 236 +++++++++++----------- 3 files changed, 204 insertions(+), 119 deletions(-) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index f60652609f5..25509dc593f 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -1,10 +1,11 @@ import { expect, it } from "@effect/vitest"; +import { NodeHttpServer } from "@effect/platform-node"; import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; import { McpSchema, McpServer } from "effect/unstable/ai"; -import { HttpServerResponse } from "effect/unstable/http"; +import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; @@ -48,6 +49,59 @@ it("normalizes empty successful notification responses to accepted", () => { expect(resultResponse.status).toBe(200); }); +it.effect("terminates HTTP MCP sessions with DELETE", () => + Effect.scoped( + Effect.gen(function* () { + const serverLayer = McpServer.layerHttp({ + name: "MCP termination test", + version: "1.0.0", + path: "/mcp", + }); + yield* HttpRouter.serve(serverLayer, { + disableListenLog: true, + disableLogger: true, + }).pipe(Layer.build); + const httpClient = yield* HttpClient.HttpClient; + + const initializeResponse = yield* httpClient.post("/mcp", { + headers: { accept: "application/json, text/event-stream" }, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"mcp-test","version":"1.0.0"}}}`, + "application/json", + ), + }); + const sessionId = initializeResponse.headers["mcp-session-id"]; + expect(initializeResponse.status).toBe(200); + expect(sessionId).not.toBeNull(); + + const missingSessionResponse = yield* httpClient.del("/mcp"); + expect(missingSessionResponse.status).toBe(400); + + const unknownSessionResponse = yield* httpClient.del("/mcp", { + headers: { "mcp-session-id": "unknown-session" }, + }); + expect(unknownSessionResponse.status).toBe(404); + + const terminateResponse = yield* httpClient.del("/mcp", { + headers: { "mcp-session-id": sessionId! }, + }); + expect(terminateResponse.status).toBe(204); + + const reusedSessionResponse = yield* httpClient.post("/mcp", { + headers: { + accept: "application/json, text/event-stream", + "mcp-session-id": sessionId!, + }, + body: HttpBody.text( + `{"jsonrpc":"2.0","id":2,"method":"ping","params":{}}`, + "application/json", + ), + }); + expect(reusedSessionResponse.status).toBe(404); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect("registers annotated tools and preserves authenticated request context", () => Effect.scoped( Effect.gen(function* () { diff --git a/patches/effect@4.0.0-beta.78.patch b/patches/effect@4.0.0-beta.78.patch index 0cacfa16152..dd4f0035af6 100644 --- a/patches/effect@4.0.0-beta.78.patch +++ b/patches/effect@4.0.0-beta.78.patch @@ -1,3 +1,34 @@ +diff --git a/dist/unstable/ai/McpServer.js b/dist/unstable/ai/McpServer.js +index 4819ff0..f5e0f60 100644 +--- a/dist/unstable/ai/McpServer.js ++++ b/dist/unstable/ai/McpServer.js +@@ -235,6 +235,26 @@ export const run = /*#__PURE__*/Effect.fnUntraced(function* (options) { + const server = yield* McpServer; + const isHttp = Option.isSome(yield* Effect.serviceOption(HttpRouter.HttpRouter)); + const clientSessions = new Map(); ++ if (isHttp && options.path !== undefined) { ++ const router = yield* HttpRouter.HttpRouter; ++ yield* router.add("DELETE", options.path, Effect.gen(function* () { ++ const request = yield* HttpServerRequest.HttpServerRequest; ++ const sessionId = request.headers[mcpSessionIdHeader]; ++ if (sessionId === undefined) { ++ return HttpServerResponse.empty({ ++ status: 400 ++ }); ++ } ++ if (!clientSessions.delete(sessionId)) { ++ return HttpServerResponse.empty({ ++ status: 404 ++ }); ++ } ++ return HttpServerResponse.empty({ ++ status: 204 ++ }); ++ })); ++ } + const handlers = yield* Layer.build(layerHandlers(options, { + clientSessions + })); diff --git a/dist/unstable/rpc/RpcClient.d.ts b/dist/unstable/rpc/RpcClient.d.ts index b0f61df..ead663e 100644 --- a/dist/unstable/rpc/RpcClient.d.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17d3f4d63e5..8e9f507caa3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,7 +73,7 @@ patchedDependencies: hash: f5d41705ce94bbafc731d92e9cb1671db710df046dd135cb894e3e3e9164a75b path: patches/@pierre%2Fdiffs@1.3.0-beta.4.patch effect@4.0.0-beta.78: - hash: 883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754 + hash: c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5 path: patches/effect@4.0.0-beta.78.patch react-native-nitro-modules@0.35.9: hash: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675 @@ -106,7 +106,7 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) + 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) '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime @@ -124,7 +124,7 @@ importers: version: link:../../packages/tailscale effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) electron: specifier: 41.5.0 version: 41.5.0 @@ -140,7 +140,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=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + 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 @@ -183,7 +183,7 @@ importers: version: 3.4.1(expo-auth-session@56.0.13(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.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.16)(expo-crypto@56.0.4(expo@56.0.8))(expo-secure-store@56.0.4(expo@56.0.8))(expo-web-browser@56.0.5(expo@56.0.8)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@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.8)(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=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(react@19.2.3)(scheduler@0.27.0) + version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0) '@expo-google-fonts/dm-sans': specifier: ^0.4.2 version: 0.4.2 @@ -243,7 +243,7 @@ importers: version: 8.0.3 effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) expo: specifier: ^56.0.0 version: 56.0.8(@babel/core@7.29.7)(@expo/dom-webview@56.0.5)(@expo/metro-runtime@56.0.13)(bufferutil@4.1.0)(expo-router@56.2.8)(expo-widgets@56.0.16)(react-dom@19.2.3(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) @@ -373,7 +373,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=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + 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) @@ -397,16 +397,16 @@ importers: version: 0.3.170(@anthropic-ai/sdk@0.93.0(zod@4.4.3))(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(zod@4.4.3) '@effect/platform-bun': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6) '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) + 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/platform-node-shared': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6) + version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6) '@effect/sql-sqlite-bun': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@ff-labs/fff-node': specifier: 0.9.4 version: 0.9.4(patch_hash=2b16019ce7ab61aec6478dd02f79ef468cc1d5c51e9d00764f7d2ab8167210c8) @@ -418,14 +418,14 @@ importers: version: 1.3.0-beta.4(patch_hash=f5d41705ce94bbafc731d92e9cb1671db710df046dd135cb894e3e3e9164a75b)(@shikijs/themes@4.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) node-pty: specifier: ^1.1.0 version: 1.1.0 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=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + 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 @@ -479,7 +479,7 @@ importers: version: 3.2.2(react@19.2.6) '@effect/atom-react': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(react@19.2.6)(scheduler@0.27.0) + version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.6)(scheduler@0.27.0) '@fontsource-variable/dm-sans': specifier: ^5.2.8 version: 5.2.8 @@ -530,7 +530,7 @@ importers: version: 0.7.1 effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) jose: specifier: 'catalog:' version: 6.2.2 @@ -570,10 +570,10 @@ importers: devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) + 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=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + 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.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))(rolldown@1.0.3) @@ -627,7 +627,7 @@ importers: version: 3.6.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@effect/sql-pg': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + version: 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@t3tools/client-runtime': specifier: workspace:* version: link:../../packages/client-runtime @@ -639,23 +639,23 @@ importers: version: link:../../packages/shared alchemy: specifier: https://pkg.ing/alchemy/078ff00 - version: https://pkg.ing/alchemy/078ff00(044daa5923befe9f328e27c790bb74e1) + version: https://pkg.ing/alchemy/078ff00(c6ed9425e2527ee8a94fe8655db2a250) 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=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)))(@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=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.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))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) devDependencies: '@cloudflare/workers-types': specifier: ^4.20260601.1 version: 4.20260604.1 '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) + 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=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + 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 @@ -673,17 +673,17 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) + 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) '@oxlint/plugins': specifier: ^1.63.0 version: 1.68.0 effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) 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=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) vite-plus: specifier: 'catalog:' version: 0.1.24(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -698,11 +698,11 @@ importers: version: link:../shared effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) 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=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) vite-plus: specifier: 'catalog:' version: 0.1.24(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -711,11 +711,11 @@ importers: dependencies: effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) 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=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) vite-plus: specifier: 'catalog:' version: 0.1.24(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -724,17 +724,17 @@ importers: dependencies: effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.0-beta.78(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + version: 4.0.0-beta.78(@effect/platform-node@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@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) + 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=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + 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 @@ -746,17 +746,17 @@ importers: dependencies: effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) devDependencies: '@effect/openapi-generator': specifier: 'catalog:' - version: 4.0.0-beta.78(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + version: 4.0.0-beta.78(@effect/platform-node@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@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) + 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=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + 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 @@ -777,7 +777,7 @@ importers: version: link:../contracts effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) jose: specifier: 'catalog:' version: 6.2.2 @@ -787,10 +787,10 @@ importers: devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) + 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=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + 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 @@ -808,14 +808,14 @@ importers: version: link:../shared effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) devDependencies: '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) + 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=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + 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 @@ -827,17 +827,17 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) + 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) '@t3tools/shared': specifier: workspace:* version: link:../shared effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) 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=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + 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 @@ -849,7 +849,7 @@ importers: dependencies: '@effect/platform-node': specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) + 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) '@t3tools/contracts': specifier: workspace:* version: link:../packages/contracts @@ -858,14 +858,14 @@ importers: version: link:../packages/shared effect: specifier: 4.0.0-beta.78 - version: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + version: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) yaml: specifier: ^2.9.0 version: 2.9.0 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=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + version: 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) vite-plus: specifier: 'catalog:' version: 0.1.24(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -10975,24 +10975,24 @@ snapshots: ajv: 6.15.0 ajv-keywords: 3.5.2(ajv@6.15.0) - '@distilled.cloud/aws@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))': + '@distilled.cloud/aws@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': dependencies: '@aws-crypto/crc32': 5.2.0 '@aws-crypto/util': 5.2.0 '@aws-sdk/credential-providers': 3.1062.0 '@aws-sdk/types': 3.973.10 - '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@smithy/shared-ini-file-loader': 4.5.6 '@smithy/types': 4.14.3 '@smithy/util-base64': 4.4.6 aws4fetch: 1.0.20 - effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) fast-xml-parser: 5.8.0 - '@distilled.cloud/axiom@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))': + '@distilled.cloud/axiom@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': dependencies: - '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) - effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) '@distilled.cloud/cloudflare-rolldown-plugin@0.10.5(rolldown@1.0.1)(workerd@1.20260526.1)': dependencies: @@ -11004,51 +11004,51 @@ snapshots: transitivePeerDependencies: - workerd - '@distilled.cloud/cloudflare-runtime@0.10.5(@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)))(@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))': + '@distilled.cloud/cloudflare-runtime@0.10.5(@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6))(@effect/platform-node@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@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': dependencies: '@alchemy.run/node-utils': 0.0.4 - '@distilled.cloud/cloudflare': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + '@distilled.cloud/cloudflare': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) capnweb: 0.7.0 chokidar: 4.0.3 - effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) workerd: 1.20260526.1 xdg-app-paths: 8.3.0 optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6) + '@effect/platform-node': 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) - '@distilled.cloud/cloudflare-vite-plugin@0.10.5(874b2206c1318ea354f257cb5515510e)': + '@distilled.cloud/cloudflare-vite-plugin@0.10.5(1a78211785aa6331880fcdb516c7c682)': dependencies: - '@distilled.cloud/cloudflare': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + '@distilled.cloud/cloudflare': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@distilled.cloud/cloudflare-rolldown-plugin': 0.10.5(rolldown@1.0.1)(workerd@1.20260526.1) - '@distilled.cloud/cloudflare-runtime': 0.10.5(@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)))(@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) - effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + '@distilled.cloud/cloudflare-runtime': 0.10.5(@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6))(@effect/platform-node@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@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0)' optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) + '@effect/platform-bun': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6) + '@effect/platform-node': 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) transitivePeerDependencies: - rolldown - workerd - '@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))': + '@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': dependencies: - '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) - effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) - '@distilled.cloud/core@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))': + '@distilled.cloud/core@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': dependencies: - effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) - '@distilled.cloud/neon@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))': + '@distilled.cloud/neon@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': dependencies: - '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) - effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) - '@distilled.cloud/planetscale@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))': + '@distilled.cloud/planetscale@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': dependencies: - '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) - effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) '@dnd-kit/accessibility@3.1.1(react@19.2.6)': dependencies: @@ -11084,44 +11084,44 @@ snapshots: '@drizzle-team/brocli@0.11.0': {} - '@effect/atom-react@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(react@19.2.3)(scheduler@0.27.0)': + '@effect/atom-react@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.3)(scheduler@0.27.0)': dependencies: - effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) react: 19.2.3 scheduler: 0.27.0 - '@effect/atom-react@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(react@19.2.6)(scheduler@0.27.0)': + '@effect/atom-react@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(react@19.2.6)(scheduler@0.27.0)': dependencies: - effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) react: 19.2.6 scheduler: 0.27.0 - '@effect/openapi-generator@4.0.0-beta.78(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))': + '@effect/openapi-generator@4.0.0-beta.78(@effect/platform-node@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@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': dependencies: - '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) - effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + '@effect/platform-node': 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: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) - '@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6)': + '@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6) - effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + '@effect/platform-node-shared': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6) + effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node-shared@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6)': + '@effect/platform-node-shared@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6)': dependencies: '@types/ws': 8.18.1 - effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) transitivePeerDependencies: - bufferutil - utf-8-validate - '@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6)': + '@effect/platform-node@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)': dependencies: - '@effect/platform-node-shared': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6) - effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + '@effect/platform-node-shared': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6) + effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) ioredis: 5.11.0 mime: 4.1.0 undici: 8.3.0 @@ -11129,9 +11129,9 @@ snapshots: - bufferutil - utf-8-validate - '@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))': + '@effect/sql-pg@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': dependencies: - effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) pg: 8.21.0 pg-connection-string: 2.12.0 pg-cursor: 2.20.0(pg@8.21.0) @@ -11140,9 +11140,9 @@ snapshots: transitivePeerDependencies: - pg-native - '@effect/sql-sqlite-bun@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))': + '@effect/sql-sqlite-bun@4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))': dependencies: - effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) '@effect/tsgo-darwin-arm64@0.13.2': optional: true @@ -11175,9 +11175,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)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))': + '@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=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) '@egjs/hammerjs@2.0.17': dependencies: @@ -14229,21 +14229,21 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 - alchemy@https://pkg.ing/alchemy/078ff00(044daa5923befe9f328e27c790bb74e1): + alchemy@https://pkg.ing/alchemy/078ff00(c6ed9425e2527ee8a94fe8655db2a250): dependencies: '@alchemy.run/node-utils': 0.0.4 '@aws-sdk/credential-providers': 3.1062.0 '@clack/prompts': 0.11.0 - '@distilled.cloud/aws': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) - '@distilled.cloud/axiom': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) - '@distilled.cloud/cloudflare': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + '@distilled.cloud/aws': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@distilled.cloud/axiom': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@distilled.cloud/cloudflare': 0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) '@distilled.cloud/cloudflare-rolldown-plugin': 0.10.5(rolldown@1.0.1)(workerd@1.20260526.1) - '@distilled.cloud/cloudflare-runtime': 0.10.5(@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)))(@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6))(@effect/platform-node@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6))(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) - '@distilled.cloud/cloudflare-vite-plugin': 0.10.5(874b2206c1318ea354f257cb5515510e) - '@distilled.cloud/core': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) - '@distilled.cloud/neon': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) - '@distilled.cloud/planetscale': 0.23.1(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) - '@effect/vitest': 4.0.0-beta.78(patch_hash=42b87cc47e70d74e62496e7a8261b3fd298ecad4464d209348ab04b96f853a5f)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + '@distilled.cloud/cloudflare-runtime': 0.10.5(@distilled.cloud/cloudflare@0.23.1(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)))(@effect/platform-bun@4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6))(@effect/platform-node@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@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) + '@distilled.cloud/cloudflare-vite-plugin': 0.10.5(1a78211785aa6331880fcdb516c7c682) + '@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)) '@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 @@ -14252,7 +14252,7 @@ snapshots: '@types/aws-lambda': 8.10.161 aws4fetch: 1.0.20 capnweb: 0.6.1 - effect: 4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) fast-glob: 3.3.3 fast-xml-parser: 5.8.0 ink: 6.8.0(@types/react@19.2.16)(bufferutil@4.1.0)(react-devtools-core@6.1.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react@19.2.6)(utf-8-validate@6.0.6) @@ -14268,11 +14268,11 @@ snapshots: undici: 7.27.1 yaml: 2.9.0 optionalDependencies: - '@effect/platform-bun': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(utf-8-validate@6.0.6) - '@effect/platform-node': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(ioredis@5.11.0)(utf-8-validate@6.0.6) - '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + '@effect/platform-bun': 4.0.0-beta.78(bufferutil@4.1.0)(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5))(utf-8-validate@6.0.6) + '@effect/platform-node': 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/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5)) drizzle-kit: 1.0.0-rc.3 - drizzle-orm: 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=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)))(@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=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) + drizzle-orm: 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))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3) vite: '@voidzero-dev/vite-plus-core@0.1.24(@types/node@24.12.4)(esbuild@0.27.7)(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: @@ -15215,13 +15215,13 @@ snapshots: get-tsconfig: 4.14.0 jiti: 2.7.0 - drizzle-orm@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=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)))(@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=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3): + drizzle-orm@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))(mysql2@3.22.4(@types/node@24.12.4))(pg@8.21.0)(zod@4.4.3): optionalDependencies: '@cloudflare/workers-types': 4.20260604.1 - '@effect/sql-pg': 4.0.0-beta.78(effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754)) + '@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=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754) + effect: 4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5) mysql2: 3.22.4(@types/node@24.12.4) pg: 8.21.0 zod: 4.4.3 @@ -15236,7 +15236,7 @@ snapshots: ee-first@1.1.1: {} - effect@4.0.0-beta.78(patch_hash=883249d8efbb462e928e21fefef96027b66aec50751178cafdce45f08eee3754): + effect@4.0.0-beta.78(patch_hash=c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5): dependencies: '@standard-schema/spec': 1.1.0 fast-check: 4.8.0 From a5efb7cb6b9e80b9543bc3e0aabfea37d3b459c5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 16 Jun 2026 10:54:00 -0700 Subject: [PATCH 2/8] Parallelize VCS status refresh and status reads (#3112) --- apps/server/src/git/GitManager.ts | 4 +- .../src/vcs/VcsStatusBroadcaster.test.ts | 66 ++++++++++++ apps/server/src/vcs/VcsStatusBroadcaster.ts | 100 +++++++++++++----- 3 files changed, 140 insertions(+), 30 deletions(-) diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 92ee9007b3a..99121182713 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1361,7 +1361,9 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }, ); const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) { - const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)]); + const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)], { + concurrency: "unbounded", + }); return mergeGitStatusParts(local, remote); }); const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn( diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index e92ab374f4e..7c5768162a9 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -17,6 +17,7 @@ import type { VcsStatusResult, VcsStatusStreamEvent, } from "@t3tools/contracts"; +import { GitManagerError } from "@t3tools/contracts"; import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; @@ -149,6 +150,71 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(makeTestLayer(state))); }); + it.effect("keeps the cached snapshot unchanged when a refresh branch fails", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + failRemoteStatus: false, + }; + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => + Effect.sync(() => { + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: () => + Effect.suspend(() => { + state.remoteStatusCalls += 1; + return state.failRemoteStatus + ? Effect.fail( + new GitManagerError({ + operation: "VcsStatusBroadcaster.test", + detail: "remote status failed", + }), + ) + : Effect.succeed(state.currentRemoteStatus); + }), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + }), + ), + ); + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + + state.currentLocalStatus = { + ...baseLocalStatus, + refName: "feature/partial-refresh", + }; + state.currentRemoteStatus = { + ...baseRemoteStatus, + aheadCount: 3, + }; + state.failRemoteStatus = true; + + const refreshExit = yield* broadcaster.refreshStatus("/repo").pipe(Effect.exit); + const cached = yield* broadcaster.getStatus({ cwd: "/repo" }); + + assert.isTrue(Exit.isFailure(refreshExit)); + assert.deepStrictEqual(cached, baseStatus); + }).pipe(Effect.provide(testLayer)); + }); + it.effect("refreshes only the cached local snapshot when requested", () => { const state = { currentLocalStatus: baseLocalStatus, diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 9cd5a6c337c..d83dc26fbed 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -179,18 +179,53 @@ export const layer = Layer.effect( }, ); - const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* ( + const updateCachedStatus = Effect.fn("VcsStatusBroadcaster.updateCachedStatus")(function* ( cwd: string, + local: VcsStatusLocalResult, + remote: VcsStatusRemoteResult | null, + options?: { publish?: boolean }, ) { - const local = yield* workflow.localStatus({ cwd }); - return yield* updateCachedLocalStatus(cwd, local); + const nextLocal = { + fingerprint: fingerprintStatusPart(local), + value: local, + } satisfies CachedValue; + const nextRemote = { + fingerprint: fingerprintStatusPart(remote), + value: remote, + } satisfies CachedValue; + const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(cwd) ?? { local: null, remote: null }; + const nextCache = new Map(cache); + nextCache.set(cwd, { + local: nextLocal, + remote: nextRemote, + }); + return [ + previous.local?.fingerprint !== nextLocal.fingerprint || + previous.remote?.fingerprint !== nextRemote.fingerprint, + nextCache, + ] as const; + }); + + if (options?.publish && shouldPublish) { + yield* PubSub.publish(changesPubSub, { + cwd, + event: { + _tag: "snapshot", + local, + remote, + }, + }); + } + + return mergeGitStatusParts(local, remote); }); - const loadRemoteStatus = Effect.fn("VcsStatusBroadcaster.loadRemoteStatus")(function* ( + const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* ( cwd: string, ) { - const remote = yield* workflow.remoteStatus({ cwd }); - return yield* updateCachedRemoteStatus(cwd, remote); + const local = yield* workflow.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local); }); const getOrLoadLocalStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")(function* ( @@ -203,36 +238,39 @@ export const layer = Layer.effect( return yield* loadLocalStatus(cwd); }); - const getOrLoadRemoteStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadRemoteStatus")( - function* (cwd: string) { - const cached = yield* getCachedStatus(cwd); - if (cached?.remote) { - return cached.remote.value; - } - return yield* loadRemoteStatus(cwd); - }, - ); - const withFileSystem = Effect.provideService(FileSystem.FileSystem, fs); const getStatus: VcsStatusBroadcasterShape["getStatus"] = Effect.fn( "VcsStatusBroadcaster.getStatus", )(function* (input) { const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); - const [local, remote] = yield* Effect.all([ - getOrLoadLocalStatus(cwd), - getOrLoadRemoteStatus(cwd), - ]); - return mergeGitStatusParts(local, remote); + const cached = yield* getCachedStatus(cwd); + if (cached?.local && cached.remote) { + return mergeGitStatusParts(cached.local.value, cached.remote.value); + } + const [local, remote] = yield* Effect.all( + [ + cached?.local ? Effect.succeed(cached.local.value) : workflow.localStatus({ cwd }), + cached?.remote ? Effect.succeed(cached.remote.value) : workflow.remoteStatus({ cwd }), + ], + { concurrency: "unbounded" }, + ); + return yield* updateCachedStatus(cwd, local, remote); }); + const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")( + function* (cwd: string) { + yield* workflow.invalidateLocalStatus(cwd); + const local = yield* workflow.localStatus({ cwd }); + return yield* updateCachedLocalStatus(cwd, local, { publish: true }); + }, + ); + const refreshLocalStatus: VcsStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn( "VcsStatusBroadcaster.refreshLocalStatus", )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - yield* workflow.invalidateLocalStatus(cwd); - const local = yield* workflow.localStatus({ cwd }); - return yield* updateCachedLocalStatus(cwd, local, { publish: true }); + return yield* refreshLocalStatusCore(cwd); }); const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( @@ -247,11 +285,15 @@ export const layer = Layer.effect( "VcsStatusBroadcaster.refreshStatus", )(function* (rawCwd) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); - const [local, remote] = yield* Effect.all([ - refreshLocalStatus(cwd), - refreshRemoteStatus(cwd), - ]); - return mergeGitStatusParts(local, remote); + yield* Effect.all( + [workflow.invalidateLocalStatus(cwd), workflow.invalidateRemoteStatus(cwd)], + { concurrency: "unbounded", discard: true }, + ); + const [local, remote] = yield* Effect.all( + [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], + { concurrency: "unbounded" }, + ); + return yield* updateCachedStatus(cwd, local, remote, { publish: true }); }); const makeRemoteRefreshLoop = ( From 4b7382733454ce582d4b27fdc0c14f3ffb519bc3 Mon Sep 17 00:00:00 2001 From: Jan Jaap Date: Tue, 16 Jun 2026 19:58:27 +0200 Subject: [PATCH 3/8] fix(settings): disable auto-open of task sidebar by default (#2421) Co-authored-by: Claude Haiku 4.5 --- packages/contracts/src/settings.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 33781f56c94..6955ab7050f 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -40,7 +40,7 @@ export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; export const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; export const ClientSettingsSchema = Schema.Struct({ - autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + autoOpenPlanSidebar: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( From 60b546cf0111f0a041e9fbac025f4c904961ad75 Mon Sep 17 00:00:00 2001 From: Icarus Wings <10465470+TheIcarusWings@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:04:47 +0100 Subject: [PATCH 4/8] Double-click a sidebar thread row to rename (#3064) Co-authored-by: Claude Opus 4.8 --- .../components/Sidebar.dblclick.browser.tsx | 255 ++++++++++++++++++ apps/web/src/components/Sidebar.logic.test.ts | 19 ++ apps/web/src/components/Sidebar.logic.ts | 9 + apps/web/src/components/Sidebar.tsx | 52 +++- 4 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/Sidebar.dblclick.browser.tsx diff --git a/apps/web/src/components/Sidebar.dblclick.browser.tsx b/apps/web/src/components/Sidebar.dblclick.browser.tsx new file mode 100644 index 00000000000..71d744be194 --- /dev/null +++ b/apps/web/src/components/Sidebar.dblclick.browser.tsx @@ -0,0 +1,255 @@ +import "../index.css"; + +import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { useCallback, useRef, useState } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; +import { cleanup, render } from "vitest-browser-react"; + +import { AppAtomRegistryProvider } from "../rpc/atomRegistry"; +import { DEFAULT_INTERACTION_MODE } from "../types"; +import type { SidebarThreadSummary } from "../types"; +import { SidebarThreadRow } from "./Sidebar"; + +// Double-click-to-rename is a desktop affordance; force the non-mobile path so +// the rename input is reachable regardless of the test browser viewport. +vi.mock("~/hooks/useMediaQuery", () => ({ + useIsMobile: () => false, + useMediaQuery: () => false, +})); + +const THREAD_ID = ThreadId.make("thread-1"); +const ENVIRONMENT_ID = EnvironmentId.make("environment-local"); +const PROJECT_ID = ProjectId.make("project-1"); +const INITIAL_TITLE = "Original title"; + +const ROW_TESTID = `thread-row-${THREAD_ID}`; +const TITLE_TESTID = `thread-title-${THREAD_ID}`; + +// Spies live at module scope so their call history survives the row's +// re-renders; reset between tests. +const spies = { + handleThreadClick: vi.fn(), + startThreadRename: vi.fn(), + navigateToThread: vi.fn(), + handleMultiSelectContextMenu: vi.fn(async () => {}), + handleThreadContextMenu: vi.fn(async () => {}), + clearSelection: vi.fn(), + commitRename: vi.fn(), + attemptArchiveThread: vi.fn(async () => {}), + openPrLink: vi.fn(), +}; + +function buildThread(title: string): SidebarThreadSummary { + return { + id: THREAD_ID, + environmentId: ENVIRONMENT_ID, + projectId: PROJECT_ID, + title, + interactionMode: DEFAULT_INTERACTION_MODE, + session: null, + createdAt: "2024-01-01T00:00:00.000Z", + archivedAt: null, + updatedAt: undefined, + latestTurn: null, + branch: null, + worktreePath: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }; +} + +// Mirrors the real parent (`SidebarProjectItem`): holds the rename state, wires +// `startThreadRename`, and commits by clearing the rename state and persisting +// the new title back onto the thread so the row re-renders with it. +function Harness() { + const [title, setTitle] = useState(INITIAL_TITLE); + const [renamingThreadKey, setRenamingThreadKey] = useState(null); + const [renamingTitle, setRenamingTitle] = useState(""); + const [confirmingArchiveThreadKey, setConfirmingArchiveThreadKey] = useState(null); + const renamingInputRef = useRef(null); + const renamingCommittedRef = useRef(false); + const confirmArchiveButtonRefs = useRef(new Map()); + + const startThreadRename = useCallback((threadKey: string, nextTitle: string) => { + spies.startThreadRename(threadKey, nextTitle); + setRenamingThreadKey(threadKey); + setRenamingTitle(nextTitle); + renamingCommittedRef.current = false; + }, []); + + const commitRename = useCallback( + async (threadRef: unknown, newTitle: string, originalTitle: string) => { + spies.commitRename(threadRef, newTitle, originalTitle); + const trimmed = newTitle.trim(); + if (trimmed.length > 0) { + setTitle(trimmed); + } + setRenamingThreadKey(null); + renamingInputRef.current = null; + }, + [], + ); + + const cancelRename = useCallback(() => { + setRenamingThreadKey(null); + renamingInputRef.current = null; + }, []); + + return ( + +
    + +
+
+ ); +} + +describe("SidebarThreadRow double-click rename", () => { + beforeEach(() => { + for (const spy of Object.values(spies)) spy.mockClear(); + }); + + afterEach(() => { + cleanup(); + }); + + it("double-clicking a row starts the inline rename, focused with text selected", async () => { + render(); + + await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible(); + + await userEvent.dblClick(page.getByTestId(ROW_TESTID)); + + const input = page.getByRole("textbox"); + await expect.element(input).toBeVisible(); + + const element = input.element() as HTMLInputElement; + expect(element.value).toBe(INITIAL_TITLE); + // The existing rename-input ref focuses + selects the whole title. + expect(document.activeElement).toBe(element); + expect(element.selectionStart).toBe(0); + expect(element.selectionEnd).toBe(INITIAL_TITLE.length); + }); + + it("Enter commits the rename and the new title persists on the row", async () => { + render(); + + await userEvent.dblClick(page.getByTestId(ROW_TESTID)); + const input = page.getByRole("textbox"); + await expect.element(input).toBeVisible(); + + await userEvent.fill(input, "Renamed thread"); + await userEvent.keyboard("{Enter}"); + + // commitRename was invoked with (threadRef, newTitle, originalTitle). + expect(spies.commitRename).toHaveBeenCalledTimes(1); + expect(spies.commitRename).toHaveBeenCalledWith( + expect.anything(), + "Renamed thread", + INITIAL_TITLE, + ); + + // Input is gone and the row now shows the persisted title. + const title = page.getByTestId(TITLE_TESTID); + await expect.element(title).toBeVisible(); + await expect.element(title).toHaveTextContent("Renamed thread"); + }); + + it("Escape cancels the rename without committing", async () => { + render(); + + await userEvent.dblClick(page.getByTestId(ROW_TESTID)); + await expect.element(page.getByRole("textbox")).toBeVisible(); + + await userEvent.keyboard("{Escape}"); + + expect(spies.commitRename).not.toHaveBeenCalled(); + const title = page.getByTestId(TITLE_TESTID); + await expect.element(title).toBeVisible(); + await expect.element(title).toHaveTextContent(INITIAL_TITLE); + }); + + it("double-clicking inside the rename input keeps the edit (does not reset to the title)", async () => { + render(); + + await userEvent.dblClick(page.getByTestId(ROW_TESTID)); + const input = page.getByRole("textbox"); + await expect.element(input).toBeVisible(); + + await userEvent.fill(input, "Edited but not committed"); + // Double-clicking inside the input (e.g. to select a word) must not bubble + // to the row and restart the rename, which would wipe the edit. + await userEvent.dblClick(input); + + expect((input.element() as HTMLInputElement).value).toBe("Edited but not committed"); + expect(spies.commitRename).not.toHaveBeenCalled(); + }); + + it("double-clicking the row chrome while already renaming does not restart/reset it", async () => { + render(); + + await userEvent.dblClick(page.getByTestId(ROW_TESTID)); + const input = page.getByRole("textbox"); + await expect.element(input).toBeVisible(); + await userEvent.fill(input, "Edited"); + expect(spies.startThreadRename).toHaveBeenCalledTimes(1); + + // Double-click the row element itself (chrome, not the input). + const rowEl = page.getByTestId(ROW_TESTID).element(); + rowEl.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true, detail: 2 })); + + // Guard short-circuits: rename is not restarted and the edit is preserved. + expect(spies.startThreadRename).toHaveBeenCalledTimes(1); + expect((input.element() as HTMLInputElement).value).toBe("Edited"); + }); + + it("modifier double-click is multi-select intent and does not start a rename", async () => { + render(); + + await userEvent.keyboard("{Shift>}"); + await userEvent.dblClick(page.getByTestId(ROW_TESTID)); + await userEvent.keyboard("{/Shift}"); + + await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible(); + expect(page.getByRole("textbox").elements()).toHaveLength(0); + }); + + it("single click routes through the navigation handler and does not start a rename", async () => { + render(); + + await userEvent.click(page.getByTestId(ROW_TESTID)); + + expect(spies.handleThreadClick).toHaveBeenCalledTimes(1); + // No rename input: the title span is still shown. + await expect.element(page.getByTestId(TITLE_TESTID)).toBeVisible(); + expect(page.getByRole("textbox").elements()).toHaveLength(0); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index bdbbf6f8491..fc6cbd1c0ed 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -11,6 +11,7 @@ import { getProjectSortTimestamp, hasUnseenCompletion, isContextMenuPointerDown, + isTrailingDoubleClick, orderItemsByPreferredIds, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, @@ -171,6 +172,24 @@ describe("shouldClearThreadSelectionOnMouseDown", () => { }); }); +describe("isTrailingDoubleClick", () => { + it("treats a single click as a normal activation", () => { + expect(isTrailingDoubleClick(1)).toBe(false); + }); + + it("treats synthetic/keyboard activations (detail 0) as a normal activation", () => { + expect(isTrailingDoubleClick(0)).toBe(false); + }); + + it("ignores the second click of a double-click so it does not navigate", () => { + expect(isTrailingDoubleClick(2)).toBe(true); + }); + + it("ignores further clicks of a triple-click", () => { + expect(isTrailingDoubleClick(3)).toBe(true); + }); +}); + describe("resolveSidebarNewThreadEnvMode", () => { it("uses the app default when the caller does not request a specific mode", () => { expect( diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index b9dd27dfb03..41f4e39bb73 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -160,6 +160,15 @@ export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); } +// A double-click dispatches two `click` events before `dblclick`: the first has +// `detail === 1`, the second `detail === 2`. The second click must not run the +// row's single-click navigation, otherwise double-click-to-rename would also +// navigate. `MouseEvent.detail` is 0 for synthetic/keyboard activations, which +// still count as a normal single activation. +export function isTrailingDoubleClick(detail: number): boolean { + return detail > 1; +} + export function resolveSidebarNewThreadEnvMode(input: { requestedEnvMode?: SidebarNewThreadEnvMode; defaultEnvMode: SidebarNewThreadEnvMode; diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 67b575e4b46..9e6ff1c34cb 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -164,6 +164,7 @@ import { getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, isContextMenuPointerDown, + isTrailingDoubleClick, resolveProjectStatusIndicator, resolveSidebarNewThreadSeedContext, resolveSidebarNewThreadEnvMode, @@ -178,6 +179,7 @@ import { import { sortThreads } from "../lib/threadSort"; import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; +import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { readEnvironmentApi } from "../environmentApi"; import { useSettings, useUpdateSettings } from "~/hooks/useSettings"; @@ -292,6 +294,7 @@ interface SidebarThreadRowProps { renamingThreadKey: string | null; renamingTitle: string; setRenamingTitle: (title: string) => void; + startThreadRename: (threadKey: string, title: string) => void; renamingInputRef: React.RefObject; renamingCommittedRef: React.RefObject; confirmingArchiveThreadKey: string | null; @@ -319,7 +322,7 @@ interface SidebarThreadRowProps { openPrLink: (event: React.MouseEvent, prUrl: string) => void; } -const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { +export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { const { orderedProjectThreadKeys, isActive, @@ -328,6 +331,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP renamingThreadKey, renamingTitle, setRenamingTitle, + startThreadRename, renamingInputRef, renamingCommittedRef, confirmingArchiveThreadKey, @@ -352,6 +356,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP environmentId: thread.environmentId, threadId: thread.id, }); + const isMobile = useIsMobile(); const discoveredPorts = useThreadDiscoveredPorts({ environmentId: thread.environmentId, threadId: thread.id, @@ -437,6 +442,24 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP }, [discoveredPorts, navigateToThread, threadRef], ); + const handleRowDoubleClick = useCallback( + (event: React.MouseEvent) => { + // Already renaming this row: a double-click on the row chrome (outside the + // input) must not restart and discard the in-progress edit. + if (renamingThreadKey === threadKey) return; + // On mobile the first tap navigates and closes the sidebar sheet, so the + // inline rename can't be shown. Renaming there stays on the context menu. + if (isMobile) return; + // cmd/ctrl/shift double-clicks are multi-select intent, not rename. + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; + // Ignore double-clicks bubbling from nested controls (PR status, port, + // archive buttons) โ€” only the row body should enter inline rename. + if ((event.target as HTMLElement).closest("button, a")) return; + event.preventDefault(); + startThreadRename(threadKey, thread.title); + }, + [isMobile, renamingThreadKey, startThreadRename, threadKey, thread.title], + ); const handleRowKeyDown = useCallback( (event: React.KeyboardEvent) => { if (event.key !== "Enter" && event.key !== " ") return; @@ -510,6 +533,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP void commitRename(threadRef, renamingTitle, thread.title); } }, [commitRename, renamingCommittedRef, renamingTitle, thread.title, threadRef]); + // Keep clicks/double-clicks inside the rename input from bubbling to the row. + // Without stopping `dblclick`, double-clicking to select a word would re-fire + // the row's rename handler and reset the in-progress edit back to the title. const handleRenameInputClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); }, []); @@ -576,6 +602,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP isSelected, })} relative isolate`} onClick={handleRowClick} + onDoubleClick={handleRowDoubleClick} onKeyDown={handleRowKeyDown} onContextMenu={handleRowContextMenu} > @@ -607,6 +634,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP onKeyDown={handleRenameInputKeyDown} onBlur={handleRenameInputBlur} onClick={handleRenameInputClick} + onDoubleClick={handleRenameInputClick} /> ) : ( @@ -789,6 +817,7 @@ interface SidebarProjectThreadListProps { renamingThreadKey: string | null; renamingTitle: string; setRenamingTitle: (title: string) => void; + startThreadRename: (threadKey: string, title: string) => void; renamingInputRef: React.RefObject; renamingCommittedRef: React.RefObject; confirmingArchiveThreadKey: string | null; @@ -839,6 +868,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( renamingThreadKey, renamingTitle, setRenamingTitle, + startThreadRename, renamingInputRef, renamingCommittedRef, confirmingArchiveThreadKey, @@ -890,6 +920,7 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( renamingThreadKey={renamingThreadKey} renamingTitle={renamingTitle} setRenamingTitle={setRenamingTitle} + startThreadRename={startThreadRename} renamingInputRef={renamingInputRef} renamingCommittedRef={renamingCommittedRef} confirmingArchiveThreadKey={confirmingArchiveThreadKey} @@ -1626,6 +1657,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } + // Ignore the trailing click of a plain double-click so it doesn't navigate + // while a double-click is starting an inline rename. Placed after the + // modifier branches so cmd/shift selection still processes every click. + if (isTrailingDoubleClick(event.detail)) { + return; + } + if (currentSelectionCount > 0) { clearSelection(); } @@ -1820,6 +1858,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec renamingInputRef.current = null; }, []); + const startThreadRename = useCallback((threadKey: string, title: string) => { + setRenamingThreadKey(threadKey); + setRenamingTitle(title); + renamingCommittedRef.current = false; + }, []); + const commitRename = useCallback( async (threadRef: ScopedThreadRef, newTitle: string, originalTitle: string) => { const threadKey = scopedThreadKey(threadRef); @@ -1979,9 +2023,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); if (clicked === "rename") { - setRenamingThreadKey(threadKey); - setRenamingTitle(thread.title); - renamingCommittedRef.current = false; + startThreadRename(threadKey, thread.title); return; } @@ -2029,6 +2071,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec markThreadUnread, memberProjectByScopedKey, project.cwd, + startThreadRename, ], ); @@ -2151,6 +2194,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec renamingThreadKey={renamingThreadKey} renamingTitle={renamingTitle} setRenamingTitle={setRenamingTitle} + startThreadRename={startThreadRename} renamingInputRef={renamingInputRef} renamingCommittedRef={renamingCommittedRef} confirmingArchiveThreadKey={confirmingArchiveThreadKey} From c2ca9de33ab88d3a5f15530d41b9fe866e5c812f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 16 Jun 2026 16:22:18 -0700 Subject: [PATCH 5/8] Add file preview comments and task toggles (#3115) --- .../review/reviewCommentSelection.test.ts | 64 +++ .../features/review/reviewCommentSelection.ts | 49 +- .../src/features/threads/ThreadFeed.tsx | 3 + .../src/components/ChatMarkdown.browser.tsx | 20 + apps/web/src/components/ChatMarkdown.tsx | 55 +- apps/web/src/components/ChatView.tsx | 33 +- apps/web/src/components/DiffPanel.tsx | 19 +- apps/web/src/components/chat/ChatComposer.tsx | 28 +- .../ComposerPendingReviewComments.browser.tsx | 41 ++ .../chat/ComposerPendingReviewComments.tsx | 60 +++ .../components/chat/MessagesTimeline.test.tsx | 36 ++ .../src/components/chat/MessagesTimeline.tsx | 11 + apps/web/src/components/chat/OpenInPicker.tsx | 30 +- .../diffs/AnnotatableFileDiff.browser.tsx | 122 +++++ .../components/diffs/AnnotatableFileDiff.tsx | 239 +++++++++ .../files/FilePreviewPanel.browser.tsx | 168 ++++++ .../components/files/FilePreviewPanel.test.ts | 82 +++ .../src/components/files/FilePreviewPanel.tsx | 407 ++++++++++++-- .../files/LocalCommentAnnotation.tsx | 90 ++++ .../files/fileCommentAnnotations.ts | 54 ++ .../components/files/fileEditorDismissal.ts | 53 ++ .../src/components/files/filePreviewMode.ts | 18 + .../files/projectFilesQueryState.test.ts | 5 + .../files/projectFilesQueryState.ts | 8 + apps/web/src/composerDraftStore.test.ts | 63 +++ apps/web/src/composerDraftStore.ts | 79 ++- apps/web/src/reviewCommentContext.test.ts | 198 +++++++ apps/web/src/reviewCommentContext.ts | 327 +++++++++++- patches/@pierre%2Fdiffs@1.3.0-beta.4.patch | 52 +- pnpm-lock.yaml | 504 +++++++++--------- 30 files changed, 2585 insertions(+), 333 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerPendingReviewComments.browser.tsx create mode 100644 apps/web/src/components/chat/ComposerPendingReviewComments.tsx create mode 100644 apps/web/src/components/diffs/AnnotatableFileDiff.browser.tsx create mode 100644 apps/web/src/components/diffs/AnnotatableFileDiff.tsx create mode 100644 apps/web/src/components/files/FilePreviewPanel.browser.tsx create mode 100644 apps/web/src/components/files/FilePreviewPanel.test.ts create mode 100644 apps/web/src/components/files/LocalCommentAnnotation.tsx create mode 100644 apps/web/src/components/files/fileCommentAnnotations.ts create mode 100644 apps/web/src/components/files/fileEditorDismissal.ts create mode 100644 apps/web/src/components/files/filePreviewMode.ts diff --git a/apps/mobile/src/features/review/reviewCommentSelection.test.ts b/apps/mobile/src/features/review/reviewCommentSelection.test.ts index 25e5f9ce482..b61735f955c 100644 --- a/apps/mobile/src/features/review/reviewCommentSelection.test.ts +++ b/apps/mobile/src/features/review/reviewCommentSelection.test.ts @@ -78,4 +78,68 @@ describe("review comment serialization", () => { ); expect(segments[2]).toEqual(expect.objectContaining({ kind: "text", text: "\nAfter" })); }); + + it("parses source-language review comments created by the web file viewer", () => { + const [segment] = parseReviewCommentMessageSegments( + [ + '', + "Clarify this.", + "```md", + "# Plan", + "- Step one", + "```", + "", + ].join("\n"), + ); + + expect(segment).toEqual( + expect.objectContaining({ + kind: "review-comment", + comment: expect.objectContaining({ + filePath: "docs/plan.md", + fenceLanguage: "md", + diff: "# Plan\n- Step one", + }), + }), + ); + }); + + it("keeps fenced examples in comment prose separate from the context fence", () => { + const [segment] = parseReviewCommentMessageSegments( + [ + '', + "Try this:", + "```ts", + "const value = 1;", + "```", + "Then retry.", + "```diff", + "@@ -0,0 +1,1 @@", + "+one", + "```", + "", + ].join("\n"), + ); + + expect(segment).toEqual( + expect.objectContaining({ + kind: "review-comment", + comment: expect.objectContaining({ + text: ["Try this:", "```ts", "const value = 1;", "```", "Then retry."].join("\n"), + diff: "@@ -0,0 +1,1 @@\n+one", + }), + }), + ); + }); + + it("round-trips greater-than signs in review attributes", () => { + const serialized = formatReviewCommentContext( + { ...makeTarget(), sectionTitle: "Changes > 5" }, + "Check this.", + ); + const [comment] = parseReviewInlineComments(serialized); + + expect(serialized).toContain('sectionTitle="Changes > 5"'); + expect(comment?.sectionTitle).toBe("Changes > 5"); + }); }); diff --git a/apps/mobile/src/features/review/reviewCommentSelection.ts b/apps/mobile/src/features/review/reviewCommentSelection.ts index 09e1927d179..5e6e1c3683a 100644 --- a/apps/mobile/src/features/review/reviewCommentSelection.ts +++ b/apps/mobile/src/features/review/reviewCommentSelection.ts @@ -21,6 +21,7 @@ export interface ReviewInlineComment { readonly rangeLabel: string; readonly text: string; readonly diff: string; + readonly fenceLanguage?: string; } export type ReviewCommentMessageSegment = @@ -38,6 +39,7 @@ let currentTarget: ReviewCommentTarget | null = null; const listeners = new Set<() => void>(); const REVIEW_COMMENT_BLOCK_PATTERN = /]*)>\s*([\s\S]*?)<\/review_comment>/g; const REVIEW_COMMENT_ATTRIBUTE_PATTERN = /([a-zA-Z][a-zA-Z0-9_-]*)="([^"]*)"/g; +const REVIEW_COMMENT_FENCE_PATTERN = /(`{3,})([^\s`]*)[^\n]*\n([\s\S]*?)\n\1/g; function emitChange() { listeners.forEach((listener) => listener()); @@ -170,12 +172,17 @@ function formatReviewSelectedDiff(target: ReviewCommentTarget): string { } function escapeReviewCommentAttribute(value: string): string { - return value.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); } function unescapeReviewCommentAttribute(value: string): string { return value .replace(/</g, "<") + .replace(/>/g, ">") .replace(/"/g, '"') .replace(/&/g, "&"); } @@ -195,15 +202,19 @@ function readNonNegativeInteger(value: string | undefined): number | null { return Number(value); } -function extractReviewCommentText(rawBody: string): string { - const fenceIndex = rawBody.indexOf("```diff"); - const commentBody = fenceIndex >= 0 ? rawBody.slice(0, fenceIndex) : rawBody; - return commentBody.trim(); -} - -function extractReviewCommentDiff(rawBody: string): string { - const match = rawBody.match(/```diff\s*\n([\s\S]*?)\n```/); - return match?.[1]?.trim() ?? ""; +function extractReviewCommentBody(rawBody: string): { + text: string; + language: string; + contents: string; +} { + const matches = Array.from(rawBody.matchAll(REVIEW_COMMENT_FENCE_PATTERN)); + const match = matches.at(-1); + const fenceIndex = match?.index; + return { + text: rawBody.slice(0, fenceIndex ?? rawBody.length).trim(), + language: match?.[2]?.trim() || "diff", + contents: match?.[3] ?? "", + }; } function parseReviewInlineComment( @@ -219,6 +230,7 @@ function parseReviewInlineComment( if (!filePath || !sectionId || startIndex === null || endIndex === null) { return null; } + const body = extractReviewCommentBody(rawBody); return { id: `review-comment:${index}:${sectionId}:${filePath}:${startIndex}:${endIndex}`, @@ -228,13 +240,20 @@ function parseReviewInlineComment( startIndex: Math.min(startIndex, endIndex), endIndex: Math.max(startIndex, endIndex), rangeLabel: attributes.rangeLabel?.trim() || "line", - text: extractReviewCommentText(rawBody), - diff: extractReviewCommentDiff(rawBody), + text: body.text, + diff: body.contents, + fenceLanguage: body.language, }; } export function formatReviewCommentContext(target: ReviewCommentTarget, comment: string): string { const rangeLabel = formatReviewSelectedRangeLabel(target); + const diff = formatReviewSelectedDiff(target); + const longestBacktickRun = Math.max( + 0, + ...Array.from(diff.matchAll(/`+/g), (match) => match[0].length), + ); + const fence = "`".repeat(Math.max(3, longestBacktickRun + 1)); return [ [ "", ].join(""), comment.trim(), - "```diff", - formatReviewSelectedDiff(target), - "```", + `${fence}diff`, + diff, + fence, "", ].join("\n"); } diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 4f06e0812c9..a23a9caffe9 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1193,6 +1193,9 @@ const ReviewCommentCard = memo(function ReviewCommentCard(props: { }); function buildReviewCommentPatch(comment: ReviewInlineComment): string { + if ((comment.fenceLanguage ?? "diff") !== "diff") { + return ""; + } const diff = comment.diff.trim(); if (!diff) { return ""; diff --git a/apps/web/src/components/ChatMarkdown.browser.tsx b/apps/web/src/components/ChatMarkdown.browser.tsx index 4eeab3e8075..7ef34097664 100644 --- a/apps/web/src/components/ChatMarkdown.browser.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.tsx @@ -69,6 +69,26 @@ describe("ChatMarkdown", () => { document.body.innerHTML = ""; }); + it("makes task-list checkboxes interactive when a change handler is provided", async () => { + const onTaskListChange = vi.fn(); + const screen = await render( + , + ); + + try { + const checkbox = page.getByRole("checkbox", { name: "Toggle task" }); + await expect.element(checkbox).not.toBeDisabled(); + await checkbox.click(); + expect(onTaskListChange).toHaveBeenCalledWith({ markerOffset: 2, checked: true }); + } finally { + await screen.unmount(); + } + }); + it("rewrites file uri hrefs into direct paths before rendering", async () => { const filePath = "/Users/yashsingh/p/sco/claude-code-extract/src/utils/permissions/PermissionRule.ts"; diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index ecd6bc40ffe..bfb9cbd77ea 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -91,6 +91,7 @@ interface ChatMarkdownProps { text: string; cwd: string | undefined; threadRef?: ScopedThreadRef | undefined; + onTaskListChange?: ((input: { markerOffset: number; checked: boolean }) => void) | undefined; isStreaming?: boolean; skills?: ReadonlyArray>; className?: string; @@ -108,6 +109,17 @@ const highlightedCodeCache = new LRUCache( MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, ); const highlighterPromiseCache = new Map>(); + +function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { + const firstLineEnd = markdown.indexOf("\n", listItemStart); + const firstLine = markdown.slice( + listItemStart, + firstLineEnd === -1 ? markdown.length : firstLineEnd, + ); + const match = firstLine.match(/^(?:\s*(?:[-+*]|\d+[.)])\s+)(\[[ xX]\])/); + if (!match?.[1]) return null; + return listItemStart + firstLine.indexOf(match[1]); +} const CHAT_MARKDOWN_SANITIZE_SCHEMA = { ...defaultSchema, attributes: { @@ -1170,6 +1182,7 @@ function ChatMarkdown({ text, cwd, threadRef, + onTaskListChange, isStreaming = false, skills = EMPTY_MARKDOWN_SKILLS, className, @@ -1215,8 +1228,44 @@ function ChatMarkdown({ p({ node: _node, children, ...props }) { return

{renderSkillInlineMarkdownChildren(children, skills)}

; }, - li({ node: _node, children, ...props }) { - return
  • {renderSkillInlineMarkdownChildren(children, skills)}
  • ; + li({ node, children, ...props }) { + const listItemStart = node?.position?.start.offset; + const markerOffset = + typeof listItemStart === "number" ? findTaskListMarkerOffset(text, listItemStart) : null; + return ( +
  • + {renderSkillInlineMarkdownChildren(children, skills)} +
  • + ); + }, + input({ node: _node, type, checked, disabled: _disabled, ...props }) { + if (type !== "checkbox" || !onTaskListChange) { + return ( + + ); + } + return ( + { + const markerOffset = Number( + event.currentTarget.closest("li")?.dataset.taskMarkerOffset, + ); + if (!Number.isSafeInteger(markerOffset)) return; + onTaskListChange({ markerOffset, checked: event.currentTarget.checked }); + }} + /> + ); }, a({ node, href, children, ...props }) { const normalizedHref = href ? normalizeMarkdownLinkHrefKey(href) : ""; @@ -1330,9 +1379,11 @@ function ChatMarkdown({ fileLinkParentSuffixByPath, isStreaming, markdownFileLinkMetaByHref, + onTaskListChange, threadRef, resolvedTheme, skills, + text, ], ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b260ddec0fd..c12ed7ba890 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -164,6 +164,7 @@ import { formatElementContextLabel, } from "../lib/elementContext"; import { appendPreviewAnnotationPrompt } from "../lib/previewAnnotation"; +import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../reviewCommentContext"; import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer"; import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; @@ -1119,6 +1120,7 @@ function ChatViewContent(props: ChatViewProps) { const setComposerDraftPreviewAnnotations = useComposerDraftStore( (store) => store.setPreviewAnnotations, ); + const setComposerDraftReviewComments = useComposerDraftStore((store) => store.setReviewComments); const setComposerDraftModelSelection = useComposerDraftStore((store) => store.setModelSelection); const setComposerDraftRuntimeMode = useComposerDraftStore((store) => store.setRuntimeMode); const setComposerDraftInteractionMode = useComposerDraftStore( @@ -3636,6 +3638,7 @@ function ChatViewContent(props: ChatViewProps) { terminalContexts: composerTerminalContexts, elementContexts: composerElementContexts, previewAnnotations: composerPreviewAnnotations, + reviewComments: composerReviewComments, selectedProvider: ctxSelectedProvider, selectedModel: ctxSelectedModel, selectedProviderModels: ctxSelectedProviderModels, @@ -3652,7 +3655,10 @@ function ChatViewContent(props: ChatViewProps) { prompt: promptForSend, imageCount: composerImages.length, terminalContexts: composerTerminalContexts, - elementContextCount: composerElementContexts.length + composerPreviewAnnotations.length, + elementContextCount: + composerElementContexts.length + + composerPreviewAnnotations.length + + composerReviewComments.length, }); if (showPlanFollowUpPrompt && activeProposedPlan) { const followUp = resolvePlanFollowUpSubmission({ @@ -3672,7 +3678,8 @@ function ChatViewContent(props: ChatViewProps) { composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 && composerElementContexts.length === 0 && - composerPreviewAnnotations.length === 0 + composerPreviewAnnotations.length === 0 && + composerReviewComments.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) : null; if (standaloneSlashCommand) { @@ -3722,14 +3729,19 @@ function ChatViewContent(props: ChatViewProps) { const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const composerElementContextsSnapshot = [...composerElementContexts]; const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; + const composerReviewCommentsSnapshot: ReviewCommentContext[] = [...composerReviewComments]; const messageTextWithContexts = appendElementContextsToPrompt( appendTerminalContextsToPrompt(promptForSend, composerTerminalContextsSnapshot), composerElementContextsSnapshot, ); - const messageTextForSend = composerPreviewAnnotationsSnapshot.reduce( + const messageTextWithPreviewAnnotations = composerPreviewAnnotationsSnapshot.reduce( (text, annotation) => appendPreviewAnnotationPrompt(text, annotation), messageTextWithContexts, ); + const messageTextForSend = appendReviewCommentsToPrompt( + messageTextWithPreviewAnnotations, + composerReviewCommentsSnapshot, + ); const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ @@ -3899,6 +3911,8 @@ function ChatViewContent(props: ChatViewProps) { composerTerminalContextsRef.current.length === 0 && composerElementContextsRef.current.length === 0 && (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.previewAnnotations + .length ?? 0) === 0 && + (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments .length ?? 0) === 0 ) { setOptimisticUserMessages((existing) => { @@ -3919,6 +3933,7 @@ function ChatViewContent(props: ChatViewProps) { setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); setComposerDraftElementContexts(composerDraftTarget, composerElementContextsSnapshot); setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot); + setComposerDraftReviewComments(composerDraftTarget, composerReviewCommentsSnapshot); composerRef.current?.resetCursorState({ cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length), prompt: promptForSend, @@ -4874,7 +4889,7 @@ function ChatViewContent(props: ChatViewProps) { /> ) : activeRightPanelSurface?.kind === "diff" ? ( - + ) : (activeRightPanelSurface?.kind === "files" || activeRightPanelSurface?.kind === "file") && @@ -4886,6 +4901,10 @@ function ChatViewContent(props: ChatViewProps) { environmentId={activeProject.environmentId} cwd={activeWorkspaceRoot} projectName={activeProject.name} + threadRef={activeThreadRef} + composerDraftTarget={composerDraftTarget} + keybindings={keybindings} + availableEditors={availableEditors} relativePath={ activeRightPanelSurface.kind === "file" ? activeRightPanelSurface.relativePath @@ -4948,7 +4967,7 @@ function ChatViewContent(props: ChatViewProps) { /> ) : activeRightPanelSurface?.kind === "diff" ? ( - + ) : activeRightPanelSurface?.kind === "plan" ? ( typeof selectedCheckpointTurnCount === "number" @@ -673,8 +680,12 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { openDiffFile(filePath); }} > - ( store.setPrompt); @@ -636,6 +640,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const removeComposerDraftPreviewAnnotation = useComposerDraftStore( (store) => store.removePreviewAnnotation, ); + const removeComposerDraftReviewComment = useComposerDraftStore( + (store) => store.removeReviewComment, + ); const clearComposerDraftPersistedAttachments = useComposerDraftStore( (store) => store.clearPersistedAttachments, ); @@ -896,12 +903,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) prompt, imageCount: composerImages.length, terminalContexts: composerTerminalContexts, - elementContextCount: composerElementContexts.length + composerPreviewAnnotations.length, + elementContextCount: + composerElementContexts.length + + composerPreviewAnnotations.length + + composerReviewComments.length, }), [ composerElementContexts.length, composerImages.length, composerPreviewAnnotations.length, + composerReviewComments.length, composerTerminalContexts, prompt, ], @@ -1998,6 +2009,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) terminalContexts: composerTerminalContextsRef.current, elementContexts: composerElementContextsRef.current, previewAnnotations: composerPreviewAnnotations, + reviewComments: composerReviewComments, selectedPromptEffort, selectedModelOptionsForDispatch, selectedModelSelection, @@ -2017,6 +2029,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerTerminalContextsRef, composerElementContextsRef, composerPreviewAnnotations, + composerReviewComments, isConnecting, isComposerApprovalState, pendingUserInputs.length, @@ -2272,6 +2285,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) /> )} + {!isComposerCollapsedMobile && + !isComposerApprovalState && + pendingUserInputs.length === 0 && + composerReviewComments.length > 0 && ( + + removeComposerDraftReviewComment(composerDraftTarget, commentId) + } + className="mb-3" + /> + )} + {!isComposerCollapsedMobile && !isComposerApprovalState && pendingUserInputs.length === 0 && diff --git a/apps/web/src/components/chat/ComposerPendingReviewComments.browser.tsx b/apps/web/src/components/chat/ComposerPendingReviewComments.browser.tsx new file mode 100644 index 00000000000..a5aa6e224e8 --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingReviewComments.browser.tsx @@ -0,0 +1,41 @@ +import "../../index.css"; + +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; +import { render } from "vitest-browser-react"; + +import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments"; + +describe("ComposerPendingReviewComments", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("renders a removable file comment pill", async () => { + const onRemove = vi.fn(); + const screen = await render( + , + ); + + await expect.element(page.getByText("src/app.ts L2 to L3")).toBeVisible(); + await page.getByRole("button", { name: "Remove comment on src/app.ts L2 to L3" }).click(); + expect(onRemove).toHaveBeenCalledWith("comment-1"); + + await screen.unmount(); + }); +}); diff --git a/apps/web/src/components/chat/ComposerPendingReviewComments.tsx b/apps/web/src/components/chat/ComposerPendingReviewComments.tsx new file mode 100644 index 00000000000..0a47b02357a --- /dev/null +++ b/apps/web/src/components/chat/ComposerPendingReviewComments.tsx @@ -0,0 +1,60 @@ +import { MessageCircle, X } from "lucide-react"; + +import { + COMPOSER_INLINE_CHIP_CLASS_NAME, + COMPOSER_INLINE_CHIP_DISMISS_BUTTON_CLASS_NAME, + COMPOSER_INLINE_CHIP_ICON_CLASS_NAME, + COMPOSER_INLINE_CHIP_LABEL_CLASS_NAME, +} from "../composerInlineChip"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import type { ReviewCommentContext } from "~/reviewCommentContext"; +import { cn } from "~/lib/utils"; + +interface ComposerPendingReviewCommentsProps { + comments: ReadonlyArray; + onRemove: (commentId: string) => void; + className?: string; +} + +export function ComposerPendingReviewComments({ + comments, + onRemove, + className, +}: ComposerPendingReviewCommentsProps) { + if (comments.length === 0) return null; + + return ( +
    + {comments.map((comment) => { + const label = `${comment.filePath} ${comment.rangeLabel}`; + return ( + + + + {label} + + + } + /> + + {comment.text} + + + ); + })} +
    + ); +} diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 743de5aa6ca..f7da222f441 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -296,6 +296,42 @@ describe("MessagesTimeline", () => { expect(markup).not.toContain("</review_comment>"); }); + it("renders file review comments as source code instead of diffs", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + ', + "Clarify this.", + "```md", + "# Plan", + "- Step one", + "```", + "", + ].join("\n"), + createdAt: "2026-03-17T19:12:28.000Z", + streaming: false, + }, + }, + ]} + />, + ); + + expect(markup).toContain("plan.md"); + expect(markup).toContain("Clarify this."); + expect(markup).toContain("# Plan"); + expect(markup).not.toContain('data-testid="file-diff"'); + }); + it("renders a failure marker for failed tool lifecycle entries", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 250eee4d698..d340f7ac7ca 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -102,6 +102,7 @@ import { SkillInlineText } from "./SkillInlineText"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; import { buildReviewCommentRenderablePatch, + formatReviewCommentFence, parseReviewCommentMessageSegments, type ReviewCommentContext, } from "../../reviewCommentContext"; @@ -1270,6 +1271,7 @@ const UserMessageBody = memo(function UserMessageBody(props: { function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentContext }) { const ctx = use(TimelineRowCtx); + const fenceLanguage = comment.fenceLanguage ?? "diff"; const renderablePatch = getRenderablePatch( buildReviewCommentRenderablePatch(comment), `review-comment:${comment.id}`, @@ -1290,6 +1292,15 @@ function UserMessageReviewCommentCard({ comment }: { comment: ReviewCommentConte )} + {fenceLanguage !== "diff" && comment.diff.trim().length > 0 && ( + + )} {renderablePatch?.kind === "files" && renderablePatch.files.map((fileDiff) => ( ; openInCwd: string | null; + compact?: boolean; + enableShortcut?: boolean; }) { const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors); const options = useMemo( @@ -184,6 +188,7 @@ export const OpenInPicker = memo(function OpenInPicker({ ); useEffect(() => { + if (!enableShortcut) return; const handler = (e: globalThis.KeyboardEvent) => { const api = readLocalApi(); if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; @@ -195,24 +200,39 @@ export const OpenInPicker = memo(function OpenInPicker({ }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [preferredEditor, keybindings, openInCwd]); + }, [enableShortcut, preferredEditor, keybindings, openInCwd]); return ( - + - + - }> + + } + > diff --git a/apps/web/src/components/diffs/AnnotatableFileDiff.browser.tsx b/apps/web/src/components/diffs/AnnotatableFileDiff.browser.tsx new file mode 100644 index 00000000000..393c0ab1634 --- /dev/null +++ b/apps/web/src/components/diffs/AnnotatableFileDiff.browser.tsx @@ -0,0 +1,122 @@ +import "../../index.css"; + +import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; +import { scopeThreadRef } from "@t3tools/client-runtime"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; +import { render } from "vitest-browser-react"; + +import { useComposerDraftStore } from "~/composerDraftStore"; + +import { AnnotatableFileDiff } from "./AnnotatableFileDiff"; + +function dispatchPointer(target: EventTarget, type: string, pointerId: number): void { + target.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + pointerId, + pointerType: "mouse", + }), + ); +} + +const threadRef = scopeThreadRef(EnvironmentId.make("local"), ThreadId.make("thread-1")); + +function TestDiff() { + const fileDiff = parsePatchFiles( + [ + "diff --git a/src/app.ts b/src/app.ts", + "--- a/src/app.ts", + "+++ b/src/app.ts", + "@@ -1,3 +1,3 @@", + " one", + "-two", + "+TWO", + " three", + ].join("\n"), + "annotatable-file-diff-test", + )[0]!.files[0]!; + + return ( + null} + options={{ + diffStyle: "unified", + lineDiffType: "none", + themeType: "light", + }} + /> + ); +} + +async function getRenderedDiff() { + return vi.waitFor(() => { + const element = document.querySelector("diffs-container"); + expect(element?.shadowRoot).not.toBeNull(); + return element!; + }); +} + +describe("annotatable Pierre file diff", () => { + afterEach(() => { + document.body.innerHTML = ""; + useComposerDraftStore.getState().setReviewComments(threadRef, []); + }); + + it("creates a local annotation from the gutter and attaches it to the composer", async () => { + let screen = await render(); + + try { + const diff = await getRenderedDiff(); + const addedLineNumber = await vi.waitFor(() => { + const elements = Array.from( + diff.shadowRoot?.querySelectorAll('[data-column-number="2"]') ?? [], + ); + const element = elements.at(-1) ?? null; + expect(element).not.toBeNull(); + return element!; + }); + + dispatchPointer(addedLineNumber, "pointerdown", 1); + dispatchPointer(addedLineNumber, "pointerup", 1); + + const textarea = page.getByRole("textbox", { name: "Comment on lines +2" }); + await expect.element(textarea).toBeVisible(); + await textarea.fill("Use the compatible value."); + await page.getByRole("button", { name: "Comment" }).click(); + + await vi.waitFor(() => { + expect( + useComposerDraftStore.getState().getComposerDraft(threadRef)?.reviewComments, + ).toEqual([ + expect.objectContaining({ + sectionId: "turn:2", + filePath: "src/app.ts", + rangeLabel: "+2", + text: "Use the compatible value.", + diff: "@@ -0,0 +2,1 @@\n+TWO", + }), + ]); + }); + expect(document.querySelector("[data-file-comment-annotation]")?.textContent).toContain( + "Use the compatible value.", + ); + + await screen.unmount(); + screen = await render(); + await expect + .element(page.getByText("Use the compatible value.", { exact: true })) + .toBeVisible(); + } finally { + await screen.unmount(); + } + }); +}); diff --git a/apps/web/src/components/diffs/AnnotatableFileDiff.tsx b/apps/web/src/components/diffs/AnnotatableFileDiff.tsx new file mode 100644 index 00000000000..ceb2f87785a --- /dev/null +++ b/apps/web/src/components/diffs/AnnotatableFileDiff.tsx @@ -0,0 +1,239 @@ +import type { + AnnotationSide, + DiffLineAnnotation, + FileDiffMetadata, + SelectedLineRange, +} from "@pierre/diffs"; +import { FileDiff, type FileDiffProps } from "@pierre/diffs/react"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { useCallback, useMemo, useState, type ReactNode } from "react"; + +import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; +import { + buildDiffReviewComment, + restoreDiffReviewCommentRange, + type ReviewCommentContext, +} from "~/reviewCommentContext"; + +import { LocalCommentAnnotation } from "../files/LocalCommentAnnotation"; +import { nextFileCommentId } from "../files/fileCommentAnnotations"; + +interface DiffCommentAnnotationEntry { + id: string; + kind: "draft" | "comment"; + range: SelectedLineRange; + rangeLabel: string; + text: string; +} + +interface DiffCommentAnnotationGroup { + entries: DiffCommentAnnotationEntry[]; +} + +type DiffCommentLineAnnotation = DiffLineAnnotation; +const EMPTY_REVIEW_COMMENTS: ReadonlyArray = []; + +function annotationSide(range: SelectedLineRange): AnnotationSide { + return (range.endSide ?? range.side) === "deletions" ? "deletions" : "additions"; +} + +function appendAnnotationEntry( + annotations: ReadonlyArray, + range: SelectedLineRange, + entry: DiffCommentAnnotationEntry, +): DiffCommentLineAnnotation[] { + const side = annotationSide(range); + const annotationIndex = annotations.findIndex( + (annotation) => annotation.side === side && annotation.lineNumber === range.end, + ); + if (annotationIndex < 0) { + return [ + ...annotations, + { + side, + lineNumber: range.end, + metadata: { entries: [entry] }, + }, + ]; + } + return annotations.map((annotation, index) => + index === annotationIndex + ? { + ...annotation, + metadata: { entries: [...annotation.metadata.entries, entry] }, + } + : annotation, + ); +} + +interface AnnotatableFileDiffProps { + fileDiff: FileDiffMetadata; + filePath: string; + sectionId: string; + sectionTitle: string; + composerDraftTarget: ScopedThreadRef | DraftId; + options: FileDiffProps["options"]; + renderHeaderPrefix: (fileDiff: FileDiffMetadata) => ReactNode; +} + +export function AnnotatableFileDiff({ + fileDiff, + filePath, + sectionId, + sectionTitle, + composerDraftTarget, + options, + renderHeaderPrefix, +}: AnnotatableFileDiffProps) { + const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); + const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); + const reviewComments = useComposerDraftStore( + (store) => store.getComposerDraft(composerDraftTarget)?.reviewComments ?? EMPTY_REVIEW_COMMENTS, + ); + const [selectedRange, setSelectedRange] = useState(null); + const [draftAnnotation, setDraftAnnotation] = useState(null); + const persistedAnnotations = useMemo( + () => + reviewComments + .filter( + (comment) => + comment.sectionId === sectionId && + comment.filePath === filePath && + (comment.fenceLanguage ?? "diff") === "diff", + ) + .reduce((annotations, comment) => { + const range = restoreDiffReviewCommentRange(fileDiff, comment); + if (!range) return annotations; + return appendAnnotationEntry(annotations, range, { + id: comment.id, + kind: "comment", + range, + rangeLabel: comment.rangeLabel, + text: comment.text, + }); + }, []), + [fileDiff, filePath, reviewComments, sectionId], + ); + const lineAnnotations = useMemo( + () => (draftAnnotation ? [...persistedAnnotations, draftAnnotation] : persistedAnnotations), + [draftAnnotation, persistedAnnotations], + ); + + const removeAnnotationEntry = useCallback( + (entryId: string) => { + setSelectedRange(null); + if ( + draftAnnotation?.metadata.entries.some( + (entry) => entry.id === entryId && entry.kind === "draft", + ) + ) { + setDraftAnnotation(null); + return; + } + removeReviewComment(composerDraftTarget, entryId); + }, + [composerDraftTarget, draftAnnotation, removeReviewComment], + ); + + const submitAnnotationEntry = useCallback( + (entryId: string, text: string) => { + const entry = draftAnnotation?.metadata.entries.find((candidate) => candidate.id === entryId); + if (!entry) return; + + const comment = buildDiffReviewComment({ + id: entry.id, + sectionId, + sectionTitle, + filePath, + fileDiff, + range: entry.range, + text, + }); + if (comment) { + addReviewComment(composerDraftTarget, comment); + } + setSelectedRange(null); + setDraftAnnotation(null); + }, + [ + addReviewComment, + composerDraftTarget, + fileDiff, + filePath, + draftAnnotation, + sectionId, + sectionTitle, + ], + ); + + const beginComment = useCallback( + (range: SelectedLineRange) => { + const id = nextFileCommentId(); + const comment = buildDiffReviewComment({ + id, + sectionId, + sectionTitle, + filePath, + fileDiff, + range, + text: "", + }); + if (!comment) return; + + const draftEntry: DiffCommentAnnotationEntry = { + id, + kind: "draft", + range, + rangeLabel: comment.rangeLabel, + text: "", + }; + setDraftAnnotation({ + side: annotationSide(range), + lineNumber: range.end, + metadata: { entries: [draftEntry] }, + }); + }, + [fileDiff, filePath, sectionId, sectionTitle], + ); + + const hasOpenCommentForm = draftAnnotation !== null; + const handleLineSelectionEnd = useCallback( + (range: SelectedLineRange | null) => { + setSelectedRange(range); + if (range) beginComment(range); + }, + [beginComment], + ); + + return ( + + fileDiff={fileDiff} + renderHeaderPrefix={renderHeaderPrefix} + options={{ + ...options, + enableGutterUtility: !hasOpenCommentForm, + enableLineSelection: !hasOpenCommentForm, + onGutterUtilityClick: setSelectedRange, + onLineSelectionChange: setSelectedRange, + onLineSelectionEnd: handleLineSelectionEnd, + }} + selectedLines={selectedRange} + lineAnnotations={lineAnnotations} + renderAnnotation={(annotation) => ( +
    + {annotation.metadata.entries.map((entry) => ( + removeAnnotationEntry(entry.id)} + onComment={(text) => submitAnnotationEntry(entry.id, text)} + onDelete={() => removeAnnotationEntry(entry.id)} + /> + ))} +
    + )} + /> + ); +} diff --git a/apps/web/src/components/files/FilePreviewPanel.browser.tsx b/apps/web/src/components/files/FilePreviewPanel.browser.tsx new file mode 100644 index 00000000000..7886e99cba9 --- /dev/null +++ b/apps/web/src/components/files/FilePreviewPanel.browser.tsx @@ -0,0 +1,168 @@ +import "../../index.css"; + +import type { LineAnnotation, SelectedLineRange } from "@pierre/diffs"; +import { Editor } from "@pierre/diffs/editor"; +import { EditorProvider, File } from "@pierre/diffs/react"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; +import { render } from "vitest-browser-react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { installFileEditorDismissal } from "./fileEditorDismissal"; + +interface AnnotationMetadata { + label: string; +} + +function dispatchPointer(target: EventTarget, type: string, pointerId: number): void { + target.dispatchEvent( + new PointerEvent(type, { + bubbles: true, + cancelable: true, + composed: true, + pointerId, + pointerType: "mouse", + }), + ); +} + +function EditableAnnotatedFile() { + const [selectedLines, setSelectedLines] = useState(null); + const [lineAnnotations, setLineAnnotations] = useState[]>([]); + const rootRef = useRef(null); + const editor = useMemo(() => new Editor(), []); + + useEffect(() => () => editor.cleanUp(), [editor]); + useEffect(() => { + const root = rootRef.current; + if (!root) return; + return installFileEditorDismissal({ + root, + editor, + isBlocked: () => false, + onDismiss: () => setSelectedLines(null), + }); + }, [editor]); + + return ( + <> +
    + + + file={{ name: "example.ts", contents: "one\ntwo\nthree\n" }} + options={{ + disableFileHeader: true, + enableGutterUtility: true, + enableLineSelection: true, + onGutterUtilityClick: setSelectedLines, + onLineSelectionChange: setSelectedLines, + onLineSelectionEnd: (range) => { + setSelectedLines(range); + if (range) { + setLineAnnotations([ + { + lineNumber: Math.max(range.start, range.end), + metadata: { label: `${range.start}:${range.end}` }, + }, + ]); + } + }, + }} + selectedLines={selectedLines} + lineAnnotations={lineAnnotations} + renderAnnotation={(annotation) => ( +
    + {annotation.metadata.label} +
    + )} + disableWorkerPool + contentEditable + /> +
    +
    + + + ); +} + +async function getEditableFile() { + const file = await vi.waitFor(() => { + const element = document.querySelector("diffs-container"); + expect(element?.shadowRoot).not.toBeNull(); + return element!; + }); + const content = await vi.waitFor(() => { + const element = file?.shadowRoot?.querySelector("[data-content]") ?? null; + expect(element).not.toBeNull(); + return element!; + }); + return { file, content }; +} + +describe("editable Pierre file annotations", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("keeps gutter selection and annotations enabled while the file is editable", async () => { + const screen = await render(); + + try { + const { file, content } = await getEditableFile(); + const secondLineNumber = await vi.waitFor(() => { + const element = + file?.shadowRoot?.querySelector('[data-column-number="2"]') ?? null; + expect(element).not.toBeNull(); + return element; + }); + await vi.waitFor(() => { + expect( + file?.shadowRoot?.querySelector("pre")?.hasAttribute("data-interactive-line-numbers"), + ).toBe(true); + }); + + dispatchPointer(secondLineNumber!, "pointerdown", 1); + dispatchPointer(secondLineNumber!, "pointerup", 1); + + await vi.waitFor(() => { + expect(document.querySelector("[data-test-file-annotation]")?.textContent).toBe("2:2"); + }); + + expect(content.contentEditable).toBe("true"); + expect(content.getAttribute("role")).toBe("textbox"); + } finally { + await screen.unmount(); + } + }); + + it("dismisses editor focus and selection with outside click or Escape", async () => { + const screen = await render(); + + try { + const { file, content } = await getEditableFile(); + content.focus(); + expect(file?.shadowRoot?.activeElement).toBe(content); + + await page.getByRole("button", { name: "Outside file" }).click(); + await vi.waitFor(() => { + expect(file?.shadowRoot?.activeElement).not.toBe(content); + }); + + content.focus(); + expect(file?.shadowRoot?.activeElement).toBe(content); + content.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Escape", + bubbles: true, + cancelable: true, + composed: true, + }), + ); + await vi.waitFor(() => { + expect(file?.shadowRoot?.activeElement).not.toBe(content); + }); + } finally { + await screen.unmount(); + } + }); +}); diff --git a/apps/web/src/components/files/FilePreviewPanel.test.ts b/apps/web/src/components/files/FilePreviewPanel.test.ts new file mode 100644 index 00000000000..3b5295f180e --- /dev/null +++ b/apps/web/src/components/files/FilePreviewPanel.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + formatFileCommentRange, + normalizeFileCommentRange, + remapFileCommentAnnotations, +} from "./fileCommentAnnotations"; +import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode"; + +describe("file comment annotations", () => { + it("normalizes and formats selected line ranges", () => { + expect(normalizeFileCommentRange({ start: 16, end: 7 })).toEqual({ + startLine: 7, + endLine: 16, + }); + expect(formatFileCommentRange(7, 7)).toBe("L7"); + expect(formatFileCommentRange(7, 16)).toBe("L7 to L16"); + }); + + it("keeps an annotation range attached when Pierre remaps its anchor line", () => { + expect( + remapFileCommentAnnotations([ + { + lineNumber: 20, + metadata: { + entries: [ + { + id: "comment-1", + kind: "comment", + startLine: 7, + endLine: 16, + text: "Keep this guarded.", + }, + ], + }, + }, + ]), + ).toEqual([ + { + lineNumber: 20, + metadata: { + entries: [ + { + id: "comment-1", + kind: "comment", + startLine: 11, + endLine: 20, + text: "Keep this guarded.", + }, + ], + }, + }, + ]); + }); +}); + +describe("isMarkdownPreviewFile", () => { + it("recognizes markdown and MDX files case-insensitively", () => { + expect(isMarkdownPreviewFile("README.md")).toBe(true); + expect(isMarkdownPreviewFile("docs/guide.MDX")).toBe(true); + }); + + it("does not treat other text files as markdown", () => { + expect(isMarkdownPreviewFile("docs/guide.txt")).toBe(false); + expect(isMarkdownPreviewFile("docs/markdown.ts")).toBe(false); + }); +}); + +describe("setMarkdownTaskChecked", () => { + const markdown = "- [ ] First\n- [x] Second\n"; + + it("checks and unchecks the task marker at the supplied offset", () => { + expect(setMarkdownTaskChecked(markdown, 2, true)).toBe("- [x] First\n- [x] Second\n"); + expect(setMarkdownTaskChecked(markdown, 14, false)).toBe("- [ ] First\n- [ ] Second\n"); + expect(setMarkdownTaskChecked("1. [X] Ordered\n", 3, false)).toBe("1. [ ] Ordered\n"); + }); + + it("leaves the document unchanged for a stale or invalid marker offset", () => { + expect(setMarkdownTaskChecked(markdown, 0, true)).toBe(markdown); + expect(setMarkdownTaskChecked(markdown, 200, true)).toBe(markdown); + }); +}); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index ec76ee84d46..d3d24a79bd6 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -1,23 +1,51 @@ -import type { EnvironmentId } from "@t3tools/contracts"; +import type { + EditorId, + EnvironmentId, + ResolvedKeybindingsConfig, + ScopedThreadRef, +} from "@t3tools/contracts"; +import type { SelectedLineRange } from "@pierre/diffs"; import { Editor } from "@pierre/diffs/editor"; import { EditorProvider, File, Virtualizer } from "@pierre/diffs/react"; -import { ChevronRight, FolderTree, LoaderCircle } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { ChevronRight, Code2, Eye, FolderTree, Globe2, LoaderCircle } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; +import ChatMarkdown from "~/components/ChatMarkdown"; +import { OpenInPicker } from "~/components/chat/OpenInPicker"; import { ensureEnvironmentApi } from "~/environmentApi"; +import { usePrimaryEnvironmentId } from "~/environments/primary/context"; import { useTheme } from "~/hooks/useTheme"; import { resolveDiffThemeName } from "~/lib/diffRendering"; import { cn } from "~/lib/utils"; +import { isPreviewSupportedInRuntime } from "~/previewStateStore"; +import { resolvePathLinkTarget } from "~/terminal-links"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Toggle } from "~/components/ui/toggle"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; +import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; +import { buildFileReviewComment } from "~/reviewCommentContext"; import FileBrowserPanel from "./FileBrowserPanel"; +import { + type FileCommentAnnotationEntry, + type FileCommentAnnotationGroup, + type FileCommentLineAnnotation, + formatFileCommentRange, + nextFileCommentId, + normalizeFileCommentRange, + remapFileCommentAnnotations, +} from "./fileCommentAnnotations"; +import { installFileEditorDismissal } from "./fileEditorDismissal"; +import { LocalCommentAnnotation } from "./LocalCommentAnnotation"; import { projectFileCacheKey } from "./fileContentRevision"; import { fileBreadcrumbs } from "./filePath"; +import { isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode"; import { FileSaveCoordinator } from "./fileSaveCoordinator"; import { confirmProjectFileQueryData, + getOptimisticProjectFileQueryData, setProjectFileQueryData, useProjectFileQuery, } from "./projectFilesQueryState"; @@ -27,6 +55,10 @@ interface FilePreviewPanelProps { cwd: string; projectName: string; relativePath: string | null; + threadRef: ScopedThreadRef; + composerDraftTarget: ScopedThreadRef | DraftId; + keybindings: ResolvedKeybindingsConfig; + availableEditors: ReadonlyArray; onOpenFile: (relativePath: string) => void; onPendingChange: (relativePath: string, pending: boolean) => void; } @@ -38,20 +70,22 @@ interface EditableFileSurfaceProps { environmentId: EnvironmentId; cwd: string; relativePath: string; + composerDraftTarget: ScopedThreadRef | DraftId; contents: string; resolvedTheme: "light" | "dark"; onPendingChange: (relativePath: string, pending: boolean) => void; } -function EditableFileSurface({ +function useFileSaveCoordinator({ environmentId, cwd, relativePath, - contents, - resolvedTheme, onPendingChange, -}: EditableFileSurfaceProps) { - const saveCoordinator = useMemo( +}: Pick< + EditableFileSurfaceProps, + "environmentId" | "cwd" | "relativePath" | "onPendingChange" +>): FileSaveCoordinator { + const coordinator = useMemo( () => new FileSaveCoordinator({ debounceMs: FILE_SAVE_DEBOUNCE_MS, @@ -69,54 +103,270 @@ function EditableFileSurface({ }), [cwd, environmentId, onPendingChange, relativePath], ); + + useEffect(() => () => coordinator.dispose(), [coordinator]); + return coordinator; +} + +function EditableFileSurface({ + environmentId, + cwd, + relativePath, + composerDraftTarget, + contents, + resolvedTheme, + onPendingChange, +}: EditableFileSurfaceProps) { + const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); + const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); + const [lineAnnotations, setLineAnnotations] = useState([]); + const [selectedRange, setSelectedRange] = useState(null); + const surfaceRef = useRef(null); + const saveCoordinator = useFileSaveCoordinator({ + environmentId, + cwd, + relativePath, + onPendingChange, + }); const editor = useMemo( () => - new Editor({ - onChange: (file) => { + new Editor({ + onChange: (file, nextLineAnnotations) => { setProjectFileQueryData(environmentId, cwd, relativePath, file.contents); saveCoordinator.change(file.contents); + if (nextLineAnnotations) { + const remapped = remapFileCommentAnnotations( + nextLineAnnotations as FileCommentLineAnnotation[], + ); + setLineAnnotations(remapped); + for (const annotation of remapped) { + for (const entry of annotation.metadata.entries) { + if (entry.kind !== "comment") continue; + addReviewComment( + composerDraftTarget, + buildFileReviewComment({ + id: entry.id, + filePath: relativePath, + startLine: entry.startLine, + endLine: entry.endLine, + text: entry.text, + contents: file.contents, + }), + ); + } + } + } }, }), - [cwd, environmentId, relativePath, saveCoordinator], + [addReviewComment, composerDraftTarget, cwd, environmentId, relativePath, saveCoordinator], ); useEffect( () => () => { editor.cleanUp(); - saveCoordinator.dispose(); }, - [editor, saveCoordinator], + [editor], + ); + + const removeAnnotationEntry = useCallback( + (entryId: string) => { + setSelectedRange(null); + removeReviewComment(composerDraftTarget, entryId); + setLineAnnotations((current) => { + return current.flatMap((annotation) => { + const entries = annotation.metadata.entries.filter((entry) => entry.id !== entryId); + return entries.length > 0 ? [{ ...annotation, metadata: { entries } }] : []; + }); + }); + }, + [composerDraftTarget, removeReviewComment], + ); + + const submitAnnotationEntry = useCallback( + (entryId: string, text: string) => { + setSelectedRange(null); + const entry = lineAnnotations + .flatMap((annotation) => annotation.metadata.entries) + .find((candidate) => candidate.id === entryId); + if (entry) { + addReviewComment( + composerDraftTarget, + buildFileReviewComment({ + id: entry.id, + filePath: relativePath, + startLine: entry.startLine, + endLine: entry.endLine, + text, + contents, + }), + ); + } + setLineAnnotations((current) => + current.map((annotation) => ({ + ...annotation, + metadata: { + entries: annotation.metadata.entries.map((annotationEntry) => + annotationEntry.id === entryId + ? { ...annotationEntry, kind: "comment", text } + : annotationEntry, + ), + }, + })), + ); + }, + [addReviewComment, composerDraftTarget, contents, lineAnnotations, relativePath], + ); + + const beginComment = useCallback((range: SelectedLineRange) => { + const { startLine, endLine } = normalizeFileCommentRange(range); + const draftEntry: FileCommentAnnotationEntry = { + id: nextFileCommentId(), + kind: "draft", + startLine, + endLine, + text: "", + }; + setLineAnnotations((current) => { + const withoutDraft = current.flatMap((annotation) => { + const entries = annotation.metadata.entries.filter((entry) => entry.kind !== "draft"); + return entries.length > 0 ? [{ ...annotation, metadata: { entries } }] : []; + }); + const existingIndex = withoutDraft.findIndex( + (annotation) => annotation.lineNumber === endLine, + ); + if (existingIndex < 0) { + return [ + ...withoutDraft, + { + lineNumber: endLine, + metadata: { entries: [draftEntry] }, + }, + ]; + } + return withoutDraft.map((annotation, index) => + index === existingIndex + ? { + ...annotation, + metadata: { entries: [...annotation.metadata.entries, draftEntry] }, + } + : annotation, + ); + }); + }, []); + const hasOpenCommentForm = lineAnnotations.some((annotation) => + annotation.metadata.entries.some((entry) => entry.kind === "draft"), + ); + useEffect(() => { + const root = surfaceRef.current; + if (!root) return; + return installFileEditorDismissal({ + root, + editor, + isBlocked: () => hasOpenCommentForm, + onDismiss: () => setSelectedRange(null), + }); + }, [editor, hasOpenCommentForm]); + const handleLineSelectionEnd = useCallback( + (range: SelectedLineRange | null) => { + setSelectedRange(range); + if (range) { + beginComment(range); + } + }, + [beginComment], ); return ( - - + - + > + + file={{ + name: relativePath, + contents, + cacheKey: projectFileCacheKey(cwd, relativePath, contents), + }} + options={{ + disableFileHeader: true, + enableGutterUtility: !hasOpenCommentForm, + enableLineSelection: !hasOpenCommentForm, + onGutterUtilityClick: setSelectedRange, + onLineSelectionChange: setSelectedRange, + onLineSelectionEnd: handleLineSelectionEnd, + overflow: "scroll", + theme: resolveDiffThemeName(resolvedTheme), + themeType: resolvedTheme, + }} + selectedLines={selectedRange} + lineAnnotations={lineAnnotations} + renderAnnotation={(annotation) => ( +
    + {annotation.metadata.entries.map((entry) => ( + removeAnnotationEntry(entry.id)} + onComment={(text) => submitAnnotationEntry(entry.id, text)} + onDelete={() => removeAnnotationEntry(entry.id)} + /> + ))} +
    + )} + className="min-h-full" + contentEditable + /> +
    +
    ); } +function RenderedMarkdownSurface({ + environmentId, + cwd, + relativePath, + contents, + threadRef, + onPendingChange, +}: Omit & { + threadRef: ScopedThreadRef; +}) { + const saveCoordinator = useFileSaveCoordinator({ + environmentId, + cwd, + relativePath, + onPendingChange, + }); + + return ( + + { + const currentContents = + getOptimisticProjectFileQueryData(environmentId, cwd, relativePath)?.contents ?? + contents; + const nextContents = setMarkdownTaskChecked(currentContents, markerOffset, checked); + if (nextContents === currentContents) return; + setProjectFileQueryData(environmentId, cwd, relativePath, nextContents); + saveCoordinator.change(nextContents); + }} + /> + + ); +} + function initialExplorerOpen(): boolean { try { return window.localStorage.getItem(FILE_EXPLORER_STORAGE_KEY) !== "false"; @@ -130,13 +380,24 @@ export default function FilePreviewPanel({ cwd, projectName, relativePath, + threadRef, + composerDraftTarget, + keybindings, + availableEditors, onOpenFile, onPendingChange, }: FilePreviewPanelProps) { const { resolvedTheme } = useTheme(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); const file = useProjectFileQuery(environmentId, cwd, relativePath); const [explorerOpen, setExplorerOpen] = useState(initialExplorerOpen); + const [renderedMarkdownPath, setRenderedMarkdownPath] = useState(null); const breadcrumbRef = useRef(null); + const isMarkdown = relativePath ? isMarkdownPreviewFile(relativePath) : false; + const renderMarkdown = isMarkdown && renderedMarkdownPath === relativePath; + const canOpenInBrowser = + relativePath !== null && isPreviewSupportedInRuntime() && isBrowserPreviewFile(relativePath); + const absolutePath = relativePath ? resolvePathLinkTarget(relativePath, cwd) : null; const breadcrumbs = useMemo( () => (relativePath ? fileBreadcrumbs(projectName, relativePath) : []), [projectName, relativePath], @@ -159,6 +420,19 @@ export default function FilePreviewPanel({ }); }; + const handleOpenInBrowser = () => { + if (!absolutePath) return; + void openFileInPreview(threadRef, absolutePath).catch((error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open file in browser", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + }; + return (
    {relativePath ? ( @@ -195,6 +469,57 @@ export default function FilePreviewPanel({ ))}
    + {absolutePath && environmentId === primaryEnvironmentId ? ( + + ) : null} + {isMarkdown ? ( + + + setRenderedMarkdownPath(pressed ? relativePath : null) + } + aria-label={renderMarkdown ? "Show markdown source" : "Show rendered markdown"} + variant="ghost" + size="sm" + > + {renderMarkdown ? : } + + } + /> + + {renderMarkdown ? "Show markdown source" : "Show rendered markdown"} + + + ) : null} + {canOpenInBrowser ? ( + + + + + } + /> + Open file in preview browser + + ) : null} ) : relativePath && file.data ? ( - file.data.truncated ? ( + isMarkdown && renderMarkdown ? ( + + ) : file.data.truncated ? ( void; + onComment: (text: string) => void; + onDelete: () => void; +} + +export function LocalCommentAnnotation({ + kind, + rangeLabel, + text: savedText, + onCancel, + onComment, + onDelete, +}: LocalCommentAnnotationProps) { + const [text, setText] = useState(""); + + if (kind === "comment") { + return ( +
    event.stopPropagation()} + > +
    + + Local comment + {rangeLabel} + +
    +

    + {savedText} +

    +
    + ); + } + + return ( +
    event.stopPropagation()} + > +
    + + Local comment +
    +
    Comment on lines {rangeLabel}
    +