Skip to content
Draft
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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export default defineConfig({
"**/identity-archive.spec.ts",
"**/identity-archive-hide.spec.ts",
"**/relay-connectivity-screenshots.spec.ts",
"**/reply-surfacing-screenshots.spec.ts",
"**/unread-pill-screenshots.spec.ts",
"**/thread-unread-screenshots.spec.ts",
"**/animated-avatar-screenshots.spec.ts",
Expand Down
21 changes: 21 additions & 0 deletions desktop/src/features/messages/lib/mergeSurfacedReplies.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Type declarations for the pure merge in `mergeSurfacedReplies.mjs`. Runtime
* lives in `.mjs` so the (TS-loader-less) `node:test` runner imports it
* directly; this file gives TypeScript callers a typed view.
*/
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { TimelineMessage } from "@/features/messages/types";

/** A root timeline entry, or a pointer to a surfaced nested reply. */
export type MergedTimelineRow =
| { kind: "entry"; entry: MainTimelineEntry }
| { kind: "surfaced"; message: TimelineMessage };

/**
* Interleaves `surfaced` pointers into the root-level `entries`, returning a
* single sequence sorted by `(createdAt, id)` using each row's REAL message id.
*/
export function mergeSurfacedReplies(
entries: MainTimelineEntry[],
surfaced: TimelineMessage[],
): MergedTimelineRow[];
41 changes: 41 additions & 0 deletions desktop/src/features/messages/lib/mergeSurfacedReplies.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Pure merge that interleaves surfaced reply pointers into the root-level
* timeline. `buildMainTimelineEntries` yields the root entries the channel
* renders, in ascending `createdAt` order; `surfaceReplies` yields the nested
* agent->human replies to surface as pointers at their OWN `createdAt` (the
* blessed "surface it where the eye reads" position).
*
* The two streams are combined into one discriminated sequence sorted by
* `(createdAt, id)` so the existing day-divider grouping loop can run over it
* unchanged. `id` is the REAL message id (a root entry's `message.id`, a
* pointer's surfaced-message `id`), giving a stable tiebreak when a pointer and
* a root share a `createdAt` — deterministic insertion, no re-render flicker.
* This mirrors the `(createdAt, id.localeCompare)` tiebreak already used for
* review comments in `TimelineMessageList`.
*
* Lives in `.mjs` (not `.ts`) so the TS-loader-less test runner imports the
* same source production uses; the sibling `.d.mts` types the discriminant for
* TypeScript callers.
*/

/**
* @param {{ message: { id: string, createdAt: number } }[]} entries
* @param {{ id: string, createdAt: number }[]} surfaced
*/
export function mergeSurfacedReplies(entries, surfaced) {
const merged = [
...entries.map((entry) => ({ kind: "entry", entry })),
...surfaced.map((message) => ({ kind: "surfaced", message })),
];

const sortId = (row) =>
row.kind === "entry" ? row.entry.message.id : row.message.id;
const sortCreatedAt = (row) =>
row.kind === "entry" ? row.entry.message.createdAt : row.message.createdAt;

return merged.sort((left, right) => {
const byCreatedAt = sortCreatedAt(left) - sortCreatedAt(right);
if (byCreatedAt !== 0) return byCreatedAt;
return sortId(left).localeCompare(sortId(right));
});
}
76 changes: 76 additions & 0 deletions desktop/src/features/messages/lib/mergeSurfacedReplies.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import test from "node:test";

// Imports the exact source the renderer wires. No inlined copy -> no drift
// between test expectations and production behaviour.
import { mergeSurfacedReplies } from "./mergeSurfacedReplies.mjs";

// Minimal shapes: the merge only reads `message.id` and `message.createdAt`.
const entry = (id, createdAt) => ({
message: { id, createdAt },
summary: null,
});
const surfaced = (id, createdAt) => ({ id, createdAt });

// Reduce rows to a comparable trace of (kind, id) in result order.
const trace = (rows) =>
rows.map((row) =>
row.kind === "entry"
? `entry:${row.entry.message.id}`
: `surfaced:${row.message.id}`,
);

test("surfaced reply lands between root entries by its own createdAt", () => {
const rows = mergeSurfacedReplies(
[entry("a", 10), entry("c", 30)],
[surfaced("b", 20)],
);
assert.deepEqual(trace(rows), ["entry:a", "surfaced:b", "entry:c"]);
});

test("equal createdAt tiebreaks on real id so insertion is deterministic", () => {
// Pointer "m" and root "z" share createdAt 20; "m" < "z" by id.
const rows = mergeSurfacedReplies(
[entry("a", 10), entry("z", 20)],
[surfaced("m", 20)],
);
assert.deepEqual(trace(rows), ["entry:a", "surfaced:m", "entry:z"]);
});

test("tiebreak uses the real message id not a synthetic render key", () => {
// If the merge keyed on a "surfaced:" prefix it would sort AFTER "entry"
// rows at the same createdAt (lexically "s" > "e"); keying on the real id
// "b" places it before root "c".
const rows = mergeSurfacedReplies([entry("c", 5)], [surfaced("b", 5)]);
assert.deepEqual(trace(rows), ["surfaced:b", "entry:c"]);
});

test("empty surfaced list returns the root entries unchanged in order", () => {
const rows = mergeSurfacedReplies(
[entry("a", 10), entry("b", 20), entry("c", 30)],
[],
);
assert.deepEqual(trace(rows), ["entry:a", "entry:b", "entry:c"]);
});

test("empty entries returns only the surfaced pointers in createdAt order", () => {
const rows = mergeSurfacedReplies(
[],
[surfaced("late", 30), surfaced("early", 10)],
);
assert.deepEqual(trace(rows), ["surfaced:early", "surfaced:late"]);
});

test("multiple surfaced replies interleave across multiple roots", () => {
const rows = mergeSurfacedReplies(
[entry("r1", 10), entry("r2", 40)],
[surfaced("s1", 20), surfaced("s2", 30), surfaced("s3", 50)],
);
assert.deepEqual(trace(rows), [
"entry:r1",
"surfaced:s1",
"surfaced:s2",
"entry:r2",
"surfaced:s3",
]);
});
19 changes: 19 additions & 0 deletions desktop/src/features/messages/lib/surfaceReplies.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* Type declarations for the pure projection in `surfaceReplies.mjs`. Runtime
* lives in `.mjs` so the (TS-loader-less) `node:test` runner imports it
* directly; this file gives TypeScript callers a typed view.
*/
import type { TimelineMessage } from "@/features/messages/types";

/**
* Returns the nested messages to surface as root-level pointers: agent-authored
* messages that carry a human p-tag and are not already duplicated at root.
*
* @param messages - the built timeline messages (mixed root + nested).
* @param isHuman - classifier for a pubkey; the caller resolves unknown
* pubkeys to `true` (human) so unrecognized authors under-surface.
*/
export function surfaceReplies(
messages: TimelineMessage[],
isHuman: (pubkey: string | undefined) => boolean,
): TimelineMessage[];
75 changes: 75 additions & 0 deletions desktop/src/features/messages/lib/surfaceReplies.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* Pure projection that pulls agent-authored replies addressed to a human up to
* the thread root. The channel timeline only renders root-level entries
* (`buildMainTimelineEntries` keeps `parentId == null || broadcast`), so an
* agent's reply to a human posted *nested* is invisible where the human reads.
* This function returns the nested messages that should be surfaced as
* lightweight root-level pointers; the real message is never moved or copied —
* the caller (Phase 2) renders a pointer row that links down to it.
*
* Lives in `.mjs` (not `.ts`) so the test runner (`node --test`, no TS loader)
* imports the same source production uses; TypeScript callers get types from
* the sibling `.d.mts`.
*/

/**
* A message is root-level — and therefore needs no surfacing — under the exact
* condition the timeline uses to render it at root: no parent, or a broadcast
* reply. Inlined (not imported from `threading.ts`) because that module is
* `.ts` and cannot be loaded by the TS-loader-less test runner; the rule is one
* line and must stay identical to `buildMainTimelineEntries`'s filter.
*/
function isRootLevel(message) {
return (
message.parentId == null ||
(message.tags ?? []).some((t) => t[0] === "broadcast" && t[1] === "1")
);
}

/**
* Returns the subset of `messages` to surface as root-level pointers.
*
* A nested message surfaces iff ALL hold:
* 1. it is not already root-level (root needs no surfacing);
* 2. its author is an agent — `isHuman(authorPubkey) === false`;
* 3. it carries at least one human p-tag — a `["p", pubkey]` where
* `isHuman(pubkey) === true`.
*
* De-dupe is STRICT and THREAD-SCOPED: a candidate is skipped only when a
* root-level message *in the same thread* has the EXACT SAME body. Author
* identity is irrelevant — an agent's earlier, unrelated root-level post must
* not suppress a genuinely new nested reply. Scoping by thread prevents a root
* "done" in thread A from suppressing a nested "done" in thread B; short common
* bodies collide constantly across a busy channel. The key is
* `${threadId}\u0000${body}` where a message's thread id is `rootId ?? id` (a
* root's own thread id is its `id`, since its `rootId` is null) and `\u0000`
* (NUL) cannot appear in an event id or normal body, so keys never collide.
*
* Empty/whitespace-only bodies carry no content that can "already exist" at
* root, so they are never seeded and never suppress — every such candidate that
* passes the trigger conditions surfaces.
*
* `isHuman` is authoritative as given. The caller resolves unknown
* classification to `true` (human), so an unrecognized author is treated human
* and fails condition (2): the message under-surfaces rather than mis-surfaces.
*/
export function surfaceReplies(messages, isHuman) {
const threadKey = (message) =>
`${message.rootId ?? message.id}\u0000${message.body}`;
const rootBodies = new Set(
messages
.filter((message) => isRootLevel(message) && message.body.trim() !== "")
.map(threadKey),
);

return messages.filter((message) => {
if (isRootLevel(message)) return false;
if (isHuman(message.pubkey)) return false;
const tagsHuman = (message.tags ?? []).some(
(t) => t[0] === "p" && typeof t[1] === "string" && isHuman(t[1]),
);
if (!tagsHuman) return false;
if (message.body.trim() === "") return true;
return !rootBodies.has(threadKey(message));
});
}
Loading