mirror of
https://github.com/block/buzz.git
synced 2026-11-03 07:51:22 +00:00
Fix task conversation foundation issues
This commit is contained in:
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user