Fix task conversation foundation issues

This commit is contained in:
klopez4212
2026-06-27 09:56:46 +01:00
parent 9541802d27
commit b807eca7fe
8 changed files with 144 additions and 64 deletions

View File

@@ -407,3 +407,81 @@ test("continued conversation marker hides same-second messages after the anchor"
assert.deepEqual([...hiddenIds], ["after"]);
});
test("continued conversation marker with a missing anchor does not hide thread messages", () => {
const root = message({
body: "Can you look into the data model?",
createdAt: 1,
id: "root",
});
const reply = message({
body: "One note before opening.",
createdAt: 3,
id: "reply",
});
const marker = parseAgentConversationMarker(
markerEvent({ content: { agentReplyId: "missing-reply" }, createdAt: 4 }),
);
const hiddenIds = getHiddenAgentConversationMessageIds(
[root, reply],
marker ? [marker] : [],
);
assert.deepEqual([...hiddenIds], []);
});
test("continued conversation markers keep later task anchors visible", () => {
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 hiddenReply = message({
body: "This should live in the first task.",
createdAt: 3,
id: "hidden",
});
const secondAnchor = message({
body: "Let's split this into another task.",
createdAt: 4,
id: "second-anchor",
pubkey: "agent",
});
const laterReply = message({
body: "This should also be hidden.",
createdAt: 5,
id: "later",
});
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 hiddenIds = getHiddenAgentConversationMessageIds(
[root, firstAnchor, hiddenReply, secondAnchor, laterReply],
[firstMarker, secondMarker].filter(Boolean),
);
assert.deepEqual([...hiddenIds], ["hidden", "later"]);
});

View File

