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
24 changes: 24 additions & 0 deletions desktop/src/features/messages/lib/timelineSnapshot.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import test from "node:test";

import {
classifyTimelineMessageDelta,
BOTTOM_THRESHOLD_PX,
buildDayGroupBoundaries,
isDeferredTimelineSnapshotStale,
Expand Down Expand Up @@ -339,6 +340,29 @@ test("no-tearing: stale snapshot keeps all three decisions internally consistent
assert.equal(latestKey, "b");
});

test("classifyTimelineMessageDelta: detects older-history prepends", () => {
const previous = [message({ id: "a" }), message({ id: "b" })];
const current = [message({ id: "older" }), ...previous];

assert.equal(classifyTimelineMessageDelta({ current, previous }), "prepend");
});

test("classifyTimelineMessageDelta: detects latest-message appends", () => {
const previous = [message({ id: "a" }), message({ id: "b" })];
const current = [...previous, message({ id: "c" })];

assert.equal(classifyTimelineMessageDelta({ current, previous }), "append");
});

test("classifyTimelineMessageDelta: unchanged snapshots do not count as arrivals", () => {
const previous = [message({ id: "a" }), message({ id: "b" })];

assert.equal(
classifyTimelineMessageDelta({ current: previous, previous }),
"none",
);
});

