onIntent(link)}
+ onPointerLeave={(event) => {
+ if (!event.currentTarget.contains(document.activeElement)) onIntent(null);
+ }}
+ onFocus={() => onIntent(link)}
+ onBlur={(event) => {
+ if (!event.currentTarget.matches(":hover")) onIntent(null);
+ }}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
@@ -400,6 +413,37 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread
const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: true });
const watch = useAtomCommand(threadEnvironment.watchPullRequest, { reportFailure: true });
const capabilities = useServerConfigs().get(threadRef.environmentId)?.environment.capabilities;
+ const primaryEnvironmentId = usePrimaryEnvironmentId();
+ const opensInApp = useClientSettings((settings) => settings.pullRequestLinksOpenInApp);
+ const prefetch = usePullRequestIntentPrefetch((target) =>
+ links.some(
+ (link) =>
+ link.number === target.number &&
+ link.repository.toLowerCase() === target.repository.toLowerCase(),
+ ),
+ );
+ const intendLink = (link: ThreadPullRequestLink | null) => {
+ const target =
+ link === null || !opensInApp
+ ? null
+ : pullRequestLinkPanelTarget({
+ url: link.url,
+ environmentId: threadRef.environmentId,
+ primaryEnvironmentId,
+ lendHostProject: true,
+ });
+ prefetch(
+ target?.host === undefined
+ ? null
+ : {
+ environmentId: target.project.environmentId,
+ projectId: target.project.id,
+ host: target.host,
+ repository: target.repository,
+ number: target.number,
+ },
+ );
+ };
const supportsWatch = capabilities?.threadPullRequestWatch === true;
const canStartWatch = thread !== null && canStartThreadPullRequestWatch(thread);
const handleUnlink = (link: ThreadPullRequestLink) => {
@@ -427,7 +471,15 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread
};
// Legend rows update only for data/extraData changes; include all external inputs.
- const rowInputs = { speedMode, environmentProjects, capabilities, supportsWatch, thread };
+ const rowInputs = {
+ speedMode,
+ environmentProjects,
+ capabilities,
+ supportsWatch,
+ thread,
+ opensInApp,
+ primaryEnvironmentId,
+ };
if (linkCount === 0) {
return (
@@ -469,6 +521,7 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread
speedMode={speedMode}
onUnlink={handleUnlink}
onSetWatching={supportsWatch ? handleSetWatching : null}
+ onIntent={intendLink}
canStartWatch={canStartWatch}
/>
)}
diff --git a/apps/web/src/components/pullRequest/pullRequestStackSnapshot.test.ts b/apps/web/src/components/pullRequest/pullRequestStackSnapshot.test.ts
index 4b6f947232b38..2d9165c4ea6de 100644
--- a/apps/web/src/components/pullRequest/pullRequestStackSnapshot.test.ts
+++ b/apps/web/src/components/pullRequest/pullRequestStackSnapshot.test.ts
@@ -88,10 +88,11 @@ describe("saved stack navigation", () => {
isFresh: true,
notice: null,
});
- expect(pullRequestStackView({ ...query, isPending: true }, saved).isFresh).toBe(true);
+ // A shown answer being read again may be one restored from the device, so it cannot clear a merge.
+ expect(pullRequestStackView({ ...query, isPending: true }, saved).isFresh).toBe(false);
expect(pullRequestStackView({ ...query, data: null, isPending: true }, saved)).toMatchObject({
data: null,
- isFresh: true,
+ isFresh: false,
});
});
});
diff --git a/apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts b/apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts
index f2f4f8f2de9a3..05867dab4e590 100644
--- a/apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts
+++ b/apps/web/src/components/pullRequest/pullRequestStackSnapshot.ts
@@ -59,7 +59,8 @@ export function pullRequestStackView(
const data = query.isSuccess ? query.data : (query.data ?? saved);
return {
data,
- isFresh: query.isSuccess,
+ // A settled read: one still out may be showing an answer restored from the device.
+ isFresh: query.isSuccess && !query.isPending,
notice:
data === null
? null
diff --git a/apps/web/src/components/pullRequest/usePullRequestListView.ts b/apps/web/src/components/pullRequest/usePullRequestListView.ts
index 7b9e128f12b66..b8384cd6ca34f 100644
--- a/apps/web/src/components/pullRequest/usePullRequestListView.ts
+++ b/apps/web/src/components/pullRequest/usePullRequestListView.ts
@@ -6,6 +6,7 @@ import {
type ProjectId,
type PullRequestInvolvement,
type PullRequestListFilters,
+ type PullRequestListInput,
type PullRequestListResult,
type PullRequestListState,
} from "@t3tools/contracts";
@@ -203,6 +204,38 @@ export function usePullRequestScope(
};
}
+export const PAGE_SIZE = 99;
+
+function pullRequestMenuFilters(search: PullRequestListPreferences) {
+ return {
+ ...(search.draft ? { draft: search.draft } : {}),
+ ...(search.review ? { review: search.review } : {}),
+ ...(search.checks ? { checks: search.checks } : {}),
+ ...(search.author ? { author: search.author } : {}),
+ ...(search.labels ? { labels: search.labels.map((label) => [label]) } : {}),
+ };
+}
+
+/** The first page of a scope, without the typed search; the list reads the same key when unsearched. */
+export function pullRequestBaselineTargets(
+ search: PullRequestListPreferences,
+ scope: Pick
, "environmentQueries" | "scopedProjectId">,
+) {
+ const menuFilters = pullRequestMenuFilters(search);
+ return scope.environmentQueries.map(({ environmentId, projectIds }) => ({
+ environmentId,
+ input: {
+ state: search.state,
+ involvement: search.involvement,
+ limit: PAGE_SIZE,
+ ...(scope.scopedProjectId ? { projectId: scope.scopedProjectId } : {}),
+ ...(projectIds ? { projectIds } : {}),
+ ...(search.host ? { host: search.host } : {}),
+ ...(Object.keys(menuFilters).length > 0 ? { filters: menuFilters } : {}),
+ } satisfies PullRequestListInput,
+ }));
+}
+
export function usePullRequestQueryFilters(search: PullRequestsSearch, typedQuery: string) {
const [sentQuery, setSentQuery] = useState(typedQuery);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- debounce timer that sends the typed query after SEARCH_DEBOUNCE_MS
@@ -213,13 +246,7 @@ export function usePullRequestQueryFilters(search: PullRequestsSearch, typedQuer
const typedParsed = parsePullRequestQuery(typedQuery);
const sentParsed = parsePullRequestQuery(sentQuery);
- const menuFilters = {
- ...(search.draft ? { draft: search.draft } : {}),
- ...(search.review ? { review: search.review } : {}),
- ...(search.checks ? { checks: search.checks } : {}),
- ...(search.author ? { author: search.author } : {}),
- ...(search.labels ? { labels: search.labels.map((label) => [label]) } : {}),
- };
+ const menuFilters = pullRequestMenuFilters(search);
const menuFiltered = Object.keys(menuFilters).length > 0;
const filters = { ...menuFilters, ...sentParsed.filters };
const hasFilters = Object.keys(filters).length > 0;
diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts
index 9b6591f34be0e..bf0b9645e9ca0 100644
--- a/apps/web/src/lib/openPullRequestLink.ts
+++ b/apps/web/src/lib/openPullRequestLink.ts
@@ -40,6 +40,27 @@ export function usePullRequestPreviewTarget(environmentId: EnvironmentId | null,
});
}
+/** Where a click on this link opens the in-app panel, read at call time as the click reads it. */
+export function pullRequestLinkPanelTarget(input: {
+ readonly url: string;
+ readonly environmentId: EnvironmentId | undefined;
+ readonly primaryEnvironmentId: EnvironmentId | null;
+ readonly lendHostProject: boolean;
+}) {
+ // Read at click time: every provider status push replaces the config map, and
+ // subscribing would hand every rendered link a new handler. Projects likewise, so a
+ // link can resolve against a snapshot that arrived after this handler was built.
+ const serverConfigs = appAtomRegistry.get(environmentServerConfigsAtom);
+ return resolveChangeRequestLinkTarget({
+ url: input.url,
+ projects: readProjects(),
+ configLoaded: (environmentId) => serverConfigs.has(environmentId),
+ environmentId: input.environmentId,
+ primaryEnvironmentId: input.primaryEnvironmentId,
+ lendHostProject: input.lendHostProject,
+ });
+}
+
function shouldOpenPullRequestExternally(
event: Pick, "metaKey" | "ctrlKey"> &
Partial, "button">>,
@@ -76,14 +97,8 @@ export function useOpenChangeRequestLink(
const resolvedThreadRef = targetThreadRef ?? threadRef;
const resolvedPanelRef = panelRef ?? resolvedThreadRef;
const openPullRequest = () => {
- // Read at click time: every provider status push replaces the config map, and
- // subscribing would hand every rendered link a new handler. Projects likewise, so a
- // link can resolve against a snapshot that arrived after this handler was built.
- const serverConfigs = appAtomRegistry.get(environmentServerConfigsAtom);
- const target = resolveChangeRequestLinkTarget({
+ const target = pullRequestLinkPanelTarget({
url: targetUrl,
- projects: readProjects(),
- configLoaded: (environmentId) => serverConfigs.has(environmentId),
environmentId: resolvedThreadRef?.environmentId ?? targetEnvironmentId,
primaryEnvironmentId,
lendHostProject: resolvedPanelRef !== undefined,
diff --git a/apps/web/src/state/usePullRequestStack.ts b/apps/web/src/state/usePullRequestStack.ts
index 99619a5f9ff98..9776ced468dfc 100644
--- a/apps/web/src/state/usePullRequestStack.ts
+++ b/apps/web/src/state/usePullRequestStack.ts
@@ -10,6 +10,8 @@ import { pullRequestStackAtom } from "./pullRequests";
import { useEnvironmentQuery } from "./query";
import { environmentThreadShells } from "./threads";
+const NOT_ANSWERED = Atom.make(false);
+
const sameLinks = Schema.toEquivalence(Schema.Array(ThreadPullRequestLink));
// Shell pushes recreate link arrays; reuse equal lists so streaming does not re-render the stack.
@@ -34,5 +36,12 @@ export function usePullRequestStack(
const query = useEnvironmentQuery(
reference === null ? null : pullRequestStackAtom({ environmentId, input: reference }),
);
- return { ...query, ...pullRequestStackView(query, saved) };
+ const answered = useAtomValue(
+ reference === null
+ ? NOT_ANSWERED
+ : pullRequestStackAtom.answered({ environmentId, input: reference }),
+ );
+ const view = pullRequestStackView(query, saved);
+ // A saved answer paints; merging waits for this session's own host answer.
+ return { ...query, ...view, isFresh: view.isFresh && answered };
}
diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts
index 4788e56f8b849..af7871121d860 100644
--- a/packages/client-runtime/src/state/pullRequests.test.ts
+++ b/packages/client-runtime/src/state/pullRequests.test.ts
@@ -45,6 +45,7 @@ import {
applyPullRequestSummaryEdits,
createLinkedPullRequestSummaryAtomFamily,
createPullRequestEnvironmentAtoms,
+ createPullRequestStackAtomFamily,
editHeldPullRequestListEntries,
heldPullRequestListEntriesAtom,
pullRequestHostAnswer,
@@ -2381,6 +2382,121 @@ it.effect.each([
),
);
+it.effect.each([false, true])(
+ "reads a list ahead once online, only when the device saved none for it (saved: %s)",
+ (alreadySaved) =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+ yield* Effect.addFinalizer(() => Effect.sync(() => vi.useRealTimers()));
+ let reads = 0;
+ const saved = new Map();
+ const input = { state: "open", limit: 99 } as const;
+ if (alreadySaved) {
+ saved.set(
+ queryPersistenceKey("environment-data:pull-requests:list", input),
+ JSON.stringify(listResult([])),
+ );
+ }
+ const listRead = yield* Deferred.make();
+ const client = actionReplyClient({
+ [WS_METHODS.pullRequestsList]: () =>
+ Effect.sync(() => {
+ reads++;
+ }).pipe(
+ Effect.andThen(Deferred.succeed(listRead, undefined)),
+ Effect.as(listResult([{ ...actionReference, state: "open", isDraft: false }])),
+ ),
+ });
+ const { atoms, registry, supervisor } = yield* makeTestRuntime(
+ client,
+ undefined,
+ false,
+ {},
+ {
+ load: (_environmentId, key) =>
+ Effect.sync(() => Option.fromUndefinedOr(saved.get(key))),
+ save: (_environmentId, key, payload) => Effect.sync(() => void saved.set(key, payload)),
+ },
+ );
+ yield* SubscriptionRef.update(supervisor.state, (state) => ({
+ ...state,
+ phase: "offline" as const,
+ }));
+ const target = { environmentId: TARGET.environmentId, input };
+ const warm = atoms.warmList(target);
+ const unmount = registry.mount(warm);
+ yield* Effect.addFinalizer(() => Effect.sync(unmount));
+ yield* AtomRegistry.getResult(registry, warm, { suspendOnWaiting: true });
+ expect(reads).toBe(0);
+ // The list's idle wait ends while still offline, so only the warm-up can read again.
+ yield* Effect.promise(() => vi.advanceTimersByTimeAsync(11 * 60_000));
+
+ yield* SubscriptionRef.update(supervisor.state, (state) => ({
+ ...state,
+ phase: "connected" as const,
+ }));
+ if (!alreadySaved) yield* Deferred.await(listRead);
+ yield* AtomRegistry.getResult(registry, warm, { suspendOnWaiting: true });
+ expect(reads > 0).toBe(!alreadySaved);
+ // The page's own read of the same key then paints at once from what the warm-up saved.
+ const list = atoms.list(target);
+ registry.mount(list);
+ expect(Option.getOrThrow(AsyncResult.value(registry.get(list))).entries.length).toBe(
+ alreadySaved ? 0 : 1,
+ );
+ }),
+ ),
+);
+
+it.effect("reads the list again when the page opens after the warm-up's read failed", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+ yield* Effect.addFinalizer(() => Effect.sync(() => vi.useRealTimers()));
+ let reads = 0;
+ const saved = new Map();
+ const readAgain = yield* Deferred.make();
+ const client = actionReplyClient({
+ [WS_METHODS.pullRequestsList]: () =>
+ Effect.suspend(() =>
+ ++reads === 1
+ ? new PullRequestOperationError({ operation: "list", detail: "paused" })
+ : Deferred.succeed(readAgain, undefined).pipe(
+ Effect.as(listResult([{ ...actionReference, state: "open", isDraft: false }])),
+ ),
+ ),
+ });
+ const { atoms, registry } = yield* makeTestRuntime(
+ client,
+ undefined,
+ false,
+ {},
+ {
+ load: (_environmentId, key) => Effect.sync(() => Option.fromUndefinedOr(saved.get(key))),
+ save: (_environmentId, key, payload) => Effect.sync(() => void saved.set(key, payload)),
+ },
+ );
+ const target = { environmentId: TARGET.environmentId, input: { state: "open" } } as const;
+ const warm = atoms.warmList(target);
+ const unmount = registry.mount(warm);
+ yield* Effect.addFinalizer(() => Effect.sync(unmount));
+ yield* AtomRegistry.getResult(registry, warm, { suspendOnWaiting: true });
+ expect(reads).toBe(1);
+ // The page opens a moment later, well inside the list's five-minute idle wait.
+ yield* Effect.promise(() => vi.advanceTimersByTimeAsync(1_000));
+
+ const list = atoms.list(target);
+ registry.mount(list);
+ // The page's first visit reads again rather than showing the warm-up's failure.
+ yield* Deferred.await(readAgain);
+ yield* Effect.promise(() => vi.advanceTimersByTimeAsync(0));
+ const answer = yield* AtomRegistry.getResult(registry, list, { suspendOnWaiting: true });
+ expect(answer.entries.length).toBe(1);
+ }),
+ ),
+);
+
it.effect("puts readers back when the host refuses an action, and saves nothing it guessed", () =>
Effect.scoped(
Effect.gen(function* () {
@@ -2739,6 +2855,201 @@ it.effect.each(["accepted", "refused"] as const)(
),
);
+it.effect.each([
+ "rewrites the title",
+ "rewrites the title but re-read fails",
+ "answers a read before it commits",
+ "rewrites the title for readers opened during it",
+ "rewrites the title for readers still loading at send",
+] as const)(
+ "ends on the host's own title when a read answers during an accepted edit (host %s)",
+ (timing) =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ let title = "Cached";
+ let failReads = false;
+ const sent = yield* Deferred.make();
+ const release = yield* Deferred.make();
+ const readsHeld = timing === "rewrites the title for readers still loading at send";
+ const releaseReads = yield* Deferred.make();
+ const held = (answer: () => A) =>
+ Effect.andThen(
+ readsHeld ? Deferred.await(releaseReads) : Effect.void,
+ Effect.suspend(() =>
+ failReads
+ ? new PullRequestOperationError({ operation: "detail", detail: "read refused" })
+ : Effect.sync(answer),
+ ),
+ );
+ const read = () => held(() => ({ ...actionReference, state: "open", title }));
+ const client = actionReplyClient({
+ [WS_METHODS.pullRequestsDetail]: read,
+ [WS_METHODS.pullRequestsPreview]: read,
+ [WS_METHODS.pullRequestsList]: () =>
+ held(() =>
+ listResult([
+ { ...actionReference, state: "open", title, updatedAt: actionReply.updatedAt },
+ ]),
+ ),
+ [WS_METHODS.pullRequestsUpdate]: (input: { readonly title: string }) =>
+ Effect.gen(function* () {
+ title = timing.startsWith("rewrites") ? `Draft: ${input.title}` : "Original";
+ yield* Deferred.succeed(sent, undefined);
+ yield* Deferred.await(release);
+ if (!timing.startsWith("rewrites")) title = input.title;
+ failReads = timing === "rewrites the title but re-read fails";
+ }),
+ });
+ let accepted: string | undefined;
+ const { atoms, registry } = yield* makeTestRuntime(client, undefined, false, {
+ acceptedEdit: (_target: unknown, edit: { readonly title?: string }) =>
+ Effect.sync(() => {
+ accepted = edit.title;
+ }),
+ });
+ const target = { environmentId: TARGET.environmentId, input: actionReference };
+ const readers: ReadonlyArray<
+ Atom.Atom>
+ > = [atoms.detail(target), atoms.preview(target)];
+ // Pages past the first are not refreshed by the server.
+ const page = atoms.list({
+ environmentId: target.environmentId,
+ input: { state: "open", cursors: { "github.example.com": "page-2" } },
+ });
+ const loaded: ReadonlyArray>> = [
+ ...readers,
+ page,
+ ];
+ const opensDuring = timing === "rewrites the title for readers opened during it";
+ const mount = Effect.forEach(loaded, (atom) =>
+ Effect.gen(function* () {
+ const unmount = registry.mount(atom);
+ yield* Effect.addFinalizer(() => Effect.sync(unmount));
+ }),
+ );
+ const load = Effect.forEach(loaded, (atom) =>
+ Effect.promise(() => executeAtomQuery(registry, atom)),
+ );
+ if (!opensDuring) yield* mount;
+ if (!opensDuring && !readsHeld) yield* load;
+ const editing = yield* Effect.promise(() =>
+ atoms.update.run(registry, { ...target, input: { ...actionReference, title: "Edited" } }),
+ ).pipe(Effect.forkScoped);
+ yield* Deferred.await(sent);
+ if (opensDuring) yield* Effect.andThen(mount, load);
+ else if (readsHeld) yield* Effect.andThen(Deferred.succeed(releaseReads, undefined), load);
+ else
+ for (const atom of loaded)
+ yield* Effect.promise(() => executeAtomQuery(registry, atom, { refresh: true }));
+ yield* Deferred.succeed(release, undefined);
+ yield* Fiber.join(editing);
+ // Readers whose title changed during the write read it again.
+ for (const atom of loaded) yield* Effect.promise(() => executeAtomQuery(registry, atom));
+ expect([
+ ...readers.map((atom) => Option.getOrThrow(AsyncResult.value(registry.get(atom))).title),
+ Option.getOrThrow(AsyncResult.value(registry.get(page))).entries[0]?.title,
+ ]).toEqual(Array(3).fill(title));
+ // Text that changed during the write is re-read, so shared summaries do not take the edit.
+ expect(accepted).toBeUndefined();
+ }),
+ ),
+);
+
+it.effect(
+ "keeps an accepted title when a reader opened during the save reads the text from before it",
+ () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ let title = "Cached";
+ const sent = yield* Deferred.make();
+ const release = yield* Deferred.make();
+ const read = () => Effect.sync(() => ({ ...actionReference, state: "open", title }));
+ const client = actionReplyClient({
+ [WS_METHODS.pullRequestsDetail]: read,
+ [WS_METHODS.pullRequestsPreview]: read,
+ [WS_METHODS.pullRequestsUpdate]: (input: { readonly title: string }) =>
+ Effect.gen(function* () {
+ yield* Deferred.succeed(sent, undefined);
+ yield* Deferred.await(release);
+ title = input.title;
+ }),
+ });
+ let accepted: string | undefined;
+ const { atoms, registry } = yield* makeTestRuntime(client, undefined, false, {
+ acceptedEdit: (_target: unknown, edit: { readonly title?: string }) =>
+ Effect.sync(() => {
+ accepted = edit.title;
+ }),
+ });
+ const target = { environmentId: TARGET.environmentId, input: actionReference };
+ const detail = atoms.detail(target);
+ const preview = atoms.preview(target);
+ registry.mount(detail);
+ yield* Effect.promise(() => executeAtomQuery(registry, detail));
+ const editing = yield* Effect.promise(() =>
+ atoms.update.run(registry, { ...target, input: { ...actionReference, title: "Edited" } }),
+ ).pipe(Effect.forkScoped);
+ yield* Deferred.await(sent);
+ registry.mount(preview);
+ yield* Effect.promise(() => executeAtomQuery(registry, preview));
+ yield* Deferred.succeed(release, undefined);
+ yield* Fiber.join(editing);
+ expect(Option.getOrThrow(AsyncResult.value(registry.get(preview))).title).toBe("Edited");
+ expect(accepted).toBe("Edited");
+ }),
+ ),
+);
+
+it.effect("re-reads every reader a save touched when one of them sees the host rewrite it", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ let title = "Cached";
+ const sent = yield* Deferred.make();
+ const release = yield* Deferred.make();
+ const client = actionReplyClient({
+ [WS_METHODS.pullRequestsDetail]: () =>
+ Effect.sync(() => ({ ...actionReference, state: "open", title })),
+ [WS_METHODS.pullRequestsList]: () =>
+ Effect.sync(() =>
+ listResult([
+ { ...actionReference, state: "open", title, updatedAt: actionReply.updatedAt },
+ ]),
+ ),
+ [WS_METHODS.pullRequestsUpdate]: (input: { readonly title: string }) =>
+ Effect.gen(function* () {
+ title = `Draft: ${input.title}`;
+ yield* Deferred.succeed(sent, undefined);
+ yield* Deferred.await(release);
+ }),
+ });
+ const { atoms, registry } = yield* makeTestRuntime(client);
+ const target = { environmentId: TARGET.environmentId, input: actionReference };
+ const detail = atoms.detail(target);
+ // Pages past the first are not refreshed by the server.
+ const page = atoms.list({
+ environmentId: target.environmentId,
+ input: { state: "open", cursors: { "github.example.com": "page-2" } },
+ });
+ registry.mount(detail);
+ registry.mount(page);
+ yield* Effect.promise(() => executeAtomQuery(registry, detail));
+ yield* Effect.promise(() => executeAtomQuery(registry, page));
+ const editing = yield* Effect.promise(() =>
+ atoms.update.run(registry, { ...target, input: { ...actionReference, title: "Edited" } }),
+ ).pipe(Effect.forkScoped);
+ yield* Deferred.await(sent);
+ // Only the detail reads again during the save, and it sees the host's own title.
+ yield* Effect.promise(() => executeAtomQuery(registry, detail, { refresh: true }));
+ yield* Deferred.succeed(release, undefined);
+ yield* Fiber.join(editing);
+ const pageAnswer = yield* Effect.promise(() => executeAtomQuery(registry, page));
+ expect(pageAnswer._tag === "Success" && pageAnswer.value.entries[0]?.title).toBe(
+ "Draft: Edited",
+ );
+ }),
+ ),
+);
+
it.effect.each(["accepted", "refused"] as const)(
"keeps a label change on retained rows only when the host takes it (%s)",
(outcome) =>
@@ -2930,3 +3241,52 @@ it.effect.each(["accepted", "refused"] as const)(
}),
),
);
+
+it.effect("paints a saved stack answer at once while the host read is out", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const saved = new Map();
+ const store = {
+ load: (_environmentId: EnvironmentId, key: string) =>
+ Effect.sync(() => Option.fromUndefinedOr(saved.get(key))),
+ save: (_environmentId: EnvironmentId, key: string, payload: string) =>
+ Effect.sync(() => void saved.set(key, payload)),
+ };
+ const earlier = yield* makeTestRuntime(
+ {
+ [WS_METHODS.pullRequestsStack]: () => Effect.succeed(null),
+ } as unknown as WsRpcProtocolClient,
+ undefined,
+ false,
+ {},
+ store,
+ );
+ const target = { environmentId: TARGET.environmentId, input: actionReference };
+ const stacks = createPullRequestStackAtomFamily(earlier.runtime);
+ const first = stacks(target);
+ earlier.registry.mount(first);
+ yield* Effect.promise(() => executeAtomQuery(earlier.registry, first));
+ expect(earlier.registry.get(stacks.answered(target))).toBe(true);
+
+ // A later session finds the answer saved while its own read waits on the host.
+ const sent = yield* Deferred.make();
+ const waiting = yield* makeTestRuntime(
+ {
+ [WS_METHODS.pullRequestsStack]: () =>
+ Deferred.succeed(sent, undefined).pipe(Effect.andThen(Effect.never)),
+ } as unknown as WsRpcProtocolClient,
+ undefined,
+ false,
+ {},
+ store,
+ );
+ const waitingStacks = createPullRequestStackAtomFamily(waiting.runtime);
+ const stack = waitingStacks(target);
+ waiting.registry.mount(stack);
+ yield* Deferred.await(sent);
+ expect(waiting.registry.get(stack)).toMatchObject({ _tag: "Success", value: null });
+ // Offline, a saved answer settles without the host; only a host answer may clear a merge.
+ expect(waiting.registry.get(waitingStacks.answered(target))).toBe(false);
+ }),
+ ),
+);
diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts
index 623ced4fc4f5b..83a36cf2fdd64 100644
--- a/packages/client-runtime/src/state/pullRequests.ts
+++ b/packages/client-runtime/src/state/pullRequests.ts
@@ -4,6 +4,7 @@ import {
PullRequestDetail as PullRequestDetailSchema,
PullRequestPreview,
PullRequestReviewGuideResult,
+ PullRequestStack,
PullRequestListResult,
PullRequestChecks,
PullRequestDiffResult,
@@ -44,6 +45,7 @@ import {
createEnvironmentRpcQueryAtomFamily,
createEnvironmentRpcSubscriptionAtomFamily,
createEnvironmentQueryAtomFamily,
+ environmentSessionGenerationAtom,
queryPersistenceKey,
type QueryAnswerHandler,
} from "./runtime.ts";
@@ -327,15 +329,80 @@ function showWhileInFlight;
}
-/** Keeps an accepted edit in loaded readers once it stops being in flight. */
+function editedResults(
+ registry: AtomRegistry.AtomRegistry,
+ environmentId: EnvironmentId,
+ edit: PullRequestPendingEdit | null,
+) {
+ const results = new Map>();
+ if (edit === null) return results;
+ for (const [atom, node] of registry.getNodes()) {
+ const reader = typeof atom === "string" ? undefined : editableReaders.get(atom);
+ if (reader?.environmentId !== environmentId || !reader.concerns(edit.reference)) continue;
+ const result = Option.getOrUndefined(node.valueOption());
+ if (result !== undefined)
+ results.set(atom, result as AsyncResult.AsyncResult);
+ }
+ return results;
+}
+
+function editedText(
+ value: unknown,
+ reference: PullRequestRef,
+): { readonly title?: string; readonly body?: string } | undefined {
+ const held = value as {
+ readonly entries?: ReadonlyArray;
+ readonly title?: string;
+ readonly body?: string;
+ } | null;
+ const entries = Array.isArray(value)
+ ? (value as ReadonlyArray)
+ : held?.entries;
+ if (entries === undefined) return held ?? undefined;
+ return entries.find((entry) => samePullRequest(entry, reference));
+}
+
+function editedTextAtSend(
+ atSend: ReadonlyMap> | undefined,
+ edit: PullRequestPendingEdit,
+) {
+ for (const result of atSend?.values() ?? []) {
+ const text = editedText(Option.getOrUndefined(AsyncResult.value(result)), edit.reference);
+ if (
+ text !== undefined &&
+ (["title", "body"] as const).every((field) => edit[field] === undefined || field in text)
+ )
+ return text;
+ }
+ return undefined;
+}
+
+/** Whether the edited text changed while the write was out. */
+function hostRewroteEdit(sent: unknown, shown: unknown, edit: PullRequestPendingEdit) {
+ const before = editedText(sent, edit.reference);
+ const after = editedText(shown, edit.reference);
+ if (before === undefined || after === undefined) return false;
+ return (["title", "body"] as const).some(
+ (field) => edit[field] !== undefined && before[field] !== after[field],
+ );
+}
+
+/** Keeps an accepted edit in loaded readers once it stops being in flight; true when the host rewrote it. */
function settleEdit(
registry: AtomRegistry.AtomRegistry,
environmentId: EnvironmentId,
edit: PullRequestPendingEdit | null,
readsAfterWrite = false,
+ atSend?: ReadonlyMap>,
) {
- if (edit === null) return;
+ if (edit === null) return false;
+ let rewritten = false;
const pending = registry.get(pullRequestPendingEditsAtom(environmentId));
+ const textAtSend = editedTextAtSend(atSend, edit);
+ const touched: Array<{
+ readonly reader: EditableReader;
+ readonly writable: Atom.Writable>;
+ }> = [];
for (const atom of registry.getNodes().keys()) {
const reader = typeof atom === "string" ? undefined : editableReaders.get(atom);
if (reader?.environmentId !== environmentId || !reader.concerns(edit.reference)) continue;
@@ -347,11 +414,33 @@ function settleEdit(
// A read still waiting may predate the write, unless the caller started it after the commit.
if (!readsAfterWrite && registry.get(writable).waiting) registry.refresh(writable);
if (Option.isNone(shown)) continue;
- registry.update(
- writable,
- AsyncResult.map((value) => reader.edit(value, [edit], pending) as never),
- );
+ // Text that changed during the write may be the host's own version of it, or an older read.
+ // A reader with no text from before the write checks against another reader's text from then,
+ // so a read answered before the commit is not taken for a rewrite; with none, the edit itself.
+ const sent = atSend?.get(atom);
+ const changed =
+ atSend !== undefined &&
+ hostRewroteEdit(
+ (sent === undefined ? undefined : Option.getOrUndefined(AsyncResult.value(sent))) ??
+ textAtSend ??
+ edit,
+ shown.value,
+ edit,
+ );
+ if (changed) rewritten = true;
+ touched.push({ reader, writable });
}
+ // A rewrite one reader saw holds for all of them, and a reader the server does not refresh
+ // would otherwise keep the typed text.
+ for (const { reader, writable } of touched) {
+ if (!rewritten) {
+ registry.update(
+ writable,
+ AsyncResult.map((value) => reader.edit(value, [edit], pending) as never),
+ );
+ } else if (!registry.get(writable).waiting) registry.refresh(writable);
+ }
+ return rewritten;
}
function pullRequestReference(input: PullRequestRef): PullRequestRef {
@@ -776,14 +865,31 @@ export function createPullRequestStackAtomFamily(
refreshes = createPullRequestRefreshAtomFamily(runtime),
) {
const routedRequest = createPullRequestRouter();
- return createEnvironmentRpcQueryAtomFamily(runtime, {
+ const hostAnswers = Atom.family((_key: string) =>
+ Atom.make(false).pipe(Atom.setIdleTTL(5 * 60_000)),
+ );
+ const answeredKey = (target: {
+ readonly environmentId: EnvironmentId;
+ readonly input: PullRequestRef;
+ }) => JSON.stringify([target.environmentId, target.input]);
+ const stack = createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:pull-requests:stack",
+ // A saved answer paints at once; `answered` stays false until the host answers.
+ persistence: persistQuery(Schema.NullOr(PullRequestStack)),
tag: WS_METHODS.pullRequestsStack,
execute: (input) => routedRequest(WS_METHODS.pullRequestsStack, input),
staleTimeMs: 60_000,
idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS,
refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }),
+ onAnswer: (target, _value, registry) =>
+ Effect.sync(() => registry.set(hostAnswers(answeredKey(target)), true)),
});
+ /** Whether this session has the host's own answer, which is what merging one layer alone needs. */
+ const answered = (target: {
+ readonly environmentId: EnvironmentId;
+ readonly input: PullRequestRef;
+ }) => hostAnswers(answeredKey(target));
+ return Object.assign(stack, { answered });
}
export function pullRequestDetailToVcsStatus(
@@ -1176,10 +1282,11 @@ export function createPullRequestEnvironmentAtoms(
environmentId: EnvironmentId,
edit: PullRequestPendingEdit | null,
readsAfterWrite = false,
+ atSend?: ReadonlyMap>,
) =>
- Effect.sync(() => settleEdit(registry, environmentId, edit, readsAfterWrite)).pipe(
- Effect.andThen(() =>
- edit === null
+ Effect.sync(() => settleEdit(registry, environmentId, edit, readsAfterWrite, atSend)).pipe(
+ Effect.andThen((rewritten) =>
+ edit === null || rewritten
? Effect.void
: (onAnswer.acceptedEdit?.({ environmentId, input: edit.reference }, edit, registry) ??
Effect.void),
@@ -1188,6 +1295,55 @@ export function createPullRequestEnvironmentAtoms(
const listLabel = "environment-data:pull-requests:list";
const listPersistence = persistQuery(PullRequestListResult);
+ const listQuery = createEnvironmentRpcQueryAtomFamily(runtime, {
+ label: listLabel,
+ persistence: listPersistence,
+ tag: WS_METHODS.pullRequestsList,
+ staleTimeMs: 30_000,
+ refreshTrigger: ({ environmentId, input }) =>
+ input.cursors === undefined ? refreshes({ environmentId, input: {} }) : undefined,
+ ...(onAnswer.list === undefined ? {} : { onAnswer: onAnswer.list }),
+ });
+ const list = actionPatchedQuery(listQuery, {
+ label: listLabel,
+ persistence: listPersistence,
+ onAnswer: onAnswer.list,
+ // Rows carry no account scope, so a reply read through another account leaves them alone.
+ concerns: (input, reference) =>
+ reference.expectedAccountId === undefined &&
+ (input.projectId === undefined || input.projectId === reference.projectId) &&
+ (input.projectIds === undefined || input.projectIds.includes(reference.projectId)) &&
+ (input.host === undefined ||
+ reference.host === undefined ||
+ input.host.toLowerCase() === reference.host.toLowerCase()),
+ refreshedByServer: (input) => input.cursors === undefined,
+ patch: patchListWithReply,
+ edit: (input, value, edits, pending) => {
+ const entries = editPullRequestListEntries(input, value.entries, edits, pending);
+ return entries === value.entries ? value : { ...value, entries };
+ },
+ });
+ // A first visit paints from a saved answer; with none saved, one read now fills it.
+ const listWarmups = Atom.family((key: string) => {
+ const [environmentId, input] = JSON.parse(key) as [EnvironmentId, PullRequestListInput];
+ const target = { environmentId, input };
+ return runtime.atom((get) => {
+ // A read that failed offline tries again on the next session.
+ get(environmentSessionGenerationAtom(runtime, environmentId));
+ return Effect.gen(function* () {
+ const saved = yield* listPersistence.load(
+ environmentId,
+ queryPersistenceKey(listLabel, input),
+ );
+ if (Option.isSome(saved)) return;
+ // The bare query, not the page's wrapped reader: the wrappers stay cached after this lets
+ // go and would keep a failed read on screen, while the bare one re-reads it on mount.
+ yield* AtomRegistry.mount(get.registry, listQuery(target));
+ yield* AtomRegistry.getResult(get.registry, listQuery(target), { suspendOnWaiting: true });
+ }).pipe(Effect.scoped, Effect.ignore);
+ });
+ });
+
return {
refreshes,
linkedThreads: createEnvironmentRpcQueryAtomFamily(runtime, {
@@ -1205,36 +1361,11 @@ export function createPullRequestEnvironmentAtoms(
staleTimeMs: 0,
idleTtlMs: 0,
}),
- list: actionPatchedQuery(
- createEnvironmentRpcQueryAtomFamily(runtime, {
- label: listLabel,
- persistence: listPersistence,
- tag: WS_METHODS.pullRequestsList,
- staleTimeMs: 30_000,
- refreshTrigger: ({ environmentId, input }) =>
- input.cursors === undefined ? refreshes({ environmentId, input: {} }) : undefined,
- ...(onAnswer.list === undefined ? {} : { onAnswer: onAnswer.list }),
- }),
- {
- label: listLabel,
- persistence: listPersistence,
- onAnswer: onAnswer.list,
- // Rows carry no account scope, so a reply read through another account leaves them alone.
- concerns: (input, reference) =>
- reference.expectedAccountId === undefined &&
- (input.projectId === undefined || input.projectId === reference.projectId) &&
- (input.projectIds === undefined || input.projectIds.includes(reference.projectId)) &&
- (input.host === undefined ||
- reference.host === undefined ||
- input.host.toLowerCase() === reference.host.toLowerCase()),
- refreshedByServer: (input) => input.cursors === undefined,
- patch: patchListWithReply,
- edit: (input, value, edits, pending) => {
- const entries = editPullRequestListEntries(input, value.entries, edits, pending);
- return entries === value.entries ? value : { ...value, entries };
- },
- },
- ),
+ list,
+ warmList: (target: {
+ readonly environmentId: EnvironmentId;
+ readonly input: PullRequestListInput;
+ }) => listWarmups(JSON.stringify([target.environmentId, target.input])),
/**
* Its own query because the listing is quicker without them — measured over twelve
* repositories, ~4.0s against ~7.1s — so the rows arrive first and their stats a moment
@@ -1362,8 +1493,10 @@ export function createPullRequestEnvironmentAtoms(
label: "environment-data:pull-requests:update",
tag: WS_METHODS.pullRequestsUpdate,
execute: (input) => routedRequest(WS_METHODS.pullRequestsUpdate, input),
- onSuccess: (target, registry) =>
- acceptEdit(registry, target.environmentId, updateEdit(target.input)),
+ onStart: ({ environmentId, input }, registry) =>
+ editedResults(registry, environmentId, updateEdit(input)),
+ onSuccess: (target, registry, _reply, atSend) =>
+ acceptEdit(registry, target.environmentId, updateEdit(target.input), false, atSend),
scheduler: commandScheduler,
concurrency: serialPerEnvironment,
}),