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,
@@ -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<T>(resetKey: unknown = null): {
hasTarget: boolean;
store: PanelReturnTargetStore<T>;
} {
const storeRef = React.useRef<PanelReturnTargetStore<T> | null>(null);
storeRef.current ??= createPanelReturnTargetStore<T>();
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 };
}
@@ -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);
});
@@ -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<T> = {
/** 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<T>(): PanelReturnTargetStore<T> {
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);
};
},
};
}
+4
View File
@@ -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();
+47
View File
@@ -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,
}) => {