// --- deferred reply-list render state (thread side pane) --------------------
//
// When MessageThreadPanel gates its reply render behind useDeferredValue, the
Expand Down
40 changes: 40 additions & 0 deletions desktop/src/features/messages/lib/timelineSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,46 @@ export function selectTimelineBodySurface({
return renderState;
}

export type TimelineMessageDelta = "prepend" | "append" | "replace" | "none";

export function classifyTimelineMessageDelta({
current,
previous,
}: {
current: readonly Pick<TimelineMessage, "id">[];
previous: readonly Pick<TimelineMessage, "id">[];
}): TimelineMessageDelta {
if (previous.length === 0 || current.length === 0) {
return previous.length === current.length ? "none" : "replace";
}

const previousFirstId = previous[0]?.id;
const previousLastId = previous[previous.length - 1]?.id;
const currentFirstId = current[0]?.id;
const currentLastId = current[current.length - 1]?.id;

if (previousFirstId === currentFirstId && previousLastId === currentLastId) {
if (previous.length === current.length) {
return "none";
}
return current.length > previous.length ? "append" : "replace";
}

if (
previousFirstId !== undefined &&
currentFirstId !== previousFirstId &&
current.some((message) => message.id === previousFirstId)
) {
return "prepend";
}

if (previousLastId !== undefined && currentLastId !== previousLastId) {
return "append";
}

return "replace";
}

export type TimelineSnapshotIdentity = {
channelId: string | null;
};
Expand Down
18 changes: 12 additions & 6 deletions desktop/src/features/messages/ui/useAnchoredScroll.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from "react";

import { classifyTimelineMessageDelta } from "@/features/messages/lib/timelineSnapshot";
import type { TimelineMessage } from "@/features/messages/types";

/**
Expand Down Expand Up @@ -165,6 +166,7 @@ export function useAnchoredScroll({
const prevLastMessageIdRef = React.useRef<string | undefined>(undefined);
const prevFirstMessageIdRef = React.useRef<string | undefined>(undefined);
const prevMessageCountRef = React.useRef(0);
const prevMessagesRef = React.useRef<TimelineMessage[]>([]);
const handledTargetIdRef = React.useRef<string | null>(null);
const highlightTimeoutRef = React.useRef<number | null>(null);
// Tracks a pending rAF queued by pinToBottomOnMount so it can be cancelled
Expand Down Expand Up @@ -194,6 +196,7 @@ export function useAnchoredScroll({
prevLastMessageIdRef.current = undefined;
prevFirstMessageIdRef.current = undefined;
prevMessageCountRef.current = 0;
prevMessagesRef.current = [];
handledTargetIdRef.current = null;
forceBottomOnNextAppendRef.current = false;
settlingRef.current = false;
Expand Down Expand Up @@ -363,27 +366,28 @@ export function useAnchoredScroll({
prevLastMessageIdRef.current = messages[messages.length - 1]?.id;
prevFirstMessageIdRef.current = messages[0]?.id;
prevMessageCountRef.current = messages.length;
prevMessagesRef.current = messages;
return;
}

const anchor = anchorRef.current;
const lastMessage = messages[messages.length - 1];
const firstMessage = messages[0];
const prevLastId = prevLastMessageIdRef.current;
const prevFirstId = prevFirstMessageIdRef.current;
const prevCount = prevMessageCountRef.current;
const newLatestArrived =
lastMessage !== undefined && lastMessage.id !== prevLastId;
// Count growth, not tail-id change, is the reliable "messages arrived"
// signal. The relay can deliver a message that sorts ahead of an existing
// same-second row, so the list grows without the *last* id changing —
// `newLatestArrived` misses that case and the unread counter never bumps.
const prevMessages = prevMessagesRef.current;
const messagesArrived = messages.length - prevCount;
const frontChanged =
firstMessage !== undefined &&
prevFirstId !== undefined &&
firstMessage.id !== prevFirstId;
const isPrepend = frontChanged && !newLatestArrived;
const isPrepend =
classifyTimelineMessageDelta({
current: messages,
previous: prevMessages,
}) === "prepend";

// One-shot: an outbound send armed `scrollToBottomOnNextUpdate`. When the
// resulting append lands, snap to bottom regardless of the current anchor,
Expand All @@ -399,6 +403,7 @@ export function useAnchoredScroll({
prevLastMessageIdRef.current = lastMessage?.id;
prevFirstMessageIdRef.current = firstMessage?.id;
prevMessageCountRef.current = messages.length;
prevMessagesRef.current = messages;
return;
}

Expand Down Expand Up @@ -436,6 +441,7 @@ export function useAnchoredScroll({
prevLastMessageIdRef.current = lastMessage?.id;
prevFirstMessageIdRef.current = firstMessage?.id;
prevMessageCountRef.current = messages.length;
prevMessagesRef.current = messages;
}, [
isLoading,
messages,
Expand Down
39 changes: 20 additions & 19 deletions desktop/tests/e2e/persona-env-vars.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,23 @@ async function invokeTauri<T>(
);
}

type DropdownName = "Anthropic" | "Default" | "OpenAI" | "Custom";

async function selectDropdownOption(
trigger: import("@playwright/test").Locator,
name: DropdownName,
) {
await expect(trigger).toBeEnabled();
await trigger.click();
const item = trigger
.page()
.getByRole("menuitemradio", { name, exact: true })
.last();
await expect(item).toBeVisible();
await item.click();
await expect(item).toBeHidden();
}

async function openModelMenu(
page: import("@playwright/test").Page,
model: import("@playwright/test").Locator,
Expand Down Expand Up @@ -300,10 +317,7 @@ test("persona model options follow the selected LLM provider", async ({
await expect(model).toContainText("Default model");

// Switch to OpenAI — the API-key field appears and is labelled correctly.
await llmProvider.click();
await page
.getByRole("menuitemradio", { name: "OpenAI", exact: true })
.click();
await selectDropdownOption(llmProvider, "OpenAI");
const providerApiKey = page.getByTestId("persona-provider-api-key");
await expect(page.getByText("OpenAI API key")).toBeVisible();
await expect(providerApiKey).toBeVisible();
Expand All @@ -317,10 +331,7 @@ test("persona model options follow the selected LLM provider", async ({
.click();

// Switch to Anthropic — API-key field label changes and value clears.
await llmProvider.click();
await page
.getByRole("menuitemradio", { name: "Anthropic", exact: true })
.click();
await selectDropdownOption(llmProvider, "Anthropic");
await expect(page.getByText("Anthropic API key")).toBeVisible();
await expect(providerApiKey).toHaveValue("");
await expect(model).toBeVisible();
Expand All @@ -330,17 +341,7 @@ test("persona model options follow the selected LLM provider", async ({
await expect(model).toBeVisible();

// Switch to Default (no explicit provider) — model resets to "Default model".
await llmProvider.click();
// Wait for Radix menu animations to settle before locating the menu item.
// The prior approach held a filtered locator across the open→animate boundary
// and clicked a node that Radix was still re-mounting, producing
// "element is not stable" / "element was detached from the DOM" failures.
// Matching the OpenAI/Anthropic steps above: wait for animations, then
// locate fresh at click-time so no stale reference crosses the re-render.
await waitForAnimations(page);
await page
.getByRole("menuitemradio", { name: "Default", exact: true })
.click();
await selectDropdownOption(llmProvider, "Default");
await expect(model).toBeVisible();
await expect(model).toContainText("Default model");
});
Loading