Fix task view reply and read scoping

This commit is contained in:
klopez4212
2026-06-28 09:54:53 +01:00
parent cb773d0d48
commit e0d62a4e28
3 changed files with 155 additions and 10 deletions

View File

@@ -564,3 +564,96 @@ test("dedicated conversation view stops at the next task anchor", () => {
assert.deepEqual(visibleIds, ["root", "agent-reply", "first-task-reply"]);
});
test("dedicated conversation view keeps older task descendant replies", () => {
const root = message({
body: "Can you look into the data model?",
createdAt: 1,
id: "root",
});
const firstAnchor = message({
body: "I'll look into it.",
createdAt: 2,
id: "agent-reply",
pubkey: "agent",
});
const firstTaskReply = message({
body: "This belongs in the first task.",
createdAt: 3,
id: "first-task-reply",
});
const secondAnchor = message({
body: "Let's split this into another task.",
createdAt: 4,
id: "second-anchor",
pubkey: "agent",
});
const secondTaskReply = message({
body: "This belongs in the second task.",
createdAt: 5,
id: "second-task-reply",
});
const firstTaskFollowup = {
...message({
body: "Continuing the first task after task two started.",
createdAt: 6,
id: "first-task-followup",
}),
parentId: "first-task-reply",
rootId: "root",
};
const plainRootReply = message({
body: "A plain root reply after task two should not join task one.",
createdAt: 7,
id: "plain-root-reply",
});
const messages = [
root,
firstAnchor,
firstTaskReply,
secondAnchor,
secondTaskReply,
firstTaskFollowup,
plainRootReply,
];
const conversation = buildAgentConversation({
agentName: "Fizz",
agentPubkey: "agent",
agentReply: firstAnchor,
channel: { id: "channel", name: "general" },
contextMessages: messages,
parentMessage: root,
threadRootMessage: root,
});
const firstMarker = parseAgentConversationMarker(
markerEvent({ content: { startedAt: 2 }, createdAt: 2 }),
);
const secondMarker = parseAgentConversationMarker({
...markerEvent({
content: { agentReplyId: "second-anchor", startedAt: 4 },
createdAt: 4,
id: "second-marker",
}),
tags: [
["h", "channel"],
["e", "root", "", "root"],
["e", "second-anchor", "", "agent-reply"],
["p", "agent"],
["title", "Second task"],
],
});
const markers = [firstMarker, secondMarker].filter(Boolean);
const visibleIds = messages
.filter((entry) =>
isConversationMessage(entry, conversation, markers, messages),
)
.map((entry) => entry.id);
assert.deepEqual(visibleIds, [
"root",
"agent-reply",
"first-task-reply",
"first-task-followup",
]);
});

View File

@@ -93,6 +93,16 @@ export function isConversationMessage(
return false;
}
const markerAnchorIds = new Set(
markers
.filter(
(marker) =>
marker.channelId === conversation.channelId &&
marker.threadRootId === conversation.threadRootId &&
marker.agentReplyId !== conversation.agentReply.id,
)
.map((marker) => marker.agentReplyId),
);
const orderedThreadMessages =
messages.length > 0
? messages.filter(
@@ -133,7 +143,44 @@ export function isConversationMessage(
}
}
return messageIndex < nextAnchorIndex;
if (messageIndex < nextAnchorIndex) {
return true;
}
const selectedTaskMessageIds = new Set<string>();
for (const candidate of orderedThreadMessages) {
const candidateIndex = messageIndexById.get(candidate.id);
if (
candidateIndex !== undefined &&
candidateIndex >= anchorIndex &&
candidateIndex < nextAnchorIndex
) {
selectedTaskMessageIds.add(candidate.id);
}
}
selectedTaskMessageIds.delete(conversation.threadRootId);
const messageById = new Map(
orderedThreadMessages.map((candidate) => [candidate.id, candidate]),
);
let parentId = message.parentId;
const visited = new Set<string>([message.id]);
while (parentId && !visited.has(parentId)) {
if (selectedTaskMessageIds.has(parentId)) {
return true;
}
if (
parentId === conversation.threadRootId ||
markerAnchorIds.has(parentId)
) {
return false;
}
visited.add(parentId);
parentId = messageById.get(parentId)?.parentId ?? null;
}
return false;
}
const currentMarker =

View File

@@ -135,7 +135,6 @@ export function AgentConversationScreen({
const {
getMessageReadAt,
isThreadMuted,
markThreadRead,
markMessageRead,
updateAgentConversationTitle,
} = useAppShell();
@@ -420,11 +419,6 @@ export function AgentConversationScreen({
});
}, [conversation, conversationSourceMessages, updateAgentConversationTitle]);
React.useEffect(() => {
const latestMessage = timelineMessages[timelineMessages.length - 1] ?? null;
if (latestMessage) {
markThreadRead(conversation.threadRootId, latestMessage.createdAt);
}
if (isThreadMuted(conversation.threadRootId)) {
return;
}
@@ -440,9 +434,19 @@ export function AgentConversationScreen({
getMessageReadAt,
isThreadMuted,
markMessageRead,
markThreadRead,
timelineMessages,
]);
const replyParentEventId = React.useMemo(() => {
const latestTaskMessage = [...timelineMessages]
.reverse()
.find((message) => message.id !== conversation.threadRootId);
return (
latestTaskMessage?.id ??
conversation.agentReply.id ??
conversation.threadRootId
);
}, [conversation.agentReply.id, conversation.threadRootId, timelineMessages]);
const routeableAgentPubkeys = React.useMemo(
() =>
agentParticipants
@@ -539,10 +543,10 @@ export function AgentConversationScreen({
autoRouteAgentPubkeys: autoRoutedAgentPubkeys,
mentionPubkeys,
}),
parentEventId: conversation.threadRootId,
parentEventId: replyParentEventId,
});
},
[autoRoutedAgentPubkeys, conversation.threadRootId, sendMessageMutation],
[autoRoutedAgentPubkeys, replyParentEventId, sendMessageMutation],
);
const isComposerDisabled =
@@ -734,6 +738,7 @@ export function AgentConversationScreen({
emptyTitle="No conversation messages yet"
hasComposerOverlay
isLoading={messagesQuery.isLoading && timelineMessages.length === 0}
layoutShiftKey={conversation.id}
messageListPlacement="top"
messages={timelineMessages}
profiles={profiles}