Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
9c3c7ee
perf(pr): actions, title edits and labels show at once on every host
Bil0000 Nov 8, 2026
4a19a46
perf(web): title editor and label picker stop waiting on the host
Bil0000 Nov 8, 2026
d295113
fix(pr): preserve optimistic titles across shared summaries
maria-rcks Nov 10, 2026
8e4a2bc
fix(pr): retain accepted titles when saved summaries tie
maria-rcks Nov 10, 2026
fcf73a0
fix(pr): a queued reopen keeps a closed pull request in open lists
Bil0000 Nov 10, 2026
b42e869
fix(pr): a stored detail snapshot no longer hides a pending title
Bil0000 Nov 10, 2026
3fbca24
fix(pr): a label read still loading restarts when a label change lands
Bil0000 Nov 10, 2026
2ca2584
perf(pr): merge shows its result at once where the host says whether …
Bil0000 Nov 8, 2026
d9655bf
perf(web): hover on a PR row also reads its activity
Bil0000 Nov 10, 2026
2ac76f4
test(pr): fold the unknown-queue merge case into the queue-status table
Bil0000 Nov 10, 2026
5839e47
fix(pr): the checks read shows every queued state change
Bil0000 Nov 10, 2026
150b938
perf(pr): a saved stack paints at once and merging still waits for th…
Bil0000 Nov 8, 2026
098d030
perf(web): PR links, thread rows and the Code tab read ahead on intent
Bil0000 Nov 8, 2026
52ad114
fix(pr): an accepted edit ends on the host's own title
Bil0000 Nov 8, 2026
bccd49e
perf(pr): a first visit to the PR list paints from a read made ahead
Bil0000 Nov 8, 2026
ab5fd5d
fix(web): Code tab reads ahead only after the pointer rests, stale me…
maria-rcks Nov 10, 2026
f886923
test(client-runtime): list warm-up test awaits the reconnect read ins…
maria-rcks Nov 10, 2026
5e9b2ca
test(web): read-ahead tests check only the key a click reads
Bil0000 Nov 10, 2026
8defad4
fix(pr): a reader opened or still loading during a title save ends on…
Bil0000 Nov 10, 2026
bc51211
Merge remote-tracking branch 'github/main' into t3/instant-pr-load
maria-rcks Nov 11, 2026
f372d0a
fix(web): a requested PR refresh spins until its read settles
maria-rcks Nov 11, 2026
a20daf6
Merge branch 'main' into t3/instant-pr-merge-and-load
maria-rcks Nov 11, 2026
87d2141
Merge remote-tracking branch 'github/main' into t3/instant-pr-load
maria-rcks Nov 11, 2026
138bba6
Merge remote-tracking branch 'origin/main' into rv/32774
Bil0000 Nov 13, 2026
2ce33a1
fix(pr): keep pending edits out of retained list rows
Bil0000 Nov 13, 2026
30240c5
fix(pr): merge list fixes and wait for host merge results
Bil0000 Nov 13, 2026
29d26a9
fix(pr): merge stack updates and preserve host titles on read failure
Bil0000 Nov 13, 2026
7f989bc
chore(pr): merge current main into saved pull request reads
Bil0000 Nov 13, 2026
342dfe6
fix(pr): a title the host rewrote stays out of shared summaries
Bil0000 Nov 13, 2026
7a1d2d3
fix(pr): an accepted edit stays on retained list rows
Bil0000 Nov 13, 2026
db380fc
chore(pr): merge retained row fix into rv/32842
Bil0000 Nov 13, 2026
327f8a2
Merge branch 'rv/32842' into rv/32911
Bil0000 Nov 13, 2026
49ae4f6
fix(pr): the PR page loads again, and a pending title shows over a st…
shivamhwp Nov 14, 2026
afbd547
Merge remote-tracking branch 'origin/main' into pr-32774
shivamhwp Nov 14, 2026
697b854
Merge branch 'pr-32774' into pr-32842
shivamhwp Nov 14, 2026
c089e6d
Merge branch 'pr-32842' into pr-32911
shivamhwp Nov 14, 2026
37bc21d
Merge remote-tracking branch 'origin/main' into pr-32911
shivamhwp Nov 14, 2026
40fceb5
Merge remote-tracking branch 'origin/main' into pr-32842
shivamhwp Nov 15, 2026
9273ced
Merge commit '40fceb576c' into pr-32911
shivamhwp Nov 15, 2026
e80cad7
fix(pr): a failed warm-up no longer sticks, and a title save stops fl…
shivamhwp Nov 15, 2026
d74a207
fix(pr): a title the host rewrote reaches every reader, not only the …
shivamhwp Nov 15, 2026
7ace3f3
test(pr): mount the cross-reader rewrite test's readers one at a time
shivamhwp Nov 15, 2026
318dac7
Merge remote-tracking branch 'origin/main' into pr-32911
shivamhwp Nov 15, 2026
f4e90f7
Merge remote-tracking branch 'origin/main' into t3/instant-pr-load
juliusmarminge Nov 15, 2026
d97655e
chore(pr): keep the panel's look as it was; this change is about data
juliusmarminge Nov 15, 2026
329dfe9
Merge branch 't3/instant-pr-load' of https://github.com/Bil0000/t3cod…
juliusmarminge Nov 15, 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
2 changes: 2 additions & 0 deletions apps/mobile/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import {
import { useMobileNavigationTheme } from "./lib/useMobileNavigationTheme";
import { installUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarningGc";
import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator";
import { PullRequestListWarmup } from "./features/pull-requests/PullRequestListWarmup";
import { ConnectDeviceApprovalCoordinator } from "./features/connection/ConnectDeviceApprovalCoordinator";
import { installSubscriptionUsageRefresh } from "./widgets/useSubscriptionUsage";
import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl";
Expand Down Expand Up @@ -282,6 +283,7 @@ function AppContent() {
<>
<SplashScreenCoordinator />
<SubscriptionUsageCoordinator />
<PullRequestListWarmup />
<ConnectDeviceApprovalCoordinator />
<GestureHandlerRootView className="flex-1">
<KeyboardProvider statusBarTranslucent>
Expand Down
36 changes: 36 additions & 0 deletions apps/mobile/src/features/pull-requests/PullRequestListWarmup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useAtomMount, useAtomValue } from "@effect/atom-react";
import type { EnvironmentId, PullRequestListInput } from "@t3tools/contracts";
import { AsyncResult } from "effect/reactivity";

import { useProjects } from "../../state/entities";
import { mobilePreferencesAtom } from "../../state/preferences";
import { composerPullRequests } from "../../state/pull-requests";
import { useEnabledNavigationTabs } from "../layout/workspace/navigation-tabs";
import { usePullRequestListTargets } from "./usePullRequestListControls";

/** Fills an empty saved list for the scope the PRs tab opens on, so its first visit paints. */
export function PullRequestListWarmup() {
// Until the saved filters load, the tab's scope is not known and a read could be wasted.
const loaded = AsyncResult.isSuccess(useAtomValue(mobilePreferencesAtom));
const enabled = useEnabledNavigationTabs().includes("pullRequests");
return loaded && enabled ? <WarmLists /> : null;
}

function WarmLists() {
const { targets } = usePullRequestListTargets("");
const projects = useProjects();
return targets
.filter((target) =>
projects.some(
(project) => project.environmentId === target.environmentId && !project.isScratch,
),
)
.map((target) => <WarmList key={target.environmentId} target={target} />);
}

function WarmList(props: {
target: { readonly environmentId: EnvironmentId; readonly input: PullRequestListInput };
}) {
useAtomMount(composerPullRequests.warmList(props.target));
return null;
}
32 changes: 2 additions & 30 deletions apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,16 +30,14 @@ import {
type PullRequestFilterMenu,
} from "./PullRequestListFilters";
import { usePullRequestList } from "./usePullRequestList";
import { usePullRequestListControls } from "./usePullRequestListControls";
import { usePullRequestListTargets } from "./usePullRequestListControls";
import { usePullRequestListActions } from "./usePullRequestListActions";
import {
filterPullRequestList,
pullRequestListFacets,
pullRequestListInput,
pullRequestListRowsAreEqual,
scopedPullRequestListKey as pullRequestKey,
type EnvironmentPullRequestListEntry,
withoutScratchProjectScope,
pullRequestProjectChoices,
} from "./pullRequestList";
import { useDebouncedValue } from "../../state/queries";
Expand Down Expand Up @@ -121,11 +119,7 @@ export function PullRequestsScreen(
const query = tab.searchMode === "resting" ? "" : debouncedQuery;
const typedQuery = tab.searchMode === "resting" ? "" : tab.query;
const typedDraft = parsePullRequestQuery(typedQuery).filters.draft;
const listControls = usePullRequestListControls();
const selectedEnvironmentId = listControls.environmentId;
const environmentId = environments.some((item) => item.environmentId === selectedEnvironmentId)
? selectedEnvironmentId
: null;
const { listControls, environmentId, controls, targets } = usePullRequestListTargets(query);
const selectDetail = useSelectWorkspaceDetail();
const selectPullRequest = (item: EnvironmentPullRequestListEntry) => {
Keyboard.dismiss();
Expand All @@ -140,28 +134,6 @@ export function PullRequestsScreen(
selectDetail({ name: "PullRequest", params });
};
const allProjects = useProjects();
const savedControls = listControls.controlsFor(environmentId);
const controls = useMemo(
() => withoutScratchProjectScope(savedControls, allProjects),
[allProjects, savedControls],
);
const targets = useMemo(
() =>
environments
.filter(
(environment) =>
configs.get(environment.environmentId) !== undefined &&
(environmentId === null || environment.environmentId === environmentId) &&
(!controls.projectEnvironmentId ||
environment.environmentId === controls.projectEnvironmentId),
)
.sort((left, right) => left.environmentId.localeCompare(right.environmentId))
.map((environment) => ({
environmentId: environment.environmentId,
input: pullRequestListInput(controls, query),
})),
[environments, environmentId, configs, controls, query],
);
const result = usePullRequestList(targets);
const connectedTargets = targets.filter((target) =>
environments.some(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import { AsyncResult } from "effect/reactivity";
import { useMemo } from "react";

import { useProjects } from "../../state/entities";
import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
import { DEFAULT_PR_LIST_CONTROLS, type PullRequestListControls } from "./pullRequestList";
import { environmentServerConfigsAtom } from "../../state/server";
import { useWorkspaceState } from "../../state/workspace";
import {
DEFAULT_PR_LIST_CONTROLS,
pullRequestListInput,
withoutScratchProjectScope,
type PullRequestListControls,
} from "./pullRequestList";

export function usePullRequestListControls() {
const preferences = useAtomValue(mobilePreferencesAtom);
Expand Down Expand Up @@ -45,3 +54,38 @@ function withEnvironmentControls(
): Record<string, PullRequestListControls> {
return { ...saved, [environment]: controls };
}

/** The list reads for the saved controls; the PRs tab and its warm-up share these keys. */
export function usePullRequestListTargets(query: string) {
const { environments } = useWorkspaceState();
const configs = useAtomValue(environmentServerConfigsAtom);
const allProjects = useProjects();
const listControls = usePullRequestListControls();
const selectedEnvironmentId = listControls.environmentId;
const environmentId = environments.some((item) => item.environmentId === selectedEnvironmentId)
? selectedEnvironmentId
: null;
const savedControls = listControls.controlsFor(environmentId);
const controls = useMemo(
() => withoutScratchProjectScope(savedControls, allProjects),
[allProjects, savedControls],
);
const targets = useMemo(
() =>
environments
.filter(
(environment) =>
configs.get(environment.environmentId) !== undefined &&
(environmentId === null || environment.environmentId === environmentId) &&
(!controls.projectEnvironmentId ||
environment.environmentId === controls.projectEnvironmentId),
)
.sort((left, right) => left.environmentId.localeCompare(right.environmentId))
.map((environment) => ({
environmentId: environment.environmentId,
input: pullRequestListInput(controls, query),
})),
[environments, environmentId, configs, controls, query],
);
return { listControls, environmentId, controls, targets };
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility";
import type { PullRequestDetail } from "@t3tools/contracts";
import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
import { useAtomValue } from "@effect/atom-react";
import { Atom } from "effect/reactivity";
import { useMemo } from "react";
import { useEnvironmentServerConfig, useNavigationThreadShells } from "../../state/entities";
import { pullRequestStackAtom } from "../../state/pull-requests";
import { useEnvironmentQuery } from "../../state/query";
import { savedPullRequestStack } from "./pullRequestStack";
import { pullRequestTarget, type PullRequestParams } from "./usePullRequestDiff";

const NOT_ANSWERED = Atom.make(false);

export function usePullRequestStack(params: PullRequestParams, pr: PullRequestDetail | null) {
const target = pullRequestTarget(params);
const config = useEnvironmentServerConfig(target.environmentId);
Expand Down Expand Up @@ -37,11 +41,14 @@ export function usePullRequestStack(params: PullRequestParams, pr: PullRequestDe
const query = useEnvironmentQuery(
supported ? pullRequestStackAtom({ ...target, input: reference }) : null,
);
const answered = useAtomValue(
supported ? pullRequestStackAtom.answered({ ...target, input: reference }) : NOT_ANSWERED,
);
const stack = query.isSuccess ? query.data : (query.data ?? saved);
return {
...query,
stack,
isFresh: supported && query.isSuccess && !query.isPending,
isFresh: supported && query.isSuccess && !query.isPending && answered,
supportsStackActions: supported && pr?.capabilities.stackActions === true,
};
}
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/AppSidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
} from "../panelAnimations";
import { BackgroundThreadAnnouncer } from "./BackgroundThreadAnnouncer";
import { HistorySwipeNavigation } from "./HistorySwipeNavigation";
import { PullRequestListWarmup } from "./pullRequest/PullRequestListWarmup";
import ThreadSidebar from "./Sidebar";
import { SidebarBrandWidthProbe } from "./sidebar/SidebarChrome";
import { isAppModalPage } from "./sidebar/mainAppLocation";
Expand Down Expand Up @@ -494,6 +495,7 @@ export function AppSidebarLayout({
) : null}
{children}
<BackgroundThreadAnnouncer />
<PullRequestListWarmup />
<SidebarControl showSidebar={showSidebar} />
{isElectron ? <HistorySwipeNavigation /> : null}
</SidebarProvider>
Expand Down
14 changes: 12 additions & 2 deletions apps/web/src/components/pullRequest/PullRequestDetailLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ArrowLeftIcon, FileDiffIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { useRef, type ComponentProps, type ReactNode } from "react";

import { cn } from "~/lib/utils";

Expand Down Expand Up @@ -77,6 +77,8 @@ export function PullRequestDetailTabBar<Tab extends string>({
inert?: boolean;
children?: ReactNode;
}) {
// A pointer passing over a tab is not intent; it must rest like a list row does.
const pointerIntent = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
return (
<nav
className="col-span-2 flex min-w-0 flex-wrap items-center gap-2 border-t border-border/60 px-4 py-2"
Expand All @@ -97,7 +99,15 @@ export function PullRequestDetailTabBar<Tab extends string>({
<Toggle
key={tab.value}
value={tab.value}
onPointerEnter={onTabIntent ? () => onTabIntent(tab.value) : undefined}
onPointerEnter={
onTabIntent
? () => {
clearTimeout(pointerIntent.current);
pointerIntent.current = setTimeout(() => onTabIntent(tab.value), 150);
}
: undefined
}
onPointerLeave={onTabIntent ? () => clearTimeout(pointerIntent.current) : undefined}
onFocus={onTabIntent ? () => onTabIntent(tab.value) : undefined}
>
{tab.label}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import {
type PullRequestDetailView,
} from "@t3tools/contracts";
import { expect, it, vi } from "vite-plus/test";
import { userEvent } from "vite-plus/test/browser";
import { DraftId, useComposerDraftStore } from "~/composerDraftStore";
import { act, render } from "../../../test/render";
import { act, fakeTimers, render } from "../../../test/render";

const { permissions } = vi.hoisted(() => ({
const { permissions, reads } = vi.hoisted(() => ({
permissions: { checkout: true, operate: true },
reads: { diff: [] as unknown[] },
}));

vi.mock(import("~/state/session"), async (original) => ({
Expand Down Expand Up @@ -51,6 +53,19 @@ vi.mock(import("~/state/usePullRequestStack"), async (original) => ({
refresh: vi.fn(),
})) as never,
}));
vi.mock(import("~/state/pullRequests"), async (original) => {
const actual = await original();
return {
...actual,
pullRequestEnvironment: {
...actual.pullRequestEnvironment,
diff: ((target: unknown) => {
reads.diff.push(target);
return actual.pullRequestEnvironment.diff(target as never);
}) as never,
},
};
});
vi.mock(import("~/state/use-atom-command"), async (original) => ({
...(await original()),
useAtomCommand: (() => vi.fn()) as never,
Expand Down Expand Up @@ -133,6 +148,7 @@ const panel = (attach = false) => (
function setup() {
permissions.checkout = true;
permissions.operate = true;
reads.diff = [];
useComposerDraftStore.setState({ draftsByThreadKey: {} });
}

Expand Down Expand Up @@ -162,3 +178,21 @@ it("can still add conflict resolution to an existing composer without checkout s
expect(draft?.prompt).toContain("conflicts");
expect(draft?.prompt).toContain(detail.url);
});

it("reads the first diff page once the pointer rests on the Code tab", async () => {
setup();
await using screen = await render(panel());
using _timers = fakeTimers({ now: 0 });
const code = screen.getByRole("button", { name: "Code", exact: true });
await expect.element(code).toBeVisible();

await userEvent.hover(code);
await act(() => vi.advanceTimersByTime(149));
expect(reads.diff).toEqual([]);
await act(() => vi.advanceTimersByTime(1));
// The page the Code tab reads first, keyed as the tab keys it, so a click paints from it.
expect(reads.diff).toContainEqual({
environmentId,
input: { projectId: detail.projectId, repository: detail.repository, number: detail.number },
});
});
Comment thread
Bil0000 marked this conversation as resolved.
Resolved
11 changes: 10 additions & 1 deletion apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,13 @@ export function PullRequestDetailPanel({
} else if (!tabMountState.tabs.has(tab)) {
setTabMountState({ key: tabScopeKey, tabs: new Set(tabMountState.tabs).add(tab) });
}
// A pointer on the Code tab reads the page it opens on; only intent costs the host a read.
const [codeIntentKey, setCodeIntentKey] = useState<string | null>(null);
useEnvironmentQuery(
codeIntentKey === pullRequestKey && !mountedTabs.has("code")
? pullRequestEnvironment.diff({ environmentId, input: { ...reference } })
: null,
);
// Each tab restores the condensed state its own scrolling left behind.
const [condensedByTab, setCondensedByTab] = useState<
Partial<Record<PullRequestDetailTab, boolean>>
Expand Down Expand Up @@ -1714,7 +1721,9 @@ export function PullRequestDetailPanel({
value={tab}
onValueChange={selectTab}
onTabIntent={(item) => {
if (item === "code") void loadCodeTab();
if (item !== "code") return;
void loadCodeTab();
setCodeIntentKey(pullRequestKey);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}}
>
{tab === "summary" ? (
Expand Down
Loading
Loading