Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
370c668
perf(desktop): trace channel switches from click to settled paint
Maxwellimus Aug 20, 2026
0cfabee
test(desktop): high-membership channel-switch perf harness
Maxwellimus Aug 20, 2026
89910c5
fix(desktop): suspense-aware settle, StrictMode-safe abandon, log cap
Maxwellimus Aug 24, 2026
705899b
fix(desktop): guard-gated traces, on-disk log cap, honest projects be…
Maxwellimus Aug 25, 2026
446174b
fix(desktop): drop frame-starved settles; harden sink and harness hon…
Maxwellimus Aug 25, 2026
f6d3388
fix(desktop): bound start marks for traces that never record
Maxwellimus Aug 25, 2026
96817ab
fix(desktop): drop starved settles and hidden-overlap traces; clean b…
Maxwellimus Aug 25, 2026
083e948
fix(desktop): discard stale longtask deliveries at perf-sample start
Maxwellimus Aug 25, 2026
46dd5d5
fix(desktop): degrade to unrotated append when perf-log rotation fails
Maxwellimus Aug 25, 2026
087d9b7
fix(desktop): drop live traces on any exit from the channel surface
Maxwellimus Aug 25, 2026
116b73b
fix(desktop): gate roster attribution on abort; warn on dead sink; dr…
Maxwellimus Aug 25, 2026
b6f18d0
test(desktop): distinguish honest truncation from tracer bugs in sett…
Maxwellimus Aug 25, 2026
ad981ff
fix(desktop): signal-free roster attribution gate; seed the frame-gap…
Maxwellimus Aug 25, 2026
e0f4768
fix(desktop): only the exact channel route keeps a live switch trace
Maxwellimus Aug 25, 2026
83eb45c
fix(desktop): guard document at settle entry; pin rotation in concurr…
Maxwellimus Aug 25, 2026
559e1e4
test(desktop): assert the tracer contract before the timing budget
Maxwellimus Aug 25, 2026
5ea0231
fix(desktop): begin revokes pending same-channel abandons; null-safe …
Maxwellimus Aug 25, 2026
a355a0b
fix(desktop): anchor the switch trace at the input event, not handler…
Maxwellimus Aug 25, 2026
a0a7989
fix(desktop): stamp the start mark at the anchored input event
Maxwellimus Aug 25, 2026
6e9942f
fix(desktop): guard the final paint frame; trace DM entry paths
Maxwellimus Aug 26, 2026
2795754
fix(desktop): terminate the starvation heartbeat; account for every drop
Maxwellimus Aug 27, 2026
c299a22
refactor(desktop): cut the switch tracer to a simple, honest metric
Maxwellimus Aug 27, 2026
be26be0
test(desktop): drop the import left behind by the tracer reduction
Maxwellimus Aug 27, 2026
8a8e01c
refactor(desktop): drop the switch tracer, keep the perf harness
Maxwellimus Aug 27, 2026
2f44a0e
test(desktop): drop tracer leftovers from the guard tests
Maxwellimus Aug 27, 2026
8d649e4
fix(desktop): test that a refused navigation actually refuses
Maxwellimus Aug 27, 2026
8bc0999
refactor(desktop): stop touching MessageTimeline; the benchmark doesn…
Maxwellimus Aug 27, 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
140 changes: 140 additions & 0 deletions desktop/src/app/navigation/commitGuardedNavigation.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import assert from "node:assert/strict";
import test from "node:test";

import { commitGuardedNavigation } from "./commitGuardedNavigation.ts";

const route = (href) => ({ kind: "route", href });

test("an accepted navigation consults the guard before navigating", async () => {
const order = [];
const committed = await commitGuardedNavigation(
{
currentHref: "/channels/aaaa",
nextHref: "/channels/bbbb",
guardedTarget: route("/channels/bbbb"),
navigate: async () => {
order.push("navigate");
},
},
{
allow: () => {
order.push("guard");
return true;
},
},
);
assert.equal(committed, true);
assert.deepEqual(order, ["guard", "navigate"]);
});

