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
16 changes: 16 additions & 0 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ import { relayClient } from "@/shared/api/relayClient";
import { useIdentityQuery } from "@/shared/api/hooks";
import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal";
import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup";
import { preloadAgentsScreen } from "@/app/routes/agents";
import { preloadChannelRouteScreen } from "@/app/routes/channels.$channelId";
import { preloadChannelViews } from "@/features/channels/ui/ChannelScreenLazyViews";
import { joinChannel } from "@/shared/api/tauri";
import type { Channel, RelayEvent, SearchHit } from "@/shared/api/types";
import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext";
Expand Down Expand Up @@ -537,6 +540,19 @@ export function AppShell() {
};
}, []);

// Warm the lazy route chunks (channel timeline, forum, agents) once the shell
// is idle, so the FIRST main-nav transition doesn't stall on a cold chunk
// fetch+parse. `startupReady` is the existing idle-or-timeout gate; the chunk
// imports dedupe, so racing an actual navigation is harmless.
React.useEffect(() => {
if (!startupReady) {
return;
}
preloadChannelRouteScreen();
preloadChannelViews();
preloadAgentsScreen();
}, [startupReady]);

React.useEffect(() => {
const numericCount =
highPriorityUnreadChannelIds.size + homeBadgeCountExcludingHighPriority;
Expand Down
12 changes: 11 additions & 1 deletion desktop/src/app/routes/agents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,21 @@ import { createFileRoute } from "@tanstack/react-router";

import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";

// The chunk import is hoisted so it can be triggered eagerly (route preload)
// as well as lazily on render — calling it twice is a no-op, the module loader
// dedupes and caches the in-flight promise.
const importAgentsScreen = () => import("@/features/agents/ui/AgentsScreen");

const AgentsScreen = React.lazy(async () => {
const module = await import("@/features/agents/ui/AgentsScreen");
const module = await importAgentsScreen();
return { default: module.AgentsScreen };
});

/** Warms the AgentsScreen route chunk so first navigation doesn't stall. */
export function preloadAgentsScreen(): void {
void importAgentsScreen();
}

export const Route = createFileRoute("/agents")({
component: AgentsRouteComponent,
});
Expand Down
11 changes: 10 additions & 1 deletion desktop/src/app/routes/channels.$channelId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,20 @@ export const Route = createFileRoute("/channels/$channelId")({
component: ChannelRouteComponent,
});

// Hoisted so the chunk can be warmed eagerly (route preload) as well as loaded
// lazily on render; the loader dedupes repeat calls.
const importChannelRouteScreen = () => import("./ChannelRouteScreen");

const ChannelRouteScreen = React.lazy(async () => {
const module = await import("./ChannelRouteScreen");
const module = await importChannelRouteScreen();
return { default: module.ChannelRouteScreen };
});

/** Warms the ChannelRouteScreen chunk so first channel open doesn't stall. */
export function preloadChannelRouteScreen(): void {
void importChannelRouteScreen();
}

function ChannelRouteComponent() {
const { channelId } = Route.useParams();
const search = Route.useSearch();
Expand Down
15 changes: 13 additions & 2 deletions desktop/src/features/channels/ui/ChannelScreenLazyViews.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import * as React from "react";

// Hoisted chunk imports so each view can be warmed eagerly (route preload) as
// well as loaded lazily on render; the module loader dedupes repeat calls.
const importChannelPane = () => import("@/features/channels/ui/ChannelPane");
const importForumView = () => import("@/features/forum/ui/ForumView");

export const ChannelPane = React.lazy(async () => {
const module = await import("@/features/channels/ui/ChannelPane");
const module = await importChannelPane();
return { default: module.ChannelPane };
});

export const ForumView = React.lazy(async () => {
const module = await import("@/features/forum/ui/ForumView");
const module = await importForumView();
return { default: module.ForumView };
});

/** Warms the channel/forum view chunks so first open doesn't stall. */
export function preloadChannelViews(): void {
void importChannelPane();
void importForumView();
}
210 changes: 210 additions & 0 deletions desktop/src/features/messages/lib/scrollConvergence.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import assert from "node:assert/strict";
import test from "node:test";

import { CONVERGENCE_FRAME_CAP, convergenceStep } from "./scrollConvergence.ts";

function input(overrides) {
return {
targetMessageId: "target",
indexByMessageId: new Map([["target", 100]]),
lastIssuedIndex: null,
librarySettled: false,
stalledOffTarget: false,
framesUsed: 0,
...overrides,
};
}

// --- re-aim / staleness guard ------------------------------------------------

test("convergenceStep: first frame aims at the resolved index, not yet done", () => {
const step = convergenceStep(input({ lastIssuedIndex: null }));
assert.equal(step.nextIndex, 100);
assert.equal(step.done, false);
assert.equal(step.converged, false);
});

test("convergenceStep: re-resolves a shifted index from the map each frame", () => {
// A prepend shifted the target from 100 to 105. The library is still chasing
// the old index (lastIssuedIndex 100); the reducer must aim at the NEW index
// so the adapter re-issues scrollToIndex(105). This is the staleness guard.
const step = convergenceStep(
input({
indexByMessageId: new Map([["target", 105]]),
lastIssuedIndex: 100,
}),
);
assert.equal(step.nextIndex, 105);
assert.equal(step.done, false);
assert.equal(step.converged, false);
});

test("convergenceStep: target removed mid-settle stops with converged=false", () => {
// Target deleted from the map while the loop was chasing it. Terminate so the
// adapter clears the highlight instead of chasing a vanished row.
const step = convergenceStep(
input({
indexByMessageId: new Map(), // target gone
lastIssuedIndex: 100,
framesUsed: 3,
}),
);
assert.equal(step.nextIndex, null);
assert.equal(step.done, true);
assert.equal(step.converged, false);
});

// --- settle ------------------------------------------------------------------

test("convergenceStep: library settled while aiming at current index converges", () => {
const step = convergenceStep(
input({ lastIssuedIndex: 100, librarySettled: true }),
);
assert.equal(step.nextIndex, 100);
assert.equal(step.done, true);
assert.equal(step.converged, true);
});

test("convergenceStep: a settle reported WHILE re-aiming is ignored", () => {
// The index just moved (105) but the library reports settled — that settle is
// on the OLD index (100), so it must NOT count as convergence. The reducer
// keeps going and aims at the new index.
const step = convergenceStep(
input({
indexByMessageId: new Map([["target", 105]]),
lastIssuedIndex: 100,
librarySettled: true,
}),
);
assert.equal(step.nextIndex, 105);
assert.equal(step.done, false);
assert.equal(step.converged, false);
});

test("convergenceStep: aiming at current but not yet settled keeps waiting", () => {
// Library is chasing the right index but its offset hasn't stabilized. The
// reducer returns the same index (so the adapter re-issues NOTHING — issuing
// would reset the library's stableFrames and prevent settling) and waits.
const step = convergenceStep(
input({ lastIssuedIndex: 100, librarySettled: false }),
);
assert.equal(step.nextIndex, 100);
assert.equal(step.reissue, false);
assert.equal(step.done, false);
assert.equal(step.converged, false);
});

// --- off-target stall (liveness) ---------------------------------------------

test("convergenceStep: stalled off-target while aiming at current re-issues same index", () => {
// The library's offset stopped moving but never reached the current index's
// target (its internal reconcile deadlocked after rows re-measured). The
// reducer signals a same-index re-issue to kick it — the loop continues.
const step = convergenceStep(
input({ lastIssuedIndex: 100, stalledOffTarget: true }),
);
assert.equal(step.nextIndex, 100);
assert.equal(step.reissue, true);
assert.equal(step.done, false);
assert.equal(step.converged, false);
});

test("convergenceStep: a stall reported WHILE re-aiming does not re-issue", () => {
// The index just moved (105) but the library reports a stall on the OLD index
// (100). The reducer re-aims at the new index normally; the stale stall must
// NOT trigger a same-index kick (there is no current-index stall to kick).
const step = convergenceStep(
input({
indexByMessageId: new Map([["target", 105]]),
lastIssuedIndex: 100,
stalledOffTarget: true,
}),
);
assert.equal(step.nextIndex, 105);
assert.equal(step.reissue, false);
assert.equal(step.done, false);
assert.equal(step.converged, false);
});

test("convergenceStep: a settle takes priority over a concurrent stall flag", () => {
// Defensive: the adapter computes settle and stall as mutually exclusive, but
// if both arrive, a genuine settle must win (converge) rather than spin on a
// pointless re-issue.
const step = convergenceStep(
input({
lastIssuedIndex: 100,
librarySettled: true,
stalledOffTarget: true,
}),
);
assert.equal(step.done, true);
assert.equal(step.converged, true);
assert.equal(step.reissue, false);
});

// --- frame cap ---------------------------------------------------------------

test("convergenceStep: terminates at the frame cap without converging", () => {
// A row that never settles (librarySettled stays false) must still stop at the
// cap rather than spin forever.
const step = convergenceStep(
input({
lastIssuedIndex: 100,
librarySettled: false,
framesUsed: CONVERGENCE_FRAME_CAP - 1,
}),
);
assert.equal(step.done, true);
assert.equal(step.converged, false);
assert.equal(step.nextIndex, 100);
});

test("convergenceStep: frame cap bounds a perpetually shifting target", () => {
// Drive the loop the way the adapter would: the target index moves every
// frame, so the library never settles. The loop must terminate at the cap.
let lastIssuedIndex = null;
let framesUsed = 0;
let done = false;
let converged = true;

while (framesUsed < CONVERGENCE_FRAME_CAP + 5) {
const movingIndex = 100 + framesUsed; // shifts every frame
const step = convergenceStep(
input({
indexByMessageId: new Map([["target", movingIndex]]),
lastIssuedIndex,
librarySettled: false,
framesUsed,
}),
);
lastIssuedIndex = step.nextIndex;
framesUsed += 1;
if (step.done) {
done = step.done;
converged = step.converged;
break;
}
}

assert.equal(done, true);
assert.equal(converged, false);
assert.ok(framesUsed <= CONVERGENCE_FRAME_CAP);
});

test("convergenceStep: converges once a re-aimed index then settles", () => {
// Realistic flow: frame 0 aims (lastIssued null -> 100), frame 1 the library
// is chasing 100 and reports settled -> converged.
const aim = convergenceStep(input({ lastIssuedIndex: null }));
assert.equal(aim.nextIndex, 100);
assert.equal(aim.done, false);

const settle = convergenceStep(
input({
lastIssuedIndex: aim.nextIndex,
librarySettled: true,
framesUsed: 1,
}),
);
assert.equal(settle.done, true);
assert.equal(settle.converged, true);
});
Loading
Loading