Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 17 additions & 2 deletions apps/web/src/state/pullRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -502,13 +502,28 @@ function savedPullRequestKey(
: `${environmentId}:${threadPullRequestKeyOf({ ...reference, host: reference.host })}`;
}

// Any thread's streaming tick rebuilds the map below from every linked pull request. Links keep
// their identity until they change, so each link's URL is parsed once, not once per tick.
const savedLinkSummaries = new WeakMap<
ThreadPullRequestLink,
{ readonly projectId: ProjectId; readonly summary: PullRequestSummary | null }
>();

function savedLinkSummary(link: ThreadPullRequestLink, projectId: ProjectId) {
const cached = savedLinkSummaries.get(link);
if (cached?.projectId === projectId) return cached.summary;
const summary = threadPullRequestLinkToSummary(link, projectId);
savedLinkSummaries.set(link, { projectId, summary });
return summary;
}

const savedPullRequestSummariesAtom = Atom.make((get) => {
const summaries = new Map<string, PullRequestSummary>();
for (const thread of get(environmentThreadShells.threadShellsAtom)) {
for (const link of visibleThreadPullRequests(thread.pullRequests)) {
const summary = threadPullRequestLinkToSummary(link, thread.projectId);
const summary = savedLinkSummary(link, thread.projectId);
if (summary === null) continue;
const key = savedPullRequestKey(thread.environmentId, link);
const key = `${thread.environmentId}:${threadPullRequestKeyOf(link)}`;
summaries.set(key, newestPullRequestSummary(summaries.get(key) ?? null, summary) ?? summary);
}
}
Expand Down
28 changes: 28 additions & 0 deletions packages/client-runtime/src/state/shellReducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,34 @@ describe("applyShellStreamEvents", () => {
expect(applyShellStreamEvents(seed, [])).toBe(seed);
});

it("keeps an unchanged links array when a streaming row changes", () => {
const link: ThreadPullRequestLink = {
host: "github.com",
repository: "pingdotgg/t3code",
number: 3,
url: "https://github.com/pingdotgg/t3code/pull/3",
source: "agent",
linkedAt: "2026-06-20T00:00:00.000Z",
snapshot: null,
stack: null,
};
const linked = { ...a, pullRequests: [link] };
const seed = { ...v2ShellSnapshot, threads: [linked, b], snapshotSequence: 4 };
const tick = { ...linked, itemCount: 9, pullRequests: [{ ...link }] };
const relinked = { ...linked, pullRequests: [{ ...link, number: 4 }] };

expect(applyShellStreamEvent(seed, update(5, tick)).threads[0]?.pullRequests).toBe(
linked.pullRequests,
);
const batched = applyShellStreamEvents(seed, [update(5, tick), update(6, { ...b })]);
expect(batched.threads.find((thread) => thread.id === a.id)?.pullRequests).toBe(
linked.pullRequests,
);
expect(applyShellStreamEvent(seed, update(5, relinked)).threads[0]?.pullRequests).toBe(
relinked.pullRequests,
);
});

it("does not advance the cursor for an unknown future event", () => {
const events = [
{ kind: "future.event", sequence: 99 } as never,
Expand Down
25 changes: 20 additions & 5 deletions packages/client-runtime/src/state/shellReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ export function sameThreadPullRequests(
return left === right || sameLinks(left, right);
}

// A streaming thread resends its whole shell on each tick. Keeping the prior links array when it
// has not changed lets later comparisons stop at identity and keeps per-link caches warm.
function retainUnchangedLinks(
previous: OrchestrationV2ThreadShell | undefined,
next: OrchestrationV2ThreadShell,
): OrchestrationV2ThreadShell {
if (previous?.pullRequests === undefined || next.pullRequests === undefined) return next;
if (previous.pullRequests === next.pullRequests) return next;
return sameLinks(previous.pullRequests, next.pullRequests)
? { ...next, pullRequests: previous.pullRequests }
: next;
}

/**
* Links are compared apart from the rest of the row because a deferred snapshot arrives without
* them; such a row keeps its links until they fill in.
Expand All @@ -39,7 +52,7 @@ export function reuseUnchangedThreadShells(
const candidate =
thread.pullRequests === undefined && prior.pullRequests !== undefined
? { ...thread, pullRequests: prior.pullRequests }
: thread;
: retainUnchangedLinks(prior, thread);
const retained = sameThreadShell(prior, candidate) ? prior : candidate;
if (retained !== thread) changed = true;
return retained;
Expand Down Expand Up @@ -160,7 +173,8 @@ export function applyShellStreamEvent(
event.location === "active"
? snapshot.threads.find((thread) => thread.id === event.thread.id)
: undefined;
if (existing !== undefined && sameThreadShell(existing, event.thread)) {
const thread = retainUnchangedLinks(existing, event.thread);
if (existing !== undefined && sameThreadShell(existing, thread)) {
return { ...snapshot, snapshotSequence: event.sequence };
}
const withoutThread = (threads: OrchestrationV2ShellSnapshot["threads"]) =>
Expand All @@ -169,7 +183,7 @@ export function applyShellStreamEvent(
...snapshot,
threads:
event.location === "active"
? upsertById(snapshot.threads, event.thread)
? upsertById(snapshot.threads, thread)
: withoutThread(snapshot.threads),
// The archive has its own bounded query/subscription. Older servers may
// still send archive-located deltas here; remove them from the normal
Expand Down Expand Up @@ -243,8 +257,9 @@ export function applyShellStreamEvents(
const id = event.thread.id;
if (event.location === "active") {
const previous = threads.get(id);
if (previous !== undefined && sameThreadShell(previous, event.thread)) break;
threads.set(event.thread);
const thread = retainUnchangedLinks(previous, event.thread);
if (previous !== undefined && sameThreadShell(previous, thread)) break;
threads.set(thread);
} else {
threads.remove(id);
}
Expand Down
17 changes: 17 additions & 0 deletions packages/source-control-core/src/shared/threadPullRequests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,25 @@ export function makeThreadPullRequests(
return `${normalized.host}/${normalized.repository}#${normalized.number}`;
}

// Shells keep their links array while a run streams, and several components read the
// current pull request on each shell change, so resolve each array's chains once.
const currentByLinks = new WeakMap<
ReadonlyArray<ThreadPullRequestLink>,
ThreadCurrentPullRequest | null
>();

function resolveThreadCurrentPullRequest(
links: ReadonlyArray<ThreadPullRequestLink>,
): ThreadCurrentPullRequest | null {
const cached = currentByLinks.get(links);
if (cached !== undefined) return cached;
const current = resolveUncachedThreadCurrentPullRequest(links);
currentByLinks.set(links, current);
return current;
}

function resolveUncachedThreadCurrentPullRequest(
links: ReadonlyArray<ThreadPullRequestLink>,
): ThreadCurrentPullRequest | null {
const visible = visibleThreadPullRequests(links);
if (visible.length === 0) return null;
Expand Down
Loading