Skip to content
Merged
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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export default defineConfig({
"**/profile-active-turn.spec.ts",
"**/config-bridge-screenshots.spec.ts",
"**/observer-feed-screenshots.spec.ts",
"**/activity-scope-label-screenshots.spec.ts",
"**/file-attachment.spec.ts",
"**/image-attachment-gallery.spec.ts",
"**/video-attachment.spec.ts",
Expand Down
19 changes: 13 additions & 6 deletions desktop/src/app/navigation/useAppNavigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ export function useAppNavigation() {
(
channelId: string,
options?: {
/** Open the agent activity pane for this agent pubkey on arrival. */
agentSession?: string;
messageId?: string;
replace?: boolean;
threadRootId?: string | null;
Expand All @@ -156,12 +158,17 @@ export function useAppNavigation() {
params: {
channelId,
},
search: options?.messageId
? {
messageId: options.messageId,
threadRootId: options.threadRootId ?? undefined,
}
: {},
search: {
...(options?.messageId
? {
messageId: options.messageId,
threadRootId: options.threadRootId ?? undefined,
}
: {}),
...(options?.agentSession
? { agentSession: options.agentSession }
: {}),
},
},
{
replace: options?.replace,
Expand Down
8 changes: 7 additions & 1 deletion desktop/src/features/agents/ui/ManagedAgentRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
import { Badge } from "@/shared/ui/badge";
import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge";
import { useAgentWorking } from "@/features/agents/agentWorkingSignal";
import { useOpenAgentActivity } from "@/features/agents/useOpenAgentActivity";
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
import { useNow } from "@/shared/lib/useNow";
import type {
Expand Down Expand Up @@ -202,6 +203,7 @@ function AgentSummary({
presenceStatus: PresenceStatus | undefined;
}) {
const { goChannel } = useAppNavigation();
const { openAgentActivity } = useOpenAgentActivity();

return (
<div className="min-w-0">
Expand Down Expand Up @@ -275,7 +277,11 @@ function AgentSummary({
channelId={channel.id}
name={channel.name}
anchorAt={channel.anchorAt}
onNavigate={goChannel}
// Deep-link straight into the agent's activity pane in the
// working channel, not just the channel timeline.
onNavigate={(channelId) =>
openAgentActivity(agent.pubkey, { channelId })
}
/>
))}
</div>
Expand Down
84 changes: 84 additions & 0 deletions desktop/src/features/agents/useOpenAgentActivity.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";

import {
isChannelOpenable,
resolveOpenableActivityChannelId,
} from "./useOpenAgentActivity.ts";

describe("isChannelOpenable", () => {
it("allows joined channels regardless of visibility", () => {
assert.equal(
isChannelOpenable({ isMember: true, visibility: "private" }),
true,
);
assert.equal(
isChannelOpenable({ isMember: true, visibility: "open" }),
true,
);
});

it("allows open channels the viewer hasn't joined (read-only)", () => {
assert.equal(
isChannelOpenable({ isMember: false, visibility: "open" }),
true,
);
});

it("rejects private channels the viewer hasn't joined", () => {
assert.equal(
isChannelOpenable({ isMember: false, visibility: "private" }),
false,
);
});

it("rejects channels missing from the viewer's channel list", () => {
assert.equal(isChannelOpenable(undefined), false);
});
});

describe("resolveOpenableActivityChannelId", () => {
it("prefers the first openable working channel", () => {
assert.equal(
resolveOpenableActivityChannelId({
agentChannelIds: ["member-1"],
openableChannelIds: new Set(["working-2", "member-1"]),
workingChannelIds: ["working-1", "working-2"],
}),
"working-2",
);
});

it("falls back to the agent's first openable member channel", () => {
assert.equal(
resolveOpenableActivityChannelId({
agentChannelIds: ["hidden-2", "member-1"],
openableChannelIds: new Set(["member-1"]),
workingChannelIds: ["hidden-1"],
}),
"member-1",
);
});

it("returns null when the agent is only active in inaccessible rooms", () => {
assert.equal(
resolveOpenableActivityChannelId({
agentChannelIds: ["hidden-2"],
openableChannelIds: new Set(["unrelated"]),
workingChannelIds: ["hidden-1"],
}),
null,
);
});

it("returns null with no candidate channels at all", () => {
assert.equal(
resolveOpenableActivityChannelId({
agentChannelIds: [],
openableChannelIds: new Set(),
workingChannelIds: [],
}),
null,
);
});
});
178 changes: 178 additions & 0 deletions desktop/src/features/agents/useOpenAgentActivity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import * as React from "react";
import { toast } from "sonner";

import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelsQuery } from "@/features/channels/hooks";
import { useAgentSession } from "@/shared/context/AgentSessionContext";
import type { Channel } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { getAgentWorkingState } from "./agentWorkingSignal";
import { useRelayAgentsQuery } from "./hooks";

const INACCESSIBLE_ACTIVITY_MESSAGE =
"This agent is active in a channel you haven't joined, so its activity can't be opened from here.";

/**
* Can the viewer actually open this channel? Joined channels always;
* open-visibility channels are readable without joining. Channels missing
* from the viewer's channel list (e.g. private rooms they aren't in) are
* not navigable destinations.
*/
export function isChannelOpenable(
channel: Pick<Channel, "isMember" | "visibility"> | undefined,
): boolean {
return (
channel !== undefined && (channel.isMember || channel.visibility === "open")
);
}

/**
* Pick the channel to land in when opening an agent's activity from a
* non-channel route: the agent's first working channel the viewer can open,
* else the agent's first member channel the viewer can open, else null.
*/
export function resolveOpenableActivityChannelId({
agentChannelIds,
openableChannelIds,
workingChannelIds,
}: {
agentChannelIds: readonly string[];
openableChannelIds: ReadonlySet<string>;
workingChannelIds: readonly string[];
}): string | null {
for (const channelId of workingChannelIds) {
if (openableChannelIds.has(channelId)) {
return channelId;
}
}
for (const channelId of agentChannelIds) {
if (openableChannelIds.has(channelId)) {
return channelId;
}
}
return null;
}

/**
* Universal ingress for opening an agent's activity pane.
*
* Inside a channel screen the AgentSessionContext handler opens the pane in
* place. Everywhere else (agents page, home profile panel, popovers reached
* from non-channel routes) there is no provider, so we navigate to a channel
* with the `agentSession` search param instead — preferring a channel the
* agent is currently working in (unified working signal), then falling back
* to the first channel the agent is a member of.
*
* Navigation only ever targets channels the viewer can actually open
* (joined, or open visibility). Owner-global ingestion means the working
* signal can report activity in rooms the viewer can't access; deep-linking
* there would land on a screen they can't read. In that case we surface a
* safe warning instead of navigating — no channel content, no trap-door.
*
* This replaces the old behavior where "View activity log" silently
* disappeared on routes without an AgentSessionProvider.
*/
export function useOpenAgentActivity() {
const { onOpenAgentSession } = useAgentSession();
const { goChannel } = useAppNavigation();
const relayAgentsQuery = useRelayAgentsQuery();
const relayAgents = relayAgentsQuery.data;
const channelsQuery = useChannelsQuery();
const channels = channelsQuery.data;

const findOpenableChannel = React.useCallback(
(channelId: string): boolean =>
isChannelOpenable(channels?.find((entry) => entry.id === channelId)),
[channels],
);

const resolveChannelId = React.useCallback(
(pubkey: string): string | null => {
const key = normalizePubkey(pubkey);
const relayAgent = relayAgents?.find(
(agent) => normalizePubkey(agent.pubkey) === key,
);
const openableChannelIds = new Set(
(channels ?? [])
.filter((channel) => isChannelOpenable(channel))
.map((channel) => channel.id),
);
return resolveOpenableActivityChannelId({
agentChannelIds: relayAgent?.channelIds ?? [],
openableChannelIds,
// Deliberately an unsubscribed snapshot: this callback runs on click
// (and in canOpenAgentActivity), not in render, so we don't need to
// recompute when working state changes — its deps are only
// [channels, relayAgents]. Worst case the preferred working-channel
// target lags a just-changed signal; the member-channel fallback in
// resolveOpenableActivityChannelId keeps the destination valid.
workingChannelIds: getAgentWorkingState(pubkey).channels.map(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [doc-clarity] getAgentWorkingState read outside the useCallback dep array — preferred-channel target can lag.

resolveChannelId reads getAgentWorkingState(pubkey).channels as a synchronous module-store snapshot, but its useCallback deps are [channels, relayAgents] — the working signal isn't a dep, so this won't recompute when working-state changes mid-render. The gate itself is stable (it falls back to "any member channel → true"), so this only affects which channel is chosen as the preferred target, not whether the affordance shows. Acceptable, but worth a comment noting the working-state read is deliberately unsubscribed and the member-channel fallback covers staleness.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated by Ned 🤖 in 3e6e633.

(working) => working.channelId,
),
});
},
[channels, relayAgents],
);

const canOpenAgentActivity = React.useCallback(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [IMPORTANT · correctness] canOpenAgentActivity returns false while channels are still loading — transient hidden button.

resolveChannelId builds openableChannelIds from channels ?? []. When useChannelsQuery hasn't resolved (channels === undefined), the set is empty, resolveChannelId returns null, and canOpenAgentActivity returns false (absent onOpenAgentSession). So "View activity log" is hidden on first render and flips to visible once channels load — a transient false-negative on cold start / slow relay. The old Boolean(onOpenAgentSession) gate was stable.

Net still an improvement over the previously-always-hidden agent page, so non-blocking. One-line fix: treat loading as optimistic — return true while channels is undefined, only gate to false once channels have resolved and the agent has no openable channel.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated by Ned 🤖 in 3e6e633.

(pubkey: string | null | undefined): boolean => {
if (!pubkey) {
return false;
}
if (onOpenAgentSession) {
return true;
}
// While channels are still loading, resolveChannelId can only see an
// empty openable set and would report a transient false. Stay
// optimistic until channels resolve so "View activity log" doesn't
// flicker in on cold start; openAgentActivity still guards the actual
// navigation.
if (channels === undefined) {
return true;
}
return resolveChannelId(pubkey) !== null;
},
[channels, onOpenAgentSession, resolveChannelId],
);

const openAgentActivity = React.useCallback(
(pubkey: string, options?: { channelId?: string | null }): boolean => {
// An explicit channel target (e.g. clicking a "Working in #channel"
// badge) navigates so the pane opens scoped to that channel — but only
// when the viewer can actually open that channel. Scoping the pane to
// an inaccessible room (in place or via navigation) would expose that
// room's activity content, so we warn and stop instead.
if (options?.channelId) {
if (!findOpenableChannel(options.channelId)) {
toast.warning(INACCESSIBLE_ACTIVITY_MESSAGE);
return false;
}
if (!onOpenAgentSession) {
void goChannel(options.channelId, { agentSession: pubkey });
return true;
}
onOpenAgentSession(pubkey, options.channelId);
return true;
}
if (onOpenAgentSession) {
onOpenAgentSession(pubkey);
return true;
}
const channelId = resolveChannelId(pubkey);
if (channelId) {
void goChannel(channelId, { agentSession: pubkey });
return true;
}
// The agent may be working somewhere, just nowhere the viewer can open.
// Say so plainly rather than failing silently — without leaking which
// room, or navigating into it.
if (getAgentWorkingState(pubkey).channels.length > 0) {
toast.warning(INACCESSIBLE_ACTIVITY_MESSAGE);
}
return false;
},
[findOpenableChannel, goChannel, onOpenAgentSession, resolveChannelId],
);

return { canOpenAgentActivity, openAgentActivity };
}
32 changes: 32 additions & 0 deletions desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
useTranscriptTimestampsEnabled,
} from "@/features/agents/ui/transcriptTimestampPreference";
import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions";
import { useChannelsQuery } from "@/features/channels/hooks";

type AgentSessionThreadPanelProps = {
agent: ChannelAgentSessionAgent;
Expand Down Expand Up @@ -126,6 +127,29 @@ export function AgentSessionThreadPanel({
? undefined
: `Last updated ${new Date(latestActivityAt).toLocaleString()}`;
const rawFeedScopeKey = `${agent.pubkey}:${sessionChannelId ?? "all"}`;
// Scope label input: prefer the passed channel's name; when the pane is
// channel-scoped without a full Channel object (#1380's channelId prop),
// resolve the name from the channels cache.
const channelsQuery = useChannelsQuery({
enabled: Boolean(sessionChannelId),
});
const scopeChannelName = React.useMemo(() => {
if (!sessionChannelId) {
return null;
}
if (channel && channel.id === sessionChannelId) {
return channel.name;
}
return (
channelsQuery.data?.find((entry) => entry.id === sessionChannelId)
?.name ?? null
);
}, [channel, channelsQuery.data, sessionChannelId]);
const scopeLabel = sessionChannelId
? scopeChannelName
? `#${scopeChannelName}`
: "1 channel"
: "All channels";
const [rawFeedState, setRawFeedState] = React.useState(() => ({
scopeKey: rawFeedScopeKey,
show: false,
Expand Down Expand Up @@ -323,6 +347,14 @@ export function AgentSessionThreadPanel({
subtitleTitle={lastUpdatedTitle}
title={showRawFeed ? "Raw ACP Activity" : "Activity"}
/>
{/* Scope label: makes channel-targeted vs all-channels state obvious
(an all-channels pane can look "wrong" without it). */}
<span
className="min-w-0 shrink truncate text-xs text-muted-foreground"
data-testid="agent-session-scope-label"
>
{scopeLabel}
</span>
</AuxiliaryPanelHeaderGroup>
{agentHeaderActions}
</>
Expand Down
Loading
Loading