diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index 4a2067143..1d60d46d6 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -52,7 +52,14 @@ type AgentSessionThreadPanelProps = { layout?: "standalone" | "split"; isSinglePanelView?: boolean; profiles?: UserProfileLookup; - onBackToProfile: () => void; + /** + * Fired by the header back arrow. Restores the pane this panel replaced + * (thread or profile) via the captured return target — see + * useChannelAgentSessions.backFromAgentSession. Omit when there is no + * target (composer/no-pane open, direct/restored URL): the arrow hides + * and the close affordance is the fallback. + */ + onBack?: () => void; onClose: () => void; widthPx: number; transparentChrome?: boolean; @@ -67,7 +74,7 @@ export function AgentSessionThreadPanel({ layout = "standalone", isSinglePanelView = false, profiles, - onBackToProfile, + onBack, onClose, widthPx, transparentChrome = false, @@ -270,7 +277,7 @@ export function AgentSessionThreadPanel({ align="start" backButtonAriaLabel="Back from activity" backButtonTestId="agent-session-back" - onBack={onBackToProfile} + onBack={onBack} > onOpenProfilePanel(selectedAgent.pubkey)} + onBack={onBackFromAgentSession} onClose={onCloseAgentSession} widthPx={threadPanelWidthPx} /> diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 4a4e45286..d6989d960 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -42,6 +42,12 @@ export type ChannelPaneProps = { canResetThreadPanelWidth: boolean; onCancelEdit?: () => void; onCancelThreadReply: () => void; + /** + * Fired by the header back arrow when Activity has a captured pane to + * return to. Absent (arrow hidden) for composer/no-pane opens and + * direct/restored Activity URLs — the close affordance is the fallback. + */ + onBackFromAgentSession?: () => void; onCloseAgentSession: () => void; onCloseChannelManagement?: () => void; onChannelManagementDeleted?: () => void; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 662d6a74e..78752b115 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -578,8 +578,10 @@ export function ChannelScreen({ }, [setChannelManagementOpen, goHome]); const { agentSessionAgents, + backFromAgentSession: handleBackFromAgentSession, channelAgentSessionAgents, closeAgentSession: handleCloseAgentSession, + hasAgentSessionReturnTarget, openAgentSession: handleOpenAgentSession, openThreadAndCloseAgentSession: handleOpenThreadAndCloseAgentSession, } = useChannelAgentSessions({ @@ -597,6 +599,7 @@ export function ChannelScreen({ handleOpenThread, managedAgents: agentSessionCandidates, openAgentSessionPubkey, + openThreadHeadId: effectiveOpenThreadHeadId, profilePanelPubkey, setChannelManagementOpen, setExpandedThreadReplyIds, @@ -913,6 +916,11 @@ export function ChannelScreen({ : undefined } onCloseAgentSession={handleCloseAgentSession} + onBackFromAgentSession={ + hasAgentSessionReturnTarget + ? handleBackFromAgentSession + : undefined + } onCloseChannelManagement={handleCloseChannelManagement} onCloseThread={handleCloseThread} onDelete={ diff --git a/desktop/src/features/channels/ui/agentSessionSelection.test.mjs b/desktop/src/features/channels/ui/agentSessionSelection.test.mjs new file mode 100644 index 000000000..af3db4e53 --- /dev/null +++ b/desktop/src/features/channels/ui/agentSessionSelection.test.mjs @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveAgentSessionReturnTarget } from "./agentSessionSelection.ts"; + +test("returns the open thread when activity opens over a thread", () => { + assert.deepEqual( + resolveAgentSessionReturnTarget({ + openThreadHeadId: "head-1", + profilePanelPubkey: null, + }), + { kind: "thread", threadHeadId: "head-1" }, + ); +}); + +test("returns the profile when activity opens over the profile panel", () => { + assert.deepEqual( + resolveAgentSessionReturnTarget({ + openThreadHeadId: null, + profilePanelPubkey: "abc", + }), + { kind: "profile", pubkey: "abc" }, + ); +}); + +test("prefers the thread when both params linger, matching pane priority", () => { + assert.deepEqual( + resolveAgentSessionReturnTarget({ + openThreadHeadId: "head-1", + profilePanelPubkey: "abc", + }), + { kind: "thread", threadHeadId: "head-1" }, + ); +}); + +test("returns null when activity opens over no pane", () => { + assert.equal( + resolveAgentSessionReturnTarget({ + openThreadHeadId: null, + profilePanelPubkey: null, + }), + null, + ); +}); diff --git a/desktop/src/features/channels/ui/agentSessionSelection.ts b/desktop/src/features/channels/ui/agentSessionSelection.ts index b48f8c387..fa1a063a9 100644 --- a/desktop/src/features/channels/ui/agentSessionSelection.ts +++ b/desktop/src/features/channels/ui/agentSessionSelection.ts @@ -42,6 +42,42 @@ export function resolveSelectedAgentSession({ }; } +/** + * Where the Activity panel should return to when its back arrow fires. + * + * Captured when the panel opens (see useChannelAgentSessions) and consumed + * exactly once on back — an explicit breadcrumb instead of popping the + * app/browser history stack. + */ +export type AgentSessionReturnTarget = + | { kind: "profile"; pubkey: string } + | { kind: "thread"; threadHeadId: string }; + +/** + * Resolve the pane the Activity panel is opening over. Threads win over the + * profile panel because that's the render priority of the right pane — a + * lingering `profile` URL param never shows while a thread is open. + * Returns null when Activity opens over no pane (composer/activity bar from + * the main timeline, or a direct/restored `agentSession` URL). + */ +export function resolveAgentSessionReturnTarget({ + openThreadHeadId, + profilePanelPubkey, +}: { + openThreadHeadId: string | null; + profilePanelPubkey: string | null; +}): AgentSessionReturnTarget | null { + if (openThreadHeadId) { + return { kind: "thread", threadHeadId: openThreadHeadId }; + } + + if (profilePanelPubkey) { + return { kind: "profile", pubkey: profilePanelPubkey }; + } + + return null; +} + export function isAgentInActivityList({ activityAgents, selectedAgent, diff --git a/desktop/src/features/channels/ui/useChannelAgentSessions.ts b/desktop/src/features/channels/ui/useChannelAgentSessions.ts index aed40a39d..69bd61ee9 100644 --- a/desktop/src/features/channels/ui/useChannelAgentSessions.ts +++ b/desktop/src/features/channels/ui/useChannelAgentSessions.ts @@ -7,7 +7,12 @@ import type { ManagedAgent, RelayAgent, } from "@/shared/api/types"; +import { usePanelReturnTarget } from "@/shared/hooks/usePanelReturnTarget"; import { normalizePubkey } from "@/shared/lib/pubkey"; +import { + type AgentSessionReturnTarget, + resolveAgentSessionReturnTarget, +} from "./agentSessionSelection"; import type { PanelValueSetter } from "./useChannelPanelHistoryState"; export type ChannelAgentSessionAgent = Pick< @@ -28,6 +33,7 @@ type UseChannelAgentSessionsOptions = { handleOpenThread: (message: TimelineMessage) => void; managedAgents: ChannelAgentSessionAgent[]; openAgentSessionPubkey: string | null; + openThreadHeadId: string | null; profilePanelPubkey?: string | null; setChannelManagementOpen: (open: boolean) => void; setExpandedThreadReplyIds: (value: Set) => void; @@ -162,6 +168,7 @@ export function useChannelAgentSessions({ handleOpenThread, managedAgents, openAgentSessionPubkey, + openThreadHeadId, profilePanelPubkey = null, setChannelManagementOpen, setExpandedThreadReplyIds, @@ -184,12 +191,29 @@ export function useChannelAgentSessions({ ); const agentSessionAgents = managedAgents; + // Breadcrumb for the Activity panel back arrow: captured on the + // closed→open transition, consumed exactly once on back, cleared on any + // other close so a stale target can't resurface later. Channel switches + // drop it via the reset key. + const { hasTarget: hasAgentSessionReturnTarget, store: returnTarget } = + usePanelReturnTarget(activeChannelId); + const isAgentSessionOpen = openAgentSessionPubkey != null; + const closeAgentSession = React.useCallback(() => { + returnTarget.clear(); setOpenAgentSessionPubkey(null); - }, [setOpenAgentSessionPubkey]); + }, [returnTarget, setOpenAgentSessionPubkey]); const openAgentSession = React.useCallback( (pubkey: string, channelId?: string | null) => { + if (!isAgentSessionOpen) { + returnTarget.capture( + resolveAgentSessionReturnTarget({ + openThreadHeadId, + profilePanelPubkey, + }), + ); + } setOpenThreadHeadId(null); setExpandedThreadReplyIds(new Set()); setThreadScrollTargetId(null); @@ -199,6 +223,10 @@ export function useChannelAgentSessions({ setOpenAgentSessionChannelId(channelId ?? null); }, [ + isAgentSessionOpen, + openThreadHeadId, + profilePanelPubkey, + returnTarget, setChannelManagementOpen, setExpandedThreadReplyIds, setOpenAgentSessionChannelId, @@ -209,6 +237,26 @@ export function useChannelAgentSessions({ ], ); + // Back restores the pane the Activity panel replaced; with no recorded + // target (opened from the composer with no pane, or a direct/restored + // `agentSession` URL) it simply closes — never a blind history pop. + const backFromAgentSession = React.useCallback(() => { + const target = returnTarget.consume(); + setOpenAgentSessionPubkey(null); + if (target?.kind === "thread") { + setOpenThreadHeadId(target.threadHeadId); + return; + } + if (target?.kind === "profile") { + setProfilePanelPubkey(target.pubkey); + } + }, [ + returnTarget, + setOpenAgentSessionPubkey, + setOpenThreadHeadId, + setProfilePanelPubkey, + ]); + const selectAgentSession = React.useCallback( (pubkey: string, channelId?: string | null) => { setOpenAgentSessionPubkey(pubkey); @@ -219,6 +267,7 @@ export function useChannelAgentSessions({ const openThreadAndCloseAgentSession = React.useCallback( (message: TimelineMessage) => { + returnTarget.clear(); setOpenAgentSessionPubkey(null); setProfilePanelPubkey(null); setChannelManagementOpen(false); @@ -226,6 +275,7 @@ export function useChannelAgentSessions({ }, [ handleOpenThread, + returnTarget, setChannelManagementOpen, setOpenAgentSessionPubkey, setProfilePanelPubkey, @@ -248,6 +298,7 @@ export function useChannelAgentSessions({ normalizePubkey(openAgentSessionPubkey), ) ) { + returnTarget.clear(); setOpenAgentSessionPubkey(null, { replace: true }); } }, [ @@ -255,13 +306,16 @@ export function useChannelAgentSessions({ agentsLoaded, openAgentSessionPubkey, profilePanelPubkey, + returnTarget, setOpenAgentSessionPubkey, ]); return { agentSessionAgents, + backFromAgentSession, channelAgentSessionAgents, closeAgentSession, + hasAgentSessionReturnTarget, openAgentSession, openAgentSessionPubkey, openThreadAndCloseAgentSession, diff --git a/desktop/src/shared/hooks/usePanelReturnTarget.ts b/desktop/src/shared/hooks/usePanelReturnTarget.ts new file mode 100644 index 000000000..9545742ee --- /dev/null +++ b/desktop/src/shared/hooks/usePanelReturnTarget.ts @@ -0,0 +1,40 @@ +import * as React from "react"; + +import { + createPanelReturnTargetStore, + type PanelReturnTargetStore, +} from "@/shared/lib/panelReturnTarget"; + +/** + * React binding for `createPanelReturnTargetStore`: a stable return-target + * breadcrumb for mutually-exclusive panels, plus a reactive `hasTarget` so + * back affordances can hide when there is nowhere to return to. + * + * The store identity is stable for the component's lifetime, so callbacks + * can list it as a dependency without churning. A change of `resetKey` + * (e.g. the active channel id) drops any recorded target, keeping + * breadcrumbs from leaking across contexts. + */ +export function usePanelReturnTarget(resetKey: unknown = null): { + hasTarget: boolean; + store: PanelReturnTargetStore; +} { + const storeRef = React.useRef | null>(null); + storeRef.current ??= createPanelReturnTargetStore(); + const store = storeRef.current; + + const previousResetKeyRef = React.useRef(resetKey); + if (previousResetKeyRef.current !== resetKey) { + previousResetKeyRef.current = resetKey; + // Render-safe silent drop: useSyncExternalStore re-reads the snapshot + // during this same render, so no notification is needed (or allowed). + store.reset(); + } + + const hasTarget = React.useSyncExternalStore( + store.subscribe, + () => store.peek() != null, + ); + + return { hasTarget, store }; +} diff --git a/desktop/src/shared/lib/panelReturnTarget.test.mjs b/desktop/src/shared/lib/panelReturnTarget.test.mjs new file mode 100644 index 000000000..fbdd55f35 --- /dev/null +++ b/desktop/src/shared/lib/panelReturnTarget.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createPanelReturnTargetStore } from "./panelReturnTarget.ts"; + +test("consume returns the captured target exactly once", () => { + const store = createPanelReturnTargetStore(); + + store.capture({ kind: "thread", threadHeadId: "head-1" }); + + assert.deepEqual(store.consume(), { kind: "thread", threadHeadId: "head-1" }); + assert.equal(store.consume(), null); +}); + +test("capture overwrites a previous target", () => { + const store = createPanelReturnTargetStore(); + + store.capture({ kind: "thread", threadHeadId: "head-1" }); + store.capture({ kind: "profile", pubkey: "abc" }); + + assert.deepEqual(store.consume(), { kind: "profile", pubkey: "abc" }); +}); + +test("capturing null clears the target", () => { + const store = createPanelReturnTargetStore(); + + store.capture({ kind: "profile", pubkey: "abc" }); + store.capture(null); + + assert.equal(store.consume(), null); +}); + +test("clear drops the target without consuming", () => { + const store = createPanelReturnTargetStore(); + + store.capture({ kind: "profile", pubkey: "abc" }); + store.clear(); + + assert.equal(store.peek(), null); + assert.equal(store.consume(), null); +}); + +test("peek reads without consuming", () => { + const store = createPanelReturnTargetStore(); + + store.capture({ kind: "thread", threadHeadId: "head-1" }); + + assert.deepEqual(store.peek(), { kind: "thread", threadHeadId: "head-1" }); + assert.deepEqual(store.consume(), { kind: "thread", threadHeadId: "head-1" }); +}); + +test("subscribe notifies on capture, clear, and consume", () => { + const store = createPanelReturnTargetStore(); + let notifications = 0; + const unsubscribe = store.subscribe(() => { + notifications += 1; + }); + + store.capture({ kind: "profile", pubkey: "abc" }); + assert.equal(notifications, 1); + + store.consume(); + assert.equal(notifications, 2); + + store.clear(); + assert.equal(notifications, 3); + + unsubscribe(); + store.capture({ kind: "profile", pubkey: "abc" }); + assert.equal(notifications, 3); +}); + +test("reset drops the target silently", () => { + const store = createPanelReturnTargetStore(); + let notifications = 0; + store.subscribe(() => { + notifications += 1; + }); + + store.capture({ kind: "thread", threadHeadId: "head-1" }); + assert.equal(notifications, 1); + + store.reset(); + assert.equal(notifications, 1); + assert.equal(store.peek(), null); +}); diff --git a/desktop/src/shared/lib/panelReturnTarget.ts b/desktop/src/shared/lib/panelReturnTarget.ts new file mode 100644 index 000000000..e56230512 --- /dev/null +++ b/desktop/src/shared/lib/panelReturnTarget.ts @@ -0,0 +1,72 @@ +/** + * One-shot "where did this panel transition come from?" breadcrumb. + * + * Panels that replace one another (rather than stacking) can capture an + * explicit return target when they open, then consume it exactly once when + * their back affordance fires. This avoids popping the wholesale app/browser + * history stack, so an in-panel back press never leaves the current screen + * unexpectedly. + * + * The store is subscribable so UI can react to target presence (e.g. hide + * the back arrow when there is nowhere to return to). Pure store so the + * semantics are unit-testable; the React binding lives in + * `@/shared/hooks/usePanelReturnTarget`. + */ +export type PanelReturnTargetStore = { + /** Record where the panel is coming from. `null` means "nowhere useful". */ + capture: (target: T | null) => void; + /** Drop any recorded target without consuming it (e.g. on plain close). */ + clear: () => void; + /** Take the recorded target, resetting the store — one back per capture. */ + consume: () => T | null; + /** Read without consuming (for tests and conditional affordances). */ + peek: () => T | null; + /** + * Drop the target without notifying subscribers. Render-safe: the React + * binding calls this while rendering on reset-key changes, where notifying + * would schedule updates mid-render. + */ + reset: () => void; + /** Subscribe to target changes. Returns an unsubscribe function. */ + subscribe: (listener: () => void) => () => void; +}; + +export function createPanelReturnTargetStore(): PanelReturnTargetStore { + let target: T | null = null; + const listeners = new Set<() => void>(); + + const notify = () => { + for (const listener of [...listeners]) { + listener(); + } + }; + + return { + capture(next) { + target = next; + notify(); + }, + clear() { + target = null; + notify(); + }, + consume() { + const current = target; + target = null; + notify(); + return current; + }, + peek() { + return target; + }, + reset() { + target = null; + }, + subscribe(listener) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 74df14f4e..378c507bf 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -1079,6 +1079,10 @@ test("shows and clears activity indicators for active channel agents", async ({ await expect(page.getByTestId("agent-session-thread-panel")).toContainText( "alice", ); + // Opened from the composer with no prior pane: there is nowhere to go + // "back" to, so the header shows only the close affordance. + await expect(page.getByTestId("agent-session-back")).toHaveCount(0); + await expect(page.getByTestId("auxiliary-panel-close")).toBeVisible(); await expect(page.getByTestId("agent-transcript-now-summary")).toHaveCount(0); await page.getByTestId("agent-session-settings-menu-trigger").click(); await expect(page.getByTestId("agent-session-stop-turn")).toBeVisible(); diff --git a/desktop/tests/e2e/profile.spec.ts b/desktop/tests/e2e/profile.spec.ts index eb8f37b2e..fd3db1d5d 100644 --- a/desktop/tests/e2e/profile.spec.ts +++ b/desktop/tests/e2e/profile.spec.ts @@ -3,6 +3,7 @@ import { expect, test, type Page } from "@playwright/test"; import { createMockAgentMemoryListing, installMockBridge, + TEST_IDENTITIES, } from "../helpers/bridge"; import { openProfileMenu, openSettings } from "../helpers/settings"; @@ -799,6 +800,52 @@ test("renders agent profile ingress subviews from the Playwright mock bridge", a await expect(page.getByTestId("agent-memory-list")).toContainText("orphan"); }); +test("restored activity deep link hides the back arrow", async ({ page }) => { + // Charlie is a `bot` member of #agents and authors a seeded message there; + // seeding a managed agent with the same pubkey makes that message's avatar + // open a managed-agent profile panel with the Activity ingress. Unlike an + // agent created at runtime through the bridge, this seed survives + // `page.reload()` because init scripts re-run on navigation. + const agentPubkey = TEST_IDENTITIES.charlie.pubkey; + await installMockBridge(page, { + managedAgents: [ + { + channelNames: ["agents"], + name: "Charlie", + pubkey: agentPubkey, + status: "running", + }, + ], + }); + await page.goto("/"); + + await page.getByTestId("channel-agents").click(); + await expect(page.getByTestId("chat-title")).toHaveText("agents"); + + const messageRow = page + .getByTestId("message-row") + .filter({ hasText: "Indexing the channel catalog now." }); + await expect(messageRow).toBeVisible(); + await messageRow.locator("button").first().click(); + await expect(page.getByTestId("user-profile-panel")).toBeVisible(); + + // Opened from the profile panel: a return target was captured, so the + // header shows the back arrow. + await page.getByTestId(`user-profile-view-activity-${agentPubkey}`).click(); + await expect(page.getByTestId("agent-session-thread-panel")).toBeVisible(); + await expect(page.getByTestId("agent-session-back")).toBeVisible(); + + // A reload keeps the `agentSession` URL param but drops the in-memory + // return target, so the restored panel hides the back arrow and close is + // the only affordance — never a blind history pop. + await page.reload(); + await expect(page.getByTestId("agent-session-thread-panel")).toBeVisible(); + await expect(page.getByTestId("agent-session-back")).toHaveCount(0); + await expect(page.getByTestId("auxiliary-panel-close")).toBeVisible(); + await page.getByTestId("auxiliary-panel-close").click(); + await expect(page.getByTestId("agent-session-thread-panel")).toHaveCount(0); +}); + test("declared owner sees runtime tab for a remote relay agent", async ({ page, }) => {