-
Notifications
You must be signed in to change notification settings - Fork 3.2k
feat(desktop): universal activity ingresses and explicit panel scope #1495
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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, | ||
| ); | ||
| }); | ||
| }); |
| 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( | ||
| (working) => working.channelId, | ||
| ), | ||
| }); | ||
| }, | ||
| [channels, relayAgents], | ||
| ); | ||
|
|
||
| const canOpenAgentActivity = React.useCallback( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 [IMPORTANT · correctness]
Net still an improvement over the previously-always-hidden agent page, so non-blocking. One-line fix: treat loading as optimistic — return
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 [doc-clarity]
getAgentWorkingStateread outside theuseCallbackdep array — preferred-channel target can lag.resolveChannelIdreadsgetAgentWorkingState(pubkey).channelsas a synchronous module-store snapshot, but itsuseCallbackdeps 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.There was a problem hiding this comment.
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.