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

import {
computeAutoContinueAgentMentions,
resolveAutoContinueAnchorId,
} from "./autoContinueAgent.ts";

const AGENT = "a".repeat(64);
const HUMAN = "b".repeat(64);

const anchor = {
signerPubkey: AGENT,
author: AGENT,
tags: [["p", HUMAN]],
};

test("auto-continues when the agent anchor p-tagged the current user", () => {
const result = computeAutoContinueAgentMentions({
anchor,
currentPubkey: HUMAN,
agentPubkeys: new Set([AGENT]),
existingMentionPubkeys: [],
});
assert.deepEqual(result, [AGENT]);
});

test("no-op when the agent did not p-tag the current user", () => {
const result = computeAutoContinueAgentMentions({
anchor: { ...anchor, tags: [] },
currentPubkey: HUMAN,
agentPubkeys: new Set([AGENT]),
existingMentionPubkeys: [],
});
assert.deepEqual(result, []);
});

test("anchor resolves to the latest message, not the thread root", () => {
assert.equal(
resolveAutoContinueAnchorId({
replyTargetId: null,
latestMessageId: "latest",
threadHeadId: "root",
}),
"latest",
);
});

test("anchor prefers an explicit reply target over the latest message", () => {
assert.equal(
resolveAutoContinueAnchorId({
replyTargetId: "target",
latestMessageId: "latest",
threadHeadId: "root",
}),
"target",
);
});
72 changes: 72 additions & 0 deletions desktop/src/features/messages/lib/autoContinueAgent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { TimelineMessage } from "@/features/messages/types";
import { normalizePubkey } from "@/shared/lib/pubkey";

export type AutoContinueAnchor = Pick<
TimelineMessage,
"signerPubkey" | "pubkey" | "author" | "tags"
>;

type ComputeAutoContinueAgentMentionsInput = {
anchor: AutoContinueAnchor | null | undefined;
currentPubkey: string | null | undefined;
agentPubkeys: ReadonlySet<string> | null | undefined;
existingMentionPubkeys: readonly string[];
};

function anchorSignerPubkey(anchor: AutoContinueAnchor): string | null {
const raw = anchor.signerPubkey ?? anchor.pubkey ?? anchor.author;
if (!raw) {
return null;
}
const normalized = normalizePubkey(raw);
return normalized.length > 0 ? normalized : null;
}

function anchorMentions(anchor: AutoContinueAnchor, pubkey: string): boolean {
const tags = anchor.tags ?? [];
return tags.some(
(tag) => tag[0] === "p" && normalizePubkey(tag[1] ?? "") === pubkey,
);
}

export function resolveAutoContinueAnchorId({
replyTargetId,
latestMessageId,
threadHeadId,
}: {
replyTargetId: string | null | undefined;
latestMessageId: string | null | undefined;
threadHeadId: string | null | undefined;
}): string | null {
return replyTargetId ?? latestMessageId ?? threadHeadId ?? null;
}

export function computeAutoContinueAgentMentions({
anchor,
currentPubkey,
agentPubkeys,
existingMentionPubkeys,
}: ComputeAutoContinueAgentMentionsInput): string[] {
if (!anchor || !currentPubkey || !agentPubkeys || agentPubkeys.size === 0) {
return [];
}

const self = normalizePubkey(currentPubkey);
if (self.length === 0) {
return [];
}

const agentPubkey = anchorSignerPubkey(anchor);
if (!agentPubkey || !agentPubkeys.has(agentPubkey) || agentPubkey === self) {
return [];
}

if (!anchorMentions(anchor, self)) {
return [];
}

const alreadyMentioned = new Set(
existingMentionPubkeys.map((pubkey) => normalizePubkey(pubkey)),
);
return alreadyMentioned.has(agentPubkey) ? [] : [agentPubkey];
}
12 changes: 11 additions & 1 deletion desktop/src/features/messages/ui/MessageThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { TypingIndicatorRow } from "./TypingIndicatorRow";
import { UnreadDivider } from "./UnreadDivider";
import { useComposerHeightPadding } from "./useComposerHeightPadding";
import { useAnchoredScroll } from "./useAnchoredScroll";
import { useAutoContinueThreadSend } from "./useAutoContinueThreadSend";
import { selectDeferredListRenderState } from "@/features/messages/lib/timelineSnapshot";

