Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e478f29
feat(prs): navigate, merge and rebase GitHub stacks
juliusmarminge Sep 9, 2026
27db0bf
fix(web): use standard stack confirmation spacing
juliusmarminge Sep 9, 2026
10e1725
fix(prs): preserve stack action compatibility and host routing
juliusmarminge Sep 9, 2026
2ae0243
fix(prs): wait for a successful stack lookup before single merges
juliusmarminge Sep 9, 2026
db64af8
fix(prs): validate every stack layer before remote updates
juliusmarminge Sep 9, 2026
ba53102
fix(prs): keep stack navigation independent from list filters
juliusmarminge Sep 9, 2026
ebe7efd
fix(prs): distinguish selected reviews by host
juliusmarminge Sep 9, 2026
2933f1f
fix(prs): preserve reviewed revisions during stack rebases
juliusmarminge Sep 9, 2026
b0ce670
fix(prs): refresh stack state with detail and completed turns
juliusmarminge Sep 9, 2026
ba93a51
fix(prs): use ghost header actions and standard stack menu
juliusmarminge Sep 9, 2026
b067236
refactor(prs): use typed stack errors and Effect service requirements
juliusmarminge Sep 9, 2026
f6441f9
fix(prs): preserve stack badges in fallback listings
juliusmarminge Sep 9, 2026
77ad206
fix(prs): retain destination host when following review links
juliusmarminge Sep 9, 2026
4373986
fix(prs): retain stack identity and rejected merge responses
juliusmarminge Sep 9, 2026
124188b
feat(web): open stack navigation from pull request list markers
juliusmarminge Sep 9, 2026
8d24d0a
fix(web): open linked threads in the command palette
juliusmarminge Sep 9, 2026
d829b76
feat(prs): merge stacks through the selected pull request
juliusmarminge Sep 9, 2026
18d8567
fix(web): use shared tooltips for pull request header actions
juliusmarminge Sep 9, 2026
26bf982
fix(web): share tooltip timing across pull request actions
juliusmarminge Sep 9, 2026
1d5a55c
feat(clients): present PR linking tools as pull request activity
juliusmarminge Sep 9, 2026
3cc0eb4
fix(web): share linked PR badges with the composer footer
juliusmarminge Sep 9, 2026
332ff3e
fix(web): reuse the complete PR badge control in the composer
juliusmarminge Sep 9, 2026
3418b94
fix(web): add hover variants to the shared PR badge
juliusmarminge Sep 9, 2026
b50bc90
fix(web): retain saved stack navigation when refresh fails
juliusmarminge Sep 9, 2026
e79be2a
fix(prs): reduce redundant GitHub refresh requests
juliusmarminge Sep 9, 2026
135dabe
fix(prs): address stack readiness and navigation reviews
juliusmarminge Sep 9, 2026
e1acb55
fix(server): encode rebase node IDs through Schema
juliusmarminge Sep 9, 2026
18a5ff9
fix(web): polish stack refresh and confirmation controls
juliusmarminge Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export const THREAD_DISCLOSURE_TRANSITION_MS = 180;
const WORK_LOG_LAYOUT_TRANSITION = LinearTransition.duration(THREAD_DISCLOSURE_TRANSITION_MS);
const WORK_LOG_DETAIL_ENTER_TRANSITION = FadeIn.duration(140);
const WORK_LOG_DETAIL_EXIT_TRANSITION = FadeOut.duration(120);
type WorkContentIcon = AppSymbolName | "browser" | "t3-code";
type WorkContentIcon = AppSymbolName | "browser" | "t3-code" | "pull-request";

