From 2021c815fd0e071bb778f6bbed07db1858dc4d1d Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:04:51 +0200 Subject: [PATCH 1/5] fix(server): retain branch PR when linking another PR --- .../src/orchestration-v2/Orchestrator.ts | 20 ++++ .../src/orchestration-v2/runtimeLayer.test.ts | 96 +++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index c24162a57803..8cfd4d097c8f 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1,6 +1,7 @@ import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests"; import { normalizeThreadPullRequestKey, + visibleThreadPullRequests, threadPullRequestKeysEqual, legacyThreadPullRequestKey, } from "@t3tools/shared/threadPullRequests"; @@ -1978,7 +1979,26 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio snapshot: null, stack: null, }; + const branch = thread.branchPullRequest; + const branchKey = branch ? legacyThreadPullRequestKey(branch) : null; pullRequests = [ + ...(command.source === "manual" && + branch && + branchKey && + visibleThreadPullRequests(links).length === 0 && + !threadPullRequestKeysEqual(branchKey, key) && + !links.some((entry) => threadPullRequestKeysEqual(entry, branchKey)) + ? [ + { + ...branchKey, + url: branch.url, + source: "manual" as const, + linkedAt: DateTime.formatIso(now), + snapshot: null, + stack: null, + }, + ] + : []), ...links.filter((entry) => !threadPullRequestKeysEqual(entry, key)), link, ]; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 7b62d9b4cc29..eb41b0d7e8a1 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1765,6 +1765,102 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("keeps the branch pull request when linking another pull request", () => + Effect.gen(function* () { + const orchestrator = yield* OrchestratorV2; + const maintenance = yield* ProjectionMaintenanceV2; + const projects = yield* ProjectionProjectRepository; + const threadId = ThreadId.make("branch-pr-link"); + const projectId = ProjectId.make("branch-pr-project"); + yield* projects.upsert({ + projectId, + title: "PR links", + workspaceRoot: "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/workspace/pr-links", + defaultModelSelection: null, + defaultThreadEnvMode: null, + autoPull: false, + scripts: [], + createdAt: "2026-09-17T00:00:00.000Z", + updatedAt: "2026-09-17T00:00:00.000Z", + deletedAt: null, + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("branch-pr-create"), + threadId, + projectId, + title: "PR links", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/pr-links", + worktreePath: null, + }); + const snapshot = yield* orchestrator.getShellSnapshot(); + yield* orchestrator.dispatch({ + type: "thread.pull-request.sync", + commandId: CommandId.make("branch-pr-discover"), + threadId, + projectId, + snapshotSequence: snapshot.snapshotSequence, + expected: { + workspaceRoot: "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/workspace/pr-links", + branch: "feature/pr-links", + worktreePath: null, + linkedPullRequest: null, + branchPullRequest: null, + }, + branchPullRequest: { + projectId, + repository: "pingdotgg/t3code", + number: 1, + url: "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/pingdotgg/t3code/pull/1", + }, + }); + for (const [index, number] of [2, 2, 1, 3].entries()) { + yield* orchestrator.dispatch({ + type: "thread.pull-request.link", + commandId: CommandId.make(`branch-pr-link-${index}`), + threadId, + host: "GitHub.com", + repository: "Pingdotgg/T3code", + number, + url: `https://github.com/pingdotgg/t3code/pull/${number}`, + source: "manual", + }); + assert.deepEqual( + (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.map((link) => link.number), + number === 3 ? [1, 2, 3] : [1, 2], + ); + } + yield* orchestrator.dispatch({ + type: "thread.pull-request.unlink", + commandId: CommandId.make("branch-pr-unlink"), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 1, + }); + yield* orchestrator.dispatch({ + type: "thread.pull-request.link", + commandId: CommandId.make("branch-pr-link-after-unlink"), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 4, + url: "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/pingdotgg/t3code/pull/4", + source: "manual", + }); + assert.isTrue((yield* maintenance.rebuild).valid); + assert.deepEqual( + (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.map((link) => link.number), + [2, 3, 4], + ); + }), + ); + it.effect("retains multiple pull requests and dismissed stack members through rebuilds", () => Effect.gen(function* () { const orchestrator = yield* OrchestratorV2; From 493acfe0039b8595de00e72441554ad951b37cda Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:05:15 +0200 Subject: [PATCH 2/5] fix(web): list linked PRs in badge tooltips --- .../src/components/BranchToolbarBranchSelector.tsx | 1 + apps/web/src/components/Sidebar.tsx | 1 + apps/web/src/components/ThreadStatusIndicators.tsx | 13 ++++++++++++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index ae9c63e5207b..fc244343b542 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -797,6 +797,7 @@ export function BranchToolbarBranchSelector({ ; number?: number | undefined; url?: string | undefined; status: PrStatusIndicator | null; @@ -221,6 +223,7 @@ export function ThreadPullRequestBadgeControl({ const presentation = resolveThreadPullRequestBadgePresentation({ badge, number, url, status }); if (presentation === null) return null; const isStack = badge?.kind === "stack"; + const showList = isStack || (badge?.kind === "pull-request" && badge.others > 0); const className = cn( variant === "ghost" ? buttonVariants({ variant: "ghost", size: "xs" }) @@ -266,7 +269,15 @@ export function ThreadPullRequestBadgeControl({ > {content} - {presentation.label} + + {showList ? : presentation.label} + ); } From d7123fa614cb39878da251718648df6e467ef4b0 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:31:42 +0200 Subject: [PATCH 3/5] chore(v2): remove unused cleanup and activity exports --- apps/server/src/storageCleanup.ts | 15 +-------------- .../client-runtime/src/work-log/presentation.ts | 13 ------------- 2 files changed, 1 insertion(+), 27 deletions(-) diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 36483b4dc962..ef25977c27d2 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -16,17 +16,14 @@ import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; -import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as FileSystem from "effect/FileSystem"; -import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import type { PlatformError } from "effect/PlatformError"; import * as Schedule from "effect/Schedule"; -import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as ServerConfig from "./config.ts"; @@ -41,14 +38,6 @@ import * as TerminalManager from "./terminal/Manager.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import { withWorkspaceLease } from "./workspace/workspaceLease.ts"; -export class StorageCleanup extends Context.Service< - StorageCleanup, - { - readonly start: () => Effect.Effect; - readonly drain: Effect.Effect; - } ->()("t3/storageCleanup") {} - const decodeCleanupThread = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationV2AppThreadJson), ); @@ -508,7 +497,5 @@ export const make = Effect.gen(function* () { ), ); }); - return { start, drain: worker.drain } satisfies StorageCleanup["Service"]; + return { start, drain: worker.drain }; }); - -export const layer = Layer.effect(StorageCleanup, make); diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index e6eebce1a117..3963ce2c94ef 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -43,19 +43,6 @@ export function toolItemForDisplay(item: OrchestrationV2TurnItem): Orchestration } } -/** - * Activities the worktree setup card already represents. The settled record - * is rendered by the card on web and mobile, never as a - * worklog entry, so it is hidden from the activity feed even when it failed. - */ -export function isWorktreeSetupActivity(kind: string): boolean { - return ( - kind === "setup-script.requested" || - kind === "setup-script.started" || - kind === "worktree-setup" - ); -} - export function contextCompactionLabel( item: Pick< Extract, From dafa593de9e28060b578c788d58f6c2d50a9762c Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:32:04 +0200 Subject: [PATCH 4/5] test(v2): align rollback fixtures with current safety rules --- apps/mobile/src/lib/threadActivityInspector.test.ts | 10 +++------- apps/mobile/src/lib/threadActivityInspector.ts | 2 +- .../testkit/ThreadFork.integration.test.ts | 2 ++ .../src/orchestration-v2/testkit/fixtures/shared.ts | 1 + 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/lib/threadActivityInspector.test.ts b/apps/mobile/src/lib/threadActivityInspector.test.ts index 482dd69f6681..26c8df79ca47 100644 --- a/apps/mobile/src/lib/threadActivityInspector.test.ts +++ b/apps/mobile/src/lib/threadActivityInspector.test.ts @@ -14,7 +14,7 @@ import { import * as DateTime from "effect/DateTime"; import { describe, expect, it } from "vite-plus/test"; -import { buildThreadFeed, type ThreadFeedActivity } from "./threadActivity"; +import type { ThreadFeedActivity } from "./threadActivity"; import { buildThreadActivityInspector } from "./threadActivityInspector"; const threadId = ThreadId.make("thread-1"); @@ -43,7 +43,7 @@ function itemBase(id: string) { }; } -function activityFor(item: OrchestrationV2TurnItem): ThreadFeedActivity { +function activityFor(item: OrchestrationV2TurnItem): Pick { const row: OrchestrationV2ProjectedTurnItem = { position: 0, visibility: "inherited", @@ -51,11 +51,7 @@ function activityFor(item: OrchestrationV2TurnItem): ThreadFeedActivity { sourceItemId: item.id, item, }; - const group = buildThreadFeed([row])[0]; - if (group?.type !== "activity-group" || !group.activities[0]) { - throw new Error("Expected an activity group"); - } - return group.activities[0]; + return { projectedItem: row }; } describe("buildThreadActivityInspector", () => { diff --git a/apps/mobile/src/lib/threadActivityInspector.ts b/apps/mobile/src/lib/threadActivityInspector.ts index 0e7c6614a610..22b74d30536a 100644 --- a/apps/mobile/src/lib/threadActivityInspector.ts +++ b/apps/mobile/src/lib/threadActivityInspector.ts @@ -72,7 +72,7 @@ function addBlock( } export function buildThreadActivityInspector( - activity: ThreadFeedActivity, + activity: Pick, support: V2ItemSupport, currentThreadId: ThreadId, ): ThreadActivityInspectorModel { diff --git a/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts index 835ef4054b3e..041168b6ff13 100644 --- a/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ThreadFork.integration.test.ts @@ -1160,6 +1160,7 @@ describe("orchestration V2 thread fork", () => { }, { type: "checkpoint.rollback", + restoreFiles: false, commandId: yield* ids.allocate.command({ fixtureName: "thread-fork-native-prior-turn-source-rollback", commandName: "rollback-source-to-alpha", @@ -1361,6 +1362,7 @@ describe("orchestration V2 thread fork", () => { }, { type: "checkpoint.rollback", + restoreFiles: false, commandId: yield* ids.allocate.command({ fixtureName: "thread-fork-native-fork-local-rollback", commandName: "rollback-fork-to-first", diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts index d3729e5efb60..ef33409425fa 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/shared.ts @@ -760,6 +760,7 @@ export function materializeFixtureInput(input: { }); pushDispatch({ type: "checkpoint.rollback", + restoreFiles: false, commandId: yield* idAllocator.allocate.command({ fixtureName: input.scenario, commandName: `rollback-${step.checkpointSuffix}`, From a923ef9c199ffcb18939052d3b96945272a2d946 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:36:53 +0200 Subject: [PATCH 5/5] test(client-runtime): use an explicit handoff import extension --- packages/client-runtime/src/handoff.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client-runtime/src/handoff.test.ts b/packages/client-runtime/src/handoff.test.ts index f99ac7adcfea..5f72982fae0b 100644 --- a/packages/client-runtime/src/handoff.test.ts +++ b/packages/client-runtime/src/handoff.test.ts @@ -1,6 +1,6 @@ import { ProviderInstanceId, RunId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { resolveHandoffEndpoints, type HandoffTimelineRun } from "./handoff"; +import { resolveHandoffEndpoints, type HandoffTimelineRun } from "./handoff.ts"; const from = ProviderInstanceId.make("codex_personal"); const to = ProviderInstanceId.make("claudeAgent");