Skip to content
Closed
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 @@ -540,6 +543,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();
}
160 changes: 160 additions & 0 deletions desktop/src/features/messages/lib/scrollConvergence.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
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,
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.done, false);
assert.equal(step.converged, 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);
});
103 changes: 103 additions & 0 deletions desktop/src/features/messages/lib/scrollConvergence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* Pure staleness + termination decision for scrolling a virtualized timeline to
* a message that may be far off-screen.
*
* @tanstack/react-virtual already owns the OFFSET convergence: a single
* `scrollToIndex(index)` captures that index in `scrollState`, and its internal
* `reconcileScroll` rAF loop re-runs `getOffsetForIndex(index)` every frame —
* re-aiming as off-screen rows mount and `measureElement` corrects their
* heights — until the offset is stable (or a 5s safety valve fires). We do NOT
* recompute offsets; duplicating `getOffsetForIndex` against the library's own
* `measurementsCache`/`scrollMargin`/`scrollPadding` would only drift.
*
* What the library does NOT do: it chases the INDEX captured at call time, with
* no concept of a message id. If the data shifts mid-settle — a prepend or a
* delete above the target — the captured index now points at the wrong row and
* the library happily settles on it. This reducer owns exactly that gap: each
* frame it re-resolves the target's CURRENT index from the live map and decides
* whether the adapter must re-aim the library, let it settle, or stop.
*
* Two correctness properties this enforces and the `.mjs` suite gates:
* - The target index is re-resolved by id every frame (never frozen), so a
* concurrent prepend/delete that shifts the target re-aims the library at the
* new index instead of stranding it on the old one.
* - If the target id leaves the data mid-settle (deleted), the loop terminates
* with `converged: false` rather than chasing a vanished row to the cap.
*/

/** Where a scroll target should land in the viewport. Mirrors the library's align. */
export type ConvergenceAlign = "start" | "center" | "end";

export type ConvergenceInput = {
/** Id of the message to settle on — re-resolved against the map each frame. */
targetMessageId: string;
/** Live message-id -> item-index map; re-read every frame (staleness guard). */
indexByMessageId: Map<string, number>;
/**
* Index the library is currently chasing (the last index the adapter issued
* via `scrollToIndex`), or `null` before the first issue. Lets the reducer
* tell a re-aim (index moved) from a steady settle (index unchanged).
*/
lastIssuedIndex: number | null;
/**
* Whether the library reports its scroll has settled this frame
* (`virtualizer.scrollState === null`). Only meaningful once the library is
* chasing the CURRENT index; a settle reported while re-aiming is ignored.
*/
librarySettled: boolean;
/** Frames already spent in the loop (the adapter increments per rAF). */
framesUsed: number;
};

export type ConvergenceDecision = {
/**
* Index the adapter should be aiming the library at, or `null` when the
* target is gone. The adapter only re-issues `scrollToIndex` when this differs
* from `lastIssuedIndex`, so a steady settle issues no redundant scroll (which
* would reset the library's `stableFrames` and prevent it from ever settling).
*/
nextIndex: number | null;
/** True once the loop must stop (settled, target gone, or frame cap hit). */
done: boolean;
/** True only when the loop stopped because the target row actually settled. */
converged: boolean;
};

/**
* Hard cap on frames so a perpetually re-measuring row, or a target whose index
* keeps shifting, can't spin the loop forever. The library has its own 5s valve;
* this is the adapter-side bound expressed in frames for deterministic testing.
*/
export const CONVERGENCE_FRAME_CAP = 32;

/**
* One frame of the convergence loop. Pure: given the live map and the library's
* settle state, decides the index to aim at and whether to stop.
*/
export function convergenceStep(input: ConvergenceInput): ConvergenceDecision {
const currentIndex = input.indexByMessageId.get(input.targetMessageId);

// Target left the data mid-settle (deleted) — stop without converging so the
// adapter clears the highlight instead of chasing a vanished row.
if (currentIndex === undefined) {
return { nextIndex: null, done: true, converged: false };
}

const aimingAtCurrent = input.lastIssuedIndex === currentIndex;

// The library only settles meaningfully once it is chasing the CURRENT index.
// A settle reported while we are still re-aiming (index just moved) is stale.
if (aimingAtCurrent && input.librarySettled) {
return { nextIndex: currentIndex, done: true, converged: true };
}

// Frame cap: accept the best index we have rather than spin forever on a row
// whose height never settles or a target whose index keeps shifting.
if (input.framesUsed + 1 >= CONVERGENCE_FRAME_CAP) {
return { nextIndex: currentIndex, done: true, converged: false };
}

// Either the index moved (adapter will re-issue scrollToIndex) or the library
// is still settling on the current index (adapter issues nothing, just waits).
return { nextIndex: currentIndex, done: false, converged: false };
}
Loading
Loading