test("a same-destination no-op never reaches the guard", async () => {
const order = [];
const committed = await commitGuardedNavigation(
{
currentHref: "/channels/aaaa",
nextHref: "/channels/aaaa",
guardedTarget: route("/channels/aaaa"),
navigate: async () => {
order.push("navigate");
},
},
{
allow: () => {
order.push("guard");
return true;
},
},
);
assert.equal(committed, false);
assert.deepEqual(order, []);
});

test("force overrides the same-destination no-op but still runs the guard first", async () => {
const order = [];
const committed = await commitGuardedNavigation(
{
currentHref: "/channels/aaaa",
nextHref: "/channels/aaaa",
force: true,
guardedTarget: route("/channels/aaaa"),
navigate: async () => {
order.push("navigate");
},
},
{
allow: () => {
order.push("guard");
return true;
},
},
);
assert.equal(committed, true);
assert.deepEqual(order, ["guard", "navigate"]);
});

test("a same-destination navigation carrying router state still commits", async () => {
const order = [];
const committed = await commitGuardedNavigation(
{
currentHref: "/channels/aaaa",
nextHref: "/channels/aaaa",
guardedTarget: route("/channels/aaaa"),
hasStateUpdate: true,
navigate: async () => {
order.push("navigate");
},
},
{
allow: () => {
order.push("guard");
return true;
},
},
);
assert.equal(committed, true);
assert.deepEqual(order, ["guard", "navigate"]);
});

test("a refused navigation does not navigate", async () => {
// The guard's whole purpose: an unsaved thread edit blocks the switch.
// Asserting only that the guard is CONSULTED is not enough — consulting it
// and discarding the answer passes every ordering test in this file.
const order = [];
const committed = await commitGuardedNavigation(
{
currentHref: "/channels/aaaa",
nextHref: "/channels/bbbb",
guardedTarget: route("/channels/bbbb"),
navigate: async () => {
order.push("navigate");
},
},
{
allow: () => {
order.push("guard");
return false;
},
},
);
assert.equal(committed, false);
assert.deepEqual(order, ["guard"]);
});

test("a refused forced navigation does not navigate either", async () => {
// force defeats the same-destination skip, never the guard.
const order = [];
const committed = await commitGuardedNavigation(
{
currentHref: "/channels/aaaa",
nextHref: "/channels/aaaa",
force: true,
guardedTarget: route("/channels/aaaa"),
navigate: async () => {
order.push("navigate");
},
},
{ allow: () => false },
);
assert.equal(committed, false);
assert.deepEqual(order, []);
});
40 changes: 40 additions & 0 deletions desktop/src/app/navigation/commitGuardedNavigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {
allowNavigation,
type GuardedNavigation,
} from "@/app/navigation/navigationGuard";

/**
* commitGuardedNavigation runs the shared commit flow for app navigations:
* skip same-destination no-ops, consult the navigation guard, then navigate.
* `force` and `hasStateUpdate` both defeat the no-op skip — a same-href
* navigation that writes router state (setting or clearing the search
* highlight) must commit, or the state never lands. Returns whether the
* navigation was performed. `deps` exists for unit tests.
*/
export async function commitGuardedNavigation(
input: {
currentHref: string;
nextHref: string;
force?: boolean;
guardedTarget: GuardedNavigation;
hasStateUpdate?: boolean;
navigate: () => Promise<unknown>;
},
deps: {
allow?: typeof allowNavigation;
} = {},
): Promise<boolean> {
const allow = deps.allow ?? allowNavigation;
if (
input.currentHref === input.nextHref &&
!input.force &&
!input.hasStateUpdate
) {
return false;
}
if (!allow(input.guardedTarget)) {
return false;
}
await input.navigate();
return true;
}
42 changes: 17 additions & 25 deletions desktop/src/app/navigation/useAppNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import {
useRouter,
} from "@tanstack/react-router";

