Skip to content
2 changes: 1 addition & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ const overrides = new Map([
["src/features/messages/ui/MessageComposer.tsx", 1010],
// continued-agent-conversations: channel sidebar children and active
// conversation unread suppression. Queued to split with sidebar sections.
["src/features/sidebar/ui/AppSidebar.tsx", 1081],
["src/features/sidebar/ui/AppSidebar.tsx", 1087],
// PersistBackend enum + marker-on-keyring-success plumbing and its three
// fail-closed regression tests (silent identity rotation on keyring outage).
// A small overage from load-bearing security plumbing on a file already at
Expand Down
17 changes: 14 additions & 3 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate"
import { relayClient } from "@/shared/api/relayClient";
import { useIdentityQuery } from "@/shared/api/hooks";
import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal";
import { CHANNEL_TASKS_FEATURE_ID, useFeatureEnabled } from "@/shared/features";
import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup";
import { joinChannel } from "@/shared/api/tauri";
import type { SearchHit } from "@/shared/api/types";
Expand All @@ -89,6 +90,7 @@ const LazySettingsScreen = React.lazy(async () => {
export function AppShell() {
useWebviewZoomShortcuts();
useTauriWindowDrag();
const isChannelTasksEnabled = useFeatureEnabled(CHANNEL_TASKS_FEATURE_ID);

const workspacesHook = useWorkspaces();
const [isAddWorkspaceOpen, setIsAddWorkspaceOpen] = React.useState(false);
Expand Down Expand Up @@ -212,6 +214,7 @@ export function AppShell() {
} = useAgentConversationShellState({
channels,
currentPubkey,
enabled: isChannelTasksEnabled,
goAgents,
goChannel,
selectedView,
Expand Down Expand Up @@ -715,7 +718,11 @@ export function AppShell() {
}}
onAddWorkspaceOpenChange={setIsAddWorkspaceOpen}
onNewDmOpenChange={setIsNewDmOpen}
onHideAgentConversation={handleHideAgentConversation}
onHideAgentConversation={
isChannelTasksEnabled
? handleHideAgentConversation
: undefined
}
onCreateChannelOpenChange={setIsCreateChannelOpen}
onOpenAddWorkspace={() => setIsAddWorkspaceOpen(true)}
onUpdateWorkspace={workspacesHook.updateWorkspace}
Expand Down Expand Up @@ -790,7 +797,9 @@ export function AppShell() {
await goChannel(directMessage.id);
}}
onSelectAgentConversation={
handleSelectAgentConversation
isChannelTasksEnabled
? handleSelectAgentConversation
: undefined
}
onSelectAgents={() => {
clearSelectedAgentConversation();
Expand Down Expand Up @@ -839,7 +848,9 @@ export function AppShell() {
}
selectedChannelId={selectedChannelId}
selectedAgentConversationId={
selectedAgentConversationId
isChannelTasksEnabled
? selectedAgentConversationId
: null
}
selectedView={selectedView}
unreadChannelIds={unreadChannelIds}
Expand Down
15 changes: 10 additions & 5 deletions desktop/src/app/routes/ChannelRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useProfileQuery } from "@/features/profile/hooks";
import { useIdentityQuery } from "@/shared/api/hooks";
import { getEventById } from "@/shared/api/tauri";
import type { RelayEvent } from "@/shared/api/types";
import { CHANNEL_TASKS_FEATURE_ID, useFeatureEnabled } from "@/shared/features";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";

type ChannelRouteScreenProps = {
Expand Down Expand Up @@ -102,6 +103,7 @@ export function ChannelRouteScreen({
targetReplyId,
targetThreadRootId,
}: ChannelRouteScreenProps) {
const isChannelTasksEnabled = useFeatureEnabled(CHANNEL_TASKS_FEATURE_ID);
const { closeForumPost, goForumPost } = useAppNavigation();
const channelsQuery = useChannelsQuery();
const identityQuery = useIdentityQuery();
Expand All @@ -115,6 +117,9 @@ export function ChannelRouteScreen({
const cachedTarget = getCachedSearchHitEvent(targetMessageId);
return cachedTarget ? [cachedTarget] : [];
});
const effectiveAgentConversationReplyId = isChannelTasksEnabled
? targetAgentConversationReplyId
: null;

// Reset spliced target events when the channel context changes (channel
// switch or entering/leaving a forum post). Tied to channel identity rather
Expand Down Expand Up @@ -143,7 +148,7 @@ export function ChannelRouteScreen({
// param-clear blanks the timeline. Resetting on channel / forum-post change
// is handled by the effect below; here we only fetch when there's a target.
if (
(!targetAgentConversationReplyId &&
(!effectiveAgentConversationReplyId &&
!targetMessageId &&
!targetThreadRootId) ||
selectedPostId
Expand All @@ -163,7 +168,7 @@ export function ChannelRouteScreen({
}

const eventIds = [
targetAgentConversationReplyId,
effectiveAgentConversationReplyId,
targetMessageId,
targetThreadRootId && targetThreadRootId !== targetMessageId
? targetThreadRootId
Expand All @@ -172,7 +177,7 @@ export function ChannelRouteScreen({

void fetchRouteTargetEvents(
eventIds,
targetAgentConversationReplyId ?? targetMessageId,
effectiveAgentConversationReplyId ?? targetMessageId,
targetThreadRootId,
).then((events) => {
if (!isCancelled) {
Expand All @@ -191,7 +196,7 @@ export function ChannelRouteScreen({
};
}, [
selectedPostId,
targetAgentConversationReplyId,
effectiveAgentConversationReplyId,
targetMessageId,
targetThreadRootId,
]);
Expand All @@ -217,7 +222,7 @@ export function ChannelRouteScreen({
void goForumPost(channelId, postId);
}}
selectedForumPostId={selectedPostId}
targetAgentConversationReplyId={targetAgentConversationReplyId}
targetAgentConversationReplyId={effectiveAgentConversationReplyId}
targetForumReplyId={targetReplyId}
targetMessageEvents={targetMessageEvents}
targetMessageId={targetMessageId}
Expand Down
95 changes: 91 additions & 4 deletions desktop/src/features/agents/agentConversations.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,12 @@ test("continued conversation title condenses a refined Buzz data thread", () =>
});
});

function markerEvent({ content = {}, createdAt = 1, id = "marker" } = {}) {
function markerEvent({
content = {},
createdAt = 1,
id = "marker",
includeAgent = true,
} = {}) {
return {
id,
pubkey: "starter",
Expand All @@ -69,15 +74,15 @@ function markerEvent({ content = {}, createdAt = 1, id = "marker" } = {}) {
["h", "channel"],
["e", "root", "", "root"],
["e", "agent-reply", "", "agent-reply"],
["p", "agent"],
...(includeAgent ? [["p", "agent"]] : []),
["title", "Data in Buzz app"],
],
content: JSON.stringify({
version: 1,
title: "Data in Buzz app",
titleStatus: "resolved",
agentName: "Fizz",
agentPubkey: "agent",
agentName: includeAgent ? "Fizz" : "",
agentPubkey: includeAgent ? "agent" : "",
threadRootId: "root",
threadRootMessageId: "root",
parentMessageId: "root",
Expand Down Expand Up @@ -130,6 +135,16 @@ test("continued conversation marker parses summary metadata", () => {
assert.equal(marker?.summaryCreatedAt, 12);
});

test("continued conversation marker can anchor a task without a primary agent", () => {
const marker = parseAgentConversationMarker(
markerEvent({ includeAgent: false }),
);

assert.equal(marker?.agentName, "Task");
assert.equal(marker?.agentPubkey, "");
assert.equal(marker?.agentReplyId, "agent-reply");
});

test("continued conversations persist across app restarts", () => {
withMockLocalStorage(() => {
const workspaceScope = "wss://relay.example.com";
Expand Down Expand Up @@ -169,6 +184,34 @@ test("continued conversations persist across app restarts", () => {
});
});

test("message-anchored tasks persist without a primary agent", () => {
withMockLocalStorage(() => {
const workspaceScope = "wss://relay.example.com";
const root = message({
body: "Can someone turn this into a task?",
createdAt: 1,
id: "root",
});
const conversation = buildAgentConversation({
agentName: "",
agentPubkey: "",
agentReply: root,
channel: { id: "channel", name: "general" },
contextMessages: [root],
parentMessage: null,
threadRootMessage: root,
});

writePersistedAgentConversations("human", workspaceScope, [conversation]);
const persisted = readPersistedAgentConversations("human", workspaceScope);

assert.equal(persisted.length, 1);
assert.equal(persisted[0].id, conversation.id);
assert.equal(persisted[0].agentPubkey, "");
assert.equal(persisted[0].agentReply.id, "root");
});
});

test("continued conversation marker summary update replaces earlier marker", () => {
const markers = buildAgentConversationMarkers([
markerEvent({
Expand Down Expand Up @@ -393,6 +436,50 @@ test("continued conversation marker with a missing anchor does not hide thread m
assert.deepEqual([...hiddenIds], []);
});

test("source-message task marker does not hide later thread replies", () => {
const root = message({
body: "Can you look into the data model?",
createdAt: 1,
id: "root",
});
const humanAnchor = message({
body: "Let's make this a task.",
createdAt: 2,
id: "human-anchor",
});
const laterReply = message({
body: "This normal thread reply should stay visible.",
createdAt: 3,
id: "later",
});
const marker = parseAgentConversationMarker({
...markerEvent({
content: {
agentName: "",
agentPubkey: "",
agentReplyId: "human-anchor",
startedAt: 2,
},
createdAt: 2,
id: "source-marker",
includeAgent: false,
}),
tags: [
["h", "channel"],
["e", "root", "", "root"],
["e", "human-anchor", "", "agent-reply"],
["title", "Source task"],
],
});

const hiddenIds = getHiddenAgentConversationMessageIds(
[root, humanAnchor, laterReply],
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?",
Expand Down
35 changes: 21 additions & 14 deletions desktop/src/features/agents/agentConversations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export type AgentConversation = {
export type OpenAgentConversationInput = {
agentName: string;
agentPubkey: string;
/** Source message the task was started from. Kept as `agentReply` for link compatibility. */
agentReply: TimelineMessage;
channel: Pick<Channel, "id" | "name">;
contextMessages?: TimelineMessage[];
Expand Down Expand Up @@ -213,8 +214,8 @@ function parseStoredAgentConversation(
}

const id = maybeString(value.id);
const agentName = maybeString(value.agentName);
const agentPubkey = maybeString(value.agentPubkey);
const agentName = maybeString(value.agentName) ?? "Task";
const agentPubkey = maybeString(value.agentPubkey) ?? "";
const channelId = maybeString(value.channelId);
const channelName = maybeString(value.channelName);
const threadRootId = maybeString(value.threadRootId);
Expand Down Expand Up @@ -244,8 +245,6 @@ function parseStoredAgentConversation(

if (
!id ||
!agentName ||
!agentPubkey ||
!agentReply ||
!channelId ||
!channelName ||
Expand Down Expand Up @@ -424,7 +423,7 @@ export function parseAgentConversationMarker(
(typeof content.agentReplyId === "string" ? content.agentReplyId : null);
const agentPubkey =
getTagValue(event.tags, "p") ??
(typeof content.agentPubkey === "string" ? content.agentPubkey : null);
(typeof content.agentPubkey === "string" ? content.agentPubkey : "");
const parentMessageId =
typeof content.parentMessageId === "string"
? content.parentMessageId
Expand All @@ -433,7 +432,7 @@ export function parseAgentConversationMarker(
typeof content.threadRootMessageId === "string"
? content.threadRootMessageId
: null;
const agentName = trimmedString(content.agentName) || agentPubkey || "Agent";
const agentName = trimmedString(content.agentName) || agentPubkey || "Task";
const title =
trimmedString(content.title) ??
getTagValue(event.tags, "title") ??
Expand All @@ -451,7 +450,7 @@ export function parseAgentConversationMarker(
? content.startedAt
: event.created_at;

if (!channelId || !threadRootId || !agentReplyId || !agentPubkey) {
if (!channelId || !threadRootId || !agentReplyId) {
return null;
}

Expand Down Expand Up @@ -576,16 +575,20 @@ export async function publishAgentConversationMarker(
}
: {}),
});
const tags = [
["h", conversation.channelId],
["e", conversation.threadRootId, "", "root"],
["e", conversation.agentReply.id, "", "agent-reply"],
["title", conversation.title],
];
if (conversation.agentPubkey) {
tags.splice(3, 0, ["p", conversation.agentPubkey]);
}

const event = await signRelayEvent({
kind: KIND_AGENT_CONVERSATION_COMPAT,
content,
tags: [
["h", conversation.channelId],
["e", conversation.threadRootId, "", "root"],
["e", conversation.agentReply.id, "", "agent-reply"],
["p", conversation.agentPubkey],
["title", conversation.title],
],
tags,
});

return relayClient.publishEvent(
Expand Down Expand Up @@ -642,6 +645,10 @@ export function getHiddenAgentConversationMessageIds(
anchorMessageIds.add(marker.agentReplyId);
anchorMessageIdsByThreadRootId.set(marker.threadRootId, anchorMessageIds);

if (!marker.agentPubkey || anchorMessage.pubkey !== marker.agentPubkey) {
continue;
}

const current = cutoffByThreadRootId.get(marker.threadRootId);
const candidate = {
anchorIndex,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,8 @@ export function buildKnownAgentParticipants({
});
}

if (!participants.has(normalizePubkey(conversation.agentPubkey))) {
const primaryAgentKey = normalizePubkey(conversation.agentPubkey);
if (primaryAgentKey && !participants.has(primaryAgentKey)) {
add({
canMessage: true,
displayName: conversation.agentName,
Expand Down
Loading
Loading