@@ -626,26 +626,42 @@ export function getHiddenAgentConversationMessageIds(
const messageIndexById = new Map(
orderedMessages.map(({ message }, index) => [message.id, index]),
);
const messageById = new Map(
orderedMessages.map(({ message }) => [message.id, message]),
);
const anchorMessageIdsByThreadRootId = new Map<string, Set<string>>();
const cutoffByThreadRootId = new Map<
string,
{
anchorIndex: number | null;
anchorMessageId: string;
anchorIndex: number;
startedAt: number;
}
>();
for (const marker of markers) {
const anchorMessage = messageById.get(marker.agentReplyId);
const anchorIndex = messageIndexById.get(marker.agentReplyId);
if (!anchorMessage || anchorIndex === undefined) {
continue;
}
const anchorThreadRootId =
anchorMessage.rootId ?? anchorMessage.parentId ?? anchorMessage.id;
if (anchorThreadRootId !== marker.threadRootId) {
continue;
}
const anchorMessageIds =
anchorMessageIdsByThreadRootId.get(marker.threadRootId) ?? new Set();
anchorMessageIds.add(marker.agentReplyId);
anchorMessageIdsByThreadRootId.set(marker.threadRootId, anchorMessageIds);
const current = cutoffByThreadRootId.get(marker.threadRootId);
const candidate = {
anchorIndex: messageIndexById.get(marker.agentReplyId) ?? null,
anchorMessageId: marker.agentReplyId,
anchorIndex,
startedAt: marker.startedAt,
};
const isEarlier =
current === undefined ||
(candidate.anchorIndex !== null && current.anchorIndex !== null
? candidate.anchorIndex < current.anchorIndex
: candidate.startedAt < current.startedAt);
current === undefined || candidate.anchorIndex < current.anchorIndex;
if (isEarlier) {
cutoffByThreadRootId.set(marker.threadRootId, candidate);
}
@@ -659,12 +675,15 @@ export function getHiddenAgentConversationMessageIds(
}
const cutoff = cutoffByThreadRootId.get(threadRootId);
if (cutoff === undefined || message.id === cutoff.anchorMessageId) {
if (
cutoff === undefined ||
anchorMessageIdsByThreadRootId.get(threadRootId)?.has(message.id)
) {
continue;
}
const messageIndex = messageIndexById.get(message.id);
if (cutoff.anchorIndex !== null && messageIndex !== undefined) {
if (messageIndex !== undefined) {
if (messageIndex > cutoff.anchorIndex) {
hiddenIds.add(message.id);
}

View File

@@ -242,17 +242,6 @@ export const ChannelPane = React.memo(function ChannelPane({
return true;
}, [findLastOwnEditable, messages, onEdit]);
const handleEditLastOwnThreadMessage = React.useCallback((): boolean => {
if (!onEdit) return false;
const scope: TimelineMessage[] = [];
if (threadHeadMessage) scope.push(threadHeadMessage);
for (const entry of threadMessages) scope.push(entry.message);
const target = findLastOwnEditable(scope);
if (!target) return false;
onEdit(target);
return true;
}, [findLastOwnEditable, onEdit, threadHeadMessage, threadMessages]);
const isComposerDisabled =
!activeChannel?.isMember ||
activeChannel.archivedAt !== null ||

View File

@@ -86,6 +86,24 @@ test("buildTimelineItems: system messages flatten to a 'system' item", () => {
assert.deepEqual(kinds(items), ["day-divider", "message", "system"]);
});
test("buildTimelineItems: can omit only the initial day divider", () => {
const entries = [
entry({ id: "d1a", createdAt: dayAt(2026, 6, 13) }),
entry({ id: "d1b", createdAt: dayAt(2026, 6, 13, 13) }),
entry({ id: "d2a", createdAt: dayAt(2026, 6, 14) }),
];
const { items } = buildTimelineItems(entries, null, {
showInitialDayDivider: false,
});
assert.deepEqual(kinds(items), [
"message",
"message",
"day-divider",
"message",
]);
});
test("buildTimelineItems: empty entries produce no items", () => {
const { items } = buildTimelineItems([], null);
assert.equal(items.length, 0);

View File

@@ -30,6 +30,10 @@ export type TimelineItemsResult = {
items: TimelineItem[];
};
type BuildTimelineItemsOptions = {
showInitialDayDivider?: boolean;
};
/** Stable per-item key, unique across the flattened stream. */
export function getTimelineItemKey(item: TimelineItem): string {
return item.key;
@@ -47,6 +51,7 @@ function entryRenderKey(entry: MainTimelineEntry): string {
export function buildTimelineItems(
entries: MainTimelineEntry[],
firstUnreadMessageId: string | null,
{ showInitialDayDivider = true }: BuildTimelineItemsOptions = {},
): TimelineItemsResult {
const items: TimelineItem[] = [];
@@ -66,7 +71,7 @@ export function buildTimelineItems(
const renderKey = entryRenderKey(entry);
const dayBoundary = dayBoundariesByStartIndex.get(i);
if (dayBoundary) {
if (dayBoundary && (showInitialDayDivider || i !== 0)) {
items.push({
kind: "day-divider",
key: dayBoundary.key,

View File

@@ -94,6 +94,7 @@ type MessageTimelineProps = {
searchMatchingMessageIds?: Set<string>;
/** The current find-in-channel query string. */
searchQuery?: string;
showInitialDayDivider?: boolean;
targetMessageId?: string | null;
onTargetReached?: (messageId: string) => void;
/** Event id of the oldest unread top-level message at channel open, or null. */
@@ -189,6 +190,7 @@ const MessageTimelineBase = React.forwardRef<
searchActiveMessageId = null,
searchMatchingMessageIds,
searchQuery,
showInitialDayDivider = true,
targetMessageId = null,
onTargetReached,
firstUnreadMessageId = null,
@@ -239,7 +241,7 @@ const MessageTimelineBase = React.forwardRef<
// scrollTop into the current scroll element during navigation; reusing the
// same node across channel routes can leave the newly-loaded message list
// painted at a stale offset until the user's next scroll event forces layout.
const scrollContainerDomKey = channelId ?? "none";
const scrollContainerDomKey = `${channelId ?? "none"}:${layoutShiftKey ?? "none"}`;
const timelineBodySurface = selectTimelineBodySurface({
deferredCount: deferredMessages.length,
@@ -634,6 +636,7 @@ const MessageTimelineBase = React.forwardRef<
searchActiveMessageId={searchActiveMessageId}
searchMatchingMessageIds={searchMatchingMessageIds}
searchQuery={searchQuery}
showInitialDayDivider={showInitialDayDivider}
threadUnreadCounts={threadUnreadCounts}
unfollowThreadById={unfollowThreadById}
/>

View File

@@ -78,6 +78,7 @@ type TimelineMessageListProps = {
searchMatchingMessageIds?: Set<string>;
/** The current find-in-channel query string. */
searchQuery?: string;
showInitialDayDivider?: boolean;
/** Per-thread unread counts keyed by thread root id. */
threadUnreadCounts?: ReadonlyMap<string, number>;
};
@@ -112,6 +113,7 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
searchActiveMessageId = null,
searchMatchingMessageIds,
searchQuery,
showInitialDayDivider = true,
threadUnreadCounts,
unfollowThreadById,
}: TimelineMessageListProps) {
@@ -168,8 +170,11 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
// The flattened item stream, memoized on the entries and the unread boundary
// (the unread divider is its own item, so it shifts subsequent rows).
const itemsResult = React.useMemo(
() => buildTimelineItems(entries, firstUnreadMessageId),
[entries, firstUnreadMessageId],
() =>
buildTimelineItems(entries, firstUnreadMessageId, {
showInitialDayDivider,
}),
[entries, firstUnreadMessageId, showInitialDayDivider],
);
const agentConversationMarkerByMessageId = React.useMemo(
() =>

View File

@@ -493,45 +493,8 @@ export function AppSidebar({
return byChannelId;
}, [agentConversations]);
const isAgentConversationActive = selectedView === "agents";
const activeAgentConversationChannelId = React.useMemo(() => {
if (!isAgentConversationActive || !selectedAgentConversationId) {
return null;
}
return (
agentConversations.find(
(conversation) => conversation.id === selectedAgentConversationId,
)?.channelId ?? null
);
}, [
agentConversations,
isAgentConversationActive,
selectedAgentConversationId,
]);
const displayUnreadChannelIds = React.useMemo(() => {
if (
!activeAgentConversationChannelId ||
!unreadChannelIds.has(activeAgentConversationChannelId)
) {
return unreadChannelIds;
}
const next = new Set(unreadChannelIds);
next.delete(activeAgentConversationChannelId);
return next;
}, [activeAgentConversationChannelId, unreadChannelIds]);
const displayUnreadChannelCounts = React.useMemo(() => {
if (
!activeAgentConversationChannelId ||
!unreadChannelCounts.has(activeAgentConversationChannelId)
) {
return unreadChannelCounts;
}
const next = new Map(unreadChannelCounts);
next.delete(activeAgentConversationChannelId);
return next;
}, [activeAgentConversationChannelId, unreadChannelCounts]);
const displayUnreadChannelIds = unreadChannelIds;
const displayUnreadChannelCounts = unreadChannelCounts;
const sidebarLoadingShape = useSidebarLoadingShape({
activeWorkspaceId: activeWorkspace?.id,
currentPubkey,