Skip to content
Open
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
2 changes: 1 addition & 1 deletion desktop/src/app/AppShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks";
import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar";
import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay";
import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";

const LazySettingsScreen = React.lazy(async () => {
const module = await import("@/features/settings/ui/SettingsScreen");
return { default: module.SettingsScreen };
Expand All @@ -105,7 +106,6 @@ export function AppShell() {
useWebviewZoomShortcuts();
useTauriWindowDrag();
useWebviewScrollBoundaryLock();

const communitiesHook = useCommunities();
const hasCommunityRail = communitiesHook.communities.length > 1;
const addCommunityDialog = useAddCommunityDialogState();
Expand Down
15 changes: 15 additions & 0 deletions desktop/src/app/useThreadActivityFeedItems.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,18 @@ test("channel-presence fence applies before mute filter — unknown channel + mu

assert.deepEqual(items, []);
});

test("top-level DM activity is included in Activity", () => {
const dmItem = threadActivityItem({
id: "agent-dm-reply",
tags: [["h", CHANNEL_ID]],
});
const channels = [{ id: CHANNEL_ID, name: "Agent DM", channelType: "dm" }];

assert.deepEqual(
buildThreadActivityFeedItems([dmItem], new Set(), channels).map(
(item) => item.id,
),
["agent-dm-reply"],
);
});
3 changes: 2 additions & 1 deletion desktop/src/app/useThreadActivityFeedItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export function buildThreadActivityFeedItems(
// is present in the active community's channel set. Rows persisted under
// a different relay's scope key should never reach this function, but
// this filter is the last line of defense against cross-community leaks.
if (channelById.get(item.channelId) === undefined) return false;
const channel = channelById.get(item.channelId);
if (channel === undefined) return false;
const rootId = getThreadReference(item.tags).rootId;
return !rootId || !mutedRootIds.has(rootId);
})
Expand Down
28 changes: 27 additions & 1 deletion desktop/src/features/agents/knownAgentPubkeys.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import test from "node:test";

import { mergeKnownAgentPubkeys } from "./knownAgentPubkeys.ts";
import {
mergeKnownAgentPubkeys,
mergeOwnedAgentPubkeys,
} from "./knownAgentPubkeys.ts";

const MANAGED =
"1111111111111111111111111111111111111111111111111111111111111111";
Expand Down Expand Up @@ -34,3 +37,26 @@ test("normalisesCaseAndWhitespace_dedupingAcrossSources", () => {

assert.deepEqual([...merged], [MANAGED]);
});

test("owned agents include managed and profile-declared agents", () => {
const merged = mergeOwnedAgentPubkeys(
[{ pubkey: MANAGED }],
{
[RELAY]: { ownerPubkey: " owner " },
other: { ownerPubkey: "somebody-else" },
},
"OWNER",
);

assert.deepEqual([...merged].sort(), [MANAGED, RELAY].sort());
});

test("owned agents exclude agents controlled by somebody else", () => {
const merged = mergeOwnedAgentPubkeys(
undefined,
{ [RELAY]: { ownerPubkey: "somebody-else" } },
"owner",
);

assert.equal(merged.size, 0);
});
28 changes: 28 additions & 0 deletions desktop/src/features/agents/knownAgentPubkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,34 @@ export function mergeKnownAgentPubkeys(
return pubkeys;
}

/** Agent identities controlled by the current user. */
export function mergeOwnedAgentPubkeys(
managedAgents: readonly { pubkey: string }[] | undefined,
profiles:
| Readonly<Record<string, { ownerPubkey?: string | null }>>
| undefined,
currentPubkey: string | null | undefined,
): ReadonlySet<string> {
const pubkeys = new Set<string>();
for (const agent of managedAgents ?? []) {
pubkeys.add(normalizePubkey(agent.pubkey));
}

if (!currentPubkey) return pubkeys;

const ownerPubkey = normalizePubkey(currentPubkey);
for (const [pubkey, profile] of Object.entries(profiles ?? {})) {
if (
profile.ownerPubkey &&
normalizePubkey(profile.ownerPubkey) === ownerPubkey
) {
pubkeys.add(normalizePubkey(pubkey));
}
}

return pubkeys;
}

/**
* Channel-scoped variant: the managed ∪ relay baseline plus this channel's
* bot members (role `bot` or `isAgent`), so member-only agents are included.
Expand Down
9 changes: 8 additions & 1 deletion desktop/src/features/channels/useLiveChannelUpdates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,13 @@ export function isChannelUnreadTriggerKind(kind: number, isDmChannel: boolean) {
: UNREAD_TRIGGER_KINDS.has(kind);
}

export function isHomeActivityEvent(
isDmChannel: boolean,
isThreadedReply: boolean,
) {
return isThreadedReply || isDmChannel;
}

export function withChannelTagFallback(
event: RelayEvent,
channelId: string,
Expand Down Expand Up @@ -278,7 +285,7 @@ export function useLiveChannelUpdates(
}
} else {
options.onChannelMessage?.(channelId, event);
if (isThreadedReply) {
if (isHomeActivityEvent(isDmChannel, isThreadedReply)) {
options.onThreadReplyNotification?.(channelId, event);
}
}
Expand Down
122 changes: 122 additions & 0 deletions desktop/src/features/home/lib/activityListRows.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import assert from "node:assert/strict";
import test from "node:test";

import { buildActivityListRows } from "./activityListRows.ts";

function inboxItem(
id,
latestActivityAt,
conversationId = `conversation:${id}`,
) {
return {
conversationId,
groupItems: [],
id,
item: { id },
latestActivityAt,
};
}

function draftItem(key, updatedAt, rootStatus = "available") {
return {
entry: {
key,
draft: { createdAt: updatedAt, updatedAt },
},
rootStatus,
};
}

function reminder(
id,
createdAt,
status = "pending",
{ eventId, notBefore } = {},
) {
return {
id,
createdAt,
notBefore,
content: {
status,
target: eventId ? { eventId } : undefined,
},
};
}

test("Activity All combines rows in latest-first order", () => {
const rows = buildActivityListRows({
drafts: [draftItem("draft", "2026-07-21T12:00:00.000Z")],
items: [inboxItem("message", 1_753_099_300)],
reminders: [reminder("reminder", 1_753_099_100)],
});

assert.deepEqual(
rows.map((row) => row.kind),
["draft", "inbox", "reminder"],
);
});

test("Activity All excludes completed reminders and deleted-root drafts", () => {
const rows = buildActivityListRows({
drafts: [draftItem("deleted", "2026-07-21T12:00:00.000Z", "deleted")],
items: [],
reminders: [reminder("done", 1_753_099_100, "done")],
});

assert.deepEqual(rows, []);
});

test("Activity conversation keys stay stable when the representative changes", () => {
const first = buildActivityListRows({
drafts: [],
items: [inboxItem("reply-1", 1, "thread-root")],
reminders: [],
});
const second = buildActivityListRows({
drafts: [],
items: [inboxItem("reply-2", 2, "thread-root")],
reminders: [],
});

assert.equal(first[0].key, "inbox:thread-root");
assert.equal(second[0].key, first[0].key);
});

test("due reminder enriches its existing conversation instead of duplicating it", () => {
const item = inboxItem("message", 100);
item.groupItems = [{ id: "reminded-reply" }];
const rows = buildActivityListRows({
drafts: [],
items: [item],
reminders: [
reminder("reminder", 50, "pending", {
eventId: "reminded-reply",
notBefore: 200,
}),
],
});

assert.equal(rows.length, 1);
assert.equal(rows[0].kind, "inbox");
assert.equal(rows[0].dueReminder?.id, "reminder");
assert.equal(rows[0].sortAt, 200);
});

test("due reminder without a represented conversation sorts at trigger time", () => {
const rows = buildActivityListRows({
drafts: [],
items: [inboxItem("newer-than-creation", 150)],
reminders: [
reminder("reminder", 50, "pending", {
eventId: "not-in-feed",
notBefore: 200,
}),
],
});

assert.deepEqual(
rows.map((row) => row.kind),
["reminder", "inbox"],
);
});
112 changes: 112 additions & 0 deletions desktop/src/features/home/lib/activityListRows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import type { InboxItem } from "@/features/home/lib/inbox";
import type { DraftViewItem } from "@/features/messages/ui/DraftsPanel";
import type { Reminder } from "@/features/reminders/lib/reminderTypes";

export type ActivityListRow =
| {
key: string;
kind: "inbox";
item: InboxItem;
dueReminder?: Reminder;
sortAt: number;
}
| {
key: string;
kind: "reminder";
reminder: Reminder;
sortAt: number;
}
| {
key: string;
kind: "draft";
item: DraftViewItem;
sortAt: number;
};

function draftActivityAt(item: DraftViewItem): number {
for (const value of [
item.entry.draft.updatedAt,
item.entry.draft.createdAt,
]) {
const timestamp = Date.parse(value);
if (Number.isFinite(timestamp)) return timestamp / 1_000;
}
return 0;
}

export function buildActivityListRows({
drafts,
items,
reminders,
}: {
drafts: readonly DraftViewItem[];
items: readonly InboxItem[];
reminders: readonly Reminder[];
}): ActivityListRow[] {
const consumedReminderIds = new Set<string>();
const inboxRows = items.map((item): ActivityListRow => {
const eventIds = new Set([
item.id,
item.item.id,
...item.groupItems.map((groupItem) => groupItem.id),
]);
const matchingReminders = reminders
.filter(
(reminder) =>
reminder.content.status === "pending" &&
Boolean(
reminder.content.target?.eventId &&
eventIds.has(reminder.content.target.eventId),
),
)
.sort(
(left, right) =>
(right.notBefore ?? right.createdAt) -
(left.notBefore ?? left.createdAt),
);
const dueReminder = matchingReminders[0];

for (const reminder of matchingReminders) {
consumedReminderIds.add(reminder.id);
}

return {
key: `inbox:${item.conversationId}`,
kind: "inbox",
item,
dueReminder,
sortAt: Math.max(
item.latestActivityAt,
dueReminder?.notBefore ?? dueReminder?.createdAt ?? 0,
),
};
});

return [
...inboxRows,
...reminders
.filter(
(reminder) =>
reminder.content.status === "pending" &&
!consumedReminderIds.has(reminder.id),
)
.map(
(reminder): ActivityListRow => ({
key: `reminder:${reminder.id}`,
kind: "reminder",
reminder,
sortAt: reminder.notBefore ?? reminder.createdAt,
}),
),
...drafts
.filter((item) => item.rootStatus !== "deleted")
.map(
(item): ActivityListRow => ({
key: `draft:${item.entry.key}`,
kind: "draft",
item,
sortAt: draftActivityAt(item),
}),
),
].sort((left, right) => right.sortAt - left.sortAt);
}
Loading
Loading