import { commitGuardedNavigation } from "@/app/navigation/commitGuardedNavigation";
import type { SearchHighlightNavigation } from "@/app/navigation/searchHighlightNavigation";
import { openSearchHitWithNavigation } from "@/app/navigation/searchHitNavigation";
import {
allowNavigation,
type GuardedNavigation,
traverseHistory,
} from "@/app/navigation/navigationGuard";
Expand Down Expand Up @@ -43,30 +43,22 @@ export function useAppNavigation() {
guardedTarget?: GuardedNavigation,
) => {
const nextLocation = router.buildLocation(next as never);
const hasStateUpdate = next.state !== undefined;

if (
location.href === nextLocation.href &&
!behavior.force &&
!hasStateUpdate
) {
return false;
}

if (
!allowNavigation(
guardedTarget ?? { kind: "route", href: nextLocation.href },
)
) {
return false;
}

await navigate({
...next,
replace: behavior.replace,
resetScroll: behavior.resetScroll,
} as never);
return true;
return commitGuardedNavigation({
currentHref: location.href,
force: behavior.force,
guardedTarget: guardedTarget ?? {
kind: "route",
href: nextLocation.href,
},
hasStateUpdate: next.state !== undefined,
navigate: () =>
navigate({
...next,
replace: behavior.replace,
resetScroll: behavior.resetScroll,
} as never),
nextHref: nextLocation.href,
});
},
[location.href, navigate, router],
);
Expand Down
36 changes: 5 additions & 31 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// biome-ignore-all format: line-count ratchet requires compact forwarding in this legacy component
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useAppShell } from "@/app/AppShellContext";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader";
Expand Down Expand Up @@ -49,11 +48,6 @@ import {
getThreadReference,
isThreadReply,
} from "@/features/messages/lib/threading";
import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache";
import {
resolveTimelineLoadingLatch,
selectTimelineLoadingState,
} from "@/features/messages/lib/timelineLoadingState";
import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages";
import { useIndependentThreadPanel } from "@/features/messages/useIndependentThreadPanel";
import { useThreadReplies } from "@/features/messages/useThreadReplies";
Expand All @@ -71,6 +65,7 @@ import { useHuddleReadMarker } from "@/features/channels/ui/useHuddleReadMarker"
import { useHuddleThreadIsolation } from "@/features/channels/ui/useHuddleThreadIsolation";
import { AgentSessionProvider } from "@/shared/context/AgentSessionContext";
import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext";
import { useChannelTimelineLoading } from "@/features/channels/useChannelTimelineLoading";
import { useMainInsetRef } from "@/shared/layout/MainInsetContext";
import { channelContentTopPaddingMeasurement } from "@/shared/layout/chromeLayout";
import { useMeasuredCssVariable } from "@/shared/layout/useMeasuredCssVariable";
Expand Down Expand Up @@ -104,7 +99,6 @@ export function ChannelScreen({
targetMessageEvents, targetMessageId,
...searchTarget
}: ChannelScreenProps) {
const queryClient = useQueryClient();
const { goHome } = useAppNavigation();
const { activeCommunity } = useCommunities();
const {
Expand Down Expand Up @@ -615,30 +609,10 @@ export function ChannelScreen({
setThreadReplyTargetId,
setThreadScrollTargetId,
});
const settledChannelIdRef = React.useRef<string | null>(null);
const hasSettledThisChannel =
activeChannelId !== null && settledChannelIdRef.current === activeChannelId;
const timelineLoadingNow =
activeChannel !== null &&
activeChannel.channelType !== "forum" &&
selectTimelineLoadingState(
{
isPending: messagesQuery.isPending,
isFetching: messagesQuery.isFetching,
isPlaceholderData: messagesQuery.isPlaceholderData,
dataLength: messagesQuery.data?.length ?? null,
},
hasSettledThisChannel ||
(activeChannelId !== null &&
hasPersistedHydratedChannel(queryClient, activeChannelId)),
);
const { settledChannelId, isLoading: isTimelineLoading } =
resolveTimelineLoadingLatch(
settledChannelIdRef.current,
activeChannelId,
timelineLoadingNow,
);
settledChannelIdRef.current = settledChannelId;
const isTimelineLoading = useChannelTimelineLoading(
activeChannel,
messagesQuery,
);
const { welcomeKickoffStage, welcomeKickoffSettingUp } =
useWelcomeKickoffStagePresence(
activeChannel,
Expand Down
55 changes: 55 additions & 0 deletions desktop/src/features/channels/useChannelTimelineLoading.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import * as React from "react";
import { useQueryClient } from "@tanstack/react-query";

import { hasPersistedHydratedChannel } from "@/features/messages/lib/channelHeadCache";
import {
resolveTimelineLoadingLatch,
selectTimelineLoadingState,
} from "@/features/messages/lib/timelineLoadingState";
import type { Channel } from "@/shared/api/types";

/**
* Latches the timeline loading state per channel, so a channel that has
* already settled once does not flash its skeleton again while an
* authoritative refresh is in flight.
*/
export function useChannelTimelineLoading(
activeChannel: Channel | null,
messagesQuery: {
data: readonly unknown[] | undefined;
isFetching: boolean;
isPending: boolean;
isPlaceholderData: boolean;
},
): boolean {
const queryClient = useQueryClient();
const activeChannelId = activeChannel?.id ?? null;
const settledChannelIdRef = React.useRef<string | null>(null);
const hasSettledThisChannel =
activeChannelId !== null && settledChannelIdRef.current === activeChannelId;
const timelineLoadingNow =
activeChannel !== null &&
activeChannel.channelType !== "forum" &&
selectTimelineLoadingState(
{
isPending: messagesQuery.isPending,
isFetching: messagesQuery.isFetching,
isPlaceholderData: messagesQuery.isPlaceholderData,
dataLength: messagesQuery.data?.length ?? null,
},
// A persisted head only counts as hydrated when it has rows to paint
// (channelHeadCache.ts), so this bypass never settles onto an empty
// placeholder while the authoritative refresh is still in flight.
hasSettledThisChannel ||
(activeChannelId !== null &&
hasPersistedHydratedChannel(queryClient, activeChannelId)),
);
const { settledChannelId, isLoading: isTimelineLoading } =
resolveTimelineLoadingLatch(
settledChannelIdRef.current,
activeChannelId,
timelineLoadingNow,
);
settledChannelIdRef.current = settledChannelId;
return isTimelineLoading;
}
10 changes: 10 additions & 0 deletions desktop/src/features/projects/ui/ProjectsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,15 @@ export function ProjectsView() {
snapshotProjects,
activeCommunity?.reposDir,
);
// Switch-perf hydration marker (shell commits long before the fan loads).
// isLoading: first load only — warm refetches/disabled queries stay false.
const projectsHydrating =
projectsQuery.isLoading ||
projectsWorkItemsQuery.isLoading ||
repoSnapshotsQuery.isLoading ||
activitySummariesQuery.isLoading ||
repositoryActivitySummariesQuery.isLoading ||
localRepositoriesQuery.isLoading;
const memberChannelIds = useMemberChannelIds();
const repositoryUnavailableReasonFor = useRepositoryUnavailableReasonFor(
repoSnapshotsQuery.data?.unavailable,
Expand Down Expand Up @@ -722,6 +731,7 @@ export function ProjectsView() {
data-project-context-detached={
isNarrowProjectsLayout ? undefined : "true"
}
data-projects-hydrating={projectsHydrating ? "true" : undefined}
data-testid="projects-overview-layout"
>
<ProjectsWorkspaceChrome
Expand Down
Loading
Loading