function WorkLogIcon(props: {
readonly icon: WorkContentIcon;
Expand All @@ -92,7 +92,13 @@ function WorkLogIcon(props: {
}
return (
<SymbolView
name={props.icon === "browser" ? { ios: "globe", android: "public" } : props.icon}
name={
props.icon === "pull-request"
? "arrow.triangle.pull"
: props.icon === "browser"
? { ios: "globe", android: "public" }
: props.icon
}
size={14}
weight="medium"
{...(colorClassName ? { tintColorClassName: colorClassName } : { tintColor: props.color })}
Expand Down Expand Up @@ -893,7 +899,7 @@ export function ThreadWorkGroupToggle(props: {
readonly iconSubtleColor: import("react-native").ColorValue;
readonly summary: string;
readonly summaryKind: ToolGroupSummaryKind;
readonly summaryToolIcon?: "browser" | "t3-code";
readonly summaryToolIcon?: "browser" | "t3-code" | "pull-request";
readonly themeAppearance: "light" | "dark";
readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface;
readonly toolIcon?: ToolActivityIcon;
Expand Down Expand Up @@ -1231,6 +1237,11 @@ function ToolActivityImage(props: {

function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {
switch (kind) {
case "pull-request":
case "link-pr":
case "unlink-pr":
case "list-prs":
return "arrow.triangle.pull";
case "read":
return { ios: "eye", android: "visibility" };
case "edit":
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/lib/threadActivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export type ThreadFeedEntry =
readonly summaryKind: ToolGroupSummaryKind;
readonly toolSurface?: WorkLogEntry["toolSurface"];
readonly toolIcon?: WorkLogEntry["toolIcon"];
readonly summaryToolIcon?: "browser" | "t3-code";
readonly summaryToolIcon?: "browser" | "t3-code" | "pull-request";
readonly hasFailure: boolean;
readonly live: boolean;
readonly shimmer: boolean;
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ export const make = Effect.gen(function* () {
threadActiveReorder: true,
threadTitleRegeneration: true,
threadPullRequests: true,
pullRequestStackActions: true,
threadPullRequestLinking: true,
environmentIcon: true,
...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/orchestration/PullRequestSyncReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,9 @@ const makeHarness = Effect.fn("makePullRequestSyncHarness")(function* (options:
return yield* options.summary?.(input) ?? Effect.succeed(makeSummary(input));
});

const stack: PullRequestService["Service"]["stack"] = (input) =>
const stack: PullRequestService["Service"]["stack"] = (input, readOptions) =>
Effect.gen(function* () {
assert.strictEqual(readOptions?.includeDetails, false);
yield* Ref.update(stackCalls, (calls) => [...calls, input]);
return yield* options.stack?.(input) ?? Effect.succeed(null);
});
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/orchestration/PullRequestSyncReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ export const make = Effect.gen(function* () {
entry.link.snapshot === null || !snapshotFieldsEqual(entry.link.snapshot, fields),
);
const fetchedStack = needsStack
? yield* pullRequests.stack(ref).pipe(
? yield* pullRequests.stack(ref, { includeDetails: false }).pipe(
Effect.map((stack) => ({
stack: stack === null ? null : ({ kind: "native", ...stack } as const),
})),
Expand Down
213 changes: 121 additions & 92 deletions apps/server/src/orchestration/ThreadPullRequestReactor.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {
CheckpointRef,
EventId,
GitManagerError,
ProjectId,
ProviderInstanceId,
ThreadId,
TurnId,
type OrchestrationCommand,
type OrchestrationEvent,
type OrchestrationProjectShell,
Expand Down Expand Up @@ -311,54 +313,79 @@ describe("ThreadPullRequestReactor", () => {
),
);

it.effect("refreshes discovery after a turn ends without client demand", () =>
Effect.scoped(
Effect.gen(function* () {
const current = thread("turn-thread");
const detected = yield* Ref.make<GitBranchPullRequest | null>(null);
const fixture = yield* makeHarness({
threads: [current],
branchPullRequest: (_input, options) =>
options?.refresh
? Ref.set(detected, branchPullRequest()).pipe(Effect.andThen(Ref.get(detected)))
: Ref.get(detected),
});
yield* Effect.gen(function* () {
const reactor = yield* fixture.start();
expect(yield* Ref.get(fixture.commands)).toHaveLength(0);
yield* fixture.publish({
type: "thread.session-set",
sequence: 2,
eventId: EventId.make("turn-finished"),
aggregateKind: "thread",
aggregateId: current.id,
occurredAt: NOW,
commandId: null,
causationEventId: null,
correlationId: null,
metadata: {},
payload: {
threadId: current.id,
session: {
it.effect.each(["session-first", "checkpoint-first"] as const)(
"refreshes discovery once after a turn ends with %s event ordering",
(order) =>
Effect.scoped(
Effect.gen(function* () {
const current = thread("turn-thread");
const detected = yield* Ref.make<GitBranchPullRequest | null>(null);
const fixture = yield* makeHarness({
threads: [current],
branchPullRequest: (_input, options) =>
options?.refresh
? Ref.set(detected, branchPullRequest()).pipe(Effect.andThen(Ref.get(detected)))
: Ref.get(detected),
});
yield* Effect.gen(function* () {
const reactor = yield* fixture.start();
expect(yield* Ref.get(fixture.commands)).toHaveLength(0);
const sessionEvent: OrchestrationEvent = {
type: "thread.session-set",
sequence: 2,
eventId: EventId.make("turn-finished"),
aggregateKind: "thread",
aggregateId: current.id,
occurredAt: NOW,
commandId: null,
causationEventId: null,
correlationId: null,
metadata: {},
payload: {
threadId: current.id,
session: {
threadId: current.id,
status: "ready",
providerName: "Codex",
runtimeMode: "full-access",
activeTurnId: null,
lastError: null,
updatedAt: NOW,
},
},
};
const checkpointEvent: OrchestrationEvent = {
...sessionEvent,
type: "thread.turn-diff-completed",
sequence: 3,
eventId: EventId.make("checkpoint-finished"),
payload: {
threadId: current.id,
turnId: TurnId.make("turn"),
checkpointTurnCount: 1,
checkpointRef: CheckpointRef.make("checkpoint"),
status: "ready",
providerName: "Codex",
runtimeMode: "full-access",
activeTurnId: null,
lastError: null,
updatedAt: NOW,
files: [],
assistantMessageId: null,
completedAt: NOW,
},
},
});
yield* Queue.take(fixture.reads);
yield* reactor.drain;
expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42));
expect((yield* Ref.get(fixture.branchCalls)).filter((call) => call.refresh)).toHaveLength(
1,
);
}).pipe(Effect.provide(fixture.layer));
}),
),
};
const events =
order === "session-first"
? [sessionEvent, checkpointEvent]
: [checkpointEvent, sessionEvent];
for (const event of events) {
yield* fixture.publish(event);
yield* Queue.take(fixture.reads);
yield* reactor.drain;
}
expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42));
expect(
(yield* Ref.get(fixture.branchCalls)).filter((call) => call.refresh),
).toHaveLength(1);
}).pipe(Effect.provide(fixture.layer));
}),
),
);

it.effect("uses live worktrees and falls back to the project for removed worktrees", () =>
Expand Down Expand Up @@ -455,54 +482,56 @@ describe("ThreadPullRequestReactor", () => {
),
);

it.effect("retries failed settled backfills and stops querying them after success", () =>
Effect.scoped(
Effect.gen(function* () {
const online = yield* Ref.make(false);
const fixture = yield* makeHarness({
threads: [
thread("backfill", { settledOverride: "settled", settledAt: NOW }),
thread("known", {
branch: "known",
settledOverride: "settled",
settledAt: NOW,
branchPullRequest: reference(1),
}),
],
branchPullRequest: ({ cwd }) =>
Ref.get(online).pipe(
Effect.flatMap((connected) =>
connected
? Effect.succeed(branchPullRequest(42, "merged"))
: Effect.fail(
new GitManagerError({
operation: "branchPullRequest",
cwd,
detail: "Offline",
}),
),
it.effect.each(["settled", null] as const)(
"retries settled backfills and stops after success with settledOverride %s",
(settledOverride) =>
Effect.scoped(
Effect.gen(function* () {
const online = yield* Ref.make(false);
const fixture = yield* makeHarness({
threads: [
thread("backfill", { settledOverride, settledAt: NOW }),
thread("known", {
branch: "known",
settledOverride,
settledAt: NOW,
branchPullRequest: reference(1),
}),
],
branchPullRequest: ({ cwd }) =>
Ref.get(online).pipe(
Effect.flatMap((connected) =>
connected
? Effect.succeed(branchPullRequest(42, "merged"))
: Effect.fail(
new GitManagerError({
operation: "branchPullRequest",
cwd,
detail: "Offline",
}),
),
),
),
),
});
yield* Effect.gen(function* () {
const reactor = yield* fixture.start();
expect(yield* Ref.get(fixture.commands)).toHaveLength(0);
yield* Ref.set(online, true);
yield* TestClock.adjust("1 minute");
yield* Queue.take(fixture.reads);
yield* reactor.drain;
expect((yield* Ref.get(fixture.commands))[0]?.threadId).toBe("backfill");
yield* TestClock.adjust("1 minute");
yield* Queue.take(fixture.reads);
yield* reactor.drain;
expect((yield* Ref.get(fixture.branchCalls)).map((call) => call.branch)).toEqual([
"feature",
"feature",
"feature",
]);
}).pipe(Effect.provide(fixture.layer));
}),
),
});
yield* Effect.gen(function* () {
const reactor = yield* fixture.start();
expect(yield* Ref.get(fixture.commands)).toHaveLength(0);
yield* Ref.set(online, true);
yield* TestClock.adjust("1 minute");
yield* Queue.take(fixture.reads);
yield* reactor.drain;
expect((yield* Ref.get(fixture.commands))[0]?.threadId).toBe("backfill");
yield* TestClock.adjust("1 minute");
yield* Queue.take(fixture.reads);
yield* reactor.drain;
expect((yield* Ref.get(fixture.branchCalls)).map((call) => call.branch)).toEqual([
"feature",
"feature",
"feature",
]);
}).pipe(Effect.provide(fixture.layer));
}),
),
);

it.effect("stops retrying a settled backfill after repeated lookup failures", () =>
Expand Down
11 changes: 8 additions & 3 deletions apps/server/src/orchestration/ThreadPullRequestReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,10 @@ export const make = Effect.gen(function* () {
const projects = new Map(snapshot.projects.map((project) => [project.id, project]));
if (request.backfill) {
for (const thread of snapshot.threads) {
if (thread.settledOverride === "settled" && thread.branchPullRequest == null) {
if (
(thread.settledOverride === "settled" || thread.settledAt !== null) &&
thread.branchPullRequest == null
) {
pendingBackfill.set(thread.id, BACKFILL_ATTEMPTS);
}
}
Expand All @@ -114,7 +117,7 @@ export const make = Effect.gen(function* () {
(thread) =>
thread.archivedAt === null &&
(request.threadId === null || thread.id === request.threadId) &&
(thread.settledOverride !== "settled" ||
((thread.settledOverride !== "settled" && thread.settledAt === null) ||
request.threadId !== null ||
pendingBackfill.has(thread.id)) &&
(thread.branch !== null || thread.branchPullRequest != null),
Expand Down Expand Up @@ -327,7 +330,9 @@ export const make = Effect.gen(function* () {
event.payload.session.status !== "running" &&
event.payload.session.status !== "starting"
) {
return worker.enqueue({ threadId: event.payload.threadId, refresh: true });
// Checkpoint completion forces the post-turn read. Session lifecycle
// events reuse it regardless of which event reaches this worker first.
return worker.enqueue({ threadId: event.payload.threadId, refresh: false });
}
break;
case "thread.turn-diff-completed":
Expand Down
Loading
Loading