History

feat(prs): navigate, merge and rebase GitHub stacks (#10875)

Julius Marminge committed 2 months ago

Commit de379641 parent f0401c653 files · +3104 −464Browse files

Unified · Split

53 files
  1. apps/mobile/src/features/threads/thread-work-log.tsx +14 −3
  2. apps/mobile/src/lib/threadActivity.ts +1 −1
  3. apps/server/src/environment/ServerEnvironment.ts +1 −0
  4. apps/server/src/orchestration/PullRequestSyncReactor.test.ts +2 −1
  5. apps/server/src/orchestration/PullRequestSyncReactor.ts +1 −1
  6. apps/server/src/orchestration/ThreadPullRequestReactor.test.ts +121 −92
  7. apps/server/src/orchestration/ThreadPullRequestReactor.ts +8 −3
  8. apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +177 −1
  9. apps/server/src/pullRequest/GitHubPullRequestCli.ts +82 −1
  10. apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts +1 −0
  11. apps/server/src/pullRequest/GitHubPullRequestProvider.ts +6 −0
  12. apps/server/src/pullRequest/PullRequestProvider.ts +9 −1
  13. apps/server/src/pullRequest/PullRequestService.test.ts +127 −0
  14. apps/server/src/pullRequest/PullRequestService.ts +43 −8
  15. apps/server/src/pullRequest/gitHubPullRequestJson.test.ts +75 −0
  16. apps/server/src/pullRequest/gitHubPullRequestJson.ts +81 −2
  17. apps/server/src/pullRequest/githubStackActions.test.ts +413 −0
  18. apps/server/src/pullRequest/githubStackActions.ts +418 −0
  19. apps/web/src/commandPaletteBus.ts +9 −0
  20. apps/web/src/components/BranchToolbarBranchSelector.tsx +42 −43
  21. apps/web/src/components/ChatView.tsx +9 −0
  22. apps/web/src/components/CommandPalette.logic.test.ts +69 −0
  23. apps/web/src/components/CommandPalette.logic.ts +42 −3
  24. apps/web/src/components/CommandPalette.tsx +40 −2
  25. apps/web/src/components/Sidebar.tsx +14 −76
  26. apps/web/src/components/ThreadStatusIndicators.tsx +85 −3
  27. apps/web/src/components/chat/MessagesTimeline.logic.ts +1 −1
  28. apps/web/src/components/chat/MessagesTimeline.tsx +9 −0
  29. apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +119 −51
  30. apps/web/src/components/pullRequest/PullRequestRow.tsx +22 −1
  31. apps/web/src/components/pullRequest/PullRequestStackHeader.tsx +26 −0
  32. apps/web/src/components/pullRequest/PullRequestStackLayerContent.tsx +28 −0
  33. apps/web/src/components/pullRequest/PullRequestStackLayers.tsx +39 −0
  34. apps/web/src/components/pullRequest/PullRequestStackMap.tsx +0 −87
  35. apps/web/src/components/pullRequest/PullRequestStackMenu.tsx +257 −0
  36. apps/web/src/components/pullRequest/PullRequestStackPopover.tsx +108 −0
  37. apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx +20 −38
  38. apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +19 −0
  39. apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +13 −0
  40. apps/web/src/components/pullRequest/pullRequestStackSnapshot.test.ts +93 −0
  41. apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts +73 −0
  42. apps/web/src/lib/openPullRequestLink.ts +2 −0
  43. apps/web/src/routes/_chat.pull-requests.tsx +99 −32
  44. apps/web/src/state/pullRequests.ts +4 −1
  45. apps/web/src/state/query.ts +2 −0
  46. apps/web/src/state/usePullRequestStack.ts +33 −0
  47. docs/user/source-control.md +12 −0
  48. packages/client-runtime/src/state/pullRequests.test.ts +70 −3
  49. packages/client-runtime/src/state/pullRequests.ts +2 −0
  50. packages/client-runtime/src/work-log/presentation.test.ts +76 −0
  51. packages/client-runtime/src/work-log/presentation.ts +62 −9
  52. packages/contracts/src/environment.ts +1 −0
  53. packages/contracts/src/pullRequest.ts +24 −0
apps/mobile/src/features/threads/thread-work-log.tsx+14−3
@@ -76,7 +76,7 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180; · 75 unmodified lines
7676 const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
7777 const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
7878 const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);
79−type WorkContentIcon = AppSymbolName | "browser" | "t3-code";
79+type WorkContentIcon = AppSymbolName | "browser" | "t3-code" | "pull-request";
8080
8181 function WorkLogIcon(props: {
8282 readonly icon: WorkContentIcon;
@@ -92,7 +92,13 @@ function WorkLogIcon(props: { · 9 unmodified lines
9292 }
9393 return (
9494 <SymbolView
95− name={props.icon === "browser" ? { ios: "globe", android: "public" } : props.icon}
95+ name={
96+ props.icon === "pull-request"
97+ ? "arrow.triangle.pull"
98+ : props.icon === "browser"
99+ ? { ios: "globe", android: "public" }
100+ : props.icon
101+ }
96102 size={14}
97103 weight="medium"
98104 {...(colorClassName ? { tintColorClassName: colorClassName } : { tintColor: props.color })}
@@ -893,7 +899,7 @@ export function ThreadWorkGroupToggle(props: { · 794 unmodified lines
893899 readonly iconSubtleColor: import("react-native").ColorValue;
894900 readonly summary: string;
895901 readonly summaryKind: ToolGroupSummaryKind;
896− readonly summaryToolIcon?: "browser" | "t3-code";
902+ readonly summaryToolIcon?: "browser" | "t3-code" | "pull-request";
897903 readonly themeAppearance: "light" | "dark";
898904 readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface;
899905 readonly toolIcon?: ToolActivityIcon;
@@ -1231,6 +1237,11 @@ function ToolActivityImage(props: { · 331 unmodified lines
12311237
12321238 function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {
12331239 switch (kind) {
1240+ case "pull-request":
1241+ case "link-pr":
1242+ case "unlink-pr":
1243+ case "list-prs":
1244+ return "arrow.triangle.pull";
12341245 case "read":
12351246 return { ios: "eye", android: "visibility" };
12361247 case "edit":
apps/mobile/src/lib/threadActivity.ts+1−1
@@ -167,7 +167,7 @@ export type ThreadFeedEntry = · 166 unmodified lines
167167 readonly summaryKind: ToolGroupSummaryKind;
168168 readonly toolSurface?: WorkLogEntry["toolSurface"];
169169 readonly toolIcon?: WorkLogEntry["toolIcon"];
170− readonly summaryToolIcon?: "browser" | "t3-code";
170+ readonly summaryToolIcon?: "browser" | "t3-code" | "pull-request";
171171 readonly hasFailure: boolean;
172172 readonly live: boolean;
173173 readonly shimmer: boolean;
apps/server/src/environment/ServerEnvironment.ts+1−0
@@ -231,6 +231,7 @@ export const make = Effect.gen(function* () { · 230 unmodified lines
231231 threadActiveReorder: true,
232232 threadTitleRegeneration: true,
233233 threadPullRequests: true,
234+ pullRequestStackActions: true,
234235 threadPullRequestLinking: true,
235236 environmentIcon: true,
236237 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
apps/server/src/orchestration/PullRequestSyncReactor.test.ts+2−1
@@ -178,8 +178,9 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options: · 177 unmodified lines
178178 return yield* options.summary?.(input) ?? Effect.succeed(makeSummary(input));
179179 });
180180
181− const stack: PullRequestService["Service"]["stack"] = (input) =>
181+ const stack: PullRequestService["Service"]["stack"] = (input, readOptions) =>
182182 Effect.gen(function* () {
183+ assert.strictEqual(readOptions?.includeDetails, false);
183184 yield* Ref.update(stackCalls, (calls) => [...calls, input]);
184185 return yield* options.stack?.(input) ?? Effect.succeed(null);
185186 });
apps/server/src/orchestration/PullRequestSyncReactor.ts+1−1
@@ -256,7 +256,7 @@ export const make = Effect.gen(function* () { · 255 unmodified lines
256256 entry.link.snapshot === null || !snapshotFieldsEqual(entry.link.snapshot, fields),
257257 );
258258 const fetchedStack = needsStack
259− ? yield* pullRequests.stack(ref).pipe(
259+ ? yield* pullRequests.stack(ref, { includeDetails: false }).pipe(
260260 Effect.map((stack) => ({
261261 stack: stack === null ? null : ({ kind: "native", ...stack } as const),
262262 })),
apps/server/src/orchestration/ThreadPullRequestReactor.test.ts+121−92
@@ -1,9 +1,11 @@
11 import {
2+ CheckpointRef,
23 EventId,
34 GitManagerError,
45 ProjectId,
56 ProviderInstanceId,
67 ThreadId,
8+ TurnId,
79 type OrchestrationCommand,
810 type OrchestrationEvent,
911 type OrchestrationProjectShell,
@@ -311,54 +313,79 @@ describe("ThreadPullRequestReactor", () => { · 301 unmodified lines
311313 ),
312314 );
313315
314− it.effect("refreshes discovery after a turn ends without client demand", () =>
315− Effect.scoped(
316− Effect.gen(function* () {
317− const current = thread("turn-thread");
318− const detected = yield* Ref.make<GitBranchPullRequest | null>(null);
319− const fixture = yield* makeHarness({
320− threads: [current],
321− branchPullRequest: (_input, options) =>
322− options?.refresh
323− ? Ref.set(detected, branchPullRequest()).pipe(Effect.andThen(Ref.get(detected)))
324− : Ref.get(detected),
325− });
326− yield* Effect.gen(function* () {
327− const reactor = yield* fixture.start();
328− expect(yield* Ref.get(fixture.commands)).toHaveLength(0);
329− yield* fixture.publish({
330− type: "thread.session-set",
331− sequence: 2,
332− eventId: EventId.make("turn-finished"),
333− aggregateKind: "thread",
334− aggregateId: current.id,
335− occurredAt: NOW,
336− commandId: null,
337− causationEventId: null,
338− correlationId: null,
339− metadata: {},
340− payload: {
341− threadId: current.id,
342− session: {
316+ it.effect.each(["session-first", "checkpoint-first"] as const)(
317+ "refreshes discovery once after a turn ends with %s event ordering",
318+ (order) =>
319+ Effect.scoped(
320+ Effect.gen(function* () {
321+ const current = thread("turn-thread");
322+ const detected = yield* Ref.make<GitBranchPullRequest | null>(null);
323+ const fixture = yield* makeHarness({
324+ threads: [current],
325+ branchPullRequest: (_input, options) =>
326+ options?.refresh
327+ ? Ref.set(detected, branchPullRequest()).pipe(Effect.andThen(Ref.get(detected)))
328+ : Ref.get(detected),
329+ });
330+ yield* Effect.gen(function* () {
331+ const reactor = yield* fixture.start();
332+ expect(yield* Ref.get(fixture.commands)).toHaveLength(0);
333+ const sessionEvent: OrchestrationEvent = {
334+ type: "thread.session-set",
335+ sequence: 2,
336+ eventId: EventId.make("turn-finished"),
337+ aggregateKind: "thread",
338+ aggregateId: current.id,
339+ occurredAt: NOW,
340+ commandId: null,
341+ causationEventId: null,
342+ correlationId: null,
343+ metadata: {},
344+ payload: {
343345 threadId: current.id,
346+ session: {
347+ threadId: current.id,
348+ status: "ready",
349+ providerName: "Codex",
350+ runtimeMode: "full-access",
351+ activeTurnId: null,
352+ lastError: null,
353+ updatedAt: NOW,
354+ },
355+ },
356+ };
357+ const checkpointEvent: OrchestrationEvent = {
358+ ...sessionEvent,
359+ type: "thread.turn-diff-completed",
360+ sequence: 3,
361+ eventId: EventId.make("checkpoint-finished"),
362+ payload: {
363+ threadId: current.id,
364+ turnId: TurnId.make("turn"),
365+ checkpointTurnCount: 1,
366+ checkpointRef: CheckpointRef.make("checkpoint"),
344367 status: "ready",
345− providerName: "Codex",
346− runtimeMode: "full-access",
347− activeTurnId: null,
348− lastError: null,
349− updatedAt: NOW,
368+ files: [],
369+ assistantMessageId: null,
370+ completedAt: NOW,
350371 },
351− },
352− });
353− yield* Queue.take(fixture.reads);
354− yield* reactor.drain;
355− expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42));
356− expect((yield* Ref.get(fixture.branchCalls)).filter((call) => call.refresh)).toHaveLength(
357− 1,
358− );
359− }).pipe(Effect.provide(fixture.layer));
360− }),
361− ),
372+ };
373+ const events =
374+ order === "session-first"
375+ ? [sessionEvent, checkpointEvent]
376+ : [checkpointEvent, sessionEvent];
377+ for (const event of events) {
378+ yield* fixture.publish(event);
379+ yield* Queue.take(fixture.reads);
380+ yield* reactor.drain;
381+ }
382+ expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42));
383+ expect(
384+ (yield* Ref.get(fixture.branchCalls)).filter((call) => call.refresh),
385+ ).toHaveLength(1);
386+ }).pipe(Effect.provide(fixture.layer));
387+ }),
388+ ),
362389 );
363390
364391 it.effect("uses live worktrees and falls back to the project for removed worktrees", () =>
@@ -455,54 +482,56 @@ describe("ThreadPullRequestReactor", () => { · 90 unmodified lines
455482 ),
456483 );
457484
458− it.effect("retries failed settled backfills and stops querying them after success", () =>
459− Effect.scoped(
460− Effect.gen(function* () {
461− const online = yield* Ref.make(false);
462− const fixture = yield* makeHarness({
463− threads: [
464− thread("backfill", { settledOverride: "settled", settledAt: NOW }),
465− thread("known", {
466− branch: "known",
467− settledOverride: "settled",
468− settledAt: NOW,
469− branchPullRequest: reference(1),
470− }),
471− ],
472− branchPullRequest: ({ cwd }) =>
473− Ref.get(online).pipe(
474− Effect.flatMap((connected) =>
475− connected
476− ? Effect.succeed(branchPullRequest(42, "merged"))
477− : Effect.fail(
478− new GitManagerError({
479− operation: "branchPullRequest",
480− cwd,
481− detail: "Offline",
482− }),
483− ),
485+ it.effect.each(["settled", null] as const)(
486+ "retries settled backfills and stops after success with settledOverride %s",
487+ (settledOverride) =>
488+ Effect.scoped(
489+ Effect.gen(function* () {
490+ const online = yield* Ref.make(false);
491+ const fixture = yield* makeHarness({
492+ threads: [
493+ thread("backfill", { settledOverride, settledAt: NOW }),
494+ thread("known", {
495+ branch: "known",
496+ settledOverride,
497+ settledAt: NOW,
498+ branchPullRequest: reference(1),
499+ }),
500+ ],
501+ branchPullRequest: ({ cwd }) =>
502+ Ref.get(online).pipe(
503+ Effect.flatMap((connected) =>
504+ connected
505+ ? Effect.succeed(branchPullRequest(42, "merged"))
506+ : Effect.fail(
507+ new GitManagerError({
508+ operation: "branchPullRequest",
509+ cwd,
510+ detail: "Offline",
511+ }),
512+ ),
513+ ),
484514 ),
485− ),
486− });
487− yield* Effect.gen(function* () {
488− const reactor = yield* fixture.start();
489− expect(yield* Ref.get(fixture.commands)).toHaveLength(0);
490− yield* Ref.set(online, true);
491− yield* TestClock.adjust("1 minute");
492− yield* Queue.take(fixture.reads);
493− yield* reactor.drain;
494− expect((yield* Ref.get(fixture.commands))[0]?.threadId).toBe("backfill");
495− yield* TestClock.adjust("1 minute");
496− yield* Queue.take(fixture.reads);
497− yield* reactor.drain;
498− expect((yield* Ref.get(fixture.branchCalls)).map((call) => call.branch)).toEqual([
499− "feature",
500− "feature",
501− "feature",
502− ]);
503− }).pipe(Effect.provide(fixture.layer));
504− }),
505− ),
515+ });
516+ yield* Effect.gen(function* () {
517+ const reactor = yield* fixture.start();
518+ expect(yield* Ref.get(fixture.commands)).toHaveLength(0);
519+ yield* Ref.set(online, true);
520+ yield* TestClock.adjust("1 minute");
521+ yield* Queue.take(fixture.reads);
522+ yield* reactor.drain;
523+ expect((yield* Ref.get(fixture.commands))[0]?.threadId).toBe("backfill");
524+ yield* TestClock.adjust("1 minute");
525+ yield* Queue.take(fixture.reads);
526+ yield* reactor.drain;
527+ expect((yield* Ref.get(fixture.branchCalls)).map((call) => call.branch)).toEqual([
528+ "feature",
529+ "feature",
530+ "feature",
531+ ]);
532+ }).pipe(Effect.provide(fixture.layer));
533+ }),
534+ ),
506535 );
507536
508537 it.effect("stops retrying a settled backfill after repeated lookup failures", () =>
apps/server/src/orchestration/ThreadPullRequestReactor.ts+8−3
@@ -101,7 +101,10 @@ export const make = Effect.gen(function* () { · 100 unmodified lines
101101 const projects = new Map(snapshot.projects.map((project) => [project.id, project]));
102102 if (request.backfill) {
103103 for (const thread of snapshot.threads) {
104− if (thread.settledOverride === "settled" && thread.branchPullRequest == null) {
104+ if (
105+ (thread.settledOverride === "settled" || thread.settledAt !== null) &&
106+ thread.branchPullRequest == null
107+ ) {
105108 pendingBackfill.set(thread.id, BACKFILL_ATTEMPTS);
106109 }
107110 }
@@ -114,7 +117,7 @@ export const make = Effect.gen(function* () { · 6 unmodified lines
114117 (thread) =>
115118 thread.archivedAt === null &&
116119 (request.threadId === null || thread.id === request.threadId) &&
117− (thread.settledOverride !== "settled" ||
120+ ((thread.settledOverride !== "settled" && thread.settledAt === null) ||
118121 request.threadId !== null ||
119122 pendingBackfill.has(thread.id)) &&
120123 (thread.branch !== null || thread.branchPullRequest != null),
@@ -327,7 +330,9 @@ export const make = Effect.gen(function* () { · 206 unmodified lines
327330 event.payload.session.status !== "running" &&
328331 event.payload.session.status !== "starting"
329332 ) {
330− return worker.enqueue({ threadId: event.payload.threadId, refresh: true });
333+ // Checkpoint completion forces the post-turn read. Session lifecycle
334+ // events reuse it regardless of which event reaches this worker first.
335+ return worker.enqueue({ threadId: event.payload.threadId, refresh: false });
331336 }
332337 break;
333338 case "thread.turn-diff-completed":
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+177−1
@@ -1,6 +1,7 @@
11 import { afterEach, assert, expect, it, vi } from "@effect/vitest";
22 import * as Effect from "effect/Effect";
33 import * as Layer from "effect/Layer";
4+import * as Schema from "effect/Schema";
45 import * as TestClock from "effect/testing/TestClock";
56 import { ChildProcessSpawner } from "effect/unstable/process";
67
@@ -9,14 +10,22 @@ import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; · 2 unmodified lines
910 import * as GitHubPullRequestCli from "./GitHubPullRequestCli.ts";
1011 import { BASE_COMPARISON_GRAPHQL_QUERY } from "./gitHubPullRequestJson.ts";
1112
13+const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
14+
1215 const mockedExecute = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>();
16+const mockedStackMemberships = vi.fn<GitHubCli.GitHubCli["Service"]["execute"]>(() =>
17+ Effect.succeed(output('{"data":{}}')),
18+);
1319 const mockedGetPullRequest = vi.fn<GitHubCli.GitHubCli["Service"]["getPullRequest"]>();
1420
1521 const layer = it.layer(
1622 GitHubPullRequestCli.layer.pipe(
1723 Layer.provide(
1824 Layer.mock(GitHubCli.GitHubCli)({
19− execute: mockedExecute,
25+ execute: (input) =>
26+ input.args.some((arg) => arg.includes("query PullRequestStackMemberships"))
27+ ? mockedStackMemberships(input)
28+ : mockedExecute(input),
2029 getPullRequest: mockedGetPullRequest,
2130 }),
2231 ),
@@ -179,6 +188,7 @@ function searchQueryOfCall(index: number): string | undefined { · 156 unmodified lines
179188
180189 afterEach(() => {
181190 mockedExecute.mockReset();
191+ mockedStackMemberships.mockReset();
182192 mockedGetPullRequest.mockReset();
183193 });
184194
@@ -320,6 +330,51 @@ layer("GitHubPullRequestCli.layer", (it) => { · 135 unmodified lines
320330 }),
321331 );
322332
333+ it.effect("fetches layer titles only when the caller asks for stack details", () =>
334+ Effect.gen(function* () {
335+ const minimal = {
336+ url: "https://api.github.com/repos/acme/web/stacks/3",
337+ number: 3,
338+ base: { ref: "main" },
339+ pull_requests: [
340+ { number: 7, head: { ref: "feat/two", sha: "abc123" }, state: "open", merged_at: null },
341+ ],
342+ };
343+ // @effect-diagnostics-next-line preferSchemaOverJson:off
344+ mockedExecute.mockReturnValueOnce(Effect.succeed(output(JSON.stringify([minimal]))));
345+ mockedExecute.mockReturnValueOnce(
346+ Effect.succeed(
347+ output(
348+ // @effect-diagnostics-next-line preferSchemaOverJson:off
349+ JSON.stringify({
350+ ...minimal,
351+ pull_requests: [{ ...minimal.pull_requests[0], title: "Second layer", draft: false }],
352+ }),
353+ ),
354+ ),
355+ );
356+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
357+ const stack = yield* cli.getPullRequestStack({
358+ cwd: "/w",
359+ repository: "acme/web",
360+ host: "github.com",
361+ number: 7,
362+ includeDetails: true,
363+ });
364+ expect(stack?.layers[0]).toMatchObject({
365+ title: "Second layer",
366+ headSha: "abc123",
367+ isDraft: false,
368+ });
369+ expect(callAt(1).args).toEqual([
370+ "api",
371+ "--hostname",
372+ "github.com",
373+ "repos/acme/web/stacks/3",
374+ ]);
375+ }),
376+ );
377+
323378 it.effect("reads an empty stacks listing as not stacked", () =>
324379 Effect.gen(function* () {
325380 mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
@@ -715,6 +770,127 @@ layer("GitHubPullRequestCli.layer", (it) => { · 389 unmodified lines
715770 }),
716771 );
717772
773+ it.effect("enriches only the visible fallback rows after filtering and widening", () =>
774+ Effect.gen(function* () {
775+ mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
776+ mockedExecute.mockReturnValueOnce(
777+ Effect.succeed(output(pullRequests(3, 1, () => ({ isDraft: true })))),
778+ );
779+ mockedExecute.mockReturnValueOnce(
780+ Effect.succeed(output(pullRequests(6, 1, (number) => ({ isDraft: number < 4 })))),
781+ );
782+ mockedStackMemberships.mockReturnValueOnce(
783+ Effect.succeed(
784+ output(
785+ encodeJson({
786+ data: {
787+ s0: {
788+ pullRequest: {
789+ stack: { number: 3, size: 2, baseRefName: "main" },
790+ stackEntry: { position: 1 },
791+ },
792+ },
793+ s1: { pullRequest: { stack: null, stackEntry: null } },
794+ },
795+ }),
796+ ),
797+ ),
798+ );
799+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
800+ const batch = yield* cli.listPullRequests({
801+ cwd: "/w",
802+ repository: "acme/web",
803+ host: "github.com",
804+ state: "open",
805+ involvement: "all",
806+ viewer: "bilal",
807+ limit: 2,
808+ filters: { draft: "hide" },
809+ });
810+ expect(batch.items.map((item) => item.number)).toEqual([4, 5]);
811+ expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 });
812+ expect(batch.items[1]?.stack).toBeUndefined();
813+ expect(batch.truncated).toBe(true);
814+ expect(batch.continues).toBe(false);
815+ expect(mockedStackMemberships).toHaveBeenCalledTimes(1);
816+ const query = mockedStackMemberships.mock.calls[0]?.[0].args.at(-1) ?? "";
817+ expect(query).toContain("pullRequest(number: 4)");
818+ expect(query).toContain("pullRequest(number: 5)");
819+ expect(query).not.toContain("pullRequest(number: 1)");
820+ expect(query).not.toContain("pullRequest(number: 6)");
821+ }),
822+ );
823+
824+ it.effect("batches membership reads and keeps successful rows when one chunk fails", () =>
825+ Effect.gen(function* () {
826+ mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(27, 1))));
827+ mockedStackMemberships.mockImplementation((input) =>
828+ input.args.at(-1)?.includes("pullRequest(number: 26)")
829+ ? Effect.fail(
830+ new GitHubCli.GitHubCliCommandError({
831+ command: "gh",
832+ cwd: "/w",
833+ cause: new Error("HTTP 502"),
834+ }),
835+ )
836+ : Effect.succeed(
837+ output(
838+ encodeJson({
839+ data: {
840+ s0: {
841+ pullRequest: {
842+ stack: { number: 3, size: 2, baseRefName: "main" },
843+ stackEntry: { position: 1 },
844+ },
845+ },
846+ },
847+ }),
848+ ),
849+ ),
850+ );
851+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
852+ const batch = yield* cli.listPullRequests({
853+ cwd: "/w",
854+ repository: "acme/web",
855+ host: "github.com",
856+ state: "open",
857+ involvement: "all",
858+ viewer: "bilal",
859+ limit: 26,
860+ });
861+ expect(batch.items.map((item) => item.number)).toEqual(
862+ Array.from({ length: 26 }, (_, i) => i + 1),
863+ );
864+ expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, base: "main", position: 1 });
865+ expect(batch.items[25]?.stack).toBeUndefined();
866+ expect(batch.truncated).toBe(true);
867+ expect(batch.continues).toBe(true);
868+ expect(mockedStackMemberships).toHaveBeenCalledTimes(2);
869+ }),
870+ );
871+
872+ it.effect("skips membership enrichment for empty pages and enterprise hosts", () =>
873+ Effect.gen(function* () {
874+ mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
875+ mockedExecute.mockReturnValueOnce(Effect.succeed(output("[]")));
876+ mockedExecute.mockReturnValueOnce(Effect.succeed(output(pullRequests(1, 7))));
877+ const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli;
878+ const input = {
879+ cwd: "/w",
880+ repository: "acme/web",
881+ state: "open" as const,
882+ involvement: "all" as const,
883+ viewer: "bilal",
884+ limit: 2,
885+ };
886+ const empty = yield* cli.listPullRequests({ ...input, host: "github.com" });
887+ const enterprise = yield* cli.listPullRequests({ ...input, host: "github.acme.test" });
888+ expect(empty.items).toEqual([]);
889+ expect(enterprise.items.map((item) => item.number)).toEqual([7]);
890+ expect(mockedStackMemberships).not.toHaveBeenCalled();
891+ }),
892+ );
893+
718894 it.effect("reads the line counts in chunks, and files them back by position", () =>
719895 Effect.gen(function* () {
720896 const changeRequests = Array.from({ length: 26 }, (_, index) => ({
apps/server/src/pullRequest/GitHubPullRequestCli.ts+82−1
@@ -1,3 +1,4 @@
1+import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts";
12 import * as Context from "effect/Context";
23 import * as Effect from "effect/Effect";
34 import * as Layer from "effect/Layer";
@@ -6,6 +7,7 @@ import * as Schema from "effect/Schema"; · 2 unmodified lines
67 import {
78 resolvePullRequestAuthorFilter,
89 type PullRequestAction,
10+ type PullRequestStackHead,
911 type PullRequestActor,
1012 type PullRequestInvolvement,
1113 type PullRequestListFilters,
@@ -51,6 +53,8 @@ import { · 39 unmodified lines
5153 decodeReviewThreadCommentsJson,
5254 decodeReviewThreadsJson,
5355 buildPullRequestStatsGraphQlQuery,
56+ buildPullRequestStackMembershipsGraphQlQuery,
57+ decodePullRequestStackMembershipsJson,
5458 encodeGraphQlRequestJson,
5559 pullRequestSearchGraphQlQuery,
5660 PULL_REQUEST_SEARCH_MAX_ROWS,
@@ -334,6 +338,7 @@ export class GitHubWorkflowApprovalHeadChangedError extends Schema.TaggedError<G · 277 unmodified lines
334338 }
335339
336340 export type GitHubPullRequestCliError =
341+ | GitHubStackActionError
337342 | GitHubCli.GitHubCliError
338343 | GitHubPullRequestReadError
339344 | GitHubDiffCursorError
@@ -488,6 +493,7 @@ export class GitHubPullRequestCli extends Context.Service< · 148 unmodified lines
488493 */
489494 readonly getPullRequestStack: (input: {
490495 readonly cwd: string;
496+ readonly includeDetails?: boolean;
491497 readonly repository: string;
492498 readonly host: string;
493499 readonly number: number;
@@ -627,6 +633,8 @@ export class GitHubPullRequestCli extends Context.Service< · 133 unmodified lines
627633 readonly host: string;
628634 readonly number: number;
629635 readonly action: PullRequestAction;
636+ readonly stackNumber?: number;
637+ readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>;
630638 readonly mergeMethod?: PullRequestMergeMethod;
631639 readonly updateMethod?: PullRequestUpdateMethod;
632640 }) => Effect.Effect<void, GitHubPullRequestCliError>;
@@ -1557,6 +1565,47 @@ export const make = Effect.gen(function* () { · 924 unmodified lines
15571565 ? read(false)
15581566 : Effect.succeed(batch),
15591567 ),
1568+ Effect.flatMap((batch) => {
1569+ // Match the search query's host support, and enrich only rows that survived paging.
1570+ if (input.host !== "github.com" || batch.items.length === 0) return Effect.succeed(batch);
1571+ const chunks: Array<ReadonlyArray<GitHubPullRequestListItem>> = [];
1572+ for (let start = 0; start < batch.items.length; start += STAT_ALIASES_PER_REQUEST) {
1573+ chunks.push(batch.items.slice(start, start + STAT_ALIASES_PER_REQUEST));
1574+ }
1575+ return Effect.forEach(
1576+ chunks,
1577+ (chunk) => {
1578+ const query = buildPullRequestStackMembershipsGraphQlQuery(
1579+ input.repository,
1580+ chunk.map((item) => item.number),
1581+ );
1582+ if (query === null) return Effect.succeed(chunk);
1583+ return graphqlRead({
1584+ cwd: input.cwd,
1585+ host: input.host,
1586+ operation: "listPullRequestStackMemberships",
1587+ query,
1588+ decode: decodePullRequestStackMembershipsJson,
1589+ }).pipe(
1590+ Effect.map((memberships) =>
1591+ chunk.map((item, index) => {
1592+ const stack = memberships.get(index);
1593+ return stack === undefined ? item : { ...item, stack };
1594+ }),
1595+ ),
1596+ // Optional badges must not take down a listing that already read successfully.
1597+ Effect.catch(() =>
1598+ Effect.logWarning("Pull request stack membership enrichment failed", {
1599+ operation: "listPullRequestStackMemberships",
1600+ host: input.host,
1601+ rows: chunk.length,
1602+ }).pipe(Effect.as(chunk)),
1603+ ),
1604+ );
1605+ },
1606+ { concurrency: STAT_REQUEST_CONCURRENCY },
1607+ ).pipe(Effect.map((chunks) => ({ ...batch, items: chunks.flat() })));
1608+ }),
15601609 );
15611610 },
15621611
@@ -1581,7 +1630,7 @@ export const make = Effect.gen(function* () { · 18 unmodified lines
15811630 operation: "searchPullRequests",
15821631 // The reader's own words are in the query, so it travels over stdin rather than in argv.
15831632 privateVariables: { q: query },
1584− query: pullRequestSearchGraphQlQuery(rows),
1633+ query: pullRequestSearchGraphQlQuery(rows, input.host === "github.com"),
15851634 decode: decodePullRequestSearchJson,
15861635 }).pipe(
15871636 Effect.map((batch) => ({
@@ -1709,6 +1758,34 @@ export const make = Effect.gen(function* () { · 121 unmodified lines
17091758 }),
17101759 );
17111760 }),
1761+ Effect.flatMap((stack) => {
1762+ if (!input.includeDetails || stack === null) return Effect.succeed(stack);
1763+ return github
1764+ .execute({
1765+ cwd: input.cwd,
1766+ args: [
1767+ "api",
1768+ "--hostname",
1769+ input.host,
1770+ `repos/${owner}/${name}/stacks/${stack.number}`,
1771+ ],
1772+ })
1773+ .pipe(
1774+ Effect.flatMap((result) => {
1775+ const decoded = decodePullRequestStacksJson(`[${result.stdout.trim()}]`);
1776+ return Result.isSuccess(decoded)
1777+ ? Effect.succeed(decoded.success)
1778+ : Effect.fail(
1779+ new GitHubPullRequestReadError({
1780+ command: "gh",
1781+ cwd: input.cwd,
1782+ operation: "getPullRequestStack",
1783+ cause: decoded.failure,
1784+ }),
1785+ );
1786+ }),
1787+ );
1788+ }),
17121789 // Hosts without the stacks preview return 404. Other failures must preserve the
17131790 // previously synced stack and let the caller retry.
17141791 Effect.catchTags({
@@ -2109,6 +2186,10 @@ export const make = Effect.gen(function* () { · 394 unmodified lines
21092186 },
21102187
21112188 runPullRequestAction: (input) => {
2189+ if (input.stackNumber !== undefined)
2190+ return runGitHubStackAction({ ...input, stackNumber: input.stackNumber }).pipe(
2191+ Effect.provideService(GitHubCli.GitHubCli, github),
2192+ );
21122193 if (input.action === "revert") {
21132194 return pullRequestNodeId({ ...input, operation: "revertPullRequest" }).pipe(
21142195 Effect.flatMap((pullRequestId) =>
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+1−0
@@ -130,6 +130,7 @@ describe("gitHubViewerPermissions", () => { · 129 unmodified lines
130130 ],
131131 comment: true,
132132 resolve: true,
133+ stackRebase: true,
133134 verdicts: ["comment", "approve", "request-changes"],
134135 requestReviewers: true,
135136 labels: true,
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+6−0
@@ -48,6 +48,7 @@ const CAPABILITIES: PullRequestCapabilities = { · 47 unmodified lines
4848 reviewers: { request: true, listCandidates: true },
4949 edit: { changeRequest: true, comment: true },
5050 stacks: true,
51+ stackActions: true,
5152 labels: true,
5253 };
5354
@@ -70,6 +71,7 @@ const CAPABILITIES: PullRequestCapabilities = { · 16 unmodified lines
7071 */
7172 export function gitHubViewerPermissions(access: GitHubViewerAccess): PullRequestViewerPermissions {
7273 return {
74+ ...(access.canWrite ? { stackRebase: true } : {}),
7375 actions: [
7476 // Arming a merge and taking the arming back are the merge, deferred: whoever may not
7577 // merge here may not leave an instruction to merge later either.
@@ -558,6 +560,10 @@ export const make = Effect.gen(function* () { · 482 unmodified lines
558560 host: input.host,
559561 number: input.number,
560562 action: input.action,
563+ ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }),
564+ ...(input.expectedStackHeads === undefined
565+ ? {}
566+ : { expectedStackHeads: input.expectedStackHeads }),
561567 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
562568 ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }),
563569 })
apps/server/src/pullRequest/PullRequestProvider.ts+9−1
@@ -1,7 +1,9 @@
11 import * as Effect from "effect/Effect";
22 import * as Schema from "effect/Schema";
33 import type {
4+ PullRequestStackMembership,
45 PullRequestAction,
6+ PullRequestStackHead,
57 PullRequestActor,
68 PullRequestBaseComparison,
79 PullRequestCapabilities,
@@ -65,6 +67,7 @@ export interface PullRequestProviderFailure { · 57 unmodified lines
6567
6668 /** A change request as the provider sees it, before the service attaches project context. */
6769 export interface ProviderChangeRequest {
70+ readonly stack?: PullRequestStackMembership;
6871 readonly number: number;
6972 readonly title: string;
7073 readonly url: string;
@@ -115,6 +118,9 @@ export interface ProviderChangeRequestSummary { · 44 unmodified lines
115118
116119 /** One layer of a host-native stack, bottom to top order is the array's. */
117120 export interface ProviderChangeRequestStackLayer {
121+ readonly title?: string;
122+ readonly isDraft?: boolean;
123+ readonly headSha?: string;
118124 readonly number: number;
119125 readonly headBranch: string;
120126 readonly state: PullRequestState;
@@ -364,7 +370,7 @@ export interface PullRequestProviderApi { · 243 unmodified lines
364370 * because most hosts have no such object; the service derives chains from base branches there.
365371 */
366372 readonly getChangeRequestStack?: (
367− input: ProviderRepositoryRef & { readonly number: number },
373+ input: ProviderRepositoryRef & { readonly includeDetails?: boolean; readonly number: number },
368374 ) => Effect.Effect<ProviderChangeRequestStack | null, PullRequestProviderError>;
369375
370376 /** Comments, line threads, and commits, kept off the critical path for the core detail. */
@@ -426,6 +432,8 @@ export interface PullRequestProviderApi { · 55 unmodified lines
426432 input: ProviderRepositoryRef & {
427433 readonly number: number;
428434 readonly action: PullRequestAction;
435+ readonly stackNumber?: number;
436+ readonly expectedStackHeads?: ReadonlyArray<PullRequestStackHead>;
429437 /** Meaningful for `merge` and `enable-auto-merge`; absent takes the host's own default. */
430438 readonly mergeMethod?: PullRequestMergeMethod;
431439 /** Only meaningful for `update-branch`; absent takes the host's own default. */
apps/server/src/pullRequest/PullRequestService.test.ts+127−0
@@ -1564,6 +1564,48 @@ it.effect("refuses a repository that does not belong to the requested project", · 1563 unmodified lines
15641564 }),
15651565 );
15661566
1567+it.effect("caches stack membership separately from action details", () =>
1568+ Effect.gen(function* () {
1569+ const reads: Array<boolean> = [];
1570+ const service = yield* makeService({
1571+ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
1572+ providers: [
1573+ fakeProvider("github", {
1574+ getChangeRequestStack: (input) =>
1575+ Effect.sync(() => {
1576+ reads.push(input.includeDetails === true);
1577+ return {
1578+ id: "9",
1579+ number: 3,
1580+ url: "https://github.com/acme/web/stacks/3",
1581+ base: "main",
1582+ layers: [
1583+ {
1584+ number: 7,
1585+ headBranch: "a",
1586+ state: "open" as const,
1587+ ...(input.includeDetails ? { title: "First layer", headSha: "abc" } : {}),
1588+ },
1589+ ],
1590+ };
1591+ }),
1592+ }),
1593+ ],
1594+ });
1595+ const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 7 };
1596+ yield* service.stack(reference, { includeDetails: false });
1597+ yield* service.stack(reference, { includeDetails: false });
1598+ const detail = yield* service.stack(reference);
1599+ yield* service.stack(reference);
1600+ assert.deepStrictEqual(reads, [false, true]);
1601+ assert.strictEqual(detail?.layers[0]?.headSha, "abc");
1602+ yield* service.invalidate({ reference });
1603+ yield* service.stack(reference, { includeDetails: false });
1604+ yield* service.stack(reference);
1605+ assert.deepStrictEqual(reads, [false, true, false, true]);
1606+ }),
1607+);
1608+
15671609 it.effect("reads a host-native stack through the provider and null where it has none", () =>
15681610 Effect.gen(function* () {
15691611 const service = yield* makeService({
@@ -3997,6 +4039,91 @@ it.effect('resolves an author filter of "me" to the viewer before narrowing a ho · 2427 unmodified lines
39974039 }),
39984040 );
39994041
4042+it.effect("authorizes stack rebases independently of whether the selected layer is behind", () =>
4043+ Effect.gen(function* () {
4044+ let taken = 0;
4045+ let summaryReads = 0;
4046+ let mutationFails = false;
4047+ let stackRebase = true;
4048+ let stackActions = true;
4049+ const capabilities = {
4050+ diff: true,
4051+ comment: true,
4052+ actions: ["update-branch"] as const,
4053+ mergeMethods: ["merge"] as const,
4054+ updateMethods: ["rebase"] as const,
4055+ get stackActions() {
4056+ return stackActions;
4057+ },
4058+ search: true,
4059+ reactions: true,
4060+ review: FULL_REVIEW,
4061+ reviewers: FULL_REVIEWERS,
4062+ };
4063+ const service = yield* makeService({
4064+ projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })],
4065+ providers: [
4066+ fakeProvider("github", {
4067+ capabilities,
4068+ getViewerPermissions: () =>
4069+ Effect.succeed({
4070+ actions: [],
4071+ stackRebase,
4072+ comment: true,
4073+ resolve: false,
4074+ verdicts: [],
4075+ requestReviewers: false,
4076+ }),
4077+ getChangeRequestSummary: () =>
4078+ Effect.sync(() => {
4079+ summaryReads++;
4080+ return changeRequest(8, "2026-07-01T00:00:00Z");
4081+ }),
4082+ runAction: () =>
4083+ Effect.gen(function* () {
4084+ taken++;
4085+ if (mutationFails) return yield* requestFailed;
4086+ }),
4087+ }),
4088+ ],
4089+ });
4090+ const input = {
4091+ projectId: "p1" as ProjectId,
4092+ repository: "acme/web",
4093+ number: 3,
4094+ action: "update-branch" as const,
4095+ updateMethod: "rebase" as const,
4096+ stackNumber: 50,
4097+ expectedStackHeads: [{ number: 3, headSha: "ccc" }],
4098+ };
4099+ yield* service.runAction(input);
4100+ assert.strictEqual(taken, 1);
4101+ const unrelated = { ...input, number: 8 };
4102+ yield* service.summary(unrelated);
4103+ assert.strictEqual(summaryReads, 1);
4104+ stackRebase = false;
4105+ assert.strictEqual(
4106+ (yield* Effect.flip(service.runAction(input)))._tag,
4107+ "PullRequestOperationError",
4108+ );
4109+ stackRebase = true;
4110+ stackActions = false;
4111+ assert.strictEqual(
4112+ (yield* Effect.flip(service.runAction(input)))._tag,
4113+ "PullRequestOperationError",
4114+ );
4115+ assert.strictEqual(taken, 1);
4116+ yield* service.summary(unrelated);
4117+ assert.strictEqual(summaryReads, 1);
4118+ stackActions = true;
4119+ mutationFails = true;
4120+ yield* Effect.flip(service.runAction(input));
4121+ assert.strictEqual(taken, 2);
4122+ yield* service.summary(unrelated);
4123+ assert.strictEqual(summaryReads, 2);
4124+ }),
4125+);
4126+
40004127 it.effect("refuses a way of updating a branch that the host or the viewer does not allow", () =>
40014128 Effect.gen(function* () {
40024129 let taken: string | null = null;
apps/server/src/pullRequest/PullRequestService.ts+43−8
@@ -155,6 +155,7 @@ export class PullRequestService extends Context.Service< · 154 unmodified lines
155155 */
156156 readonly stack: (
157157 input: PullRequestRef,
158+ options?: { readonly includeDetails?: boolean },
158159 ) => Effect.Effect<PullRequestStack | null, PullRequestError>;
159160 readonly subscribeMerges: Effect.Effect<
160161 Stream.Stream<PullRequestMergeEvent>,
@@ -907,6 +908,7 @@ export const make = Effect.gen(function* () { · 746 unmodified lines
907908 }): PullRequestListEntry => {
908909 const viewer = input.viewer.toLowerCase();
909910 return {
911+ ...(input.item.stack === undefined ? {} : { stack: input.item.stack }),
910912 provider: input.project.api.kind,
911913 host: input.project.host,
912914 projectId: input.project.project.id,
@@ -1331,7 +1333,7 @@ export const make = Effect.gen(function* () { · 418 unmodified lines
13311333 }),
13321334 );
13331335
1334− const stackUncached: PullRequestService["Service"]["stack"] = (input) =>
1336+ const stackUncached: PullRequestService["Service"]["stack"] = (input, options) =>
13351337 requireProject(input).pipe(
13361338 Effect.flatMap((project) => {
13371339 const read = project.api.getChangeRequestStack;
@@ -1341,6 +1343,7 @@ export const make = Effect.gen(function* () { · 3 unmodified lines
13411343 repository: project.repository,
13421344 host: project.host,
13431345 number: input.number,
1346+ includeDetails: options?.includeDetails !== false,
13441347 }).pipe(
13451348 Effect.mapError(toPullRequestError("stack")),
13461349 Effect.map((stack): PullRequestStack | null =>
@@ -1352,6 +1355,7 @@ export const make = Effect.gen(function* () { · 5 unmodified lines
13521355 url: stack.url,
13531356 base: stack.base,
13541357 layers: stack.layers.map((layer) => ({
1358+ ...layer,
13551359 number: layer.number,
13561360 headBranch: layer.headBranch,
13571361 state: layer.state,
@@ -1531,6 +1535,20 @@ export const make = Effect.gen(function* () { · 173 unmodified lines
15311535 const runAction = (input: PullRequestActionInput): Effect.Effect<string, PullRequestError> =>
15321536 requireProject(input).pipe(
15331537 Effect.flatMap((project): Effect.Effect<string, PullRequestError> => {
1538+ if (
1539+ input.stackNumber !== undefined &&
1540+ (project.api.capabilities.stackActions !== true ||
1541+ !["merge", "update-branch"].includes(input.action) ||
1542+ input.expectedStackHeads === undefined ||
1543+ (input.action === "update-branch" && input.updateMethod !== "rebase"))
1544+ ) {
1545+ return Effect.fail(
1546+ new PullRequestOperationError({
1547+ operation: "runAction",
1548+ detail: "This stack action is not supported or has no expected head revision.",
1549+ }),
1550+ );
1551+ }
15341552 // The surface hides what a host cannot do, and this refuses it as well: a request that
15351553 // reached here anyway must not be handed to a provider that never claimed the action.
15361554 if (!project.api.capabilities.actions.includes(input.action)) {
@@ -1573,7 +1591,10 @@ export const make = Effect.gen(function* () { · 36 unmodified lines
15731591 // above do not.
15741592 return viewerPermissionsOf(project, input, "runAction").pipe(
15751593 Effect.flatMap((viewer): Effect.Effect<string, PullRequestError> => {
1576− if (!viewer.actions.includes(input.action)) {
1594+ const stackRebase = input.stackNumber !== undefined && input.action === "update-branch";
1595+ if (
1596+ stackRebase ? viewer.stackRebase !== true : !viewer.actions.includes(input.action)
1597+ ) {
15771598 return Effect.fail(
15781599 new PullRequestOperationError({
15791600 operation: "runAction",
@@ -1582,6 +1603,7 @@ export const make = Effect.gen(function* () { · 2 unmodified lines
15821603 );
15831604 }
15841605 if (
1606+ !stackRebase &&
15851607 input.updateMethod !== undefined &&
15861608 !(viewer.updateMethods ?? []).includes(input.updateMethod)
15871609 ) {
@@ -1599,10 +1621,17 @@ export const make = Effect.gen(function* () { · 11 unmodified lines
15991621 host: project.host,
16001622 number: input.number,
16011623 action: input.action,
1624+ ...(input.stackNumber === undefined ? {} : { stackNumber: input.stackNumber }),
1625+ ...(input.expectedStackHeads === undefined
1626+ ? {}
1627+ : { expectedStackHeads: input.expectedStackHeads }),
16021628 ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }),
16031629 ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }),
16041630 })
16051631 .pipe(
1632+ // Once the authorized provider action starts, a failure may leave partial
1633+ // remote updates. Validation and permission failures above changed nothing.
1634+ Effect.ensuring(input.stackNumber === undefined ? Effect.void : refreshAfterTurn),
16061635 Effect.mapError(toPullRequestError("runAction")),
16071636 Effect.as(
16081637 project.api.kind === "azure-devops"
@@ -2327,12 +2356,18 @@ export const make = Effect.gen(function* () { · 718 unmodified lines
23272356 );
23282357 };
23292358
2330− const stackCache = yield* Cache.makeWith((key: string) => stackUncached(refOfCacheKey(key)), {
2331− capacity: DETAIL_CACHE_CAPACITY,
2332− timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero),
2333− });
2334− const stack: PullRequestService["Service"]["stack"] = (input) =>
2335− Cache.get(stackCache, refCacheKey(input));
2359+ const stackCache = yield* Cache.makeWith(
2360+ (key: string) => {
2361+ const [referenceKey, includeDetails] = JSON.parse(key) as [string, boolean];
2362+ return stackUncached(refOfCacheKey(referenceKey), { includeDetails });
2363+ },
2364+ {
2365+ capacity: DETAIL_CACHE_CAPACITY,
2366+ timeToLive: (exit) => (Exit.isSuccess(exit) ? SUMMARY_CACHE_TTL : Duration.zero),
2367+ },
2368+ );
2369+ const stack: PullRequestService["Service"]["stack"] = (input, options) =>
2370+ Cache.get(stackCache, JSON.stringify([refCacheKey(input), options?.includeDetails !== false]));
23362371
23372372 // Keys serialize positionally and parse back in the lookup, so the cache is the only holder
23382373 // of in-flight state: concurrent identical reads coalesce on the key into one host request.
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+75−0
@@ -3,6 +3,8 @@ import { describe, expect, it } from "vite-plus/test"; · 2 unmodified lines
33
44 import {
55 buildReviewSubmissionJson,
6+ buildPullRequestStackMembershipsGraphQlQuery,
7+ decodePullRequestStackMembershipsJson,
68 buildReviewerRequestJson,
79 decodeBaseComparisonJson,
810 decodePullRequestActivityJson,
@@ -21,6 +23,7 @@ import { · 12 unmodified lines
2123 decodeWorkflowRunApprovalsJson,
2224 reviewThreadConversation,
2325 REVIEW_THREADS_GRAPHQL_QUERY,
26+ pullRequestSearchGraphQlQuery,
2427 } from "./gitHubPullRequestJson.ts";
2528
2629 function listJson(entries: ReadonlyArray<Record<string, unknown>>): string {
@@ -165,6 +168,17 @@ describe("pull request search decoding", () => { · 138 unmodified lines
165168 });
166169 }
167170
171+ it("keeps stack membership beside search results without extra per-PR reads", () => {
172+ const raw = JSON.parse(searchJson(["SUCCESS", null]));
173+ raw.data.search.nodes[0].stack = { number: 3, size: 2, baseRefName: "main" };
174+ raw.data.search.nodes[0].stackEntry = { position: 1 };
175+ const batch = expectSuccess(decodePullRequestSearchJson(JSON.stringify(raw)));
176+ expect(batch.items[0]?.stack).toEqual({ number: 3, size: 2, position: 1, base: "main" });
177+ expect(batch.items[1]?.stack).toBeUndefined();
178+ expect(pullRequestSearchGraphQlQuery(20, true)).toContain("stackEntry");
179+ expect(pullRequestSearchGraphQlQuery(20)).not.toContain("stackEntry");
180+ });
181+
168182 it("maps the rollup enum the search answers with onto the same three words", () => {
169183 // The search asks GitHub for the verdict rather than the checks behind it, so this path sees
170184 // one enum where the listing sees an array.
@@ -1548,6 +1562,32 @@ describe("host-native stack decoding", () => { · 1377 unmodified lines
15481562 });
15491563 });
15501564
1565+ it("retains the detailed layer titles, draft state and expected revision", () => {
1566+ expect(
1567+ expectStack({
1568+ pull_requests: [
1569+ {
1570+ number: 11,
1571+ title: "Second layer",
1572+ draft: true,
1573+ head: { ref: "feat/two", sha: "abc123" },
1574+ state: "open",
1575+ merged_at: null,
1576+ },
1577+ ],
1578+ }).layers,
1579+ ).toEqual([
1580+ {
1581+ number: 11,
1582+ title: "Second layer",
1583+ isDraft: true,
1584+ headSha: "abc123",
1585+ headBranch: "feat/two",
1586+ state: "open",
1587+ },
1588+ ]);
1589+ });
1590+
15511591 it("accepts a base named as a bare branch, which is what the preview started out sending", () => {
15521592 expect(expectStack({ base: "develop" }).base).toBe("develop");
15531593 });
@@ -1579,3 +1619,38 @@ describe("host-native stack decoding", () => { · 25 unmodified lines
15791619 expect(Result.isSuccess(decodePullRequestStacksJson("{"))).toBe(false);
15801620 });
15811621 });
1622+
1623+describe("pull request stack membership batches", () => {
1624+ it("maps aliases while skipping missing pull requests and incomplete memberships", () => {
1625+ const memberships = expectSuccess(
1626+ decodePullRequestStackMembershipsJson(
1627+ JSON.stringify({
1628+ data: {
1629+ s0: {
1630+ pullRequest: {
1631+ stack: { number: 3, size: 2, baseRefName: "main" },
1632+ stackEntry: { position: 1 },
1633+ },
1634+ },
1635+ s1: null,
1636+ s2: { pullRequest: null },
1637+ s3: { pullRequest: { stack: null, stackEntry: null } },
1638+ s4: { pullRequest: { stack: { number: 3, size: 2, baseRefName: "main" } } },
1639+ },
1640+ }),
1641+ ),
1642+ );
1643+ expect([...memberships]).toEqual([[0, { number: 3, size: 2, base: "main", position: 1 }]]);
1644+ });
1645+
1646+ it("refuses malformed responses and unsafe query selectors", () => {
1647+ expect(Result.isFailure(decodePullRequestStackMembershipsJson('{"errors":[]}'))).toBe(true);
1648+ expect(buildPullRequestStackMembershipsGraphQlQuery('acme/web") { x } #', [1])).toBeNull();
1649+ expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [0])).toBeNull();
1650+ expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [1.5])).toBeNull();
1651+ expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [])).toBeNull();
1652+ expect(buildPullRequestStackMembershipsGraphQlQuery("acme/web", [7, 8])).toContain(
1653+ "pullRequest(number: 8)",
1654+ );
1655+ });
1656+});
apps/server/src/pullRequest/gitHubPullRequestJson.ts+81−2
@@ -3,6 +3,7 @@ import * as Exit from "effect/Exit"; · 2 unmodified lines
33 import * as Result from "effect/Result";
44 import * as Schema from "effect/Schema";
55 import type {
6+ PullRequestStackMembership,
67 PullRequestActor,
78 PullRequestCheck,
89 PullRequestCheckStatus,
@@ -111,12 +112,22 @@ const RawListItemSchema = Schema.Struct({ · 102 unmodified lines
111112 statusCheckRollup: Schema.optional(Schema.NullOr(Schema.Array(RawCheckSchema))),
112113 });
113114
115+const RawStackMembershipSchema = Schema.Struct({
116+ stack: Schema.optional(
117+ Schema.NullOr(
118+ Schema.Struct({ number: Schema.Int, size: Schema.Int, baseRefName: Schema.String }),
119+ ),
120+ ),
121+ stackEntry: Schema.optional(Schema.NullOr(Schema.Struct({ position: Schema.Int }))),
122+});
123+
114124 /**
115125 * A search's own answer, which is the listing's row one connection deeper: `gh pr list --json`
116126 * flattens reviewers and labels, and GraphQL does not. Everything below the row is optional
117127 * because a node that is not a pull request decodes as an empty object, which is skipped.
118128 */
119129 const RawSearchItemSchema = Schema.Struct({
130+ ...RawStackMembershipSchema.fields,
120131 number: Schema.Int,
121132 title: Schema.String,
122133 url: Schema.String,
@@ -221,6 +232,13 @@ const RawStatsSchema = Schema.Struct({ · 98 unmodified lines
221232 ),
222233 });
223234
235+const RawStackMembershipsSchema = Schema.Struct({
236+ data: Schema.Record(
237+ Schema.String,
238+ Schema.NullOr(Schema.Struct({ pullRequest: Schema.NullOr(RawStackMembershipSchema) })),
239+ ),
240+});
241+
224242 /** How many of a reaction's people the hover names before it counts the rest. */
225243 const REACTORS_PER_GROUP = 10;
226244
@@ -648,12 +666,13 @@ export const PULL_REQUEST_SEARCH_MAX_ROWS = GRAPHQL_PAGE_SIZE; · 421 unmodified lines
648666 * than twenty labels shows twenty, and one that has asked more than twenty people for a review
649667 * is already past what a row can say.
650668 */
651−export function pullRequestSearchGraphQlQuery(rows: number): string {
669+export function pullRequestSearchGraphQlQuery(rows: number, includeStacks = false): string {
652670 return `query($q: String!) {
653671 search(query: $q, type: ISSUE, first: ${Math.min(Math.max(Math.trunc(rows), 1), PULL_REQUEST_SEARCH_MAX_ROWS)}) {
654672 pageInfo { hasNextPage }
655673 nodes {
656674 ... on PullRequest {
675+ ${includeStacks ? "stack { number size baseRefName } stackEntry { position }" : ""}
657676 number
658677 title
659678 url
@@ -1019,6 +1038,7 @@ export const REPOSITORY_ACCESS_JSON_FIELDS = · 359 unmodified lines
10191038 "mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission";
10201039
10211040 export interface GitHubPullRequestListItem {
1041+ readonly stack?: PullRequestStackMembership;
10221042 /** The author's node id, kept so a batch can resolve the avatar the listing does not carry. */
10231043 readonly authorId: string | null;
10241044 readonly number: number;
@@ -1526,6 +1546,7 @@ export function decodePullRequestSearchJson( · 501 unmodified lines
15261546 const node = decodedNode.value;
15271547 const repository = trimmed(node.repository?.nameWithOwner);
15281548 if (repository === null) continue;
1549+ const stack = toStackMembership(node);
15291550 items.push({
15301551 ...toListItem({
15311552 ...node,
@@ -1541,6 +1562,7 @@ export function decodePullRequestSearchJson( · 9 unmodified lines
15411562 return state === null ? [] : [{ state }];
15421563 }),
15431564 }),
1565+ ...(stack === undefined ? {} : { stack }),
15441566 repository,
15451567 });
15461568 }
@@ -1551,6 +1573,19 @@ export function decodePullRequestSearchJson( · 4 unmodified lines
15511573 });
15521574 }
15531575
1576+function toStackMembership(
1577+ raw: Schema.Schema.Type<typeof RawStackMembershipSchema>,
1578+): PullRequestStackMembership | undefined {
1579+ return raw.stack && raw.stackEntry
1580+ ? {
1581+ number: raw.stack.number,
1582+ size: raw.stack.size,
1583+ base: raw.stack.baseRefName,
1584+ position: raw.stackEntry.position,
1585+ }
1586+ : undefined;
1587+}
1588+
15541589 /** What a repository selector may hold before it is written into a GraphQL document unquoted. */
15551590 const REPOSITORY_PART = /^[A-Za-z0-9._-]+$/;
15561591
@@ -1581,6 +1616,42 @@ export function buildPullRequestStatsGraphQlQuery( · 24 unmodified lines
15811616 return `query {\n${selections.join("\n")}\n}`;
15821617 }
15831618
1619+/** Stack membership for the visible rows of a per-repository listing. */
1620+export function buildPullRequestStackMembershipsGraphQlQuery(
1621+ repository: string,
1622+ numbers: ReadonlyArray<number>,
1623+): string | null {
1624+ if (numbers.length === 0) return null;
1625+ const [owner, name, ...rest] = repository.trim().split("/");
1626+ if (rest.length > 0 || owner === undefined || name === undefined) return null;
1627+ if (!REPOSITORY_PART.test(owner) || !REPOSITORY_PART.test(name)) return null;
1628+ const selections: string[] = [];
1629+ for (const [index, number] of numbers.entries()) {
1630+ if (!Number.isSafeInteger(number) || number <= 0) return null;
1631+ selections.push(
1632+ ` s${index}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${number}) { stack { number size baseRefName } stackEntry { position } } }`,
1633+ );
1634+ }
1635+ return `query PullRequestStackMemberships {\n${selections.join("\n")}\n}`;
1636+}
1637+
1638+const decodeStackMemberships = decodeJsonResult(RawStackMembershipsSchema);
1639+
1640+export function decodePullRequestStackMembershipsJson(
1641+ raw: string,
1642+): Result.Result<ReadonlyMap<number, PullRequestStackMembership>, DecodeFailure> {
1643+ const decoded = decodeStackMemberships(raw);
1644+ if (!Result.isSuccess(decoded)) return Result.fail(decoded.failure);
1645+ const memberships = new Map<number, PullRequestStackMembership>();
1646+ for (const [alias, value] of Object.entries(decoded.success.data)) {
1647+ const index = /^s(\d+)$/.exec(alias)?.[1];
1648+ if (index === undefined || value?.pullRequest == null) continue;
1649+ const stack = toStackMembership(value.pullRequest);
1650+ if (stack !== undefined) memberships.set(Number(index), stack);
1651+ }
1652+ return Result.succeed(memberships);
1653+}
1654+
15841655 /**
15851656 * The counts by the position they were asked in. A repository or a pull request GitHub answered
15861657 * nothing for is simply absent, which leaves the row with whatever it already had.
@@ -2448,8 +2519,10 @@ export function decodePullRequestFilesJson( · 861 unmodified lines
24482519
24492520 /** One pull request as the stacks API lists it: a number, a head, and whether it is done. */
24502521 const RawStackPullRequestSchema = Schema.Struct({
2522+ title: Schema.optional(Schema.String),
2523+ draft: Schema.optional(Schema.Boolean),
24512524 number: Schema.Int,
2452− head: Schema.Struct({ ref: Schema.String }),
2525+ head: Schema.Struct({ ref: Schema.String, sha: Schema.optional(Schema.String) }),
24532526 state: Schema.optional(Schema.NullOr(Schema.String)),
24542527 merged_at: Schema.optional(Schema.NullOr(Schema.String)),
24552528 });
@@ -2473,6 +2546,9 @@ const RawStackSchema = Schema.Struct({ · 17 unmodified lines
24732546 const decodeStacks = decodeJsonResult(Schema.Array(RawStackSchema));
24742547
24752548 export interface GitHubPullRequestStackLayer {
2549+ readonly title?: string;
2550+ readonly isDraft?: boolean;
2551+ readonly headSha?: string;
24762552 readonly number: number;
24772553 readonly headBranch: string;
24782554 readonly state: PullRequestState;
@@ -2505,6 +2581,9 @@ export function decodePullRequestStacksJson( · 26 unmodified lines
25052581 url: trimmed(stack.html_url) ?? stack.url,
25062582 base: typeof stack.base === "string" ? stack.base : stack.base.ref,
25072583 layers: stack.pull_requests.map((pullRequest) => ({
2584+ ...(pullRequest.title === undefined ? {} : { title: pullRequest.title }),
2585+ ...(pullRequest.draft === undefined ? {} : { isDraft: pullRequest.draft }),
2586+ ...(pullRequest.head.sha === undefined ? {} : { headSha: pullRequest.head.sha }),
25082587 number: pullRequest.number,
25092588 headBranch: pullRequest.head.ref,
25102589 state: toState({ state: pullRequest.state, mergedAt: pullRequest.merged_at }),
apps/server/src/pullRequest/githubStackActions.test.ts+413−0
@@ -0,0 +1,413 @@
1+import { expect, it } from "@effect/vitest";
2+import * as Layer from "effect/Layer";
3+import * as Effect from "effect/Effect";
4+import * as Fiber from "effect/Fiber";
5+import * as TestClock from "effect/testing/TestClock";
6+import { ChildProcessSpawner } from "effect/unstable/process";
7+import * as GitHubCli from "../sourceControl/GitHubCli.ts";
8+import { runGitHubStackAction as runStackAction } from "./githubStackActions.ts";
9+
10+const runGitHubStackAction = (
11+ execute: GitHubCli.GitHubCli["Service"]["execute"],
12+ input: Parameters<typeof runStackAction>[0],
13+) => runStackAction(input).pipe(Effect.provide(Layer.mock(GitHubCli.GitHubCli)({ execute })));
14+
15+const stack = [
16+ {
17+ number: 50,
18+ url: "https://api.github.com/repos/acme/web/stacks/50",
19+ base: { ref: "main" },
20+ pull_requests: [
21+ {
22+ number: 1,
23+ title: "Base",
24+ head: { ref: "base", sha: "aaa" },
25+ state: "closed",
26+ merged_at: "2026-01-01T00:00:00Z",
27+ },
28+ {
29+ number: 2,
30+ title: "Middle",
31+ head: { ref: "middle", sha: "bbb" },
32+ state: "open",
33+ draft: false,
34+ },
35+ { number: 3, title: "Top", head: { ref: "top", sha: "ccc" }, state: "open", draft: false },
36+ ],
37+ },
38+];
39+const input = {
40+ cwd: "/repo",
41+ repository: "acme/web",
42+ host: "github.com",
43+ number: 3,
44+ stackNumber: 50,
45+ expectedStackHeads: [
46+ { number: 2, headSha: "bbb" },
47+ { number: 3, headSha: "ccc" },
48+ ],
49+ action: "merge" as const,
50+};
51+const access = {
52+ data: {
53+ repository: {
54+ pr2: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false },
55+ pr3: { headRepository: { viewerPermission: "WRITE" }, maintainerCanModify: false },
56+ },
57+ },
58+};
59+
60+const branch = (number: number, headRefOid: string, behindBy = 1, processed: string[] = []) => ({
61+ data: {
62+ processed: processed.map((headRefOid) => ({ headRefOid })),
63+ repository: {
64+ pullRequest: { id: `PR_${number}`, headRefOid, baseRef: { compare: { behindBy } } },
65+ },
66+ },
67+});
68+const rebased = {
69+ data: { updatePullRequestBranch: { pullRequest: { headRefOid: "rebased-sha" } } },
70+};
71+const rebaseResponses = [branch(2, "bbb"), rebased, branch(3, "ccc", 1, ["rebased-sha"]), rebased];
72+
73+function fake(responses: readonly unknown[]) {
74+ const calls: ReadonlyArray<string>[] = [];
75+ const execute: GitHubCli.GitHubCli["Service"]["execute"] = (request) =>
76+ Effect.sync(() => {
77+ calls.push(request.args);
78+ const value = responses[calls.length - 1];
79+ if (value === undefined) throw new Error("Unexpected GitHub request");
80+ return {
81+ exitCode: ChildProcessSpawner.ExitCode(0),
82+ // @effect-diagnostics-next-line preferSchemaOverJson:off
83+ stdout: JSON.stringify(value),
84+ stderr: "",
85+ stdoutTruncated: false,
86+ stderrTruncated: false,
87+ stdoutInvalidUtf8: false,
88+ };
89+ });
90+ return { execute, calls };
91+}
92+
93+it.effect("submits one atomic merge with the reviewed head and respects the merge queue", () =>
94+ Effect.gen(function* () {
95+ const api = fake([stack, { status: "enqueued", details: {} }]);
96+ yield* runGitHubStackAction(api.execute, { ...input, mergeMethod: "squash" });
97+ expect(api.calls).toHaveLength(2);
98+ expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async");
99+ expect(api.calls[1]).toContain("sha=ccc");
100+ expect(api.calls[1]).toContain("merge_action=default");
101+ expect(api.calls[1]).toContain("merge_method=squash");
102+ }),
103+);
104+
105+it.effect("merges through the selected layer without including later draft layers", () =>
106+ Effect.gen(function* () {
107+ const fiveLayers = [
108+ {
109+ ...stack[0],
110+ pull_requests: Array.from({ length: 5 }, (_, index) => ({
111+ number: index + 1,
112+ head: { ref: `layer-${index + 1}`, sha: `sha-${index + 1}` },
113+ state: "open",
114+ draft: index >= 3,
115+ })),
116+ },
117+ ];
118+ const api = fake([fiveLayers, { status: "merged", details: {} }]);
119+ yield* runGitHubStackAction(api.execute, {
120+ ...input,
121+ number: 3,
122+ expectedStackHeads: [1, 2, 3].map((number) => ({ number, headSha: `sha-${number}` })),
123+ });
124+ expect(api.calls).toHaveLength(2);
125+ expect(api.calls[1]).toContain("repos/acme/web/pulls/3/merge-async");
126+ expect(api.calls[1]).toContain("sha=sha-3");
127+ expect(api.calls[1]).toContain("merge_action=default");
128+ }),
129+);
130+
131+it.effect("rejects stale reviewed heads below a selected middle layer", () =>
132+ Effect.gen(function* () {
133+ const api = fake([stack]);
134+ const result = yield* runGitHubStackAction(api.execute, {
135+ ...input,
136+ number: 2,
137+ expectedStackHeads: [{ number: 2, headSha: "old-head" }],
138+ }).pipe(Effect.result);
139+ expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } });
140+ expect(api.calls).toHaveLength(1);
141+ }),
142+);
143+
144+it.effect("does not merge from an already merged layer", () =>
145+ Effect.gen(function* () {
146+ const api = fake([stack]);
147+ const result = yield* runGitHubStackAction(api.execute, {
148+ ...input,
149+ number: 1,
150+ expectedStackHeads: [],
151+ }).pipe(Effect.result);
152+ expect(result).toMatchObject({
153+ _tag: "Failure",
154+ failure: { _tag: "GitHubStackUnsupportedError" },
155+ });
156+ expect(api.calls).toHaveLength(1);
157+ }),
158+);
159+
160+it.effect("polls an accepted merge and reports a later rule rejection", () =>
161+ Effect.gen(function* () {
162+ const api = fake([
163+ stack,
164+ { status: "pending", details: { uuid: "operation" } },
165+ { status: "failed", details: { message: "Required checks have not passed" } },
166+ ]);
167+ const fiber = yield* runGitHubStackAction(api.execute, input).pipe(
168+ Effect.result,
169+ Effect.forkChild,
170+ );
171+ yield* TestClock.adjust("1 second");
172+ const result = yield* Fiber.join(fiber);
173+ expect(result).toMatchObject({
174+ _tag: "Failure",
175+ failure: { _tag: "GitHubStackMergeRejectedError" },
176+ });
177+ expect(api.calls[2]).toContain("repos/acme/web/pulls/3/merge-async/operation");
178+ }),
179+);
180+
181+it.effect("retains stack identity and a rejection response without a message", () =>
182+ Effect.gen(function* () {
183+ const rejection = { status: "failed", details: {} };
184+ const api = fake([stack, rejection]);
185+ const result = yield* runGitHubStackAction(api.execute, input).pipe(Effect.result);
186+ expect(result).toMatchObject({
187+ _tag: "Failure",
188+ failure: {
189+ _tag: "GitHubStackMergeRejectedError",
190+ repository: input.repository,
191+ number: input.number,
192+ stackNumber: input.stackNumber,
193+ cause: rejection,
194+ },
195+ });
196+ }),
197+);
198+
199+it.effect("refuses a changed stack before performing any mutation", () =>
200+ Effect.gen(function* () {
201+ const api = fake([stack]);
202+ const result = yield* runGitHubStackAction(api.execute, {
203+ ...input,
204+ expectedStackHeads: [
205+ { number: 2, headSha: "old" },
206+ { number: 3, headSha: "ccc" },
207+ ],
208+ }).pipe(Effect.result);
209+ expect(result).toMatchObject({ _tag: "Failure", failure: { _tag: "GitHubStackChangedError" } });
210+ expect(api.calls).toHaveLength(1);
211+ }),
212+);
213+
214+it.effect("rebases unmerged layers bottom to top without local git commands", () =>
215+ Effect.gen(function* () {
216+ const api = fake([stack, access, ...rebaseResponses]);
217+ yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" });
218+ const mutations = api.calls.filter((args) =>
219+ args.some((arg) => arg.startsWith("query=mutation")),
220+ );
221+ expect(mutations).toHaveLength(2);
222+ expect(mutations[0]).toContain("id=PR_2");
223+ expect(mutations[0]).toContain("sha=bbb");
224+ expect(mutations[1]).toContain("id=PR_3");
225+ expect(mutations[1]).toContain("sha=ccc");
226+ expect(api.calls.every((args) => args[0] === "api")).toBe(true);
227+ }),
228+);
229+
230+it.effect("does not update later layers after a rebase failure", () =>
231+ Effect.gen(function* () {
232+ const api = fake([stack, access, branch(2, "bbb")]);
233+ const execute: typeof api.execute = (request) =>
234+ !request.args.some((arg) => arg.startsWith("query=mutation"))
235+ ? api.execute(request)
236+ : Effect.fail(
237+ new GitHubCli.GitHubCliAuthenticationError({
238+ command: "gh",
239+ cwd: "/repo",
240+ cause: new Error("denied"),
241+ }),
242+ );
243+ const result = yield* runGitHubStackAction(execute, { ...input, action: "update-branch" }).pipe(
244+ Effect.result,
245+ );
246+ expect(result).toMatchObject({
247+ _tag: "Failure",
248+ failure: { _tag: "GitHubStackRebaseFailedError", number: 2, completed: 0 },
249+ });
250+ }),
251+);
252+
253+it.effect("refuses the entire rebase before mutation when a later fork denies write access", () =>
254+ Effect.gen(function* () {
255+ const api = fake([
256+ stack,
257+ {
258+ data: {
259+ repository: {
260+ ...access.data.repository,
261+ pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: false },
262+ },
263+ },
264+ },
265+ ]);
266+ const result = yield* runGitHubStackAction(api.execute, {
267+ ...input,
268+ action: "update-branch",
269+ }).pipe(Effect.result);
270+ expect(result).toMatchObject({
271+ _tag: "Failure",
272+ failure: { _tag: "GitHubStackPermissionError" },
273+ });
274+ expect(api.calls).toHaveLength(2);
275+ expect(api.calls.every((args) => args[0] === "api")).toBe(true);
276+ }),
277+);
278+
279+it.effect("allows a fork that explicitly permits maintainer updates", () =>
280+ Effect.gen(function* () {
281+ const api = fake([
282+ stack,
283+ {
284+ data: {
285+ repository: {
286+ ...access.data.repository,
287+ pr3: { headRepository: { viewerPermission: "READ" }, maintainerCanModify: true },
288+ },
289+ },
290+ },
291+ ...rebaseResponses,
292+ ]);
293+ yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" });
294+ expect(api.calls.at(-1)).toContain("id=PR_3");
295+ }),
296+);
297+
298+it.effect("bounds polling and reports a still-running merge without claiming success", () =>
299+ Effect.gen(function* () {
300+ const api = fake([
301+ stack,
302+ ...Array.from({ length: 40 }, () => ({ status: "pending", details: { uuid: "operation" } })),
303+ ]);
304+ const fiber = yield* runGitHubStackAction(api.execute, input).pipe(
305+ Effect.result,
306+ Effect.forkChild,
307+ );
308+ yield* TestClock.adjust("6 minutes");
309+ expect(yield* Fiber.join(fiber)).toMatchObject({
310+ _tag: "Failure",
311+ failure: { _tag: "GitHubStackMergePendingError" },
312+ });
313+ expect(api.calls.length).toBeLessThan(40);
314+ }),
315+);
316+
317+it.effect("rejects a push after preflight without rebasing the new revision", () =>
318+ Effect.gen(function* () {
319+ const api = fake([stack, access, branch(2, "new-head")]);
320+ const result = yield* runGitHubStackAction(api.execute, {
321+ ...input,
322+ action: "update-branch",
323+ }).pipe(Effect.result);
324+ expect(result).toMatchObject({
325+ _tag: "Failure",
326+ failure: { _tag: "GitHubStackChangedError", number: 2, completed: 0 },
327+ });
328+ expect(api.calls).toHaveLength(3);
329+ }),
330+);
331+
332+it.effect("skips current layers without submitting a rebase mutation", () =>
333+ Effect.gen(function* () {
334+ const api = fake([stack, access, branch(2, "bbb", 0), branch(3, "ccc", 0, ["bbb"])]);
335+ yield* runGitHubStackAction(api.execute, { ...input, action: "update-branch" });
336+ expect(api.calls.some((args) => args.some((arg) => arg.startsWith("query=mutation")))).toBe(
337+ false,
338+ );
339+ }),
340+);
341+
342+it.effect("keeps earlier progress and stops after a later layer fails", () =>
343+ Effect.gen(function* () {
344+ const api = fake([
345+ stack,
346+ access,
347+ branch(2, "bbb"),
348+ rebased,
349+ branch(3, "ccc", 1, ["rebased-sha"]),
350+ { data: { updatePullRequestBranch: null } },
351+ ]);
352+ const result = yield* runGitHubStackAction(api.execute, {
353+ ...input,
354+ action: "update-branch",
355+ }).pipe(Effect.result);
356+ expect(result).toMatchObject({
357+ _tag: "Failure",
358+ failure: { _tag: "GitHubStackRebaseFailedError", number: 3, completed: 1 },
359+ });
360+ }),
361+);
362+
363+it.effect("reports partial progress when a later head changes during the rebase", () =>
364+ Effect.gen(function* () {
365+ const api = fake([
366+ stack,
367+ access,
368+ branch(2, "bbb"),
369+ rebased,
370+ branch(3, "concurrent-head", 1, ["rebased-sha"]),
371+ ]);
372+ const result = yield* runGitHubStackAction(api.execute, {
373+ ...input,
374+ action: "update-branch",
375+ }).pipe(Effect.result);
376+ expect(result).toMatchObject({
377+ _tag: "Failure",
378+ failure: { _tag: "GitHubStackChangedError", number: 3, completed: 1 },
379+ });
380+ if (result._tag === "Failure") {
381+ expect(result.failure.message).toContain("Earlier updates remain on GitHub");
382+ }
383+ expect(
384+ api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))),
385+ ).toHaveLength(1);
386+ }),
387+);
388+
389+it.effect.each([false, true])("rejects a push to a processed layer, rebased=%s", (rebasedParent) =>
390+ Effect.gen(function* () {
391+ const api = fake([
392+ stack,
393+ access,
394+ branch(2, "bbb", rebasedParent ? 1 : 0),
395+ ...(rebasedParent ? [rebased] : []),
396+ branch(3, "ccc", 1, ["concurrent-parent-head"]),
397+ ]);
398+ const result = yield* runGitHubStackAction(api.execute, {
399+ ...input,
400+ action: "update-branch",
401+ }).pipe(Effect.result);
402+ expect(result).toMatchObject({
403+ _tag: "Failure",
404+ failure: { _tag: "GitHubStackChangedError", number: 2, completed: 1 },
405+ });
406+ expect(api.calls.at(-1)?.some((arg) => arg.includes('processed:nodes(ids:["PR_2"])'))).toBe(
407+ true,
408+ );
409+ expect(
410+ api.calls.filter((args) => args.some((arg) => arg.startsWith("query=mutation"))),
411+ ).toHaveLength(rebasedParent ? 1 : 0);
412+ }),
413+);
apps/server/src/pullRequest/githubStackActions.ts+418−0
@@ -0,0 +1,418 @@
1+import type {
2+ PullRequestAction,
3+ PullRequestMergeMethod,
4+ PullRequestStackHead,
5+} from "@t3tools/contracts";
6+import * as Effect from "effect/Effect";
7+import * as Clock from "effect/Clock";
8+import * as Result from "effect/Result";
9+import * as Schema from "effect/Schema";
10+
11+import * as GitHubCli from "../sourceControl/GitHubCli.ts";
12+import { decodePullRequestStacksJson } from "./gitHubPullRequestJson.ts";
13+
14+const stackErrorIdentity = {
15+ repository: Schema.String,
16+ number: Schema.Int,
17+ stackNumber: Schema.Int,
18+};
19+
20+export class GitHubStackChangedError extends Schema.TaggedError<GitHubStackChangedError>()(
21+ "GitHubStackChangedError",
22+ { ...stackErrorIdentity, completed: Schema.Int },
23+) {
24+ get detail(): string {
25+ return this.message;
26+ }
27+
28+ override get message(): string {
29+ return this.completed > 0
30+ ? `The stack changed at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub. Refresh it before trying again.`
31+ : "The stack changed. Refresh it before trying again.";
32+ }
33+}
34+
35+export class GitHubStackUnsupportedError extends Schema.TaggedError<GitHubStackUnsupportedError>()(
36+ "GitHubStackUnsupportedError",
37+ stackErrorIdentity,
38+) {
39+ get detail(): string {
40+ return this.message;
41+ }
42+
43+ override get message(): string {
44+ return "This operation is not supported for this stack.";
45+ }
46+}
47+
48+export class GitHubStackResponseInvalidError extends Schema.TaggedError<GitHubStackResponseInvalidError>()(
49+ "GitHubStackResponseInvalidError",
50+ { ...stackErrorIdentity, cause: Schema.optional(Schema.Defect()) },
51+) {
52+ get detail(): string {
53+ return this.message;
54+ }
55+
56+ override get message(): string {
57+ return "GitHub returned an unreadable stack operation response.";
58+ }
59+}
60+
61+export class GitHubStackMergeRejectedError extends Schema.TaggedError<GitHubStackMergeRejectedError>()(
62+ "GitHubStackMergeRejectedError",
63+ { ...stackErrorIdentity, cause: Schema.Defect() },
64+) {
65+ get detail(): string {
66+ return this.message;
67+ }
68+
69+ override get message(): string {
70+ return "GitHub refused the stack merge. Check the stack's branch rules and merge requirements.";
71+ }
72+}
73+
74+export class GitHubStackMergePendingError extends Schema.TaggedError<GitHubStackMergePendingError>()(
75+ "GitHubStackMergePendingError",
76+ stackErrorIdentity,
77+) {
78+ get detail(): string {
79+ return this.message;
80+ }
81+
82+ override get message(): string {
83+ return "The merge is still running on GitHub. Check its status there before submitting another request.";
84+ }
85+}
86+
87+export class GitHubStackPermissionError extends Schema.TaggedError<GitHubStackPermissionError>()(
88+ "GitHubStackPermissionError",
89+ stackErrorIdentity,
90+) {
91+ get detail(): string {
92+ return this.message;
93+ }
94+
95+ override get message(): string {
96+ return "You cannot update every branch in this stack. Check write access and fork maintainer permissions before retrying.";
97+ }
98+}
99+
100+export class GitHubStackRebaseFailedError extends Schema.TaggedError<GitHubStackRebaseFailedError>()(
101+ "GitHubStackRebaseFailedError",
102+ { ...stackErrorIdentity, completed: Schema.Int, cause: Schema.Defect() },
103+) {
104+ get detail(): string {
105+ return this.message;
106+ }
107+
108+ override get message(): string {
109+ return `Stack rebase stopped at PR #${this.number} after ${this.completed} layers. Earlier updates remain on GitHub; resolve the failing layer before retrying.`;
110+ }
111+}
112+
113+export type GitHubStackActionError =
114+ | GitHubStackChangedError
115+ | GitHubStackUnsupportedError
116+ | GitHubStackResponseInvalidError
117+ | GitHubStackMergeRejectedError
118+ | GitHubStackMergePendingError
119+ | GitHubStackPermissionError
120+ | GitHubStackRebaseFailedError;
121+
122+const MergeResponse = Schema.Struct({
123+ status: Schema.Literals(["pending", "merged", "enqueued", "failed"]),
124+ details: Schema.Struct({
125+ uuid: Schema.optional(Schema.String),
126+ message: Schema.optional(Schema.String),
127+ }),
128+});
129+
130+const decodeBranchAccess = Schema.decodeEffect(
131+ Schema.fromJsonString(
132+ Schema.Struct({
133+ data: Schema.Struct({
134+ repository: Schema.NullOr(
135+ Schema.Record(
136+ Schema.String,
137+ Schema.NullOr(
138+ Schema.Struct({
139+ headRepository: Schema.NullOr(
140+ Schema.Struct({ viewerPermission: Schema.NullOr(Schema.String) }),
141+ ),
142+ maintainerCanModify: Schema.Boolean,
143+ }),
144+ ),
145+ ),
146+ ),
147+ }),
148+ }),
149+ ),
150+);
151+
152+const decodeRebaseBranch = Schema.decodeEffect(
153+ Schema.fromJsonString(
154+ Schema.Struct({
155+ data: Schema.Struct({
156+ processed: Schema.optional(
157+ Schema.Array(Schema.NullOr(Schema.Struct({ headRefOid: Schema.String }))),
158+ ),
159+ repository: Schema.Struct({
160+ pullRequest: Schema.Struct({
161+ id: Schema.String,
162+ headRefOid: Schema.String,
163+ baseRef: Schema.Struct({ compare: Schema.Struct({ behindBy: Schema.Int }) }),
164+ }),
165+ }),
166+ }),
167+ }),
168+ ),
169+);
170+const decodeRebaseResponse = Schema.decodeEffect(
171+ Schema.fromJsonString(
172+ Schema.Struct({
173+ data: Schema.Struct({
174+ updatePullRequestBranch: Schema.Struct({
175+ pullRequest: Schema.Struct({ headRefOid: Schema.String }),
176+ }),
177+ }),
178+ }),
179+ ),
180+);
181+
182+const encodeNodeIds = Schema.encodeSync(Schema.fromJsonString(Schema.Array(Schema.String)));
183+
184+const decodeMergeResponse = Schema.decodeEffect(Schema.fromJsonString(MergeResponse));
185+
186+/** Remote-only updates: a stack rebase never switches or rewrites the environment's checkout. */
187+export const runGitHubStackAction = Effect.fn("runGitHubStackAction")(function* (input: {
188+ cwd: string;
189+ repository: string;
190+ host: string;
191+ number: number;
192+ stackNumber: number;
193+ expectedStackHeads?: ReadonlyArray<PullRequestStackHead>;
194+ action: PullRequestAction;
195+ mergeMethod?: PullRequestMergeMethod;
196+}) {
197+ const github = yield* GitHubCli.GitHubCli;
198+ const identity = {
199+ repository: input.repository,
200+ number: input.number,
201+ stackNumber: input.stackNumber,
202+ };
203+ if (input.action !== "merge" && input.action !== "update-branch")
204+ return yield* new GitHubStackUnsupportedError({ ...identity });
205+ const endpoint = `repos/${input.repository}`;
206+ const read = yield* github.execute({
207+ cwd: input.cwd,
208+ args: ["api", "--hostname", input.host, `${endpoint}/stacks?pull_request=${input.number}`],
209+ });
210+ const decoded = decodePullRequestStacksJson(read.stdout);
211+ if (Result.isFailure(decoded))
212+ return yield* new GitHubStackResponseInvalidError({ ...identity, cause: decoded.failure });
213+ const stack = decoded.success;
214+ const targetIndex = stack?.layers.findIndex((layer) => layer.number === input.number) ?? -1;
215+ const target = stack?.layers[targetIndex];
216+ if (
217+ stack?.number !== input.stackNumber ||
218+ target === undefined ||
219+ (input.action === "update-branch" && targetIndex !== stack.layers.length - 1)
220+ ) {
221+ return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 });
222+ }
223+ const affectedLayers =
224+ input.action === "merge" ? stack.layers.slice(0, targetIndex + 1) : stack.layers;
225+ const open = affectedLayers.filter((layer) => layer.state !== "merged");
226+ if (input.action === "merge" && target.state !== "open")
227+ return yield* new GitHubStackUnsupportedError({ ...identity });
228+ if (
229+ !input.expectedStackHeads ||
230+ input.expectedStackHeads.length !== open.length ||
231+ new Set(input.expectedStackHeads.map((layer) => layer.number)).size !== open.length ||
232+ open.some(
233+ (layer) =>
234+ !layer.headSha ||
235+ !input.expectedStackHeads?.some(
236+ (expected) => expected.number === layer.number && expected.headSha === layer.headSha,
237+ ),
238+ )
239+ ) {
240+ return yield* new GitHubStackChangedError({ ...identity, number: input.number, completed: 0 });
241+ }
242+ if (open.length === 0 || open.some((layer) => layer.state !== "open"))
243+ return yield* new GitHubStackUnsupportedError({ ...identity });
244+ if (input.action === "update-branch") {
245+ const [owner, name] = input.repository.split("/");
246+ const permissions = yield* github.execute({
247+ cwd: input.cwd,
248+ args: [
249+ "api",
250+ "--hostname",
251+ input.host,
252+ "graphql",
253+ "-f",
254+ `owner=${owner}`,
255+ "-f",
256+ `name=${name}`,
257+ "-f",
258+ `query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){${open
259+ .map(
260+ (layer) =>
261+ `pr${layer.number}:pullRequest(number:${layer.number}){headRepository{viewerPermission} maintainerCanModify}`,
262+ )
263+ .join(" ")}}}`,
264+ ],
265+ });
266+ const access = yield* decodeBranchAccess(permissions.stdout).pipe(
267+ Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })),
268+ );
269+ // viewerCanUpdateBranch is false for an already-current layer, even if rebasing its parent
270+ // will make it stale. Check branch write access separately before touching any layer.
271+ if (
272+ open.some((layer) => {
273+ const pr = access.data.repository?.[`pr${layer.number}`];
274+ return (
275+ !pr?.headRepository ||
276+ (!pr.maintainerCanModify &&
277+ !["ADMIN", "MAINTAIN", "WRITE"].includes(pr.headRepository.viewerPermission ?? ""))
278+ );
279+ })
280+ )
281+ return yield* new GitHubStackPermissionError({ ...identity });
282+ const processed: Array<{ id: string; number: number; headSha: string }> = [];
283+ for (const [index, layer] of open.entries()) {
284+ yield* Effect.gen(function* () {
285+ const read = yield* github.execute({
286+ cwd: input.cwd,
287+ args: [
288+ "api",
289+ "--hostname",
290+ input.host,
291+ "graphql",
292+ "-f",
293+ `owner=${owner}`,
294+ "-f",
295+ `name=${name}`,
296+ "-F",
297+ `number=${layer.number}`,
298+ "-f",
299+ `sha=${layer.headSha}`,
300+ "-f",
301+ `query=query($owner:String!,$name:String!,$number:Int!,$sha:String!){${
302+ processed.length === 0
303+ ? ""
304+ : `processed:nodes(ids:${encodeNodeIds(processed.map((head) => head.id))}){... on PullRequest{headRefOid}}`
305+ } repository(owner:$owner,name:$name){pullRequest(number:$number){id headRefOid baseRef{compare(headRef:$sha){behindBy}}}}}`,
306+ ],
307+ });
308+ const {
309+ data: {
310+ processed: observed,
311+ repository: { pullRequest: pr },
312+ },
313+ } = yield* decodeRebaseBranch(read.stdout);
314+ // A push to an earlier layer must not silently become the next layer's new base.
315+ const changed = processed.find(
316+ (head, index) => observed?.[index]?.headRefOid !== head.headSha,
317+ );
318+ if (changed !== undefined)
319+ return yield* new GitHubStackChangedError({
320+ ...identity,
321+ number: changed.number,
322+ completed: index,
323+ });
324+ if (pr.headRefOid !== layer.headSha)
325+ return yield* new GitHubStackChangedError({
326+ ...identity,
327+ number: layer.number,
328+ completed: index,
329+ });
330+ if (pr.baseRef.compare.behindBy === 0) {
331+ processed.push({ id: pr.id, number: layer.number, headSha: pr.headRefOid });
332+ return;
333+ }
334+ // Pass the reviewed revision to GitHub, including when a push races this read.
335+ const updated = yield* github.execute({
336+ cwd: input.cwd,
337+ args: [
338+ "api",
339+ "--hostname",
340+ input.host,
341+ "graphql",
342+ "-f",
343+ `id=${pr.id}`,
344+ "-f",
345+ `sha=${layer.headSha}`,
346+ "-f",
347+ "query=mutation($id:ID!,$sha:GitObjectID!){updatePullRequestBranch(input:{pullRequestId:$id,expectedHeadOid:$sha,updateMethod:REBASE}){pullRequest{headRefOid}}}",
348+ ],
349+ });
350+ const response = yield* decodeRebaseResponse(updated.stdout);
351+ processed.push({
352+ id: pr.id,
353+ number: layer.number,
354+ headSha: response.data.updatePullRequestBranch.pullRequest.headRefOid,
355+ });
356+ }).pipe(
357+ Effect.mapError((cause) =>
358+ cause._tag === "GitHubStackChangedError"
359+ ? cause
360+ : new GitHubStackRebaseFailedError({
361+ ...identity,
362+ number: layer.number,
363+ completed: index,
364+ cause,
365+ }),
366+ ),
367+ );
368+ }
369+ return;
370+ }
371+ if (open.some((layer) => layer.isDraft))
372+ return yield* new GitHubStackUnsupportedError({ ...identity });
373+ const decode = (raw: string) =>
374+ decodeMergeResponse(raw).pipe(
375+ Effect.mapError((cause) => new GitHubStackResponseInvalidError({ ...identity, cause })),
376+ );
377+ const request = yield* github.execute({
378+ cwd: input.cwd,
379+ args: [
380+ "api",
381+ "--hostname",
382+ input.host,
383+ "--method",
384+ "PUT",
385+ `${endpoint}/pulls/${input.number}/merge-async`,
386+ "-f",
387+ `merge_method=${input.mergeMethod ?? "merge"}`,
388+ "-f",
389+ "merge_action=default",
390+ "-f",
391+ `sha=${target.headSha}`,
392+ ],
393+ });
394+ let result = yield* decode(request.stdout);
395+ const deadline = (yield* Clock.currentTimeMillis) + 5 * 60_000;
396+ for (
397+ let attempt = 0;
398+ result.status === "pending" && (yield* Clock.currentTimeMillis) < deadline;
399+ attempt++
400+ ) {
401+ const uuid = result.details.uuid;
402+ if (!uuid) return yield* new GitHubStackResponseInvalidError({ ...identity });
403+ yield* Effect.sleep(Math.min(1_000 * 2 ** attempt, 10_000));
404+ const poll = yield* github.execute({
405+ cwd: input.cwd,
406+ args: [
407+ "api",
408+ "--hostname",
409+ input.host,
410+ `${endpoint}/pulls/${input.number}/merge-async/${encodeURIComponent(uuid)}`,
411+ ],
412+ });
413+ result = yield* decode(poll.stdout);
414+ }
415+ if (result.status === "pending") return yield* new GitHubStackMergePendingError({ ...identity });
416+ if (result.status === "failed")
417+ return yield* new GitHubStackMergeRejectedError({ ...identity, cause: result });
418+});
apps/web/src/commandPaletteBus.ts+9−0
@@ -1,9 +1,18 @@
1+import type { EnvironmentId, PullRequestLinkedThreadsResult } from "@t3tools/contracts";
2+
3+export interface CommandPaletteLinkedThreads {
4+ readonly environmentId: EnvironmentId;
5+ readonly threads: PullRequestLinkedThreadsResult["threads"];
6+}
7+
18 // Tiny event bus allowing components to programmatically open the command palette
29 // without owning its React state.
310 const COMMAND_PALETTE_OPEN_EVENT = "t3code:open-command-palette";
411
512 export interface CommandPaletteOpenDetail {
613 readonly open?: "add-project" | "new-thread-in";
14+ readonly query?: string;
15+ readonly linkedThreads?: CommandPaletteLinkedThreads;
716 }
817
918 export function openCommandPalette(detail?: CommandPaletteOpenDetail): void {
apps/web/src/components/BranchToolbarBranchSelector.tsx+42−43
@@ -1,3 +1,6 @@
1+import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests";
2+import { resolveThreadCurrentPullRequestLink } from "@t3tools/shared/threadPullRequests";
3+import { useRightPanelStore } from "../rightPanelStore";
14 import { RefreshIcon } from "~/components/ui/refresh-icon";
25 import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
36 import {
@@ -47,7 +50,12 @@ import { · 43 unmodified lines
4750 sanitizeNewRefName,
4851 shouldIncludeBranchPickerItem,
4952 } from "./BranchToolbar.logic";
50−import { ChangeRequestStatusIcon, prStatusIndicator } from "./ThreadStatusIndicators";
53+import {
54+ ThreadPullRequestBadgeControl,
55+ prStatusIndicator,
56+ resolveThreadPullRequestBadge,
57+ useLinkedThreadPullRequest,
58+} from "./ThreadStatusIndicators";
5159 import { Button } from "./ui/button";
5260 import { Switch } from "./ui/switch";
5361 import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area";
@@ -612,7 +620,7 @@ export function BranchToolbarBranchSelector({ · 558 unmodified lines
612620 startFromOrigin,
613621 });
614622
615− // PR pill shown next to the branch selector when the active branch has one.
623+ // Branch status is the fallback when this thread has no linked pull requests.
616624 const branchPrBranch = resolveBranchToolbarPrBranch({
617625 activeThreadBranch,
618626 resolvedActiveBranch,
@@ -621,12 +629,27 @@ export function BranchToolbarBranchSelector({ · 2 unmodified lines
621629 branchPrBranch !== null && branchStatusQuery.data?.refName === branchPrBranch
622630 ? (branchStatusQuery.data.pr ?? null)
623631 : null;
624− const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider);
625− // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's
626− // state-description tooltip.
627− const branchPrTooltip = branchPr
628− ? `Open ${sourceControlPresentation.terminology.singular} #${branchPr.number} (${branchPr.state})`
629− : "";
632+ const supportsMultiplePullRequests = useSupportsMultiplePullRequests(environmentId);
633+ const linkedStatus = useLinkedThreadPullRequest(
634+ environmentId,
635+ serverThread?.linkedPullRequest,
636+ true,
637+ serverThread?.pullRequests,
638+ serverThread?.branchPullRequest,
639+ );
640+ const currentLinkedPr = supportsMultiplePullRequests
641+ ? resolveThreadCurrentPullRequestLink(serverThread?.pullRequests ?? [])
642+ : null;
643+ const prBadge = supportsMultiplePullRequests
644+ ? resolveThreadPullRequestBadge(serverThread?.pullRequests)
645+ : null;
646+ const displayedPr = linkedStatus?.pr ?? (currentLinkedPr === null ? branchPr : null);
647+ const displayedPrStatus = prStatusIndicator(
648+ displayedPr,
649+ linkedStatus?.sourceControlProvider ?? branchStatusQuery.data?.sourceControlProvider,
650+ );
651+ const prNumber = currentLinkedPr?.number ?? displayedPr?.number;
652+ const prUrl = currentLinkedPr?.url ?? displayedPr?.url;
630653 const openPrLink = useOpenPrLink(threadRef);
631654
632655 function renderPickerItem(itemValue: string, index: number) {
@@ -730,41 +753,17 @@ export function BranchToolbarBranchSelector({ · 97 unmodified lines
730753 className={cn("flex min-w-0 items-center gap-1", className)}
731754 data-composer-context-control
732755 >
733− {branchPr && branchPrStatus ? (
734− <Tooltip>
735− <TooltipTrigger
736− render={
737− <button
738− type="button"
739− aria-label={branchPrTooltip}
740− onClick={(event) => openPrLink(event, branchPrStatus.url)}
741− className={cn(
742− "inline-flex shrink-0 items-center gap-0.5 rounded px-1 py-0.5 text-[11px] font-medium tabular-nums transition-colors hover:bg-muted/60",
743− branchPrStatus.colorClass,
744− )}
745− />
746− }
747− >
748− <ChangeRequestStatusIcon
749− state={branchPr.state}
750− isDraft={branchPr.isDraft}
751− className="size-3"
752− />
753− <span
754− data-composer-label
755− className="min-w-0 max-w-12 overflow-hidden group-data-[compact]/composer-context:max-w-0"
756− >
757− <span
758− data-composer-label-motion
759− className="block w-full min-w-0 max-w-12 truncate transition-opacity duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none"
760− >
761− #{branchPr.number}
762− </span>
763− </span>
764− </TooltipTrigger>
765− <TooltipPopup side="top">{branchPrTooltip}</TooltipPopup>
766− </Tooltip>
767− ) : null}
756+ <ThreadPullRequestBadgeControl
757+ variant="ghost"
758+ badge={prBadge}
759+ number={prNumber}
760+ url={prUrl}
761+ status={displayedPrStatus}
762+ onOpenStack={() => useRightPanelStore.getState().open(threadRef, "pull-requests")}
763+ onOpenPullRequest={(event) => {
764+ if (prUrl) openPrLink(event, prUrl);
765+ }}
766+ />
768767 {/* Context menu lives on the wrapper: the disabled Button has
769768 pointer-events-none, so the trigger itself never sees right-clicks
770769 while refs are loading or a branch action is pending. */}
apps/web/src/components/ChatView.tsx+9−0
@@ -8054,6 +8054,15 @@ export default function ChatView(props: ChatViewProps) { · 8053 unmodified lines
80548054 <PullRequestDetailPanel
80558055 key={`${renderedRightPanelSurface.host ?? ""}:${renderedRightPanelSurface.repository}#${renderedRightPanelSurface.number}`}
80568056 environmentId={activeThread.environmentId}
8057+ onSelectPullRequest={(reference) => {
8058+ if (activeThreadRef)
8059+ useRightPanelStore.getState().openPullRequest(activeThreadRef, {
8060+ projectId: reference.projectId,
8061+ repository: reference.repository,
8062+ number: reference.number,
8063+ ...(reference.host ? { host: reference.host } : {}),
8064+ });
8065+ }}
80578066 threadRef={activeThreadRef}
80588067 reference={{
80598068 projectId: renderedRightPanelSurface.projectId as ProjectId,
apps/web/src/components/CommandPalette.logic.test.ts+69−0
@@ -5,6 +5,7 @@ import { · 4 unmodified lines
55 buildBrowseGroups,
66 buildProjectActionItems,
77 buildThreadActionItems,
8+ buildLinkedThreadActionItems,
89 enumerateCommandPaletteItems,
910 filterPinnedBrowseEntries,
1011 filterCommandPaletteGroups,
@@ -12,6 +13,48 @@ import { · 1 unmodified line
1213 type CommandPaletteGroup,
1314 } from "./CommandPalette.logic";
1415
16+describe("linked pull request thread navigation", () => {
17+ it("keeps archived relations searchable and routes them through the PR environment", async () => {
18+ const environmentId = EnvironmentId.make("remote");
19+ const id = ThreadId.make("archived-thread");
20+ const runThread = vi.fn(async () => {});
21+ const query = "https://github.com/acme/web/pull/42";
22+ const linkedThreads = {
23+ environmentId,
24+ threads: [
25+ {
26+ id,
27+ projectId: ProjectId.make("project"),
28+ title: "Completed work",
29+ archivedAt: "2026-09-01T00:00:00.000Z",
30+ },
31+ ],
32+ };
33+ const state = reduceCommandPaletteUiState(
34+ { open: false, mode: "command", openIntent: null },
35+ {
36+ _tag: "OpenSearch",
37+ query,
38+ linkedThreads,
39+ },
40+ );
41+ expect(state.openIntent).toEqual({ kind: "search", query, linkedThreads });
42+ const items = buildLinkedThreadActionItems({ ...linkedThreads, query, icon: null, runThread });
43+ const groups = filterCommandPaletteGroups({
44+ activeGroups: [],
45+ query,
46+ isInSubmenu: false,
47+ projectSearchItems: [],
48+ settingsSearchItems: [],
49+ threadSearchItems: items,
50+ });
51+ expect(groups.flatMap((group) => group.items)).toEqual(items);
52+ expect(items[0]?.description).toBe("Archived thread");
53+ await items[0]?.run();
54+ expect(runThread).toHaveBeenCalledWith({ environmentId, id });
55+ });
56+});
57+
1558 describe("reduceCommandPaletteUiState", () => {
1659 const closedState = { open: false, mode: "command", openIntent: null } as const;
1760
@@ -47,6 +90,32 @@ describe("reduceCommandPaletteUiState", () => { · 29 unmodified lines
4790 );
4891 });
4992
93+ it("opens PR search from another overlay and replaces an earlier search", () => {
94+ const first = reduceCommandPaletteUiState(
95+ { open: true, mode: "files", openIntent: null },
96+ {
97+ _tag: "OpenSearch",
98+ query: "https://github.com/acme/web/pull/7",
99+ },
100+ );
101+ expect(first).toEqual({
102+ open: true,
103+ mode: "command",
104+ openIntent: { kind: "search", query: "https://github.com/acme/web/pull/7" },
105+ });
106+ const second = reduceCommandPaletteUiState(first, {
107+ _tag: "OpenSearch",
108+ query: "https://github.com/acme/web/pull/8",
109+ });
110+ expect(second.openIntent).toEqual({
111+ kind: "search",
112+ query: "https://github.com/acme/web/pull/8",
113+ });
114+ expect(
115+ reduceCommandPaletteUiState(second, { _tag: "SetOpen", open: false }).openIntent,
116+ ).toBeNull();
117+ });
118+
50119 it("routes open intents to command mode", () => {
51120 const filesOpen = reduceCommandPaletteUiState(closedState, {
52121 _tag: "ToggleMode",
apps/web/src/components/CommandPalette.logic.ts+42−3
@@ -1,4 +1,5 @@
11 import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests";
2+import type { CommandPaletteLinkedThreads } from "../commandPaletteBus";
23 import {
34 type FilesystemBrowseEntry,
45 type KeybindingCommand,
@@ -18,6 +19,25 @@ export const RECENT_THREAD_LIMIT = 12; · 13 unmodified lines
1819 export const ITEM_ICON_CLASS = "size-4 text-icon-muted";
1920 export const ADDON_ICON_CLASS = "size-4";
2021
22+/** A PR's relations include archived threads that normal palette search omits. */
23+export function buildLinkedThreadActionItems(
24+ input: CommandPaletteLinkedThreads & {
25+ query: string;
26+ icon: ReactNode;
27+ runThread: (thread: Pick<SidebarThreadSummary, "environmentId" | "id">) => Promise<void>;
28+ },
29+): CommandPaletteActionItem[] {
30+ return input.threads.map((thread) => ({
31+ kind: "action",
32+ value: `thread:${input.environmentId}:${thread.id}`,
33+ title: thread.title || "Untitled thread",
34+ description: thread.archivedAt === null ? "Linked thread" : "Archived thread",
35+ searchTerms: [input.query, thread.title],
36+ icon: input.icon,
37+ run: () => input.runThread({ environmentId: input.environmentId, id: thread.id }),
38+ }));
39+}
40+
2141 export function browseInputEndPaddingClass(input: {
2242 readonly willCreateProjectPath: boolean;
2343 readonly hasHighlightedBrowseItem: boolean;
@@ -39,9 +59,13 @@ export function browseInputEndPaddingClass(input: { · 15 unmodified lines
3959 */
4060 export type SearchOverlayMode = "command" | "files" | "content";
4161
42−export interface CommandPaletteOpenIntent {
43− readonly kind: "add-project" | "new-thread-in";
44−}
62+export type CommandPaletteOpenIntent =
63+ | { readonly kind: "add-project" | "new-thread-in" }
64+ | {
65+ readonly kind: "search";
66+ readonly query: string;
67+ readonly linkedThreads?: CommandPaletteLinkedThreads;
68+ };
4569
4670 export interface CommandPaletteUiState {
4771 readonly open: boolean;
@@ -52,6 +76,11 @@ export interface CommandPaletteUiState { · 4 unmodified lines
5276 export type CommandPaletteUiAction =
5377 | { readonly _tag: "SetOpen"; readonly open: boolean }
5478 | { readonly _tag: "ToggleMode"; readonly mode: SearchOverlayMode }
79+ | {
80+ readonly _tag: "OpenSearch";
81+ readonly query: string;
82+ readonly linkedThreads?: CommandPaletteLinkedThreads;
83+ }
5584 | { readonly _tag: "OpenAddProject" }
5685 | { readonly _tag: "OpenNewThreadIn" }
5786 | { readonly _tag: "ClearOpenIntent" };
@@ -69,6 +98,16 @@ export function reduceCommandPaletteUiState( · 11 unmodified lines
6998 return state.open && state.mode === action.mode
7099 ? { ...state, open: false, openIntent: null }
71100 : { open: true, mode: action.mode, openIntent: null };
101+ case "OpenSearch":
102+ return {
103+ open: true,
104+ mode: "command",
105+ openIntent: {
106+ kind: "search",
107+ query: action.query,
108+ ...(action.linkedThreads ? { linkedThreads: action.linkedThreads } : {}),
109+ },
110+ };
72111 case "OpenAddProject":
73112 return { open: true, mode: "command", openIntent: { kind: "add-project" } };
74113 case "OpenNewThreadIn":
apps/web/src/components/CommandPalette.tsx+40−2
@@ -129,6 +129,7 @@ import { · 128 unmodified lines
129129 buildProjectActionItems,
130130 buildRootGroups,
131131 buildThreadActionItems,
132+ buildLinkedThreadActionItems,
132133 enumerateCommandPaletteItems,
133134 type CommandPaletteActionItem,
134135 type CommandPaletteOpenIntent,
@@ -482,6 +483,12 @@ export function CommandPalette({ children }: { children: ReactNode }) { · 347 unmodified lines
482483 openNewThreadIn();
483484 } else if (detail.open === "add-project") {
484485 openAddProject();
486+ } else if (detail.query !== undefined) {
487+ dispatch({
488+ _tag: "OpenSearch",
489+ query: detail.query,
490+ ...(detail.linkedThreads ? { linkedThreads: detail.linkedThreads } : {}),
491+ });
485492 } else {
486493 setOpen(true);
487494 }
@@ -573,7 +580,10 @@ function OpenCommandPaletteDialog(props: { · 85 unmodified lines
573580 const navigate = useNavigate();
574581 const pathname = useLocation({ select: (location) => location.pathname });
575582 const { clearOpenIntent, openIntent, openOverlayMode, setOpen } = props;
576− const [query, setQuery] = useState("");
583+ const [query, setQuery] = useState(openIntent?.kind === "search" ? openIntent.query : "");
584+ const [linkedThreadSearch, setLinkedThreadSearch] = useState(
585+ openIntent?.kind === "search" ? openIntent : null,
586+ );
577587 const deferredQuery = useDeferredValue(query);
578588 const isActionsOnly = deferredQuery.startsWith(">");
579589 const [highlightedItemValue, setHighlightedItemValue] = useState<string | null>(null);
@@ -696,6 +706,7 @@ function OpenCommandPaletteDialog(props: { · 116 unmodified lines
696706 );
697707 const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false);
698708 const [addProjectCloneFlow, setAddProjectCloneFlow] = useState<AddProjectCloneFlow | null>(null);
709+ const cloneLookupGeneration = useRef(0);
699710 const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false);
700711 const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false);
701712 const projectGroupingSettings = useMemo(
@@ -1497,6 +1508,18 @@ function OpenCommandPaletteDialog(props: { · 795 unmodified lines
14971508 startAddProjectSourceSelection,
14981509 ]);
14991510
1511+ useLayoutEffect(() => {
1512+ if (openIntent?.kind !== "search") return;
1513+ browseNavigation.invalidate();
1514+ cloneLookupGeneration.current += 1;
1515+ setIsRemoteProjectLookingUp(false);
1516+ setAddProjectCloneFlow(null);
1517+ setViewStack([]);
1518+ setLinkedThreadSearch(openIntent);
1519+ setQuery(openIntent.query);
1520+ clearOpenIntent();
1521+ }, [browseNavigation, clearOpenIntent, openIntent]);
1522+
15001523 useLayoutEffect(() => {
15011524 if (openIntent?.kind !== "add-project") {
15021525 return;
@@ -1805,7 +1828,20 @@ function OpenCommandPaletteDialog(props: { · 302 unmodified lines
18051828 isInSubmenu: currentView !== null,
18061829 projectSearchItems: projectSearchItems,
18071830 settingsSearchItems,
1808− threadSearchItems: allThreadItems,
1831+ threadSearchItems:
1832+ linkedThreadSearch?.linkedThreads && deferredQuery === linkedThreadSearch.query
1833+ ? buildLinkedThreadActionItems({
1834+ ...linkedThreadSearch.linkedThreads,
1835+ query: linkedThreadSearch.query,
1836+ icon: <MessageSquareIcon className={ITEM_ICON_CLASS} />,
1837+ runThread: async (thread) => {
1838+ await navigate({
1839+ to: "/$environmentId/$threadId",
1840+ params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, thread.id)),
1841+ });
1842+ },
1843+ })
1844+ : allThreadItems,
18091845 });
18101846
18111847 const handleAddProjectForEnvironment = useCallback(
@@ -2011,6 +2047,7 @@ function OpenCommandPaletteDialog(props: { · 199 unmodified lines
20112047 }
20122048
20132049 setIsRemoteProjectLookingUp(true);
2050+ const lookupGeneration = ++cloneLookupGeneration.current;
20142051 const lookupResult = await lookupRepository({
20152052 environmentId: addProjectCloneFlow.environmentId,
20162053 input: {
@@ -2018,6 +2055,7 @@ function OpenCommandPaletteDialog(props: { · 1 unmodified line
20182055 repository: rawRepository,
20192056 },
20202057 });
2058+ if (lookupGeneration !== cloneLookupGeneration.current) return;
20212059 setIsRemoteProjectLookingUp(false);
20222060 if (lookupResult._tag === "Failure") {
20232061 if (!isAtomCommandInterrupted(lookupResult)) {
apps/web/src/components/Sidebar.tsx+14−76
@@ -192,13 +192,11 @@ import { · 191 unmodified lines
192192 import { SidebarDragLifecycle, SidebarPointerSensor } from "./Sidebar.pointer";
193193 import { createSidebarListMotion } from "./Sidebar.motion";
194194 import {
195− PR_STATE_COLOR_CLASS,
196− ThreadPullRequestBadgeIcon,
195+ ThreadPullRequestBadgeControl,
197196 ThreadPullRequestsMiniList,
198197 ThreadWorktreeIndicator,
199198 prStatusIndicator,
200199 resolveThreadPullRequestBadge,
201− settledPrHoverColorClass,
202200 terminalStatusFromRunningIds,
203201 type TerminalStatusIndicator,
204202 useLinkedThreadPullRequest,
@@ -220,7 +218,7 @@ import { · 15 unmodified lines
220218 } from "../providerInstances";
221219 import { useThreadRunningTerminalIds } from "../state/terminalSessions";
222220 import { stackedThreadToast, toastManager } from "./ui/toast";
223−import { Button, InlineButton } from "./ui/button";
221+import { Button } from "./ui/button";
224222 import { Input } from "./ui/input";
225223 import {
226224 Combobox,
@@ -1179,7 +1177,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 952 unmodified lines
11791177 currentGitBranch: visibleGitStatus?.refName ?? null,
11801178 });
11811179 const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider);
1182− const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state, pr.isDraft) : undefined;
11831180
11841181 const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId;
11851182 const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null;
@@ -1488,85 +1485,26 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { · 302 unmodified lines
14881485 </span>
14891486 );
14901487
1491− // One badge shape for every thread: the glyph says stack or not, the number is the current
1492− // pull request, and "+N" counts the others behind it. A real link so cmd/ctrl+click and
1493− // middle-click open the host in the browser; a plain click opens T3's pull request view.
1488+ // Stacks show their layer count; unrelated links show the current PR and a remainder count.
1489+ // Plain clicks open T3; individual PR links also support opening the host in a new tab.
14941490 const prBadgeShape = supportsMultiplePullRequests
14951491 ? resolveThreadPullRequestBadge(thread.pullRequests)
14961492 : null;
1497− const prBadgeClassName = (state: "open" | "merged" | "closed", colorClass: string) =>
1498− cn(
1499− // Sidebar chrome follows the interface font; tabular digits keep the number from
1500− // reflowing as PR states stream in. A border rather than text-decoration, so the line
1501− // runs under the glyph as well as the number.
1502− "text-xs tabular-nums",
1503− variant === "slim" && variantAction === "unsettle"
1504− ? props.isActive
1505− ? "text-secondary-label"
1506− : cn("text-secondary-label transition-colors", settledPrHoverColorClass(state))
1507− : colorClass,
1508− );
15091493 const handlePrStackClick = useCallback(() => {
15101494 useRightPanelStore.getState().open(threadRef, "pull-requests");
15111495 if (!props.isActive) onThreadActivate(threadRef);
15121496 }, [onThreadActivate, props.isActive, threadRef]);
15131497 const prBadge =
1514− prBadgeShape?.kind === "stack" ? (
1515− // A stack is one thing with N layers; naming one of them would misrepresent it, so the
1516− // badge counts layers and opens the thread's pull-requests surface.
1517− <InlineButton
1518− underline
1519− onPointerDown={(event) => event.stopPropagation()}
1520− onClick={handlePrStackClick}
1521− className={prBadgeClassName(prBadgeShape.state, PR_STATE_COLOR_CLASS[prBadgeShape.state])}
1522− aria-label={`Stack of ${prBadgeShape.layers} pull requests, ${prBadgeShape.state}`}
1523− >
1524− <ThreadPullRequestBadgeIcon icon="stack" />
1525− {prBadgeShape.layers}
1526− </InlineButton>
1527− ) : prStatus && pr ? (
1528− <a
1529− href={pr.url}
1530− target="_blank"
1531− rel="noopener noreferrer"
1532− onPointerDown={(event) => event.stopPropagation()}
1533− onClick={handlePrClick}
1534− className={cn(
1535− // Sidebar chrome follows the interface font; tabular digits keep the
1536− // number from reflowing as PR states stream in.
1537− "inline-flex shrink-0 items-center gap-0.5 whitespace-nowrap text-xs tabular-nums hover:underline",
1538− variant === "slim" && variantAction === "unsettle"
1539− ? cn("text-secondary-label transition-colors", settledPrHoverClass)
1540− : prStatus.colorClass,
1541− )}
1542− aria-label={
1543− prBadgeShape && prBadgeShape.kind === "pull-request" && prBadgeShape.others > 0
1544− ? `${prStatus.tooltip}, and ${prBadgeShape.others} more linked`
1545− : prStatus.tooltip
1546− }
1547− >
1548− <ThreadPullRequestBadgeIcon icon="pull-request" />
1549− {pr.number}
1550− {prBadgeShape && prBadgeShape.others > 0 ? (
1551− <span className="opacity-70">+{prBadgeShape.others}</span>
1552− ) : null}
1553− </a>
1554− ) : currentLinkedPr ? (
1555− <a
1556− href={currentLinkedPr.url}
1557− target="_blank"
1558− rel="noopener noreferrer"
1559− onPointerDown={(event) => event.stopPropagation()}
1560− onClick={handlePrClick}
1561− className="inline-flex shrink-0 items-center gap-0.5 text-xs tabular-nums text-muted-foreground hover:underline"
1562− aria-label={`PR #${currentLinkedPr.number}, status pending`}
1563− >
1564− <ThreadPullRequestBadgeIcon icon="pull-request" />
1565− {currentLinkedPr.number}
1566− {prBadgeShape?.kind === "pull-request" && prBadgeShape.others > 0 ? (
1567− <span>+{prBadgeShape.others}</span>
1568− ) : null}
1569− </a>
1498+ prBadgeShape?.kind === "stack" || pr || currentLinkedPr ? (
1499+ <ThreadPullRequestBadgeControl
1500+ variant="underline"
1501+ badge={prBadgeShape}
1502+ number={pr?.number ?? currentLinkedPr?.number}
1503+ url={pr?.url ?? currentLinkedPr?.url}
1504+ status={prStatus}
1505+ onOpenStack={handlePrStackClick}
1506+ onOpenPullRequest={handlePrClick}
1507+ />
15701508 ) : null;
15711509 const terminalStatusIcon = terminalStatus ? (
15721510 <span
apps/web/src/components/ThreadStatusIndicators.tsx+85−3
@@ -12,9 +12,11 @@ import { · 11 unmodified lines
1212 resolveThreadCurrentPullRequestLink,
1313 resolveThreadPullRequestChains,
1414 visibleThreadPullRequests,
15+ type ThreadPullRequestBadge,
1516 } from "@t3tools/shared/threadPullRequests";
1617 import { FolderGit2Icon, GitPullRequestArrowIcon, LayersIcon, TerminalIcon } from "lucide-react";
17−import { useMemo } from "react";
18+import { useMemo, type MouseEvent } from "react";
19+import { buttonVariants, InlineButton } from "./ui/button";
1820 import { cn } from "../lib/utils";
1921 import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments";
2022 import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
@@ -122,7 +124,7 @@ export { · 101 unmodified lines
122124 } from "@t3tools/shared/threadPullRequests";
123125
124126 /** The glyph a row's badge wears: the layers icon for a stack, the pull-request one otherwise. */
125−export function ThreadPullRequestBadgeIcon({
127+function ThreadPullRequestBadgeIcon({
126128 icon,
127129 className,
128130 }: {
@@ -133,6 +135,86 @@ export function ThreadPullRequestBadgeIcon({ · 4 unmodified lines
133135 return <Icon aria-hidden className={cn("size-3 shrink-0", className)} />;
134136 }
135137
138+/** The complete linked-PR control shared by the sidebar and composer footer. */
139+export function ThreadPullRequestBadgeControl({
140+ variant,
141+ badge,
142+ number,
143+ url,
144+ status,
145+ onOpenStack,
146+ onOpenPullRequest,
147+}: {
148+ variant: "underline" | "ghost";
149+ badge: ThreadPullRequestBadge | null;
150+ number?: number | undefined;
151+ url?: string | undefined;
152+ status: PrStatusIndicator | null;
153+ onOpenStack: () => void;
154+ onOpenPullRequest: (event: MouseEvent<HTMLAnchorElement>) => void;
155+}) {
156+ const isStack = badge?.kind === "stack";
157+ if (!isStack && (number === undefined || url === undefined)) return null;
158+ const label = isStack
159+ ? `Stack of ${badge.layers} pull requests, ${badge.state}`
160+ : `${status?.tooltip ?? `PR #${number}, status pending`}${
161+ badge?.kind === "pull-request" && badge.others > 0
162+ ? `, and ${badge.others} more linked`
163+ : ""
164+ }`;
165+ const className = cn(
166+ variant === "ghost"
167+ ? buttonVariants({ variant: "ghost", size: "xs" })
168+ : "inline-flex shrink-0 cursor-pointer items-center gap-0.5 whitespace-nowrap border-b border-transparent hover:border-current focus-visible:outline-2 focus-visible:outline-ring",
169+ "text-xs tabular-nums",
170+ variant === "ghost" &&
171+ "font-normal text-xs! active:scale-100 [--control-icon-color:currentColor]",
172+ isStack ? PR_STATE_COLOR_CLASS[badge.state] : (status?.colorClass ?? "text-muted-foreground"),
173+ );
174+ const content = (
175+ <>
176+ <ThreadPullRequestBadgeIcon icon={badge?.kind ?? "pull-request"} />
177+ {isStack ? badge.layers : number}
178+ {badge?.kind === "pull-request" && badge.others > 0 ? (
179+ <span className="opacity-70">+{badge.others}</span>
180+ ) : null}
181+ </>
182+ );
183+ return (
184+ <Tooltip>
185+ <TooltipTrigger
186+ render={
187+ isStack ? (
188+ <InlineButton
189+ className={className}
190+ aria-label={label}
191+ onPointerDown={(event) => event.stopPropagation()}
192+ onClick={(event) => {
193+ event.preventDefault();
194+ event.stopPropagation();
195+ onOpenStack();
196+ }}
197+ />
198+ ) : (
199+ <a
200+ href={url}
201+ target="_blank"
202+ rel="noopener noreferrer"
203+ className={className}
204+ aria-label={label}
205+ onPointerDown={(event) => event.stopPropagation()}
206+ onClick={onOpenPullRequest}
207+ />
208+ )
209+ }
210+ >
211+ {content}
212+ </TooltipTrigger>
213+ <TooltipPopup side="top">{label}</TooltipPopup>
214+ </Tooltip>
215+ );
216+}
217+
136218 /**
137219 * A miniature of the pull-requests panel for the thread tooltip: same order, same indentation,
138220 * so the hover answers "what is in here" without opening the surface.
@@ -192,7 +274,7 @@ export function ThreadPullRequestsMiniList({ · 53 unmodified lines
192274 }
193275
194276 /** The ink each pull-request state wears in the sidebar, shared by the number and stack badges. */
195−export const PR_STATE_COLOR_CLASS: Record<NonNullable<ThreadPr>["state"], string> = {
277+const PR_STATE_COLOR_CLASS: Record<NonNullable<ThreadPr>["state"], string> = {
196278 open: "text-emerald-600 dark:text-emerald-300/90",
197279 merged: "text-violet-600 dark:text-violet-300/90",
198280 closed: "text-red-600 dark:text-red-300/90",
apps/web/src/components/chat/MessagesTimeline.logic.ts+1−1
@@ -337,7 +337,7 @@ export type MessagesTimelineRow = · 336 unmodified lines
337337 summaryKind: ToolGroupSummaryKind;
338338 toolSurface?: WorkLogEntry["toolSurface"];
339339 toolIcon?: WorkLogEntry["toolIcon"];
340− summaryToolIcon?: "browser" | "t3-code";
340+ summaryToolIcon?: "browser" | "t3-code" | "pull-request";
341341 hasFailure: boolean;
342342 }
343343 | {
apps/web/src/components/chat/MessagesTimeline.tsx+9−0
@@ -1,3 +1,4 @@
1+import { GitPullRequestIcon } from "lucide-react";
12 import {
23 type AssistantCitation,
34 type EnvironmentId,
@@ -2225,6 +2226,11 @@ function toolGroupSummaryIconName( · 2221 unmodified lines
22252226 kind: Extract<TimelineRow, { kind: "work-toggle" }>["summaryKind"],
22262227 ): WorkEntryIconName {
22272228 switch (kind) {
2229+ case "pull-request":
2230+ case "link-pr":
2231+ case "unlink-pr":
2232+ case "list-prs":
2233+ return "pull-request";
22282234 case "read":
22292235 return "eye";
22302236 case "edit":
@@ -2801,6 +2807,7 @@ type WorkEntryIconName = · 570 unmodified lines
28012807 | "search"
28022808 | "square-pen"
28032809 | "terminal"
2810+ | "pull-request"
28042811 | "t3-code"
28052812 | "wrench"
28062813 | "x"
@@ -3005,6 +3012,8 @@ function ToolActivityImageIcon(props: { · 198 unmodified lines
30053012
30063013 function WorkEntryIcon({ name, className }: { name: WorkEntryIconName; className: string }) {
30073014 switch (name) {
3015+ case "pull-request":
3016+ return <GitPullRequestIcon className={className} aria-hidden />;
30083017 case "bot":
30093018 return <BotIcon className={className} aria-hidden />;
30103019 case "brain":
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+119−51
@@ -1,3 +1,5 @@
1+import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
2+import { usePullRequestStack } from "~/state/usePullRequestStack";
13 import { RefreshIcon } from "~/components/ui/refresh-icon";
24 import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment";
35 import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
@@ -70,10 +72,10 @@ import { useProjects, useServerConfigs } from "~/state/entities"; · 66 unmodified lines
7072 import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments";
7173 import { useEnvironmentQuery } from "~/state/query";
7274 import { useLiveRefresh } from "~/hooks/useLiveRefresh";
73−import { pullRequestEnvironment, pullRequestStackAtom } from "~/state/pullRequests";
75+import { pullRequestEnvironment } from "~/state/pullRequests";
7476 import { usePullRequestTurnRefresh, useSharedPullRequestSummary } from "~/state/pullRequests";
7577 import { useAtomCommand } from "~/state/use-atom-command";
76−import { PullRequestStackMap } from "./PullRequestStackMap";
78+import { PullRequestStackMenu } from "./PullRequestStackMenu";
7779 import { PullRequestThreadLinks } from "./PullRequestThreadLinks";
7880 import { vcsEnvironment } from "~/state/vcs";
7981 import { formatRelativeTimeLabel } from "~/timestampFormat";
@@ -104,7 +106,7 @@ import { · 24 unmodified lines
104106 } from "../ui/menu";
105107 import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
106108 import { toastManager } from "../ui/toast";
107−import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
109+import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "../ui/tooltip";
108110 import { PullRequestDetailGhost, PullRequestTimelineGhost } from "./PullRequestGhosts";
109111 import { PullRequestActivityUnavailableState } from "./PullRequestActivityUnavailableState";
110112 import { DiffPanelLoadingState } from "../DiffPanelShell";
@@ -136,6 +138,7 @@ import { · 25 unmodified lines
136138 readPullRequestDetailSnapshot,
137139 resolveDisplayedPullRequestDetail,
138140 resolvePullRequestPrimaryControl,
141+ allowsSinglePullRequestMerge,
139142 resolveBaseFreshness,
140143 resolvePullRequestMergeMethod,
141144 type PullRequestFinding,
@@ -459,8 +462,10 @@ export function PullRequestDetailPanel({ · 317 unmodified lines
459462 context = "page",
460463 composerDraftTarget,
461464 onBack,
465+ onSelectPullRequest,
462466 }: {
463467 environmentId: EnvironmentId;
468+ onSelectPullRequest?: ((reference: PullRequestRef) => void) | undefined;
464469 /**
465470 * The thread this panel sits beside, if any. Links that are not the pull
466471 * request itself (check details, host permalinks) can open in that thread's
@@ -730,17 +735,34 @@ export function PullRequestDetailPanel({ · 263 unmodified lines
730735 isStackedPullRequestBase(detail.baseBranch, branchRefsQuery.data?.refs ?? []);
731736 // The host's own stack, where it keeps one. Only asked for once the detail has landed so a
732737 // pull request nobody can read costs one request rather than two.
733− const nativeStack = useEnvironmentQuery(
734− detail === null || detail.capabilities.stacks !== true || !supportsThreadPullRequests
735− ? null
736− : pullRequestStackAtom({ environmentId, input: reference }),
737− ).data;
738+ const stackReference = useMemo(
739+ () =>
740+ detail === null || detail.capabilities.stacks !== true || !supportsThreadPullRequests
741+ ? null
742+ : { ...reference, host: reference.host ?? parseChangeRequestUrl(detail.url)?.host },
743+ [detail, reference, supportsThreadPullRequests],
744+ );
745+ const nativeStackQuery = usePullRequestStack(environmentId, stackReference);
746+ const nativeStack = nativeStackQuery.data;
747+ const supportsStackActions =
748+ supportsThreadPullRequests &&
749+ detail?.capabilities.stacks === true &&
750+ detail.capabilities.stackActions === true &&
751+ environmentConfigs.get(environmentId)?.environment.capabilities.pullRequestStackActions ===
752+ true;
753+ const canMergeSinglePullRequest = allowsSinglePullRequestMerge({
754+ supportsStackActions,
755+ hasStack: nativeStack !== null,
756+ stackPending: !nativeStackQuery.isSuccess || nativeStackQuery.isPending,
757+ stackError: nativeStackQuery.error,
758+ });
738759 const activityPending = activityQuery.isPending && activity === null;
739760 const activityError = activity === null ? activityQuery.error : null;
740761 const refreshDetail = useCallback(() => {
741762 detailQuery.refresh();
742763 activityQuery.refresh();
743− }, [activityQuery.refresh, detailQuery.refresh]);
764+ nativeStackQuery.refresh();
765+ }, [activityQuery.refresh, detailQuery.refresh, nativeStackQuery.refresh]);
744766 const [refreshToken, setRefreshToken] = useState(0);
745767 const codeRefreshToken = refreshToken + (turnRefresh ?? 0);
746768 const activityRevision = useRef<{ readonly key: string; readonly updatedAt: string } | null>(
@@ -1351,9 +1373,9 @@ export function PullRequestDetailPanel({ · 604 unmodified lines
13511373 checksState,
13521374 autoMergeEnabled: detail.autoMergeEnabled,
13531375 hasMergeMethod: allowedMergeMethods.length > 0,
1354− canMerge: can("merge"),
1376+ canMerge: canMergeSinglePullRequest && can("merge"),
13551377 canMarkReady: can("ready"),
1356− canEnableAutoMerge: can("enable-auto-merge"),
1378+ canEnableAutoMerge: canMergeSinglePullRequest && can("enable-auto-merge"),
13571379 })
13581380 : null;
13591381 // What the menu's action group holds. Named once so the separators around it are drawn from
@@ -1363,6 +1385,7 @@ export function PullRequestDetailPanel({ · 3 unmodified lines
13631385 can(detail.isDraft ? "ready" : "draft") &&
13641386 !(detail.isDraft && primaryAction === "ready");
13651387 const showsAutoMerge =
1388+ canMergeSinglePullRequest &&
13661389 detail?.state === "open" &&
13671390 ((autoMergeArmed && can("disable-auto-merge")) ||
13681391 (!autoMergeArmed &&
@@ -1372,6 +1395,7 @@ export function PullRequestDetailPanel({ · 3 unmodified lines
13721395 can("enable-auto-merge") &&
13731396 allowedMergeMethods.length > 0));
13741397 const showsMergeNow =
1398+ canMergeSinglePullRequest &&
13751399 detail?.state === "open" &&
13761400 (primaryAction === "enable-auto-merge" || primaryAction === "auto-merge-armed") &&
13771401 can("merge") &&
@@ -1568,7 +1592,38 @@ export function PullRequestDetailPanel({ · 190 unmodified lines
15681592 </div>
15691593 <div className="mr-4 flex h-7 shrink-0 items-center justify-end gap-1">
15701594 {detail ? (
1571− <>
1595+ <TooltipProvider delay={150} closeDelay={150} timeout={400}>
1596+ {!nativeStack && supportsStackActions && nativeStackQuery.error ? (
1597+ <Button variant="ghost" size="xs" onClick={nativeStackQuery.refresh}>
1598+ Retry stack lookup
1599+ </Button>
1600+ ) : null}
1601+ {nativeStack ? (
1602+ <PullRequestStackMenu
1603+ stack={nativeStack}
1604+ notice={nativeStackQuery.notice}
1605+ onRetry={nativeStackQuery.error ? nativeStackQuery.refresh : undefined}
1606+ reference={reference}
1607+ environmentId={environmentId}
1608+ onSelect={onSelectPullRequest}
1609+ mergeMethod={selectedMergeMethod}
1610+ canMerge={
1611+ nativeStackQuery.isFresh &&
1612+ supportsStackActions &&
1613+ can("merge") &&
1614+ allowedMergeMethods.length > 0
1615+ }
1616+ canRebase={
1617+ nativeStackQuery.isFresh &&
1618+ supportsStackActions &&
1619+ detail.viewerPermissions.stackRebase === true
1620+ }
1621+ onActed={() => {
1622+ refreshDetail();
1623+ onActed?.();
1624+ }}
1625+ />
1626+ ) : null}
15721627 {context === "page" ? (
15731628 <PullRequestThreadLinks
15741629 display="count"
@@ -1585,24 +1640,34 @@ export function PullRequestDetailPanel({ · 10 unmodified lines
15851640 the page: beside a thread the branch is already checked out right there. */}
15861641 {context === "page" ? (
15871642 <Menu>
1588− <MenuTrigger
1589− disabled={handoff !== null}
1590− render={
1591− <Button
1592− size="xs"
1593− variant="outline"
1594− aria-label={
1595− handoff?.startsWith("checkout") ? "Checking out..." : "Check out"
1596− }
1597− >
1598− <GitBranchIcon aria-hidden className="size-3.5" />
1599− <span className="@max-[35rem]/pr-header:hidden">
1600− {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"}
1601− </span>
1602− <ChevronDownIcon aria-hidden className="size-3.5 text-muted-foreground" />
1603− </Button>
1604− }
1605− />
1643+ <Tooltip>
1644+ <TooltipTrigger
1645+ render={
1646+ <MenuTrigger
1647+ disabled={handoff !== null}
1648+ render={
1649+ <Button
1650+ size="xs"
1651+ variant="ghost"
1652+ aria-label={
1653+ handoff?.startsWith("checkout") ? "Checking out..." : "Check out"
1654+ }
1655+ >
1656+ <GitBranchIcon aria-hidden className="size-3.5" />
1657+ <span className="@max-[35rem]/pr-header:hidden">
1658+ {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"}
1659+ </span>
1660+ <ChevronDownIcon
1661+ aria-hidden
1662+ className="size-3.5 text-muted-foreground"
1663+ />
1664+ </Button>
1665+ }
1666+ />
1667+ }
1668+ />
1669+ <TooltipPopup>Check out this pull request</TooltipPopup>
1670+ </Tooltip>
16061671 <MenuPopup align="end" side="bottom" className="min-w-72">
16071672 <MenuItem onClick={() => startCheckout("worktree")}>
16081673 <GitBranchIcon className="mt-0.5 size-3.5 shrink-0 self-start" />
@@ -1663,7 +1728,7 @@ export function PullRequestDetailPanel({ · 54 unmodified lines
16631728 <span className="inline-flex shrink-0">
16641729 <Button
16651730 size="xs"
1666− variant="destructive-outline"
1731+ variant="ghost"
16671732 disabled={handoff !== null}
16681733 onClick={startResolveConflicts}
16691734 aria-label={
@@ -1689,6 +1754,7 @@ export function PullRequestDetailPanel({ · 19 unmodified lines
16891754 <span className="inline-flex shrink-0">
16901755 <Button
16911756 size="xs"
1757+ variant="ghost"
16921758 disabled={actionPending}
16931759 onClick={() => void perform("ready")}
16941760 aria-label="Ready for review"
@@ -1711,6 +1777,7 @@ export function PullRequestDetailPanel({ · 16 unmodified lines
17111777 <span className="inline-flex shrink-0">
17121778 <Button
17131779 size="xs"
1780+ variant="ghost"
17141781 disabled={actionPending}
17151782 onClick={() =>
17161783 setConfirmation({ open: true, action: "enable-auto-merge" })
@@ -1762,6 +1829,7 @@ export function PullRequestDetailPanel({ · 45 unmodified lines
17621829 <span className="inline-flex shrink-0">
17631830 <Button
17641831 size="xs"
1832+ variant="ghost"
17651833 disabled={actionPending}
17661834 onClick={() => setConfirmation({ open: true, action: "merge" })}
17671835 aria-label={
@@ -1791,18 +1859,25 @@ export function PullRequestDetailPanel({ · 23 unmodified lines
17911859 </Badge>
17921860 ) : null}
17931861 <Menu>
1794− <MenuTrigger
1795− render={
1796− <Button
1797− aria-label="More pull request actions"
1798− className="size-6"
1799− size="icon-xs"
1800− variant="ghost-muted"
1801− />
1802− }
1803− >
1804− <MoreHorizontalIcon className="size-4" />
1805− </MenuTrigger>
1862+ <Tooltip>
1863+ <TooltipTrigger
1864+ render={
1865+ <MenuTrigger
1866+ render={
1867+ <Button
1868+ aria-label="More pull request actions"
1869+ className="size-6"
1870+ size="icon-xs"
1871+ variant="ghost-muted"
1872+ />
1873+ }
1874+ >
1875+ <MoreHorizontalIcon className="size-4" />
1876+ </MenuTrigger>
1877+ }
1878+ />
1879+ <TooltipPopup>More pull request actions</TooltipPopup>
1880+ </Tooltip>
18061881 <MenuPopup align="end" side="bottom" className="min-w-72">
18071882 <PullRequestThreadLinks
18081883 display="menu-item"
@@ -1997,7 +2072,7 @@ export function PullRequestDetailPanel({ · 188 unmodified lines
19972072 ) : null}
19982073 </MenuPopup>
19992074 </Menu>
2000− </>
2075+ </TooltipProvider>
20012076 ) : null}
20022077 {onClose ? (
20032078 <Button
@@ -2102,13 +2177,6 @@ export function PullRequestDetailPanel({ · 98 unmodified lines
21022177 />
21032178 </span>
21042179 </div>
2105− {nativeStack ? (
2106− <PullRequestStackMap
2107− stack={nativeStack}
2108− currentNumber={detail.number}
2109− className="mt-1"
2110− />
2111− ) : null}
21122180 </div>
21132181 ) : null}
21142182 </div>
apps/web/src/components/pullRequest/PullRequestRow.tsx+22−1
@@ -1,4 +1,5 @@
11 import { SearchIcon } from "lucide-react";
2+import { PullRequestStackPopover } from "./PullRequestStackPopover";
23 import { memo, type RefCallback } from "react";
34
45 import { cn } from "~/lib/utils";
@@ -60,6 +61,11 @@ function PullRequestRowLabels({ labels }: { labels: EnvironmentPullRequestEntry[ · 55 unmodified lines
6061 );
6162 }
6263
64+export type PullRequestRowTarget = Pick<
65+ EnvironmentPullRequestEntry,
66+ "environmentId" | "projectId" | "host" | "repository" | "number"
67+>;
68+
6369 function PullRequestRowImpl({
6470 entry,
6571 selected,
@@ -86,7 +92,7 @@ function PullRequestRowImpl({ · 20 unmodified lines
8692 /** Used by the list's shared visibility observer to defer optional line-count reads. */
8793 statsKey?: string;
8894 statsRef?: RefCallback<HTMLButtonElement>;
89− onSelect: (entry: EnvironmentPullRequestEntry) => void;
95+ onSelect: (entry: PullRequestRowTarget) => void;
9096 }) {
9197 const { Icon, providerName } = getSourceControlPresentationForKind(entry.provider);
9298 return (
@@ -116,6 +122,21 @@ function PullRequestRowImpl({ · 23 unmodified lines
116122 {entry.title}
117123 </span>
118124 <span className="col-start-2 row-start-1 flex max-w-36 items-center justify-self-end gap-2 text-xs">
125+ {entry.stack ? (
126+ <PullRequestStackPopover
127+ environmentId={entry.environmentId}
128+ reference={{
129+ projectId: entry.projectId,
130+ host: entry.host,
131+ repository: entry.repository,
132+ number: entry.number,
133+ }}
134+ membership={entry.stack}
135+ onSelect={(target) =>
136+ onSelect({ ...target, host: entry.host, environmentId: entry.environmentId })
137+ }
138+ />
139+ ) : null}
119140 {/* Only a verdict somebody has actually given: "review required" is the absence of
120141 one, and saying so on every unreviewed row would say nothing. */}
121142 {entry.reviewDecision === "approved" || entry.reviewDecision === "changes-requested" ? (
apps/web/src/components/pullRequest/PullRequestStackHeader.tsx+26−0
@@ -0,0 +1,26 @@
1+import { MenuGroupLabel } from "../ui/menu";
2+import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
3+
4+export function PullRequestStackHeader({
5+ number,
6+ notice,
7+ stale = false,
8+}: {
9+ number: number;
10+ notice?: string | null | undefined;
11+ stale?: boolean;
12+}) {
13+ return (
14+ <MenuGroupLabel className="flex items-center justify-between gap-2">
15+ <span>Stack #{number}</span>
16+ {notice ? (
17+ <Tooltip>
18+ <TooltipTrigger render={<span role="status" className="text-xs font-normal" />}>
19+ {stale ? "May be stale" : "Refreshing…"}
20+ </TooltipTrigger>
21+ <TooltipPopup>{notice}</TooltipPopup>
22+ </Tooltip>
23+ ) : null}
24+ </MenuGroupLabel>
25+ );
26+}
apps/web/src/components/pullRequest/PullRequestStackLayerContent.tsx+28−0
@@ -0,0 +1,28 @@
1+import type { PullRequestStack } from "@t3tools/contracts";
2+import { cn } from "~/lib/utils";
3+import { resolvePullRequestState } from "./pullRequestPresentation";
4+
5+export function PullRequestStackLayerContent({
6+ layer,
7+ compact = false,
8+}: {
9+ layer: PullRequestStack["layers"][number];
10+ compact?: boolean;
11+}) {
12+ const state = resolvePullRequestState({
13+ state: layer.state,
14+ isDraft: layer.isDraft ?? false,
15+ });
16+ return (
17+ <>
18+ <state.Icon aria-hidden className={cn("size-4 shrink-0", state.toneClassName)} />
19+ <span className="min-w-0 flex-1">
20+ <span className="block truncate">{layer.title || layer.headBranch}</span>
21+ <span className="block truncate text-xs font-normal text-muted-foreground">
22+ #{layer.number} · {compact ? null : `${layer.headBranch} · `}
23+ {state.label}
24+ </span>
25+ </span>
26+ </>
27+ );
28+}
apps/web/src/components/pullRequest/PullRequestStackLayers.tsx+39−0
@@ -0,0 +1,39 @@
1+import type { PullRequestRef, PullRequestStack } from "@t3tools/contracts";
2+import { CheckIcon } from "lucide-react";
3+import { MenuItem, MenuGroupLabel } from "../ui/menu";
4+import { PullRequestStackLayerContent } from "./PullRequestStackLayerContent";
5+
6+export function PullRequestStackLayers({
7+ stack,
8+ reference,
9+ onSelect,
10+ pending = false,
11+}: {
12+ stack: PullRequestStack;
13+ reference: PullRequestRef;
14+ onSelect?: ((reference: PullRequestRef) => void) | undefined;
15+ pending?: boolean;
16+}) {
17+ return (
18+ <div className="max-h-80 overflow-y-auto">
19+ {stack.layers.toReversed().map((layer) => {
20+ return (
21+ <MenuItem
22+ key={layer.number}
23+ onClick={() => {
24+ onSelect?.({ ...reference, number: layer.number });
25+ }}
26+ disabled={!onSelect || pending}
27+ aria-current={layer.number === reference.number ? "true" : undefined}
28+ >
29+ <PullRequestStackLayerContent layer={layer} />
30+ {layer.number === reference.number ? (
31+ <CheckIcon aria-hidden className="size-3.5" />
32+ ) : null}
33+ </MenuItem>
34+ );
35+ })}
36+ <MenuGroupLabel>↳ {stack.base}</MenuGroupLabel>
37+ </div>
38+ );
39+}
apps/web/src/components/pullRequest/PullRequestStackMap.tsx+0−87
@@ -1,87 +0,0 @@
1−import type { PullRequestStack } from "@t3tools/contracts";
2−import { GitPullRequestArrowIcon } from "lucide-react";
3−
4−import { InlineButton } from "../ui/button";
5−import { cn } from "~/lib/utils";
6−import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
7−import { resolvePullRequestState } from "./pullRequestPresentation";
8−
9−/**
10− * The host's stack as one line of layers, bottom to top, with this pull request marked. Mirrors
11− * the map GitHub draws above a stacked pull request so a reader who came from there finds the
12− * same shape here.
13− */
14−export function PullRequestStackMap({
15− stack,
16− currentNumber,
17− onSelect,
18− className,
19−}: {
20− stack: PullRequestStack;
21− currentNumber: number;
22− /** Opens another layer in the same panel; absent where the panel cannot swap references. */
23− onSelect?: ((number: number) => void) | undefined;
24− className?: string;
25−}) {
26− return (
27− <div
28− className={cn(
29− "flex min-w-0 items-center gap-1 overflow-x-auto text-[11px] text-muted-foreground",
30− className,
31− )}
32− aria-label={`Stack ${stack.number} on ${stack.base}, ${stack.layers.length} layers`}
33− >
34− <Tooltip>
35− <TooltipTrigger
36− render={<span className="inline-flex shrink-0 items-center gap-1 font-mono" />}
37− >
38− <GitPullRequestArrowIcon aria-hidden className="size-3" />
39− <code className="truncate">{stack.base}</code>
40− </TooltipTrigger>
41− <TooltipPopup side="top">
42− Stack #{stack.number} on {stack.base}. Merging a layer lands every layer below it.
43− </TooltipPopup>
44− </Tooltip>
45− {stack.layers.map((layer) => {
46− const presentation = resolvePullRequestState({ state: layer.state, isDraft: false });
47− const isCurrent = layer.number === currentNumber;
48− const chip = (
49− <span
50− className={cn(
51− "inline-flex shrink-0 items-center gap-1 rounded-full border px-1.5 py-0 font-mono tabular-nums",
52− isCurrent
53− ? "border-foreground/40 bg-accent text-foreground"
54− : "border-border/70 hover:bg-accent/60",
55− )}
56− >
57− <presentation.Icon aria-hidden className={cn("size-3", presentation.toneClassName)} />#
58− {layer.number}
59− </span>
60− );
61− return (
62− <span key={layer.number} className="inline-flex shrink-0 items-center gap-1">
63− <span aria-hidden className="text-muted-foreground/50">
64− →
65− </span>
66− <Tooltip>
67− <TooltipTrigger
68− render={
69− onSelect && !isCurrent ? (
70− <InlineButton onClick={() => onSelect(layer.number)} />
71− ) : (
72− <span aria-current={isCurrent ? "true" : undefined} />
73− )
74− }
75− >
76− {chip}
77− </TooltipTrigger>
78− <TooltipPopup side="top">
79− #{layer.number} · {layer.headBranch} · {presentation.label}
80− </TooltipPopup>
81− </Tooltip>
82− </span>
83− );
84− })}
85− </div>
86− );
87−}
apps/web/src/components/pullRequest/PullRequestStackMenu.tsx+257−0
@@ -0,0 +1,257 @@
1+import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
2+import type {
3+ EnvironmentId,
4+ PullRequestRef,
5+ PullRequestStack,
6+ PullRequestMergeMethod,
7+} from "@t3tools/contracts";
8+import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
9+import { GitMergeIcon, LayersIcon, RefreshCwIcon, TriangleAlertIcon } from "lucide-react";
10+import { useState } from "react";
11+import { useAtomCommand } from "~/state/use-atom-command";
12+import { pullRequestEnvironment } from "~/state/pullRequests";
13+import { Button } from "../ui/button";
14+import { Menu, MenuPopup, MenuTrigger, MenuItem, MenuGroup, MenuSeparator } from "../ui/menu";
15+import {
16+ Dialog,
17+ DialogPopup,
18+ DialogTitle,
19+ DialogDescription,
20+ DialogHeader,
21+ DialogPanel,
22+ DialogFooter,
23+} from "../ui/dialog";
24+import { toastManager } from "../ui/toast";
25+import { PullRequestStackLayers } from "./PullRequestStackLayers";
26+import { PullRequestStackHeader } from "./PullRequestStackHeader";
27+import { PullRequestStackLayerContent } from "./PullRequestStackLayerContent";
28+
29+export function PullRequestStackMenu({
30+ stack,
31+ reference,
32+ environmentId,
33+ canMerge,
34+ canRebase,
35+ mergeMethod,
36+ onSelect,
37+ onActed,
38+ notice,
39+ onRetry,
40+}: {
41+ notice?: string | null;
42+ onRetry?: (() => void) | undefined;
43+ stack: PullRequestStack;
44+ reference: PullRequestRef;
45+ environmentId: EnvironmentId;
46+ canMerge: boolean;
47+ canRebase: boolean;
48+ mergeMethod: PullRequestMergeMethod;
49+ onSelect?: ((reference: PullRequestRef) => void) | undefined;
50+ onActed: () => void;
51+}) {
52+ const [open, setOpen] = useState(false);
53+ const [confirmation, setConfirmation] = useState<"merge" | "update-branch" | null>(null);
54+ const [pending, setPending] = useState(false);
55+ const runAction = useAtomCommand(pullRequestEnvironment.runAction, { reportFailure: false });
56+ const top = stack.layers.at(-1);
57+ const unmerged = stack.layers.filter((layer) => layer.state !== "merged");
58+ const hasClosed = unmerged.some((layer) => layer.state !== "open");
59+ const position = stack.layers.findIndex((layer) => layer.number === reference.number) + 1;
60+ const mergeLayers = stack.layers.slice(0, position).filter((layer) => layer.state !== "merged");
61+ const selectedLayer = stack.layers[position - 1];
62+ const mergeHasClosed = mergeLayers.some((layer) => layer.state !== "open");
63+ const expectedStackHeads = unmerged.flatMap((layer) =>
64+ layer.headSha ? [{ number: layer.number, headSha: layer.headSha }] : [],
65+ );
66+ const hasUnknownHead = expectedStackHeads.length !== unmerged.length;
67+ const mergeDisabled =
68+ pending ||
69+ selectedLayer?.state !== "open" ||
70+ mergeLayers.some((layer) => !layer.headSha) ||
71+ mergeHasClosed ||
72+ mergeLayers.length === 0 ||
73+ mergeLayers.some((layer) => layer.isDraft);
74+ const rebaseDisabled = pending || hasUnknownHead || hasClosed || unmerged.length === 0;
75+ const run = async () => {
76+ if (
77+ pending ||
78+ !confirmation ||
79+ (confirmation === "merge" ? !canMerge || mergeDisabled : !canRebase || rebaseDisabled)
80+ )
81+ return;
82+ const action = confirmation;
83+ const target = action === "merge" ? selectedLayer : top;
84+ if (!target?.headSha) return;
85+ const actionHeads = (action === "merge" ? mergeLayers : unmerged).flatMap((layer) =>
86+ layer.headSha ? [{ number: layer.number, headSha: layer.headSha }] : [],
87+ );
88+ setPending(true);
89+ const result = await runAction({
90+ environmentId,
91+ input: {
92+ ...reference,
93+ number: target.number,
94+ stackNumber: stack.number,
95+ expectedStackHeads: actionHeads,
96+ action,
97+ ...(action === "merge" ? { mergeMethod } : { updateMethod: "rebase" }),
98+ },
99+ });
100+ setPending(false);
101+ setConfirmation(null);
102+ onActed();
103+ if (result._tag === "Failure") {
104+ toastManager.add({
105+ type: "error",
106+ title: "Stack operation did not complete",
107+ description: String(squashAtomCommandFailure(result)),
108+ });
109+ } else {
110+ toastManager.add({
111+ type: "success",
112+ title: action === "merge" ? "Stack merge request completed" : "Stack rebased",
113+ description:
114+ action === "merge"
115+ ? "GitHub merged the stack or added it to its merge queue."
116+ : undefined,
117+ });
118+ }
119+ };
120+ const confirmationLayers = confirmation === "merge" ? mergeLayers : unmerged;
121+ return (
122+ <>
123+ <Menu open={open} onOpenChange={setOpen}>
124+ <Tooltip>
125+ <TooltipTrigger
126+ render={
127+ <MenuTrigger
128+ render={
129+ <Button
130+ variant="ghost"
131+ size="xs"
132+ aria-label={`Stack ${stack.number}, layer ${position} of ${stack.layers.length}`}
133+ />
134+ }
135+ >
136+ <LayersIcon aria-hidden className="size-3.5" /> {position}/{stack.layers.length}
137+ {onRetry ? <TriangleAlertIcon aria-hidden className="size-3 text-warning" /> : null}
138+ </MenuTrigger>
139+ }
140+ />
141+ <TooltipPopup>
142+ View stack #{stack.number}, layer {position} of {stack.layers.length}
143+ {notice ? ` · ${notice}` : null}
144+ </TooltipPopup>
145+ </Tooltip>
146+ <MenuPopup align="start" className="w-96 max-w-[calc(100vw-2rem)]">
147+ <MenuGroup>
148+ <PullRequestStackHeader number={stack.number} notice={notice} stale={!!onRetry} />
149+ {onRetry ? <MenuItem onClick={onRetry}>Retry stack refresh</MenuItem> : null}
150+ <PullRequestStackLayers
151+ stack={stack}
152+ reference={reference}
153+ pending={pending}
154+ onSelect={
155+ onSelect
156+ ? (target) => {
157+ setOpen(false);
158+ onSelect(target);
159+ }
160+ : undefined
161+ }
162+ />
163+ </MenuGroup>
164+ {canMerge || canRebase ? (
165+ <>
166+ <MenuSeparator />
167+ {canMerge ? (
168+ <MenuItem disabled={mergeDisabled} onClick={() => setConfirmation("merge")}>
169+ <GitMergeIcon aria-hidden />
170+ Merge stack ({mergeLayers.length})
171+ </MenuItem>
172+ ) : null}
173+ {canRebase ? (
174+ <MenuItem
175+ disabled={rebaseDisabled}
176+ onClick={() => setConfirmation("update-branch")}
177+ >
178+ <RefreshCwIcon aria-hidden />
179+ Rebase stack
180+ </MenuItem>
181+ ) : null}
182+ {mergeHasClosed || mergeLayers.some((layer) => layer.isDraft) ? (
183+ <p className="px-2 py-1 text-xs text-muted-foreground">
184+ Every layer being merged must be open and ready for review.
185+ </p>
186+ ) : null}
187+ </>
188+ ) : null}
189+ </MenuPopup>
190+ </Menu>
191+ {canMerge && selectedLayer?.state === "open" ? (
192+ <Tooltip>
193+ <TooltipTrigger
194+ render={
195+ <span className="inline-flex">
196+ <Button
197+ variant="default"
198+ size="xs"
199+ disabled={mergeDisabled}
200+ onClick={() => setConfirmation("merge")}
201+ >
202+ <GitMergeIcon aria-hidden className="size-3.5" />
203+ Merge stack
204+ </Button>
205+ </span>
206+ }
207+ />
208+ <TooltipPopup>
209+ Merge stack through #{reference.number} into {stack.base} ({mergeLayers.length}{" "}
210+ {mergeLayers.length === 1 ? "pull request" : "pull requests"})
211+ </TooltipPopup>
212+ </Tooltip>
213+ ) : null}
214+ <Dialog
215+ open={confirmation !== null}
216+ onOpenChange={(value) => {
217+ if (!value && !pending) setConfirmation(null);
218+ }}
219+ >
220+ <DialogPopup className="max-w-md" showCloseButton={!pending}>
221+ <DialogHeader>
222+ <DialogTitle>
223+ {confirmation === "merge"
224+ ? `Merge ${mergeLayers.length} pull requests?`
225+ : `Rebase ${unmerged.length} pull requests?`}
226+ </DialogTitle>
227+ <DialogDescription>
228+ {confirmation === "merge"
229+ ? `Merge #${reference.number} and its unmerged layers below into ${stack.base} using ${mergeMethod}. GitHub checks their rules before merging or queueing them and rebases the remaining stack after merging.`
230+ : `Rebase the remote branches from bottom to top onto ${stack.base}. This rewrites branch history and may restart checks. If a layer fails, earlier updates remain.`}
231+ </DialogDescription>
232+ </DialogHeader>
233+ <DialogPanel>
234+ <ul className="max-h-48 space-y-1 overflow-y-auto text-sm">
235+ {confirmationLayers.map((layer) => (
236+ <li
237+ key={layer.number}
238+ className="flex items-center gap-2 rounded-md bg-muted/50 px-3 py-2"
239+ >
240+ <PullRequestStackLayerContent layer={layer} compact />
241+ </li>
242+ ))}
243+ </ul>
244+ </DialogPanel>
245+ <DialogFooter>
246+ <Button variant="outline" disabled={pending} onClick={() => setConfirmation(null)}>
247+ Cancel
248+ </Button>
249+ <Button disabled={pending} onClick={() => void run()}>
250+ {pending ? "Working…" : confirmation === "merge" ? "Merge stack" : "Rebase stack"}
251+ </Button>
252+ </DialogFooter>
253+ </DialogPopup>
254+ </Dialog>
255+ </>
256+ );
257+}
apps/web/src/components/pullRequest/PullRequestStackPopover.tsx+108−0
@@ -0,0 +1,108 @@
1+import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
2+import type { EnvironmentId, PullRequestRef, PullRequestStackMembership } from "@t3tools/contracts";
3+import { LayersIcon } from "lucide-react";
4+import { useState } from "react";
5+import { usePullRequestStack } from "~/state/usePullRequestStack";
6+import { Menu, MenuTrigger, MenuPopup, MenuGroup, MenuGroupLabel, MenuItem } from "../ui/menu";
7+import { PullRequestStackLayers } from "./PullRequestStackLayers";
8+import { PullRequestStackHeader } from "./PullRequestStackHeader";
9+
10+/** Mounted only while the menu is open, so list rows do not each fetch a stack. */
11+function StackBody({
12+ environmentId,
13+ reference,
14+ onSelect,
15+ stackNumber,
16+}: {
17+ environmentId: EnvironmentId;
18+ reference: PullRequestRef;
19+ onSelect: (reference: PullRequestRef) => void;
20+ stackNumber: number;
21+}) {
22+ const query = usePullRequestStack(environmentId, reference);
23+ if (query.data !== null) {
24+ return (
25+ <>
26+ <PullRequestStackHeader
27+ number={query.data.number}
28+ notice={query.notice}
29+ stale={!!query.error}
30+ />
31+ {query.error ? <MenuItem onClick={query.refresh}>Retry stack refresh</MenuItem> : null}
32+ <PullRequestStackLayers stack={query.data} reference={reference} onSelect={onSelect} />
33+ </>
34+ );
35+ }
36+ return (
37+ <>
38+ <PullRequestStackHeader number={stackNumber} />
39+ <MenuGroupLabel>
40+ {query.error ??
41+ (query.isPending ? "Loading stack…" : "This pull request is no longer in a stack.")}
42+ </MenuGroupLabel>
43+ </>
44+ );
45+}
46+
47+export function PullRequestStackPopover({
48+ environmentId,
49+ reference,
50+ membership,
51+ onSelect,
52+}: {
53+ environmentId: EnvironmentId;
54+ reference: PullRequestRef;
55+ membership: PullRequestStackMembership;
56+ onSelect: (reference: PullRequestRef) => void;
57+}) {
58+ const [open, setOpen] = useState(false);
59+ return (
60+ <Menu open={open} onOpenChange={setOpen}>
61+ <Tooltip>
62+ <TooltipTrigger
63+ render={
64+ <MenuTrigger
65+ nativeButton={false}
66+ render={
67+ <span
68+ role="button"
69+ tabIndex={0}
70+ className="inline-flex shrink-0 cursor-pointer items-center gap-1 text-xs font-normal text-muted-foreground"
71+ />
72+ }
73+ aria-label={`Stack ${membership.number}, layer ${membership.position} of ${membership.size}`}
74+ onClick={(event) => event.stopPropagation()}
75+ onKeyDown={(event) => event.stopPropagation()}
76+ >
77+ <LayersIcon aria-hidden className="size-3" />
78+ {membership.position}/{membership.size}
79+ </MenuTrigger>
80+ }
81+ />
82+ <TooltipPopup>
83+ View stack #{membership.number}, layer {membership.position} of {membership.size}
84+ </TooltipPopup>
85+ </Tooltip>
86+ <MenuPopup
87+ align="start"
88+ className="w-96 max-w-[calc(100vw-2rem)]"
89+ onClick={(event) => event.stopPropagation()}
90+ onKeyDown={(event) => event.stopPropagation()}
91+ >
92+ <MenuGroup>
93+ {open ? (
94+ <StackBody
95+ environmentId={environmentId}
96+ reference={reference}
97+ stackNumber={membership.number}
98+ onSelect={(target) => {
99+ setOpen(false);
100+ onSelect(target);
101+ }}
102+ />
103+ ) : null}
104+ </MenuGroup>
105+ </MenuPopup>
106+ </Menu>
107+ );
108+}
apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx+20−38
@@ -1,5 +1,5 @@
1+import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip";
12 import { scopeThreadRef } from "@t3tools/client-runtime/environment";
2−import { Link } from "@tanstack/react-router";
33 import type { EnvironmentId, PullRequestRef, ScopedThreadRef, ThreadId } from "@t3tools/contracts";
44 import { CheckIcon, LinkIcon, MessageSquareIcon, UnlinkIcon } from "lucide-react";
55 import { useState } from "react";
@@ -11,11 +11,11 @@ import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from " · 5 unmodified lines
1111 import { pullRequestEnvironment } from "~/state/pullRequests";
1212 import { useEnvironmentQuery } from "~/state/query";
1313 import { appAtomRegistry } from "~/rpc/atomRegistry";
14−import { buildThreadRouteParams } from "~/threadRoutes";
14+import { openCommandPalette } from "~/commandPaletteBus";
1515 import { Button } from "../ui/button";
1616 import { Command, CommandInput, CommandItem, CommandList } from "../ui/command";
1717 import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog";
18−import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
18+import { MenuItem } from "../ui/menu";
1919 import { toastManager } from "../ui/toast";
2020
2121 interface PullRequestThreadLinksProps {
@@ -61,7 +61,7 @@ function EnabledPullRequestThreadLinks({ · 39 unmodified lines
6161 : null,
6262 );
6363 // Refreshes can briefly clear the query value. Keep the last response so polling
64− // does not unmount an open menu or move its highlighted thread.
64+ // does not hide the linked-thread count between responses.
6565 const [lastRelations, setLastRelations] = useState(relations.data);
6666 if (relations.data !== null && relations.data !== lastRelations) {
6767 setLastRelations(relations.data);
@@ -101,47 +101,29 @@ function EnabledPullRequestThreadLinks({ · 33 unmodified lines
101101 return (
102102 <>
103103 {display === "count" && (linkedThreads.length > 0 || relations.error !== null) ? (
104− <Menu>
105− <MenuTrigger
104+ <Tooltip>
105+ <TooltipTrigger
106106 render={
107107 <Button
108108 size="xs"
109109 variant="ghost"
110110 aria-label={linkedThreadsLabel}
111− title={linkedThreadsLabel}
112− />
113− }
114− >
115− <MessageSquareIcon aria-hidden className="size-3.5" />
116− <span className="@max-[40rem]/pr-header:hidden">{linkedThreadsLabel}</span>
117− <span aria-hidden className="hidden @max-[40rem]/pr-header:inline">
118− {linkedThreads.length || "?"}
119− </span>
120− </MenuTrigger>
121− <MenuPopup align="end" className="max-h-72 max-w-80 overflow-y-auto">
122− {relations.error !== null ? (
123− <MenuItem onClick={relations.refresh}>Could not load linked threads. Retry</MenuItem>
124− ) : null}
125− {linkedThreads.map((linkedThread) => (
126− <MenuItem
127− key={linkedThread.id}
128− render={
129− <Link
130− to="/$environmentId/$threadId"
131− params={buildThreadRouteParams(scopeThreadRef(environmentId, linkedThread.id))}
132− />
111+ onClick={() =>
112+ openCommandPalette({
113+ query: url,
114+ ...((relations.data ?? lastRelations) === null
115+ ? {}
116+ : { linkedThreads: { environmentId, threads: linkedThreads } }),
117+ })
133118 }
134119 >
135− <span className="min-w-0 flex-1 truncate">
136− {linkedThread.title || "Untitled thread"}
137− </span>
138− {linkedThread.archivedAt !== null ? (
139− <span className="text-xs text-muted-foreground">Archived</span>
140− ) : null}
141− </MenuItem>
142− ))}
143− </MenuPopup>
144− </Menu>
120+ <MessageSquareIcon aria-hidden className="size-3.5" />
121+ <span aria-hidden>{linkedThreads.length || "?"}</span>
122+ </Button>
123+ }
124+ />
125+ <TooltipPopup>{linkedThreadsLabel}. Search in the command palette.</TooltipPopup>
126+ </Tooltip>
145127 ) : null}
146128 {display === "menu-item" ? (
147129 <MenuItem
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+19−0
@@ -35,6 +35,7 @@ import { · 34 unmodified lines
3535 readPullRequestDetailSnapshot,
3636 resolveDisplayedPullRequestDetail,
3737 resolvePullRequestPrimaryControl,
38+ allowsSinglePullRequestMerge,
3839 shouldRefreshPullRequestActivity,
3940 resolveBaseFreshness,
4041 resolvePullRequestMergeMethod,
@@ -1463,3 +1464,21 @@ describe("cached pull request detail", () => { · 1422 unmodified lines
14631464 expect(readPullRequestDetailSnapshot(undefined, "env-1", reference)).toBeNull();
14641465 });
14651466 });
1467+
1468+describe("single-PR merge compatibility during stack discovery", () => {
1469+ it.each([
1470+ [false, true, false, null, true],
1471+ [false, false, true, null, true],
1472+ [true, false, true, null, false],
1473+ [true, false, false, "Lookup failed", false],
1474+ [true, true, false, null, false],
1475+ [true, false, false, null, true],
1476+ ] as const)(
1477+ "capability=%s stack=%s pending=%s error=%s permits=%s",
1478+ (supportsStackActions, hasStack, stackPending, stackError, allowed) => {
1479+ expect(
1480+ allowsSinglePullRequestMerge({ supportsStackActions, hasStack, stackPending, stackError }),
1481+ ).toBe(allowed);
1482+ },
1483+ );
1484+});
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+13−0
@@ -29,6 +29,19 @@ export const PULL_REQUEST_MERGE_METHOD_LABELS: Record<PullRequestMergeMethod, st · 28 unmodified lines
2929 rebase: "Rebase and merge",
3030 };
3131
32+/** Old environments keep their existing actions; new ones must finish stack discovery first. */
33+export function allowsSinglePullRequestMerge(input: {
34+ supportsStackActions: boolean;
35+ hasStack: boolean;
36+ stackPending: boolean;
37+ stackError: string | null;
38+}): boolean {
39+ return (
40+ !input.supportsStackActions ||
41+ (!input.hasStack && !input.stackPending && input.stackError === null)
42+ );
43+}
44+
3245 export function resolvePullRequestMergeMethod(
3346 allowed: ReadonlyArray<PullRequestMergeMethod>,
3447 current: PullRequestMergeMethod | null,
apps/web/src/components/pullRequest/pullRequestStackSnapshot.test.ts+93−0
@@ -0,0 +1,93 @@
1+import type { ThreadPullRequestLink } from "@t3tools/contracts";
2+import { ProjectId } from "@t3tools/contracts";
3+import { describe, expect, it } from "vite-plus/test";
4+import { savedPullRequestStack, pullRequestStackView } from "./pullRequestStackSnapshot";
5+
6+const reference = {
7+ projectId: ProjectId.make("project"),
8+ host: "github.com",
9+ repository: "acme/web",
10+ number: 2,
11+};
12+const link: ThreadPullRequestLink = {
13+ host: "github.com",
14+ repository: "acme/web",
15+ number: 2,
16+ url: "https://github.com/acme/web/pull/2",
17+ source: "manual",
18+ linkedAt: "2026-09-09T10:00:00Z",
19+ snapshot: {
20+ title: "Top layer",
21+ headBranch: "top",
22+ baseBranch: "bottom",
23+ state: "open",
24+ isDraft: false,
25+ updatedAt: null,
26+ syncedAt: "2026-09-09T10:00:00Z",
27+ },
28+ stack: {
29+ kind: "native",
30+ id: "stack",
31+ number: 3,
32+ url: "https://github.com/acme/web/pull/3",
33+ base: "main",
34+ layers: [
35+ { number: 1, headBranch: "bottom", state: "open" },
36+ { number: 2, headBranch: "top", state: "open" },
37+ ],
38+ },
39+};
40+
41+describe("saved stack navigation", () => {
42+ it("preserves all native layers even when only one has a linked snapshot, without action SHAs", () => {
43+ const saved = savedPullRequestStack([link], reference);
44+ expect(saved?.layers).toEqual([
45+ { number: 1, headBranch: "bottom", state: "open" },
46+ { number: 2, headBranch: "top", state: "open", title: "Top layer", isDraft: false },
47+ ]);
48+ expect(savedPullRequestStack([link], { ...reference, number: 1 })?.number).toBe(3);
49+ });
50+ it("does not borrow stacks across hosts or repositories", () => {
51+ expect(savedPullRequestStack([link], { ...reference, host: "enterprise.example" })).toBeNull();
52+ expect(savedPullRequestStack([link], { ...reference, repository: "other/web" })).toBeNull();
53+ expect(savedPullRequestStack([link], { ...reference, host: undefined })).toBeNull();
54+ });
55+ it("honors a newer saved removal across linked threads", () => {
56+ expect(
57+ savedPullRequestStack(
58+ [
59+ link,
60+ {
61+ ...link,
62+ stack: null,
63+ snapshot: { ...link.snapshot!, syncedAt: "2026-09-09T11:00:00Z" },
64+ },
65+ ],
66+ reference,
67+ ),
68+ ).toBeNull();
69+ });
70+ it("shows saved data during loading and marks a failed refresh stale", () => {
71+ const saved = savedPullRequestStack([link], reference);
72+ const query = { data: null, isSuccess: false, isPending: true, error: null };
73+ expect(pullRequestStackView(query, saved)).toMatchObject({
74+ data: saved,
75+ isFresh: false,
76+ notice: expect.stringContaining("Refreshing"),
77+ });
78+ expect(
79+ pullRequestStackView({ ...query, isPending: false, error: "Rate limited" }, saved),
80+ ).toMatchObject({ data: saved, isFresh: false, notice: expect.stringContaining("stale") });
81+ });
82+ it("prefers refreshed data and honors a successful absence", () => {
83+ const saved = savedPullRequestStack([link], reference);
84+ const query = { data: saved, isSuccess: true, isPending: false, error: null };
85+ expect(pullRequestStackView(query, null)).toEqual({ data: saved, isFresh: true, notice: null });
86+ expect(pullRequestStackView({ ...query, data: null }, saved)).toEqual({
87+ data: null,
88+ isFresh: true,
89+ notice: null,
90+ });
91+ expect(pullRequestStackView({ ...query, isPending: true }, saved).isFresh).toBe(false);
92+ });
93+});
apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts+73−0
@@ -0,0 +1,73 @@
1+import type { PullRequestRef, PullRequestStack, ThreadPullRequestLink } from "@t3tools/contracts";
2+
3+/** Saved native membership is enough for navigation, but never supplies action head SHAs. */
4+export function savedPullRequestStack(
5+ links: ReadonlyArray<ThreadPullRequestLink>,
6+ reference: PullRequestRef,
7+): PullRequestStack | null {
8+ const host = reference.host?.toLowerCase();
9+ if (!host) return null;
10+ const matching = links.filter(
11+ (link) =>
12+ link.host.toLowerCase() === host &&
13+ link.repository.toLowerCase() === reference.repository.toLowerCase(),
14+ );
15+ const exact = matching.filter((link) => link.number === reference.number);
16+ const candidates =
17+ exact.length > 0
18+ ? exact
19+ : matching.filter((link) =>
20+ link.stack?.layers.some((layer) => layer.number === reference.number),
21+ );
22+ const newest = candidates.toSorted(
23+ (a, b) =>
24+ Date.parse(b.snapshot?.syncedAt ?? b.linkedAt) -
25+ Date.parse(a.snapshot?.syncedAt ?? a.linkedAt),
26+ )[0];
27+ const stack = newest?.stack;
28+ if (!stack || !stack.layers.some((layer) => layer.number === reference.number)) return null;
29+ return {
30+ id: stack.id,
31+ number: stack.number,
32+ url: stack.url,
33+ base: stack.base,
34+ layers: stack.layers.map((layer) => {
35+ const snapshot = matching
36+ .filter((link) => link.number === layer.number)
37+ .toSorted(
38+ (a, b) =>
39+ Date.parse(b.snapshot?.syncedAt ?? b.linkedAt) -
40+ Date.parse(a.snapshot?.syncedAt ?? a.linkedAt),
41+ )[0]?.snapshot;
42+ return {
43+ ...layer,
44+ ...(snapshot ? { title: snapshot.title, isDraft: snapshot.isDraft } : {}),
45+ };
46+ }),
47+ };
48+}
49+
50+/** A fresh absence overrides saved membership; failed refreshes preserve available navigation. */
51+export function pullRequestStackView(
52+ query: {
53+ data: PullRequestStack | null;
54+ isSuccess: boolean;
55+ isPending: boolean;
56+ error: string | null;
57+ },
58+ saved: PullRequestStack | null,
59+) {
60+ const data = query.isSuccess ? query.data : (query.data ?? saved);
61+ return {
62+ data,
63+ isFresh: query.isSuccess && !query.isPending,
64+ notice:
65+ data === null
66+ ? null
67+ : query.error
68+ ? "Stack data may be stale. We couldn’t refresh it."
69+ : !query.isSuccess || query.isPending
70+ ? "Refreshing stack… Showing saved data."
71+ : null,
72+ };
73+}
apps/web/src/lib/openPullRequestLink.ts+2−0
@@ -203,6 +203,7 @@ export function useOpenChangeRequestLink( · 202 unmodified lines
203203 state: previous.state ?? "all",
204204 repository,
205205 number: parsed.number,
206+ selectedHost: parsed.host,
206207 selectedProjectId: project.id,
207208 selectedEnvironmentId: project.environmentId,
208209 }),
@@ -220,6 +221,7 @@ export function useOpenChangeRequestLink( · 11 unmodified lines
220221 state: "all",
221222 repository,
222223 number: parsed.number,
224+ selectedHost: parsed.host,
223225 selectedProjectId: project.id,
224226 // Named so the page opens the right one of two servers holding this project.
225227 selectedEnvironmentId: project.environmentId,
apps/web/src/routes/_chat.pull-requests.tsx+99−32
@@ -99,7 +99,10 @@ import { · 98 unmodified lines
9999 } from "../components/pullRequest/PullRequestListFilters";
100100 import { PullRequestListEmptyState } from "../components/pullRequest/PullRequestListEmptyState";
101101 import { PullRequestListGhost } from "../components/pullRequest/PullRequestGhosts";
102−import { PullRequestRow } from "../components/pullRequest/PullRequestRow";
102+import {
103+ PullRequestRow,
104+ type PullRequestRowTarget,
105+} from "../components/pullRequest/PullRequestRow";
103106 import { PullRequestsUnavailableState } from "../components/pullRequest/PullRequestsUnavailableState";
104107 import { RightPanelTabs, type PullRequestTabStatusSeed } from "../components/RightPanelTabs";
105108 import {
@@ -163,6 +166,8 @@ export interface PullRequestsSearch extends PullRequestListPreferences { · 57 unmodified lines
163166 readonly repository?: string;
164167 readonly number?: number;
165168 readonly selectedProjectId?: ProjectId;
169+ /** Host of the open review; changing tabs must not narrow the list host filter. */
170+ readonly selectedHost?: string;
166171 /**
167172 * Which server the selected pull request was read from. A project id only names a project on
168173 * its own server, so this is what tells two servers holding one project apart. Optional: a
@@ -263,6 +268,9 @@ export const Route = createFileRoute("/_chat/pull-requests")({ · 94 unmodified lines
263268 ...(typeof raw.selectedProjectId === "string" && raw.selectedProjectId
264269 ? { selectedProjectId: raw.selectedProjectId as ProjectId }
265270 : {}),
271+ ...(typeof raw.selectedHost === "string" && raw.selectedHost
272+ ? { selectedHost: raw.selectedHost.slice(0, 200) }
273+ : {}),
266274 ...(typeof raw.selectedEnvironmentId === "string" && raw.selectedEnvironmentId
267275 ? { selectedEnvironmentId: raw.selectedEnvironmentId as EnvironmentId }
268276 : {}),
@@ -367,6 +375,7 @@ function PullRequestsRouteView() { · 98 unmodified lines
367375
368376 // A link from a thread or the sidebar only knows the repository, so the owning project is
369377 // resolved here; an explicit `projectId` in the URL still wins.
378+ const selectedHost = search.selectedHost ?? search.host;
370379 const projectIdForRepository = useMemo(() => {
371380 const repository = search.repository?.toLowerCase();
372381 if (repository === undefined) return undefined;
@@ -378,14 +387,14 @@ function PullRequestsRouteView() { · 5 unmodified lines
378387 repository &&
379388 // The same `owner/name` can exist on two hosts. Without this the first match wins, and
380389 // a link that named its host opens the pull request from the other one.
381− (search.host === undefined ||
390+ (selectedHost === undefined ||
382391 pullRequestHostOf(
383392 project.repositoryIdentity,
384393 project.repositoryIdentity.provider as SourceControlProviderKind,
385− ) === search.host.toLowerCase()),
394+ ) === selectedHost.toLowerCase()),
386395 );
387396 return identity?.id;
388− }, [projects, search.host, search.repository]);
397+ }, [projects, selectedHost, search.repository]);
389398
390399 // The selection is resolved the same way the scope is: an id no connected environment has can
391400 // never be read here, and one that arrived before the projects did is not yet wrong.
@@ -472,6 +481,7 @@ function PullRequestsRouteView() { · 80 unmodified lines
472481 ...(next.projectId ? { projectId: next.projectId } : {}),
473482 ...(next.environmentId ? { environmentId: next.environmentId } : {}),
474483 ...(next.host ? { host: next.host } : {}),
484+ ...(next.selectedHost ? { selectedHost: next.selectedHost } : {}),
475485 ...(next.selectedProjectId ? { selectedProjectId: next.selectedProjectId } : {}),
476486 ...(next.selectedEnvironmentId
477487 ? { selectedEnvironmentId: next.selectedEnvironmentId }
@@ -494,6 +504,7 @@ function PullRequestsRouteView() { · 16 unmodified lines
494504 number: undefined,
495505 selectedProjectId: undefined,
496506 selectedEnvironmentId: undefined,
507+ selectedHost: undefined,
497508 };
498509 // List controls change the rows behind the detail, not the independent selected surface. The
499510 // reader can keep working in that panel while narrowing, sorting, or switching projects.
@@ -799,19 +810,11 @@ function PullRequestsRouteView() { · 299 unmodified lines
799810 // from the first moment rather than the second: a button that stays live through the slow half
800811 // of its own work is a button that gets pressed again, and buys the whole cascade twice.
801812 const [invalidating, setInvalidating] = useState(false);
802− const refreshFromHost = async (includeDetail = true) => {
803− const requestedStatsScope = statsScopeRef.current;
804− setInvalidating(true);
805− try {
806− // Every environment the page is reading, since what the reader pressed refresh for is the
807− // list in front of them rather than whichever machine happens to be first.
808− await Promise.all(
809− queryEnvironmentIds.map((environmentId) => invalidate({ environmentId, input: {} })),
810− );
811− } finally {
812− setInvalidating(false);
813− }
814− refreshList(true);
813+ const refreshListAndStats = (
814+ requestedStatsScope = statsScopeRef.current,
815+ actedEnvironmentId?: EnvironmentId,
816+ ) => {
817+ refreshList(true, actedEnvironmentId);
815818 const visible = visibleStatsKeys.current;
816819 const batches = pullRequestStatsRefreshBatches({
817820 requestedScope: requestedStatsScope,
@@ -822,9 +825,29 @@ function PullRequestsRouteView() { · 4 unmodified lines
822825 });
823826 if (batches !== null) {
824827 setStatsTargetState({ key: requestedStatsScope.key, batches });
825− statsQuery.refresh(batches.map(({ environmentId, input }) => ({ environmentId, input })));
828+ statsQuery.refresh(
829+ batches
830+ .filter(({ environmentId }) =>
831+ actedEnvironmentId === undefined ? true : environmentId === actedEnvironmentId,
832+ )
833+ .map(({ environmentId, input }) => ({ environmentId, input })),
834+ );
826835 }
827− if (includeDetail) setDetailRefreshToken((token) => token + 1);
836+ };
837+ const refreshFromHost = async () => {
838+ const requestedStatsScope = statsScopeRef.current;
839+ setInvalidating(true);
840+ try {
841+ // Every environment the page is reading, since what the reader pressed refresh for is the
842+ // list in front of them rather than whichever machine happens to be first.
843+ await Promise.all(
844+ queryEnvironmentIds.map((environmentId) => invalidate({ environmentId, input: {} })),
845+ );
846+ } finally {
847+ setInvalidating(false);
848+ }
849+ refreshListAndStats(requestedStatsScope);
850+ setDetailRefreshToken((token) => token + 1);
828851 };
829852 const refreshing = invalidating || listQuery.isPending;
830853
@@ -1058,17 +1081,28 @@ function PullRequestsRouteView() { · 227 unmodified lines
10581081 // re-reads only its own slice, so the rows loaded before it would never see a merge, a close,
10591082 // or a retitle. Going back to a single page long enough to cover everything on screen lets the
10601083 // merge above bring every row up to date in place.
1061− const refreshList = (includeRelated = false) => {
1062− const related = includeRelated
1063− ? [
1064− ...baselineTargets,
1065− ...facetTargets,
1066− ...partitionTargets.authored,
1067− ...partitionTargets.reviewing,
1068− ]
1069− : [];
1084+ const refreshList = (includeRelated = false, actedEnvironmentId?: EnvironmentId) => {
1085+ const related = (
1086+ includeRelated
1087+ ? [
1088+ ...baselineTargets,
1089+ ...facetTargets,
1090+ ...partitionTargets.authored,
1091+ ...partitionTargets.reviewing,
1092+ ]
1093+ : []
1094+ ).filter(
1095+ ({ environmentId }) =>
1096+ actedEnvironmentId === undefined || environmentId === actedEnvironmentId,
1097+ );
10701098 if (sentCursors === null) {
1071− listQuery.refresh([...listTargets, ...related]);
1099+ listQuery.refresh([
1100+ ...listTargets.filter(
1101+ ({ environmentId }) =>
1102+ actedEnvironmentId === undefined || environmentId === actedEnvironmentId,
1103+ ),
1104+ ...related,
1105+ ]);
10721106 return;
10731107 }
10741108 if (related.length > 0) listQuery.refresh(related);
@@ -1412,9 +1446,10 @@ function PullRequestsRouteView() { · 337 unmodified lines
14121446 repository: search.repository,
14131447 number: search.number,
14141448 projectId: selectedProject.id,
1449+ ...(selectedHost ? { host: selectedHost } : {}),
14151450 }
14161451 : null,
1417− [search.number, search.repository, selectedProject],
1452+ [search.number, search.repository, selectedProject, selectedHost],
14181453 );
14191454 const rightPanelAvailable = selectedPullRequestSurface !== null;
14201455 useEffect(() => {
@@ -1429,6 +1464,14 @@ function PullRequestsRouteView() { · 8 unmodified lines
14291464 repository: activePullRequestSurface.repository,
14301465 number: activePullRequestSurface.number,
14311466 projectId: activePullRequestSurface.projectId as ProjectId,
1467+ host:
1468+ activePullRequestSurface.host ??
1469+ (selectedProject?.repositoryIdentity
1470+ ? pullRequestHostOf(
1471+ selectedProject.repositoryIdentity,
1472+ selectedProject.repositoryIdentity.provider as SourceControlProviderKind,
1473+ )
1474+ : undefined),
14321475 }
14331476 : null;
14341477
@@ -1440,6 +1483,7 @@ function PullRequestsRouteView() { · 5 unmodified lines
14401483 repository: surface.repository,
14411484 number: surface.number,
14421485 selectedProjectId: surface.projectId as ProjectId,
1486+ selectedHost: surface.host,
14431487 ...(surface.environmentId === undefined
14441488 ? {}
14451489 : { selectedEnvironmentId: surface.environmentId as EnvironmentId }),
@@ -1503,7 +1547,7 @@ function PullRequestsRouteView() { · 57 unmodified lines
15031547
15041548 // Stable so the memoized rows can skip re-rendering when the list around them changes.
15051549 const selectEntry = useCallback(
1506− (entry: EnvironmentPullRequestEntry) => {
1550+ (entry: PullRequestRowTarget) => {
15071551 // The surface carries the row's own server, which is what its detail reads and acts on.
15081552 if (rightPanelRef === null) return;
15091553 useRightPanelStore.getState().openPullRequest(rightPanelRef, entry);
@@ -1512,6 +1556,7 @@ function PullRequestsRouteView() { · 2 unmodified lines
15121556 number: entry.number,
15131557 selectedProjectId: entry.projectId,
15141558 selectedEnvironmentId: entry.environmentId,
1559+ selectedHost: entry.host,
15151560 });
15161561 },
15171562 [rightPanelRef, updateSearch],
@@ -1628,6 +1673,7 @@ function PullRequestsRouteView() { · 110 unmodified lines
16281673 selected={
16291674 selected?.environmentId === entry.environmentId &&
16301675 selected.repository === entry.repository &&
1676+ selected.host?.toLowerCase() === entry.host.toLowerCase() &&
16311677 selected.number === entry.number
16321678 }
16331679 onSelect={selectEntry}
@@ -1938,10 +1984,30 @@ function PullRequestsRouteView() { · 304 unmodified lines
19381984 <PullRequestDetailPanel
19391985 key={renderedPullRequestSurface.id}
19401986 environmentId={panelEnvironmentId}
1987+ onSelectPullRequest={(reference) => {
1988+ if (rightPanelRef === null) return;
1989+ useRightPanelStore.getState().openPullRequest(rightPanelRef, {
1990+ projectId: reference.projectId,
1991+ repository: reference.repository,
1992+ number: reference.number,
1993+ ...(reference.host ? { host: reference.host } : {}),
1994+ environmentId: panelEnvironmentId,
1995+ });
1996+ updateSearch({
1997+ repository: reference.repository,
1998+ number: reference.number,
1999+ selectedHost: reference.host,
2000+ selectedProjectId: reference.projectId,
2001+ selectedEnvironmentId: panelEnvironmentId,
2002+ });
2003+ }}
19412004 reference={{
19422005 projectId: renderedPullRequestSurface.projectId as ProjectId,
19432006 repository: renderedPullRequestSurface.repository,
19442007 number: renderedPullRequestSurface.number,
2008+ ...(renderedPullRequestSurface.host
2009+ ? { host: renderedPullRequestSurface.host }
2010+ : {}),
19452011 }}
19462012 listEntry={
19472013 listedPullRequestsBySurface.get(
@@ -1952,7 +2018,8 @@ function PullRequestsRouteView() { · 4 unmodified lines
19522018 // Host actions can change both readiness and diff size, so refresh the counts
19532019 // alongside the list. The panel already refreshes itself after each action.
19542020 onActed={() => {
1955− void refreshFromHost(false);
2021+ // Mutations already invalidate the host's affected caches.
2022+ refreshListAndStats(undefined, panelEnvironmentId);
19562023 }}
19572024 />
19582025 </RightPanelTabs>
apps/web/src/state/pullRequests.ts+4−1
@@ -74,7 +74,10 @@ export function useSharedPullRequestSummary( · 73 unmodified lines
7474 }, [atom, current, environmentId]);
7575 return newestPullRequestSummary(current, observed);
7676 }
77−export const pullRequestStackAtom = createPullRequestStackAtomFamily(connectionAtomRuntime);
77+export const pullRequestStackAtom = createPullRequestStackAtomFamily(
78+ connectionAtomRuntime,
79+ pullRequestEnvironment.refreshes,
80+);
7881
7982 export interface EnvironmentQueryTarget<Input> {
8083 readonly environmentId: EnvironmentId;
apps/web/src/state/query.ts+2−0
@@ -11,6 +11,7 @@ export interface EnvironmentQueryView<A> { · 10 unmodified lines
1111 readonly data: A | null;
1212 readonly error: string | null;
1313 readonly isPending: boolean;
14+ readonly isSuccess: boolean;
1415 readonly refresh: () => void;
1516 }
1617
@@ -31,6 +32,7 @@ export function useEnvironmentQuery<A, E>( · 14 unmodified lines
3132 data: Option.getOrNull(AsyncResult.value(result)),
3233 error: result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null,
3334 isPending: atom !== null && result.waiting,
35+ isSuccess: result._tag === "Success",
3436 refresh,
3537 };
3638 }
apps/web/src/state/usePullRequestStack.ts+33−0
@@ -0,0 +1,33 @@
1+import type { EnvironmentId, PullRequestRef } from "@t3tools/contracts";
2+import { useMemo } from "react";
3+import {
4+ savedPullRequestStack,
5+ pullRequestStackView,
6+} from "../components/pullRequest/pullRequestStackSnapshot";
7+import { useThreadShells } from "./entities";
8+import { pullRequestStackAtom } from "./pullRequests";
9+import { useEnvironmentQuery } from "./query";
10+
11+/** Detail headers and list popovers keep saved navigation during an unavailable refresh. */
12+export function usePullRequestStack(
13+ environmentId: EnvironmentId,
14+ reference: PullRequestRef | null,
15+) {
16+ const threads = useThreadShells();
17+ const saved = useMemo(
18+ () =>
19+ reference === null
20+ ? null
21+ : savedPullRequestStack(
22+ threads
23+ .filter((thread) => thread.environmentId === environmentId)
24+ .flatMap((thread) => thread.pullRequests ?? []),
25+ reference,
26+ ),
27+ [environmentId, reference, threads],
28+ );
29+ const query = useEnvironmentQuery(
30+ reference === null ? null : pullRequestStackAtom({ environmentId, input: reference }),
31+ );
32+ return { ...query, ...pullRequestStackView(query, saved) };
33+}
docs/user/source-control.md+12−0
@@ -115,3 +115,15 @@ review is terminal. An open or unsynced link keeps it active. · 114 unmodified lines
115115
116116 Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked
117117 out from the matching organization and repository.
118+
119+## GitHub stacks
120+
121+The Pull Requests page shows each PR's position in its GitHub stack. Open the stack badge in a
122+review to navigate its layers. **Merge stack** submits the selected pull request and every unmerged
123+layer below it to GitHub together, respecting branch rules and merge queues. The confirmation shows
124+the scope and merge strategy. GitHub rebases the remaining stack after merging.
125+
126+**Rebase stack** updates remote branches from bottom to top without changing your local checkout.
127+It can rewrite history and restart checks. If a layer fails, earlier updates remain; resolve that
128+layer before retrying. GitHub may require manual conflict resolution after a lower layer is amended,
129+even when its changes look independent. Stack actions require an environment that supports them.
packages/client-runtime/src/state/pullRequests.test.ts+70−3
@@ -1,4 +1,4 @@
1−import { EnvironmentId, ProjectId, WS_METHODS } from "@t3tools/contracts";
1+import { EnvironmentId, ProjectId, WS_METHODS, type PullRequestStack } from "@t3tools/contracts";
22 import { expect, it } from "@effect/vitest";
33 import * as Effect from "effect/Effect";
44 import * as Latch from "effect/Latch";
@@ -19,7 +19,10 @@ import * as EnvironmentRegistry from "../connection/registry.ts"; · 14 unmodified lines
1919 import * as EnvironmentSupervisor from "../connection/supervisor.ts";
2020 import type { WsRpcProtocolClient } from "../rpc/protocol.ts";
2121 import type { RpcSession } from "../rpc/session.ts";
22−import { createPullRequestEnvironmentAtoms } from "./pullRequests.ts";
22+import {
23+ createPullRequestEnvironmentAtoms,
24+ createPullRequestStackAtomFamily,
25+} from "./pullRequests.ts";
2326 import { PullRequestDiffLoader } from "./pullRequestDiffHttp.ts";
2427 import { executeAtomQuery } from "./runtime.ts";
2528
@@ -80,7 +83,7 @@ const makeTestRuntime = Effect.fn("makeTestRuntime")(function* (client: WsRpcPro · 54 unmodified lines
8083 const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) =>
8184 Effect.sync(() => registry.dispose()),
8285 );
83− return { atoms, registry };
86+ return { runtime, atoms, registry };
8487 });
8588
8689 it.effect("keeps concurrent diff file reads on different hosts separate", () =>
@@ -212,3 +215,67 @@ it.effect("refreshes pull request activity after a comment is updated", () => · 125 unmodified lines
212215 }),
213216 ),
214217 );
218+
219+it.effect("refreshes stack state after reopening and head SHAs after a turn", () =>
220+ Effect.scoped(
221+ Effect.gen(function* () {
222+ const refreshEvents = yield* PubSub.unbounded<number>();
223+ let state: "closed" | "open" = "closed";
224+ let headSha = "old-head";
225+ const client = {
226+ [WS_METHODS.pullRequestsSubscribeRefreshes]: () => Stream.fromPubSub(refreshEvents),
227+ [WS_METHODS.pullRequestsStack]: () =>
228+ Effect.sync(
229+ () =>
230+ ({
231+ id: "stack-1",
232+ number: 1,
233+ url: "https://github.com/acme/web/pull/1",
234+ base: "main",
235+ layers: [
236+ {
237+ number: 1,
238+ headBranch: "feature",
239+ headSha,
240+ state,
241+ isDraft: false,
242+ },
243+ ],
244+ }) satisfies PullRequestStack,
245+ ),
246+ } as unknown as WsRpcProtocolClient;
247+ const { runtime, registry } = yield* makeTestRuntime(client);
248+ const stacks = createPullRequestStackAtomFamily(runtime);
249+ const stack = stacks({
250+ environmentId: TARGET.environmentId,
251+ input: {
252+ projectId: ProjectId.make("project-1"),
253+ repository: "acme/web",
254+ number: 1,
255+ },
256+ });
257+ const unmount = registry.mount(stack);
258+ yield* Effect.addFinalizer(() => Effect.sync(unmount));
259+ yield* Effect.promise(() => executeAtomQuery(registry, stack));
260+ expect((yield* AtomRegistry.getResult(registry, stack))?.layers[0]?.state).toBe("closed");
261+ state = "open";
262+ registry.refresh(stack);
263+ expect(
264+ (yield* AtomRegistry.getResult(registry, stack, { suspendOnWaiting: true }))?.layers[0]
265+ ?.state,
266+ ).toBe("open");
267+
268+ const refreshed = Latch.makeUnsafe();
269+ const stop = registry.subscribe(stack, (result) => {
270+ if (AsyncResult.isSuccess(result) && result.value?.layers[0]?.headSha === "new-head") {
271+ refreshed.openUnsafe();
272+ }
273+ });
274+ yield* Effect.addFinalizer(() => Effect.sync(stop));
275+ headSha = "new-head";
276+ yield* PubSub.publish(refreshEvents, 1);
277+ yield* refreshed.await;
278+ expect((yield* AtomRegistry.getResult(registry, stack))?.layers[0]?.headSha).toBe("new-head");
279+ }),
280+ ),
281+);
packages/client-runtime/src/state/pullRequests.ts+2−0
@@ -63,12 +63,14 @@ export function createLinkedPullRequestSummaryAtomFamily<R, E>( · 62 unmodified lines
6363 /** The host-native stack a pull request belongs to; null where it is not stacked. */
6464 export function createPullRequestStackAtomFamily<R, E>(
6565 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
66+ refreshes = createPullRequestRefreshAtomFamily(runtime),
6667 ) {
6768 return createEnvironmentRpcQueryAtomFamily(runtime, {
6869 label: "environment-data:pull-requests:stack",
6970 tag: WS_METHODS.pullRequestsStack,
7071 staleTimeMs: 60_000,
7172 idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS,
73+ refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
7274 });
7375 }
7476
packages/client-runtime/src/work-log/presentation.test.ts+76−0
@@ -571,3 +571,79 @@ describe("resolveViewedImageAsset", () => { · 570 unmodified lines
571571 expect(resolveViewedImageAsset("https://example.com/logo.png", { threadId })).toBeNull();
572572 });
573573 });
574+
575+describe("pull request tool presentation", () => {
576+ it.each([
577+ "mcp__t3-code__link_pull_request",
578+ "mcp__t3_code__link_pull_request",
579+ "T3-code · link_pull_request",
580+ "t3code/link_pull_request",
581+ "link_pull_request",
582+ ])("recognizes the native linking tool: %s", (label) => {
583+ const entry = { label, tone: "tool" as const, toolLifecycleStatus: "completed" };
584+ expect(resolveWorkEntryToolPresentation(entry)).toMatchObject({
585+ displayName: "Linked a pull request",
586+ icon: "pull-request",
587+ });
588+ expect(toolGroupAction(entry)).toBe("link-pr");
589+ });
590+
591+ it.each([
592+ ["inProgress", "Linking PR #42"],
593+ ["completed", "Linked PR #42"],
594+ ["failed", "Failed to link PR #42"],
595+ ["declined", "Declined to link PR #42"],
596+ ["stopped", "Stopped linking PR #42"],
597+ ])("describes the target and %s status", (toolLifecycleStatus, displayName) => {
598+ expect(
599+ resolveWorkEntryToolPresentation({
600+ label: "MCP tool call",
601+ toolTitle: "Custom title",
602+ toolLifecycleStatus,
603+ toolData: {
604+ server: "t3-code",
605+ tool: "link_pull_request",
606+ arguments: { url: "https://github.com/acme/web/pull/42" },
607+ },
608+ })?.displayName,
609+ ).toBe(displayName);
610+ });
611+
612+ it("recognizes unlink targets supplied as repository and number", () => {
613+ expect(
614+ resolveWorkEntryToolPresentation({
615+ label: "MCP tool call",
616+ toolLifecycleStatus: "completed",
617+ toolData: {
618+ toolName: "mcp__t3-code__unlink_pull_request",
619+ rawInput: { repository: "acme/web", number: 42 },
620+ },
621+ }),
622+ ).toMatchObject({ displayName: "Unlinked PR #42", icon: "pull-request", action: "unlink-pr" });
623+ });
624+
625+ it("summarizes native PR work separately from ordinary tools and integration metadata", () => {
626+ const link: WorkLogPresentationEntry = {
627+ label: "T3-code · link_pull_request",
628+ tone: "tool",
629+ itemType: "mcp_tool_call",
630+ toolLifecycleStatus: "completed",
631+ toolSource: { key: "t3-code", name: "T3 Code", kind: "integration" },
632+ };
633+ const list: WorkLogPresentationEntry = {
634+ ...link,
635+ label: "T3-code · list_thread_pull_requests",
636+ };
637+ expect(summarizeToolGroup([link, link, list])).toBe(
638+ "Linked 2 pull requests and checked linked pull requests",
639+ );
640+ expect(summarizeToolGroup([{ ...link, label: "T3-code · unlink_pull_request" }])).toBe(
641+ "Unlinked 1 pull request",
642+ );
643+ expect(toolGroupSummaryKind([link, link, list])).toBe("pull-request");
644+ expect(summarizeToolGroup([list, list])).toBe("Checked linked pull requests 2 times");
645+ expect(
646+ resolveWorkEntryToolPresentation({ label: "mcp__another-server__link_pull_request" }),
647+ ).toBeNull();
648+ });
649+});
packages/client-runtime/src/work-log/presentation.ts+62−9
@@ -8,6 +8,7 @@ import { · 7 unmodified lines
88 } from "@t3tools/contracts";
99 import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-images";
1010 import { resolveMediaSource } from "@t3tools/client-runtime/media-source";
11+import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
1112 import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview";
1213
1314 export function isWorktreeSetupActivity(kind: string): boolean {
@@ -36,6 +37,9 @@ export interface WorkLogPresentationEntry { · 22 unmodified lines
3637 }
3738
3839 export type ToolGroupAction =
40+ | "link-pr"
41+ | "unlink-pr"
42+ | "list-prs"
3943 | "read"
4044 | "edit"
4145 | "command"
@@ -46,6 +50,7 @@ export type ToolGroupAction = · 4 unmodified lines
4650 | "update";
4751
4852 export type ToolGroupSummaryKind =
53+ | "pull-request"
4954 | ToolGroupAction
5055 | "dynamic-tool"
5156 | "agent-tool"
@@ -60,6 +65,9 @@ const T3_MCP_TOOL_LABELS: Record< · 8 unmodified lines
6065 string,
6166 readonly [action: string, running: string, completed: string, detail: string]
6267 > = {
68+ link_pull_request: ["Link", "Linking", "Linked", "a pull request"],
69+ unlink_pull_request: ["Unlink", "Unlinking", "Unlinked", "a pull request"],
70+ list_thread_pull_requests: ["Check", "Checking", "Checked", "linked pull requests"],
6371 orchestrator_capabilities: ["Get", "Getting", "Got", "orchestration capabilities"],
6472 delegate_task: ["Delegate", "Delegating", "Delegated", "a child task"],
6573 task_status: ["Get", "Getting", "Got", "delegated task status"],
@@ -98,7 +106,17 @@ const T3_MCP_TOOL_LABELS: Record< · 32 unmodified lines
98106 preview_recording_stop: ["Stop", "Stopping", "Stopped", "recording the preview browser"],
99107 };
100108
101−function resolveT3McpToolPresentation(value: string | undefined, status: string | undefined) {
109+const PR_TOOL_ACTIONS: Readonly<Record<string, ToolGroupAction>> = {
110+ link_pull_request: "link-pr",
111+ unlink_pull_request: "unlink-pr",
112+ list_thread_pull_requests: "list-prs",
113+};
114+
115+function resolveT3McpToolPresentation(
116+ value: string | undefined,
117+ status: string | undefined,
118+ data?: unknown,
119+) {
102120 if (!value) return null;
103121 const name = normalizeCompactToolLabel(value).replace(
104122 /^(?:mcp__(?:t3-code|t3_code|t3code)__|(?:t3-code|t3_code|t3code)(?:[.:/]|\s*·\s*))/i,
@@ -120,9 +138,29 @@ function resolveT3McpToolPresentation(value: string | undefined, status: string · 15 unmodified lines
120138 ? `Stopped ${running.toLowerCase()}`
121139 : running;
122140
141+ const actionKind = Object.hasOwn(PR_TOOL_ACTIONS, name) ? PR_TOOL_ACTIONS[name] : undefined;
142+ const payload = asRecord(data);
143+ const input =
144+ asRecord(payload?.arguments) ?? asRecord(payload?.input) ?? asRecord(payload?.rawInput);
145+ const urlTarget = typeof input?.url === "string" ? parseChangeRequestUrl(input.url) : null;
146+ const number = urlTarget?.number ?? input?.number;
147+ const target =
148+ actionKind !== undefined &&
149+ actionKind !== "list-prs" &&
150+ typeof number === "number" &&
151+ Number.isSafeInteger(number) &&
152+ number > 0
153+ ? `PR #${number}`
154+ : detail;
123155 return {
124− displayName: `${verb} ${detail}`,
125− icon: name.startsWith("preview_") ? ("browser" as const) : ("t3-code" as const),
156+ displayName: `${verb} ${target}`,
157+ icon:
158+ actionKind !== undefined
159+ ? ("pull-request" as const)
160+ : name.startsWith("preview_")
161+ ? ("browser" as const)
162+ : ("t3-code" as const),
163+ ...(actionKind === undefined ? {} : { action: actionKind }),
126164 };
127165 }
128166
@@ -147,16 +185,16 @@ export function resolveWorkEntryToolPresentation( · 18 unmodified lines
147185 "tool" in data &&
148186 typeof data.tool === "string"
149187 ) {
150− return resolveT3McpToolPresentation(`${data.server}.${data.tool}`, status);
188+ return resolveT3McpToolPresentation(`${data.server}.${data.tool}`, status, data);
151189 }
152190 if ("toolName" in data && typeof data.toolName === "string") {
153− return resolveT3McpToolPresentation(data.toolName, status);
191+ return resolveT3McpToolPresentation(data.toolName, status, data);
154192 }
155193 }
156194
157195 return (
158− resolveT3McpToolPresentation(entry.toolTitle, status) ??
159− resolveT3McpToolPresentation(entry.label, status)
196+ resolveT3McpToolPresentation(entry.toolTitle, status, data) ??
197+ resolveT3McpToolPresentation(entry.label, status, data)
160198 );
161199 }
162200
@@ -410,7 +448,9 @@ export function toolGroupAction(entry: WorkLogPresentationEntry): ToolGroupActio · 247 unmodified lines
410448 ) {
411449 return "update";
412450 }
413− if (resolveWorkEntryToolPresentation(entry)?.icon === "browser") return "browser";
451+ const presentation = resolveWorkEntryToolPresentation(entry);
452+ if (presentation?.action !== undefined) return presentation.action;
453+ if (presentation?.icon === "browser") return "browser";
414454 if (
415455 entry.requestKind === "file-read" ||
416456 entry.itemType === "image_view" ||
@@ -504,6 +544,14 @@ function toolGroupActionCount( · 87 unmodified lines
504544
505545 function toolGroupActionLabel(action: ToolGroupAction, count: number): string {
506546 switch (action) {
547+ case "link-pr":
548+ return `Linked ${count} ${count === 1 ? "pull request" : "pull requests"}`;
549+ case "unlink-pr":
550+ return `Unlinked ${count} ${count === 1 ? "pull request" : "pull requests"}`;
551+ case "list-prs":
552+ return count === 1
553+ ? "Checked linked pull requests"
554+ : `Checked linked pull requests ${count} times`;
507555 case "read":
508556 return `Read ${count} ${count === 1 ? "file" : "files"}`;
509557 case "edit":
@@ -528,7 +576,7 @@ export function summarizeToolGroup(entries: ReadonlyArray<WorkLogPresentationEnt · 18 unmodified lines
528576 const sources = new Map<string, ToolActivitySource>();
529577 const groupedEntries = new Map<ToolGroupAction, WorkLogPresentationEntry[]>();
530578 for (const entry of summaryEntries) {
531− if (entry.toolSource) {
579+ if (entry.toolSource && resolveWorkEntryToolPresentation(entry)?.icon !== "pull-request") {
532580 sources.set(entry.toolSource.key, entry.toolSource);
533581 continue;
534582 }
@@ -601,6 +649,11 @@ export function omitSupersededLifecycleMarkers<T>( · 66 unmodified lines
601649 export function toolGroupSummaryKind(
602650 entries: ReadonlyArray<WorkLogPresentationEntry>,
603651 ): ToolGroupSummaryKind {
652+ if (
653+ entries.length > 0 &&
654+ entries.every((entry) => resolveWorkEntryToolPresentation(entry)?.icon === "pull-request")
655+ )
656+ return "pull-request";
604657 const actions = new Set(entries.map(toolGroupAction));
605658 if (actions.size !== 1) return "mixed";
606659
packages/contracts/src/environment.ts+1−0
@@ -130,6 +130,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ · 129 unmodified lines
130130 threads, and routes PullRequestRef.host across projects on the same host. Same
131131 version-skew contract as threadSettlement. */
132132 threadPullRequests: Schema.optionalKey(Schema.Boolean),
133+ pullRequestStackActions: Schema.optionalKey(Schema.Boolean),
133134 /** The update path clients should offer for this server. Absent on
134135 servers that must be relaunched manually (dev checkouts, Windows
135136 foreground runs, pre-update servers). */
packages/contracts/src/pullRequest.ts+24−0
@@ -418,6 +418,7 @@ export const PullRequestCapabilities = Schema.Struct({ · 417 unmodified lines
418418 * the stack the host shows. Absent means chains are only ever inferred from base branches.
419419 */
420420 stacks: Schema.optional(Schema.Boolean),
421+ stackActions: Schema.optional(Schema.Boolean),
421422 /**
422423 * The repository's labels can be listed, and one put on a change request or taken off it.
423424 * Optional for the same reason `edit` is: a server that says nothing about labels has no way
@@ -438,6 +439,8 @@ export type PullRequestCapabilities = typeof PullRequestCapabilities.Type; · 14 unmodified lines
438439 * offering one they may not use ends in the host's own refusal — which at least says why.
439440 */
440441 export const PullRequestViewerPermissions = Schema.Struct({
442+ /** May request remote stack rebases, including when this layer is already current. */
443+ stackRebase: Schema.optional(Schema.Boolean),
441444 /** Which of the actions this viewer may take; anything absent is theirs to look at only. */
442445 actions: Schema.Array(PullRequestAction),
443446 /** This viewer may write a remark: a comment, a reply, or a note against a line. */
@@ -469,7 +472,16 @@ export const PullRequestMergeCapabilities = Schema.Struct({ · 25 unmodified lines
469472 });
470473 export type PullRequestMergeCapabilities = typeof PullRequestMergeCapabilities.Type;
471474
475+export const PullRequestStackMembership = Schema.Struct({
476+ number: PositiveInt,
477+ position: PositiveInt,
478+ size: PositiveInt,
479+ base: TrimmedNonEmptyString,
480+});
481+export type PullRequestStackMembership = typeof PullRequestStackMembership.Type;
482+
472483 export const PullRequestListEntry = Schema.Struct({
484+ stack: Schema.optional(PullRequestStackMembership),
473485 provider: SourceControlProviderKind,
474486 /**
475487 * The host below which `repository` is addressed, so the same provider kind can serve more
@@ -686,6 +698,9 @@ export const PullRequestStack = Schema.Struct({ · 210 unmodified lines
686698 layers: Schema.Array(
687699 Schema.Struct({
688700 number: PositiveInt,
701+ title: Schema.optional(Schema.String),
702+ isDraft: Schema.optional(Schema.Boolean),
703+ headSha: Schema.optional(TrimmedNonEmptyString),
689704 headBranch: TrimmedNonEmptyString,
690705 state: PullRequestState,
691706 }),
@@ -908,7 +923,16 @@ export const PullRequestDiffFileContentsResult = Schema.Struct({ · 216 unmodified lines
908923 });
909924 export type PullRequestDiffFileContentsResult = typeof PullRequestDiffFileContentsResult.Type;
910925
926+export const PullRequestStackHead = Schema.Struct({
927+ number: PositiveInt,
928+ headSha: TrimmedNonEmptyString,
929+});
930+export type PullRequestStackHead = typeof PullRequestStackHead.Type;
931+
911932 export const PullRequestActionInput = Schema.Struct({
933+ /** Native stack scope; only send to environments advertising pullRequestStackActions. */
934+ stackNumber: Schema.optional(PositiveInt),
935+ expectedStackHeads: Schema.optional(Schema.Array(PullRequestStackHead)),
912936 ...PullRequestRef.fields,
913937 action: PullRequestAction,
914938 /**