fix(activity panel): handle back navigation (#1487)

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Taylor Ho
2026-07-05 09:24:14 -07:00
committed by GitHub
co-authored by npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent 6716cd31a5
commit caf644eda7
12 changed files with 410 additions and 5 deletions
@@ -62,7 +62,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;
@@ -77,7 +84,7 @@ export function AgentSessionThreadPanel({
layout = "standalone",
isSinglePanelView = false,
profiles,
onBackToProfile,
onBack,
onClose,
widthPx,
transparentChrome = false,
@@ -304,7 +311,7 @@ export function AgentSessionThreadPanel({
align="start"
backButtonAriaLabel="Back from activity"
backButtonTestId="agent-session-back"
onBack={onBackToProfile}
onBack={onBack}
>
<AuxiliaryPanelHeaderTitleBlock
subtitle={lastUpdatedLabel}
@@ -84,6 +84,7 @@ export const ChannelPane = React.memo(function ChannelPane({
canResetThreadPanelWidth,
onCancelEdit,
onCancelThreadReply,
onBackFromAgentSession,
onCloseAgentSession,
onCloseChannelManagement,
onChannelManagementDeleted,
@@ -855,7 +856,7 @@ export const ChannelPane = React.memo(function ChannelPane({
layout={useSplitAuxiliaryPane ? "split" : "standalone"}
transparentChrome={useSplitAuxiliaryPane}
profiles={profiles}
onBackToProfile={() => onOpenProfilePanel(selectedAgent.pubkey)}
onBack={onBackFromAgentSession}
onClose={onCloseAgentSession}
widthPx={threadPanelWidthPx}
/>
@@ -44,6 +44,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;
@@ -598,8 +598,10 @@ export function ChannelScreen({
}, [setChannelManagementOpen, goHome]);
const {
agentSessionAgents,
backFromAgentSession: handleBackFromAgentSession,
channelAgentSessionAgents,
closeAgentSession: handleCloseAgentSession,
hasAgentSessionReturnTarget,
openAgentSession: handleOpenAgentSession,
openThreadAndCloseAgentSession: handleOpenThreadAndCloseAgentSession,
} = useChannelAgentSessions({
@@ -617,6 +619,7 @@ export function ChannelScreen({
handleOpenThread,
managedAgents: agentSessionCandidates,
openAgentSessionPubkey,
openThreadHeadId: effectiveOpenThreadHeadId,
profilePanelPubkey,
setChannelManagementOpen,
setExpandedThreadReplyIds,
@@ -922,6 +925,11 @@ export function ChannelScreen({
: undefined
}
onCloseAgentSession={handleCloseAgentSession}
onBackFromAgentSession={
hasAgentSessionReturnTarget
? handleBackFromAgentSession
: undefined
}
onCloseChannelManagement={handleCloseChannelManagement}
onCloseThread={handleCloseThread}
onDelete={
@@ -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,
);
});
@@ -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,
@@ -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<string>) => 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<AgentSessionReturnTarget>(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,