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
3 changes: 3 additions & 0 deletions desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -569,9 +569,12 @@ export function AppShell() {
<ChannelNavigationProvider channels={channels}>
<AppShellProvider
value={{
agentConversations: [],
markAllChannelsRead,
markChannelRead,
markChannelUnread,
openAgentConversation: () => {},
updateAgentConversationTitle: () => {},
openCreateChannel: handleOpenCreateChannel,
openChannelManagement: (channelId?: string) => {
setManagedChannelId(
Expand Down
18 changes: 18 additions & 0 deletions desktop/src/app/AppShellContext.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import * as React from "react";
import type {
AgentConversation,
AgentConversationTitleStatus,
OpenAgentConversationInput,
} from "@/features/agents/agentConversations";
import type { ContextParentResolver } from "@/features/channels/readState/readStateManager";
import type { ThreadActivityItem } from "@/features/channels/useUnreadChannels";
import type { FeedItemState } from "@/features/home/useFeedItemState";
Expand All @@ -7,13 +12,23 @@ import type { FeedItem } from "@/shared/api/types";
const EMPTY_SET = new Set<string>();

type AppShellContextValue = {
agentConversations: readonly AgentConversation[];
markAllChannelsRead: () => void;
markChannelRead: (
channelId: string,
readAt: string | null | undefined,
options?: { topLevelOnly?: boolean },
) => void;
markChannelUnread: (channelId: string) => void;
openAgentConversation: (
input: OpenAgentConversationInput,
options?: { publishMarker?: boolean },
) => void;
updateAgentConversationTitle: (
conversationId: string,
title: string,
titleStatus: AgentConversationTitleStatus,
) => void;
openCreateChannel: () => void;
openChannelManagement: (channelId?: string) => void;
// NIP-RS read marker for a channel as a unix-seconds timestamp, or null
Expand Down Expand Up @@ -48,9 +63,12 @@ type AppShellContextValue = {
};

const AppShellContext = React.createContext<AppShellContextValue>({
agentConversations: [],
markAllChannelsRead: () => {},
markChannelRead: () => {},
markChannelUnread: () => {},
openAgentConversation: () => {},
updateAgentConversationTitle: () => {},
openCreateChannel: () => {},
openChannelManagement: () => {},
getChannelReadAt: () => null,
Expand Down
206 changes: 206 additions & 0 deletions desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
canOpenAgentConversationInChannel,
getDmAutoRouteAgentPubkeys,
getThreadAutoRouteAgentPubkeys,
mergeAutoRouteMentionPubkeys,
} from "./ChannelPane.helpers.ts";

function channel(overrides = {}) {
return {
id: "channel",
name: "Channel",
channelType: "stream",
visibility: "open",
description: "",
topic: null,
purpose: null,
memberCount: 2,
memberPubkeys: [],
lastMessageAt: null,
archivedAt: null,
participants: [],
participantPubkeys: [],
isMember: true,
ttlSeconds: null,
ttlDeadline: null,
...overrides,
};
}

function message(overrides = {}) {
return {
id: "message",
createdAt: 1,
pubkey: "human",
author: "Human",
avatarUrl: null,
role: undefined,
personaDisplayName: undefined,
time: "1:00 PM",
body: "Body",
parentId: null,
rootId: null,
depth: 0,
accent: false,
pending: undefined,
edited: false,
kind: 9,
tags: [],
reactions: undefined,
...overrides,
};
}

test("DM composer auto-routes only when exactly one other participant is an agent", () => {
const knownAgentPubkeys = new Set(["agent-one", "agent-two"]);

assert.deepEqual(
getDmAutoRouteAgentPubkeys({
channel: channel({
channelType: "dm",
participantPubkeys: ["human", "agent-one"],
}),
currentPubkey: "human",
knownAgentPubkeys,
}),
["agent-one"],
);

assert.deepEqual(
getDmAutoRouteAgentPubkeys({
channel: channel({
channelType: "dm",
participantPubkeys: ["human", "agent-one", "agent-two"],
}),
currentPubkey: "human",
knownAgentPubkeys,
}),
[],
);

assert.deepEqual(
getDmAutoRouteAgentPubkeys({
channel: channel({
channelType: "dm",
participantPubkeys: ["human", "agent-one", "human-two"],
}),
currentPubkey: "human",
knownAgentPubkeys,
}),
[],
);

assert.deepEqual(
getDmAutoRouteAgentPubkeys({
channel: channel({
participantPubkeys: ["human", "agent-one"],
}),
currentPubkey: "human",
knownAgentPubkeys,
}),
[],
);
});

test("thread composer auto-routes only for one human and one known agent", () => {
const knownAgentPubkeys = new Set(["agent-one", "agent-two"]);

assert.deepEqual(
getThreadAutoRouteAgentPubkeys({
knownAgentPubkeys,
messages: [
message({ id: "root", tags: [["p", "agent-one"]] }),
message({ id: "agent-reply", pubkey: "agent-one" }),
],
}),
["agent-one"],
);

assert.deepEqual(
getThreadAutoRouteAgentPubkeys({
knownAgentPubkeys,
messages: [
message({
id: "root",
pubkey: "human-one",
tags: [
["p", "human-one"],
["p", "agent-one"],
],
}),
message({
id: "human-two-reply",
pubkey: "human-two",
tags: [
["p", "human-two"],
["p", "agent-one"],
],
}),
message({ id: "agent-reply", pubkey: "agent-one" }),
],
}),
[],
);

assert.deepEqual(
getThreadAutoRouteAgentPubkeys({
knownAgentPubkeys,
messages: [
message({ id: "agent-one-reply", pubkey: "agent-one" }),
message({ id: "agent-two-reply", pubkey: "agent-two" }),
],
}),
[],
);
});

test("auto-routed mentions merge with explicit mentions without duplicates", () => {
assert.deepEqual(
mergeAutoRouteMentionPubkeys({
autoRouteAgentPubkeys: ["AGENT-ONE"],
mentionPubkeys: ["agent-one", "agent-two"],
}),
["AGENT-ONE", "agent-two"],
);
});

test("new agent conversations require a writable channel", () => {
assert.equal(
canOpenAgentConversationInChannel({
channel: channel(),
}),
true,
);
assert.equal(
canOpenAgentConversationInChannel({
channel: channel({ archivedAt: "2026-06-27T00:00:00.000Z" }),
}),
false,
);
assert.equal(
canOpenAgentConversationInChannel({
channel: channel({ isMember: false }),
}),
false,
);
});

test("existing agent conversation markers can open in read-only channels", () => {
assert.equal(
canOpenAgentConversationInChannel({
channel: channel({ archivedAt: "2026-06-27T00:00:00.000Z" }),
publishMarker: false,
}),
true,
);
assert.equal(
canOpenAgentConversationInChannel({
channel: channel({ isMember: false }),
publishMarker: false,
}),
true,
);
});
Loading
Loading