From 01b2c042fdc94f621aef04c19c05e8362cd04a37 Mon Sep 17 00:00:00 2001 From: jln <85513960+jln13x@users.noreply.github.com> Date: Tue, 21 Jul 2026 17:06:26 +0300 Subject: [PATCH] fix: backport upstream reliability improvements Integrate focused correctness, performance, recovery, diff rendering, Git workflow, and desktop hardening changes from upstream PRs #4136, #4009, #3903, #4187, #3830, #2338, #3885, #4213, #4205, #3520, and #4107. Document local adaptations, exclusions, and verification expectations in the personal fork inventory. --- .../src/electron/ElectronProtocol.test.ts | 3 +- apps/desktop/src/electron/ElectronProtocol.ts | 1 + apps/desktop/src/main.ts | 6 + .../src/provider/Layers/CodexAdapter.ts | 8 +- .../Layers/CodexInterruptResolution.test.ts | 207 ++++++++++++++++++ .../Layers/CodexSessionRuntime.test.ts | 3 +- .../provider/Layers/CodexSessionRuntime.ts | 85 ++++++- .../SourceControlRepositoryService.test.ts | 3 +- .../SourceControlRepositoryService.ts | 3 +- .../web/src/components/ChatView.logic.test.ts | 169 +++++++++++--- apps/web/src/components/ChatView.logic.ts | 39 ++-- apps/web/src/components/ChatView.tsx | 18 +- .../components/CommandPalette.logic.test.ts | 9 + .../src/components/CommandPalette.logic.ts | 12 +- apps/web/src/components/CommandPalette.tsx | 3 +- apps/web/src/components/DiffPanel.tsx | 45 +++- .../src/components/DiffWorkerPoolProvider.tsx | 3 + .../components/chat/MessagesTimeline.test.tsx | 14 ++ .../src/components/chat/MessagesTimeline.tsx | 5 +- .../src/components/chat/ThreadErrorBanner.tsx | 12 +- .../components/diffs/AnnotatableCodeView.tsx | 9 +- apps/web/src/lib/diffRendering.test.ts | 27 ++- apps/web/src/lib/diffRendering.ts | 11 + apps/web/src/routes/__root.tsx | 27 +++ docs/upstream-integrations.md | 56 ++++- packages/client-runtime/src/state/vcs.test.ts | 37 +++- packages/shared/package.json | 4 + packages/shared/src/Struct.edgeCases.test.ts | 72 ++++++ packages/shared/src/Struct.test.ts | 61 ++++++ packages/shared/src/Struct.ts | 21 +- packages/shared/src/providerError.test.ts | 48 ++++ packages/shared/src/providerError.ts | 36 +++ 32 files changed, 957 insertions(+), 100 deletions(-) create mode 100644 apps/server/src/provider/Layers/CodexInterruptResolution.test.ts create mode 100644 packages/shared/src/Struct.edgeCases.test.ts create mode 100644 packages/shared/src/Struct.test.ts create mode 100644 packages/shared/src/providerError.test.ts create mode 100644 packages/shared/src/providerError.ts diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 92e30000427..2db85dafc4d 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -57,7 +57,7 @@ describe("ElectronProtocol", () => { assert.equal(yield* Effect.promise(() => response.text()), "ok"); assert.include( response.headers.get("content-security-policy") ?? "", - "script-src 'self' 'unsafe-inline' https://clerk.t3.codes https://challenges.cloudflare.com", + "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://clerk.t3.codes https://challenges.cloudflare.com", ); assert.include( response.headers.get("content-security-policy") ?? "", @@ -212,6 +212,7 @@ describe("ElectronProtocol", () => { assert.deepEqual(directives["script-src"], [ "'self'", "'unsafe-inline'", + "'wasm-unsafe-eval'", "https://clerk.t3.codes", "https://challenges.cloudflare.com", ]); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 03c7ef64fd7..9d5a47806b3 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -71,6 +71,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat const scriptSources = [ "'self'", "'unsafe-inline'", + "'wasm-unsafe-eval'", ...(clerkOrigin ? [clerkOrigin] : []), "https://challenges.cloudflare.com", ]; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index ec2440579dd..55fbe3aebcd 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,3 +1,9 @@ +for (const stream of [process.stdout, process.stderr]) { + stream.on("error", (err: NodeJS.ErrnoException) => { + if (err.code !== "EPIPE") throw err; + }); +} + import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 38a5887cdc3..def4bfebab5 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -38,6 +38,7 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { extractProviderErrorMessage } from "@t3tools/shared/providerError"; import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; @@ -772,7 +773,8 @@ function mapToRuntimeEvents( if (!payload) { return []; } - const errorMessage = trimText(payload.turn.error?.message); + const rawErrorMessage = trimText(payload.turn.error?.message); + const errorMessage = rawErrorMessage ? extractProviderErrorMessage(rawErrorMessage) : undefined; return [ { ...runtimeEventBase(event, canonicalThreadId), @@ -1249,7 +1251,9 @@ function mapToRuntimeEvents( if (event.method === "error") { const payload = readPayload(EffectCodexSchema.V2ErrorNotification, event.payload); - const message = payload?.error.message ?? event.message ?? "Provider runtime error"; + const message = extractProviderErrorMessage( + payload?.error.message ?? event.message ?? "Provider runtime error", + ); const willRetry = payload?.willRetry === true; return [ { diff --git a/apps/server/src/provider/Layers/CodexInterruptResolution.test.ts b/apps/server/src/provider/Layers/CodexInterruptResolution.test.ts new file mode 100644 index 00000000000..96a45a3558d --- /dev/null +++ b/apps/server/src/provider/Layers/CodexInterruptResolution.test.ts @@ -0,0 +1,207 @@ +import * as NodeAssert from "node:assert/strict"; + +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; +import { describe, it } from "@effect/vitest"; +import { TurnId } from "@t3tools/contracts"; +import type * as CodexRpc from "effect-codex-app-server/rpc"; +import type * as EffectCodexSchema from "effect-codex-app-server/schema"; + +import { + findActiveCodexTurnId, + resolveCodexInterruptTurnId, + shouldReplaceActiveCodexTurnCandidate, +} from "./CodexSessionRuntime.ts"; + +function makeThreadReadResponse( + turns: EffectCodexSchema.V2ThreadReadResponse["thread"]["turns"], +): EffectCodexSchema.V2ThreadReadResponse { + return { + thread: { + cliVersion: "0.0.0-test", + createdAt: 1, + cwd: "/tmp/project", + ephemeral: false, + id: "provider-thread-1", + modelProvider: "openai", + preview: "test thread", + sessionId: "session-1", + source: "appServer", + status: { type: "active", activeFlags: [] }, + turns, + updatedAt: 2, + }, + }; +} + +describe("findActiveCodexTurnId", () => { + it("selects the most recently started in-progress turn", () => { + const snapshot = makeThreadReadResponse([ + { id: "turn-active-new", status: "inProgress", startedAt: 30, items: [] }, + { id: "turn-completed", status: "completed", startedAt: 20, items: [] }, + { id: "turn-active-old", status: "inProgress", startedAt: 10, items: [] }, + ]); + + NodeAssert.equal(findActiveCodexTurnId(snapshot), "turn-active-new"); + }); + + it("selects a later in-progress turn without a start timestamp", () => { + const snapshot = makeThreadReadResponse([ + { id: "turn-active-old", status: "inProgress", startedAt: 10, items: [] }, + { id: "turn-active-new", status: "inProgress", items: [] }, + ]); + + NodeAssert.equal(findActiveCodexTurnId(snapshot), "turn-active-new"); + }); + + it("selects a later timestamped turn after one without a timestamp", () => { + const snapshot = makeThreadReadResponse([ + { id: "turn-active-old", status: "inProgress", items: [] }, + { id: "turn-active-new", status: "inProgress", startedAt: 10, items: [] }, + ]); + + NodeAssert.equal(findActiveCodexTurnId(snapshot), "turn-active-new"); + }); + + it("returns undefined when no turn is active", () => { + const response = makeThreadReadResponse([]); + NodeAssert.equal(findActiveCodexTurnId(response), undefined); + }); +}); + +describe("resolveCodexInterruptTurnId", () => { + it.effect("requests turns when resolving an interrupt without a projected turn id", () => { + let requestedParams: CodexRpc.ClientRequestParamsByMethod["thread/read"] | undefined; + let readThreadCallCount = 0; + + return Effect.gen(function* () { + const turnId = yield* resolveCodexInterruptTurnId({ + providerThreadId: "provider-thread-1", + requestedTurnId: undefined, + readSessionActiveTurnId: Effect.succeed(undefined), + readThread: (params) => { + readThreadCallCount += 1; + requestedParams = params; + return Effect.succeed( + makeThreadReadResponse([ + { id: "turn-active", status: "inProgress", startedAt: 10, items: [] }, + ]), + ); + }, + }); + + NodeAssert.deepStrictEqual(requestedParams, { + threadId: "provider-thread-1", + includeTurns: true, + }); + NodeAssert.equal(readThreadCallCount, 1); + NodeAssert.equal(turnId, "turn-active"); + }); + }); + + it.effect("does not revive a stale projected turn after a successful empty read", () => + Effect.gen(function* () { + const turnId = yield* resolveCodexInterruptTurnId({ + providerThreadId: "provider-thread-1", + requestedTurnId: undefined, + readSessionActiveTurnId: Effect.succeed(TurnId.make("turn-stale")), + readThread: () => Effect.succeed(makeThreadReadResponse([])), + }); + + NodeAssert.equal(turnId, undefined); + }), + ); + + it.effect("falls back to the projected turn when the live lookup fails", () => + Effect.gen(function* () { + const projectedTurnId = TurnId.make("turn-projected"); + const turnId = yield* resolveCodexInterruptTurnId({ + providerThreadId: "provider-thread-1", + requestedTurnId: undefined, + readSessionActiveTurnId: Effect.succeed(projectedTurnId), + readThread: () => Effect.fail("lookup failed"), + }); + + NodeAssert.equal(turnId, projectedTurnId); + }), + ); + + it.effect("bounds the live lookup and falls back to the projected turn on timeout", () => + Effect.gen(function* () { + const projectedTurnId = TurnId.make("turn-projected"); + const lookupStarted = yield* Deferred.make(); + const resolution = yield* resolveCodexInterruptTurnId({ + providerThreadId: "provider-thread-1", + requestedTurnId: undefined, + readSessionActiveTurnId: Effect.succeed(projectedTurnId), + readThread: () => + Effect.gen(function* () { + yield* Deferred.succeed(lookupStarted, undefined); + return yield* Effect.never; + }), + }).pipe(Effect.forkScoped); + + yield* Deferred.await(lookupStarted); + yield* TestClock.adjust("2 seconds"); + NodeAssert.equal(yield* Fiber.join(resolution), projectedTurnId); + }), + ); + + it.effect("reads the projected fallback after a live lookup times out", () => + Effect.gen(function* () { + let projectedTurnId = TurnId.make("turn-old"); + const lookupStarted = yield* Deferred.make(); + const resolution = yield* resolveCodexInterruptTurnId({ + providerThreadId: "provider-thread-1", + requestedTurnId: undefined, + readSessionActiveTurnId: Effect.sync(() => projectedTurnId), + readThread: () => + Effect.gen(function* () { + yield* Deferred.succeed(lookupStarted, undefined); + return yield* Effect.never; + }), + }).pipe(Effect.forkScoped); + + yield* Deferred.await(lookupStarted); + // Mutate after the live lookup starts to verify that the fallback is + // evaluated lazily after the timeout instead of captured up front. + projectedTurnId = TurnId.make("turn-current"); + yield* TestClock.adjust("2 seconds"); + NodeAssert.equal(yield* Fiber.join(resolution), projectedTurnId); + }), + ); +}); + +describe("shouldReplaceActiveCodexTurnCandidate", () => { + it("selects the first candidate", () => { + NodeAssert.equal(shouldReplaceActiveCodexTurnCandidate({ startedAt: 10 }, undefined), true); + }); + + it("orders timestamped turns by start time and lets a later equal entry win", () => { + NodeAssert.equal( + shouldReplaceActiveCodexTurnCandidate({ startedAt: 20 }, { startedAt: 10 }), + true, + ); + NodeAssert.equal( + shouldReplaceActiveCodexTurnCandidate({ startedAt: 10 }, { startedAt: 20 }), + false, + ); + NodeAssert.equal( + shouldReplaceActiveCodexTurnCandidate({ startedAt: 10 }, { startedAt: 10 }), + true, + ); + }); + + it("lets the later provider entry win when either timestamp is absent", () => { + for (const [candidate, selected] of [ + [{}, { startedAt: 10 }], + [{ startedAt: null }, { startedAt: 10 }], + [{ startedAt: 10 }, {}], + [{ startedAt: 10 }, { startedAt: null }], + ] as const) { + NodeAssert.equal(shouldReplaceActiveCodexTurnCandidate(candidate, selected), true); + } + }); +}); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 119fa36303a..e0a71d3cbd4 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -1,9 +1,8 @@ import * as NodeAssert from "node:assert/strict"; -import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { describe } from "vite-plus/test"; +import { describe, it } from "@effect/vitest"; import { DEFAULT_MODEL, ThreadId } from "@t3tools/contracts"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 5a81e915e34..d5ab470d8fa 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -18,6 +18,7 @@ import { } from "@t3tools/contracts"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { normalizeModelSlug } from "@t3tools/shared/model"; +import { extractProviderErrorMessage } from "@t3tools/shared/providerError"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -52,6 +53,7 @@ const BENIGN_ERROR_LOG_SNIPPETS = [ "state db record_discrepancy: find_thread_path_by_id_str_in_subdir, falling_back", ]; const CODEX_APP_SERVER_FORCE_KILL_AFTER = "2 seconds" as const; +const CODEX_INTERRUPT_THREAD_READ_TIMEOUT = "2 seconds" as const; const RECOVERABLE_THREAD_RESUME_ERROR_SNIPPETS = [ "not found", "missing thread", @@ -692,6 +694,75 @@ function parseThreadSnapshot( }; } +type CodexTurnOrderingCandidate = Pick< + EffectCodexSchema.V2ThreadReadResponse["thread"]["turns"][number], + "startedAt" +>; + +export function shouldReplaceActiveCodexTurnCandidate( + candidate: CodexTurnOrderingCandidate, + selected: CodexTurnOrderingCandidate | undefined, +): boolean { + if (selected === undefined) { + return true; + } + + // When either timestamp is absent, provider response order is authoritative. + // The caller scans in response order, so the later candidate replaces the selection. + if (candidate.startedAt == null || selected.startedAt == null) { + return true; + } + + return candidate.startedAt >= selected.startedAt; +} + +export function findActiveCodexTurnId( + response: EffectCodexSchema.V2ThreadReadResponse, +): TurnId | undefined { + let activeTurn: EffectCodexSchema.V2ThreadReadResponse["thread"]["turns"][number] | undefined; + for (const turn of response.thread.turns) { + if (turn.status !== "inProgress") { + continue; + } + if (shouldReplaceActiveCodexTurnCandidate(turn, activeTurn)) { + activeTurn = turn; + } + } + return activeTurn === undefined ? undefined : TurnId.make(activeTurn.id); +} + +export function resolveCodexInterruptTurnId(input: { + readonly providerThreadId: string; + readonly requestedTurnId: TurnId | undefined; + readonly readSessionActiveTurnId: Effect.Effect; + readonly readThread: ( + params: CodexRpc.ClientRequestParamsByMethod["thread/read"], + ) => Effect.Effect; +}): Effect.Effect { + if (input.requestedTurnId !== undefined) { + return Effect.succeed(input.requestedTurnId); + } + + return input + .readThread({ + threadId: input.providerThreadId, + includeTurns: true, + }) + .pipe( + Effect.timeout(CODEX_INTERRUPT_THREAD_READ_TIMEOUT), + Effect.map(findActiveCodexTurnId), + Effect.tapError((cause) => + Effect.logWarning("Failed to resolve active Codex turn before interrupt.", { + providerThreadId: input.providerThreadId, + cause, + }), + ), + // A failed lookup can still use the locally projected id. A successful + // lookup with no active turn must not revive a stale local id. + Effect.catch(() => input.readSessionActiveTurnId), + ); +} + export const makeCodexSessionRuntime = ( options: CodexSessionRuntimeOptions, ): Effect.Effect< @@ -921,7 +992,7 @@ export const makeCodexSessionRuntime = ( } const lastError = payload.turn.status === "failed" && "error" in payload.turn && payload.turn.error - ? payload.turn.error.message + ? extractProviderErrorMessage(payload.turn.error.message) : undefined; return updateSession(sessionRef, { status: payload.turn.status === "failed" ? "error" : "ready", @@ -939,7 +1010,7 @@ export const makeCodexSessionRuntime = ( if (providerThreadId && payloadThreadId && payloadThreadId !== providerThreadId) { return Effect.void; } - const errorMessage = payload.error.message; + const errorMessage = extractProviderErrorMessage(payload.error.message); const willRetry = payload.willRetry; return updateSession(sessionRef, { status: willRetry ? "running" : "error", @@ -1315,8 +1386,14 @@ export const makeCodexSessionRuntime = ( interruptTurn: (turnId) => Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; - const session = yield* Ref.get(sessionRef); - const effectiveTurnId = turnId ?? session.activeTurnId; + const effectiveTurnId = yield* resolveCodexInterruptTurnId({ + providerThreadId, + requestedTurnId: turnId, + readSessionActiveTurnId: Ref.get(sessionRef).pipe( + Effect.map((session) => session.activeTurnId), + ), + readThread: (params) => client.request("thread/read", params), + }); if (!effectiveTurnId) { return; } diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 861da9a10e0..c109088016c 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -150,7 +150,7 @@ it.effect("preserves provider failures without deriving the repository message f }).pipe(Effect.provide(makeLayer({ provider }))); }); -it.effect("clones a looked-up repository into the requested destination", () => +it.effect("clones a looked-up repository over HTTPS by default", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const parent = yield* fs.makeTempDirectoryScoped({ @@ -165,7 +165,6 @@ it.effect("clones a looked-up repository into the requested destination", () => provider: "github", repository: "octocat/t3code", destinationPath, - protocol: "https", }); assert.deepStrictEqual(result, { diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 1b46369e25c..fc577f3321d 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -71,8 +71,9 @@ function selectRemoteUrl( switch (protocol ?? "auto") { case "https": return urls.url; - case "ssh": case "auto": + return urls.url; + case "ssh": return urls.sshUrl; } } diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index f3056842f0a..3746b014679 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -389,8 +389,8 @@ describe("hasServerAcknowledgedLocalDispatch", () => { localDispatch, phase: "ready", latestTurn: completedTurn, - latestUserMessageId: localDispatch.latestUserMessageId, session: readySession, + projectedMessages: [], hasPendingApproval: false, hasPendingUserInput: false, threadError: null, @@ -415,8 +415,8 @@ describe("hasServerAcknowledgedLocalDispatch", () => { localDispatch, phase: "ready", latestTurn: newerTurn, - latestUserMessageId: localDispatch.latestUserMessageId, session: { ...readySession, updatedAt: newerTurn.completedAt }, + projectedMessages: [], hasPendingApproval: false, hasPendingUserInput: false, threadError: null, @@ -442,12 +442,12 @@ describe("hasServerAcknowledgedLocalDispatch", () => { localDispatch, phase: "running", latestTurn: runningTurn, - latestUserMessageId: localDispatch.latestUserMessageId, session: { ...readySession, status: "running", activeTurnId: TurnId.make("turn-other"), }, + projectedMessages: [], hasPendingApproval: false, hasPendingUserInput: false, threadError: null, @@ -458,12 +458,12 @@ describe("hasServerAcknowledgedLocalDispatch", () => { localDispatch, phase: "running", latestTurn: runningTurn, - latestUserMessageId: localDispatch.latestUserMessageId, session: { ...readySession, status: "running", activeTurnId: runningTurn.turnId, }, + projectedMessages: [], hasPendingApproval: false, hasPendingUserInput: false, threadError: null, @@ -471,7 +471,27 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(true); }); - it("acknowledges a steering message projected onto the current running turn", () => { + it("acknowledges pending user interaction and errors immediately", () => { + const localDispatch = createLocalDispatchSnapshot(makeThread()); + const common = { + localDispatch, + phase: "ready" as const, + latestTurn: null, + session: null, + projectedMessages: [], + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }; + + expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingApproval: true })).toBe(true); + expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingUserInput: true })).toBe(true); + expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true); + }); + + it("acknowledges a steer when its user message is projected onto the running thread", () => { + const initialMessageId = MessageId.make("message-initial"); + const steerMessageId = MessageId.make("message-steer"); const runningTurn = { ...completedTurn, state: "running" as const, @@ -484,20 +504,21 @@ describe("hasServerAcknowledgedLocalDispatch", () => { }; const localDispatch = createLocalDispatchSnapshot( makeThread({ - latestTurn: runningTurn, - session: runningSession, messages: [ { - id: MessageId.make("message-before-steer"), + id: initialMessageId, role: "user", - text: "Initial prompt", + text: "start", turnId: runningTurn.turnId, - createdAt: runningTurn.requestedAt, - updatedAt: runningTurn.requestedAt, streaming: false, + createdAt: now, + updatedAt: now, }, ], + latestTurn: runningTurn, + session: runningSession, }), + { expectedUserMessageId: steerMessageId }, ); expect( @@ -505,8 +526,53 @@ describe("hasServerAcknowledgedLocalDispatch", () => { localDispatch, phase: "running", latestTurn: runningTurn, - latestUserMessageId: MessageId.make("message-steer"), session: runningSession, + projectedMessages: [ + { + id: initialMessageId, + role: "user", + }, + ], + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(false); + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "running", + latestTurn: runningTurn, + session: runningSession, + projectedMessages: [ + { + id: steerMessageId, + role: "user", + }, + ], + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(true); + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "running", + latestTurn: runningTurn, + session: runningSession, + projectedMessages: [ + { + id: steerMessageId, + role: "user", + }, + { + id: MessageId.make("message-other-client"), + role: "user", + }, + ], hasPendingApproval: false, hasPendingUserInput: false, threadError: null, @@ -514,21 +580,72 @@ describe("hasServerAcknowledgedLocalDispatch", () => { ).toBe(true); }); - it("acknowledges pending user interaction and errors immediately", () => { - const localDispatch = createLocalDispatchSnapshot(makeThread()); - const common = { - localDispatch, - phase: "ready" as const, - latestTurn: null, - latestUserMessageId: localDispatch.latestUserMessageId, - session: null, - hasPendingApproval: false, - hasPendingUserInput: false, - threadError: null, + it("requires the next steer id after the previous steer was projected", () => { + const previousSteerMessageId = MessageId.make("message-steer"); + const nextSteerMessageId = MessageId.make("message-next-steer"); + const runningTurn = { + ...completedTurn, + state: "running" as const, + completedAt: null, + }; + const runningSession = { + ...readySession, + status: "running" as const, + activeTurnId: runningTurn.turnId, }; + const localDispatch = createLocalDispatchSnapshot( + makeThread({ + messages: [ + { + id: previousSteerMessageId, + role: "user", + text: "steer", + turnId: runningTurn.turnId, + streaming: false, + createdAt: now, + updatedAt: now, + }, + ], + latestTurn: runningTurn, + session: runningSession, + }), + { expectedUserMessageId: nextSteerMessageId }, + ); - expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingApproval: true })).toBe(true); - expect(hasServerAcknowledgedLocalDispatch({ ...common, hasPendingUserInput: true })).toBe(true); - expect(hasServerAcknowledgedLocalDispatch({ ...common, threadError: "failed" })).toBe(true); + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "running", + latestTurn: runningTurn, + session: runningSession, + projectedMessages: [ + { + id: previousSteerMessageId, + role: "user", + }, + ], + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(false); + + expect( + hasServerAcknowledgedLocalDispatch({ + localDispatch, + phase: "running", + latestTurn: runningTurn, + session: runningSession, + projectedMessages: [ + { + id: nextSteerMessageId, + role: "user", + }, + ], + hasPendingApproval: false, + hasPendingUserInput: false, + threadError: null, + }), + ).toBe(true); }); }); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 99583f9143e..d1f01e3c6e9 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,6 +1,7 @@ import { type EnvironmentId, isProviderDriverKind, + type MessageId, ProjectId, type ModelSelection, type ProviderDriverKind, @@ -397,8 +398,8 @@ export async function waitForStartedServerThread( export interface LocalDispatchSnapshot { startedAt: string; preparingWorktree: boolean; - latestUserMessageId: ChatMessage["id"] | null; - latestTurnTurnId: TurnId | null; + expectedUserMessageId: MessageId | null; + latestTurnId: TurnId | null; latestTurnRequestedAt: string | null; latestTurnStartedAt: string | null; latestTurnCompletedAt: string | null; @@ -408,16 +409,15 @@ export interface LocalDispatchSnapshot { export function createLocalDispatchSnapshot( activeThread: Thread | undefined, - options?: { preparingWorktree?: boolean }, + options?: { preparingWorktree?: boolean; expectedUserMessageId?: MessageId }, ): LocalDispatchSnapshot { const latestTurn = activeThread?.latestTurn ?? null; const session = activeThread?.session ?? null; - const latestUserMessage = activeThread?.messages.findLast((message) => message.role === "user"); return { startedAt: new Date().toISOString(), preparingWorktree: Boolean(options?.preparingWorktree), - latestUserMessageId: latestUserMessage?.id ?? null, - latestTurnTurnId: latestTurn?.turnId ?? null, + expectedUserMessageId: options?.expectedUserMessageId ?? null, + latestTurnId: latestTurn?.turnId ?? null, latestTurnRequestedAt: latestTurn?.requestedAt ?? null, latestTurnStartedAt: latestTurn?.startedAt ?? null, latestTurnCompletedAt: latestTurn?.completedAt ?? null, @@ -430,8 +430,8 @@ export function hasServerAcknowledgedLocalDispatch(input: { localDispatch: LocalDispatchSnapshot | null; phase: SessionPhase; latestTurn: Thread["latestTurn"] | null; - latestUserMessageId: ChatMessage["id"] | null; session: Thread["session"] | null; + projectedMessages: ReadonlyArray>; hasPendingApproval: boolean; hasPendingUserInput: boolean; threadError: string | null | undefined; @@ -443,24 +443,29 @@ export function hasServerAcknowledgedLocalDispatch(input: { return true; } + // A prompt sent while a turn is already running is a steer. Providers can + // apply that prompt to the existing turn without opening a new one, so the + // latest turn/session fields may remain unchanged until the agent finishes. + // The projected user message is the server acknowledgement in that case. + const expectedUserMessageId = input.localDispatch.expectedUserMessageId; + if (input.localDispatch.sessionStatus === "running" && expectedUserMessageId !== null) { + for (let index = input.projectedMessages.length - 1; index >= 0; index -= 1) { + const message = input.projectedMessages[index]; + if (message?.role === "user" && message.id === expectedUserMessageId) { + return true; + } + } + } + const latestTurn = input.latestTurn ?? null; const session = input.session ?? null; - const latestUserMessageChanged = - input.localDispatch.latestUserMessageId !== input.latestUserMessageId; const latestTurnChanged = - input.localDispatch.latestTurnTurnId !== (latestTurn?.turnId ?? null) || + input.localDispatch.latestTurnId !== (latestTurn?.turnId ?? null) || input.localDispatch.latestTurnRequestedAt !== (latestTurn?.requestedAt ?? null) || input.localDispatch.latestTurnStartedAt !== (latestTurn?.startedAt ?? null) || input.localDispatch.latestTurnCompletedAt !== (latestTurn?.completedAt ?? null); if (input.phase === "running") { - // Steering adds a user message to the current running turn without - // necessarily changing any of the turn timestamps. Treat that projected - // message as the server acknowledgment so the composer does not remain - // stuck in its local "Sending" state until the turn settles. - if (latestUserMessageChanged) { - return true; - } if (!latestTurnChanged) { return false; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 57af7216a54..7f8161f357f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -460,8 +460,6 @@ function useLocalDispatchState(input: { threadError: string | null | undefined; }) { const [localDispatch, setLocalDispatch] = useState(null); - const latestUserMessageId = - input.activeThread?.messages.findLast((message) => message.role === "user")?.id ?? null; const resetLocalDispatch = useCallback(() => { setLocalDispatch(null); @@ -473,8 +471,8 @@ function useLocalDispatchState(input: { localDispatch, phase: input.phase, latestTurn: input.activeLatestTurn, - latestUserMessageId, session: input.activeThread?.session ?? null, + projectedMessages: input.activeThread?.messages ?? [], hasPendingApproval: input.activePendingApproval !== null, hasPendingUserInput: input.activePendingUserInput !== null, threadError: input.threadError, @@ -483,16 +481,16 @@ function useLocalDispatchState(input: { input.activeLatestTurn, input.activePendingApproval, input.activePendingUserInput, + input.activeThread?.messages, input.activeThread?.session, input.phase, input.threadError, - latestUserMessageId, localDispatch, ], ); const activeLocalDispatch = serverAcknowledgedLocalDispatch ? null : localDispatch; const beginLocalDispatch = useCallback( - (options?: { preparingWorktree?: boolean }) => { + (options?: { preparingWorktree?: boolean; expectedUserMessageId?: MessageId }) => { const preparingWorktree = Boolean(options?.preparingWorktree); setLocalDispatch((current) => { const active = serverAcknowledgedLocalDispatch ? null : current; @@ -4345,8 +4343,6 @@ function ChatViewContent(props: ChatViewProps) { void dockTransition.catch(() => resolveDockStarted?.()); await dockStarted; } - beginLocalDispatch({ preparingWorktree: Boolean(baseBranchForWorktree) }); - const composerImagesSnapshot = [...composerImages]; const composerTerminalContextsSnapshot = [...sendableComposerTerminalContexts]; const composerElementContextsSnapshot = [...composerElementContexts]; @@ -4365,6 +4361,10 @@ function ChatViewContent(props: ChatViewProps) { composerReviewCommentsSnapshot, ); const messageIdForSend = newMessageId(); + beginLocalDispatch({ + preparingWorktree: Boolean(baseBranchForWorktree), + expectedUserMessageId: messageIdForSend, + }); const messageCreatedAt = new Date().toISOString(); const outgoingMessageText = formatOutgoingPrompt({ provider: ctxSelectedProvider, @@ -4527,7 +4527,7 @@ function ChatViewContent(props: ChatViewProps) { : {}), } : undefined; - beginLocalDispatch({ preparingWorktree: false }); + beginLocalDispatch({ preparingWorktree: false, expectedUserMessageId: messageIdForSend }); const startResult = await startThreadTurn({ environmentId, input: { @@ -4830,7 +4830,7 @@ function ChatViewContent(props: ChatViewProps) { }); sendInFlightRef.current = true; - beginLocalDispatch({ preparingWorktree: false }); + beginLocalDispatch({ preparingWorktree: false, expectedUserMessageId: messageIdForSend }); setThreadError(threadIdForSend, null); // Position this sent row once LegendList has measured the anchored tail. diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index ef45195ffd3..e575353ea05 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -4,6 +4,7 @@ import type { Thread } from "../types"; import { buildThreadActionItems, filterCommandPaletteGroups, + getDefaultCloneRemoteUrl, reduceCommandPaletteUiState, type CommandPaletteGroup, } from "./CommandPalette.logic"; @@ -11,6 +12,14 @@ import { const LOCAL_ENVIRONMENT_ID = EnvironmentId.make("environment-local"); const PROJECT_ID = ProjectId.make("project-1"); +it("uses HTTPS for automatic repository clones", () => { + expect( + getDefaultCloneRemoteUrl({ + url: "https://github.com/octocat/t3code", + }), + ).toBe("https://github.com/octocat/t3code"); +}); + describe("reduceCommandPaletteUiState", () => { const closedState = { open: false, mode: "command", openIntent: null } as const; diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index e80e3580327..a755e62aed3 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,4 +1,8 @@ -import { type KeybindingCommand, type FilesystemBrowseEntry } from "@t3tools/contracts"; +import { + type FilesystemBrowseEntry, + type KeybindingCommand, + type SourceControlRepositoryInfo, +} from "@t3tools/contracts"; import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; @@ -97,6 +101,12 @@ export interface CommandPaletteView { export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; +export function getDefaultCloneRemoteUrl( + repository: Pick, +): string { + return repository.url; +} + export function filterBrowseEntries(input: { browseEntries: ReadonlyArray; browseFilterQuery: string; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 72d955e65c6..98dd14fb68b 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -106,6 +106,7 @@ import { filterBrowseEntries, filterCommandPaletteGroups, getCommandPaletteInputPlaceholder, + getDefaultCloneRemoteUrl, getCommandPaletteMode, ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, @@ -1298,7 +1299,7 @@ function OpenCommandPaletteDialog(props: { source: addProjectCloneFlow.source, repositoryInput: rawRepository, repository, - remoteUrl: repository.sshUrl, + remoteUrl: getDefaultCloneRemoteUrl(repository), }); setHighlightedItemValue(null); setQuery(destinationPath); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 6b0ea387cbb..1fe5473f89f 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -27,6 +27,7 @@ import { selectThreadDiffPanelSelection, useDiffPanelStore } from "../diffPanelS import { useTheme } from "../hooks/useTheme"; import { buildFileDiffRenderKey, + canRenderFileDiff, getDiffCollapseIconClassName, getRenderablePatch, resolveDiffThemeName, @@ -433,22 +434,33 @@ export default function DiffPanel({ if (!renderablePatch || renderablePatch.kind !== "files") { return []; } - return renderablePatch.files.toSorted((left, right) => - resolveFileDiffPath(left).localeCompare(resolveFileDiffPath(right), undefined, { - numeric: true, - sensitivity: "base", - }), - ); + return renderablePatch.files + .map((fileDiff) => ({ + canRender: canRenderFileDiff(fileDiff), + fileDiff, + filePath: resolveFileDiffPath(fileDiff), + })) + .toSorted((left, right) => { + const renderOrder = Number(!left.canRender) - Number(!right.canRender); + return ( + renderOrder || + left.filePath.localeCompare(right.filePath, undefined, { + numeric: true, + sensitivity: "base", + }) + ); + }); }, [renderablePatch]); const codeViewFiles = useMemo( () => - renderableFiles.map((fileDiff) => { + renderableFiles.map(({ canRender, fileDiff, filePath }) => { const fileKey = buildFileDiffRenderKey(fileDiff); return { + canRender, fileDiff, - filePath: resolveFileDiffPath(fileDiff), + filePath, fileKey, - collapsed: collapsedDiffFileKeys.has(fileKey), + collapsed: !canRender || collapsedDiffFileKeys.has(fileKey), }; }), [collapsedDiffFileKeys, renderableFiles], @@ -829,7 +841,7 @@ export default function DiffPanel({ sectionId={reviewSectionId} sectionTitle={reviewSectionTitle} composerDraftTarget={composerDraftTarget} - renderHeaderPrefix={(fileDiff, fileKey, collapsed) => { + renderHeaderPrefix={(fileDiff, fileKey, collapsed, canRender) => { const filePath = resolveFileDiffPath(fileDiff); return ( @@ -838,13 +850,18 @@ export default function DiffPanel({