diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 7f7ccce4f..4df72c2e8 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -45,6 +45,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", "**/sidebar-more-unread-overlap.spec.ts", "**/thread-unread-screenshots.spec.ts", diff --git a/desktop/src/features/messages/lib/mergeSurfacedReplies.d.mts b/desktop/src/features/messages/lib/mergeSurfacedReplies.d.mts new file mode 100644 index 000000000..9fd8a8a4a --- /dev/null +++ b/desktop/src/features/messages/lib/mergeSurfacedReplies.d.mts @@ -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[]; diff --git a/desktop/src/features/messages/lib/mergeSurfacedReplies.mjs b/desktop/src/features/messages/lib/mergeSurfacedReplies.mjs new file mode 100644 index 000000000..274ea727a --- /dev/null +++ b/desktop/src/features/messages/lib/mergeSurfacedReplies.mjs @@ -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)); + }); +} diff --git a/desktop/src/features/messages/lib/mergeSurfacedReplies.test.mjs b/desktop/src/features/messages/lib/mergeSurfacedReplies.test.mjs new file mode 100644 index 000000000..2c1e9d783 --- /dev/null +++ b/desktop/src/features/messages/lib/mergeSurfacedReplies.test.mjs @@ -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", + ]); +}); diff --git a/desktop/src/features/messages/lib/surfaceReplies.d.mts b/desktop/src/features/messages/lib/surfaceReplies.d.mts new file mode 100644 index 000000000..d5c9a0f47 --- /dev/null +++ b/desktop/src/features/messages/lib/surfaceReplies.d.mts @@ -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[]; diff --git a/desktop/src/features/messages/lib/surfaceReplies.mjs b/desktop/src/features/messages/lib/surfaceReplies.mjs new file mode 100644 index 000000000..4d1ea30ab --- /dev/null +++ b/desktop/src/features/messages/lib/surfaceReplies.mjs @@ -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)); + }); +} diff --git a/desktop/src/features/messages/lib/surfaceReplies.test.mjs b/desktop/src/features/messages/lib/surfaceReplies.test.mjs new file mode 100644 index 000000000..1bf9d7b90 --- /dev/null +++ b/desktop/src/features/messages/lib/surfaceReplies.test.mjs @@ -0,0 +1,214 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +// Imports the exact source the renderer wires in Phase 2. No inlined copy → no +// drift between test expectations and production behaviour. +import { surfaceReplies } from "./surfaceReplies.mjs"; + +const HUMAN = "human-pubkey"; +const HUMAN_2 = "human-pubkey-2"; +const AGENT = "agent-pubkey"; +const AGENT_2 = "agent-pubkey-2"; + +// Caller contract: unknown pubkeys resolve to human (true). Only the known +// agent pubkeys are non-human; everything else (including undefined) is human. +const isHuman = (pubkey) => pubkey !== AGENT && pubkey !== AGENT_2; + +// `rootId` mirrors production: a root-level message has `rootId == null` +// (its own thread id is its `id`); a nested reply carries the thread root's id. +const message = ({ + id, + pubkey, + parentId = null, + rootId = null, + body = "", + tags = [], +}) => ({ + id, + pubkey, + author: pubkey, + parentId, + rootId, + body, + createdAt: 0, + time: "", + depth: parentId == null ? 0 : 1, + tags, +}); + +const p = (pubkey) => ["p", pubkey]; +const ids = (msgs) => msgs.map((m) => m.id); + +test("agent-authored nested message tagging a human surfaces", () => { + const nested = message({ + id: "n1", + pubkey: AGENT, + parentId: "root1", + body: "here is your answer", + tags: [p(HUMAN)], + }); + const out = surfaceReplies( + [message({ id: "root1", pubkey: HUMAN }), nested], + isHuman, + ); + assert.deepEqual(ids(out), ["n1"]); +}); + +test("agent-to-agent nested message (no human p-tag) does not surface", () => { + const nested = message({ + id: "n1", + pubkey: AGENT, + parentId: "root1", + body: "investigating together", + tags: [p(AGENT_2)], + }); + const out = surfaceReplies( + [message({ id: "root1", pubkey: HUMAN }), nested], + isHuman, + ); + assert.deepEqual(out, []); +}); + +test("human-authored nested message tagging a human does not surface", () => { + const nested = message({ + id: "n1", + pubkey: HUMAN, + parentId: "root1", + body: "what do you think", + tags: [p(HUMAN_2)], + }); + const out = surfaceReplies( + [message({ id: "root1", pubkey: AGENT }), nested], + isHuman, + ); + assert.deepEqual(out, []); +}); + +test("nested agent reply whose exact body already exists at root in the same thread is de-duped", () => { + const body = "the deploy is green"; + const out = surfaceReplies( + [ + message({ id: "root1", pubkey: AGENT, body }), + message({ + id: "n1", + pubkey: AGENT, + parentId: "root1", + rootId: "root1", + body, + tags: [p(HUMAN)], + }), + ], + isHuman, + ); + assert.deepEqual(out, []); +}); + +test("near-but-not-identical body still surfaces (STRICT, not loose, de-dupe)", () => { + // Under LOOSE de-dupe (same author already at root) this would be wrongly + // skipped: the agent has an unrelated root post in this thread. STRICT + // compares body, so a genuinely new nested reply to the human still surfaces. + const out = surfaceReplies( + [ + message({ id: "root1", pubkey: AGENT, body: "the deploy is green" }), + message({ + id: "n1", + pubkey: AGENT, + parentId: "root1", + rootId: "root1", + body: "the deploy is green now", + tags: [p(HUMAN)], + }), + ], + isHuman, + ); + assert.deepEqual(ids(out), ["n1"]); +}); + +test("unknown author treated as human under-surfaces (fail-safe)", () => { + // isHuman returns true for the unknown pubkey → fails the agent-author gate. + const nested = message({ + id: "n1", + pubkey: "unknown-pubkey", + parentId: "root1", + body: "ambiguous author", + tags: [p(HUMAN)], + }); + const out = surfaceReplies( + [message({ id: "root1", pubkey: HUMAN }), nested], + isHuman, + ); + assert.deepEqual(out, []); +}); + +test("identical body in a different thread surfaces; same-thread root collision de-dupes", () => { + // Thread A has a root "done"; thread B has a nested agent→human "done". + // Thread-scoped de-dupe must NOT let thread A's root suppress thread B's + // reply — but a matching root in the SAME thread still de-dupes. + const out = surfaceReplies( + [ + message({ id: "A", pubkey: HUMAN, body: "done" }), + message({ + id: "nB", + pubkey: AGENT, + parentId: "B", + rootId: "B", + body: "done", + tags: [p(HUMAN)], + }), + message({ id: "C", pubkey: AGENT, body: "done" }), + message({ + id: "nC", + pubkey: AGENT, + parentId: "C", + rootId: "C", + body: "done", + tags: [p(HUMAN)], + }), + ], + isHuman, + ); + // nB surfaces (no same-thread root "done"); nC de-dupes (root C is "done"). + assert.deepEqual(ids(out), ["nB"]); +}); + +test("broadcast-tagged nested message is treated as root and does not surface", () => { + // Pins the broadcast branch of the inlined isRootLevel: a nested message + // (parentId != null) carrying ["broadcast","1"] is root-level, so it is + // excluded from surfacing. Guards against silent drift from + // buildMainTimelineEntries's canonical filter. + const out = surfaceReplies( + [ + message({ id: "root1", pubkey: HUMAN }), + message({ + id: "n1", + pubkey: AGENT, + parentId: "root1", + rootId: "root1", + body: "broadcast reply to a human", + tags: [["broadcast", "1"], p(HUMAN)], + }), + ], + isHuman, + ); + assert.deepEqual(out, []); +}); + +test("empty-bodied nested agent reply surfaces despite an empty-bodied root", () => { + // An empty body carries no content to "already exist" at root, so it neither + // seeds the de-dupe set nor is suppressed by it. + const out = surfaceReplies( + [ + message({ id: "root1", pubkey: HUMAN, body: "" }), + message({ + id: "n1", + pubkey: AGENT, + parentId: "root1", + rootId: "root1", + body: "", + tags: [p(HUMAN)], + }), + ], + isHuman, + ); + assert.deepEqual(ids(out), ["n1"]); +}); diff --git a/desktop/src/features/messages/ui/SurfacedReplyRow.tsx b/desktop/src/features/messages/ui/SurfacedReplyRow.tsx new file mode 100644 index 000000000..0fbee3354 --- /dev/null +++ b/desktop/src/features/messages/ui/SurfacedReplyRow.tsx @@ -0,0 +1,48 @@ +import type { TimelineMessage } from "@/features/messages/types"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +/** + * A read-time pointer that surfaces an agent's nested reply to a human up to + * the root timeline, at the reply's own `createdAt`. The real message is never + * moved or copied; clicking navigates DOWN to it in its thread (open the thread + * at its `rootId`, scroll to and highlight the nested message). + * + * Reuses the visual idiom of `MessageThreadSummaryRow` (rounded pill, avatar, + * muted label) but is a distinct component: a thread summary means + * reply-count/participants and opens a panel rooted at itself, whereas this + * points at one specific reply and navigates down to it. + */ +export function SurfacedReplyRow({ + message, + onNavigate, +}: { + message: TimelineMessage; + onNavigate: (message: TimelineMessage) => void; +}) { + return ( +