mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent
6716cd31a5
commit
caf644eda7
@@ -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);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user