type MessageThreadPanelProps = {
Expand Down Expand Up @@ -417,6 +418,15 @@ export function MessageThreadPanel({
}
: null;

const handleSend = useAutoContinueThreadSend({
agentPubkeys,
currentPubkey,
threadHead,
threadReplies,
replyTargetMessageRef,
onSend,
});

const deferredThreadReplies = React.useDeferredValue(
threadReplies,
EMPTY_THREAD_REPLIES,
Expand Down Expand Up @@ -890,7 +900,7 @@ export function MessageThreadPanel({
onCaptureSendContext={onCaptureSendContext}
onEditLastOwnMessage={onEditLastOwnMessage}
onEditSave={onEditSave}
onSend={onSend}
onSend={handleSend}
placeholder={`Reply in thread to ${threadHead.author}`}
profiles={profiles}
replyTarget={composerReplyTarget}
Expand Down
105 changes: 105 additions & 0 deletions desktop/src/features/messages/ui/useAutoContinueThreadSend.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import * as React from "react";

import {
computeAutoContinueAgentMentions,
resolveAutoContinueAnchorId,
} from "@/features/messages/lib/autoContinueAgent";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
import type { TimelineMessage } from "@/features/messages/types";

type ThreadContext = {
parentEventId: string | null;
threadHeadId: string | null;
} | null;

type ThreadSend = (
content: string,
mentionPubkeys: string[],
mediaTags?: string[][],
channelId?: string | null,
threadContext?: ThreadContext,
) => Promise<void>;

type UseAutoContinueThreadSendOptions = {
agentPubkeys?: ReadonlySet<string>;
currentPubkey?: string;
threadHead: TimelineMessage | null;
threadReplies: MainTimelineEntry[];
replyTargetMessageRef: React.MutableRefObject<TimelineMessage | null>;
onSend: ThreadSend;
};

export function useAutoContinueThreadSend({
agentPubkeys,
currentPubkey,
threadHead,
threadReplies,
replyTargetMessageRef,
onSend,
}: UseAutoContinueThreadSendOptions): ThreadSend {
const threadHeadId = threadHead?.id ?? null;

const messageById = React.useMemo(() => {
const index = new Map<string, TimelineMessage>();
if (threadHead) {
index.set(threadHead.id, threadHead);
}
for (const entry of threadReplies) {
index.set(entry.message.id, entry.message);
}
return index;
}, [threadHead, threadReplies]);

const latestMessageId = React.useMemo(() => {
let latest: TimelineMessage | null = threadHead ?? null;
for (const entry of threadReplies) {
if (!latest || entry.message.createdAt >= latest.createdAt) {
latest = entry.message;
}
}
return latest?.id ?? null;
}, [threadHead, threadReplies]);

return React.useCallback(
async (content, mentionPubkeys, mediaTags, channelId, threadContext) => {
// The auto-continue anchor is the message the reply actually continues
// from: an explicitly selected reply target, otherwise the latest message
// in the thread. `threadContext.parentEventId` is not usable here — it
// defaults to the thread ROOT when no reply target is selected, so the
// agent's p-tagging message (the last reply, not the root) would never be
// inspected and the loop would never auto-continue.
const anchorId = resolveAutoContinueAnchorId({
replyTargetId: replyTargetMessageRef.current?.id,
latestMessageId,
threadHeadId,
});
const anchor = anchorId ? messageById.get(anchorId) : null;
const autoMentions = computeAutoContinueAgentMentions({
anchor,
currentPubkey,
agentPubkeys,
existingMentionPubkeys: mentionPubkeys,
});
const effectiveMentions =
autoMentions.length > 0
? [...mentionPubkeys, ...autoMentions]
: mentionPubkeys;
await onSend(
content,
effectiveMentions,
mediaTags,
channelId,
threadContext,
);
},
[
agentPubkeys,
currentPubkey,
latestMessageId,
messageById,
onSend,
replyTargetMessageRef,
threadHeadId,
],
);
}
Loading