Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -67,7 +74,7 @@ export function AgentSessionThreadPanel({
layout = "standalone",
isSinglePanelView = false,
profiles,
onBackToProfile,
onBack,
onClose,
widthPx,
transparentChrome = false,
Expand Down Expand Up @@ -270,7 +277,7 @@ export function AgentSessionThreadPanel({
align="start"
backButtonAriaLabel="Back from activity"
backButtonTestId="agent-session-back"
onBack={onBackToProfile}
onBack={onBack}
>
<AuxiliaryPanelHeaderTitleBlock
subtitle={lastUpdatedLabel}
Expand Down
3 changes: 2 additions & 1 deletion desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export const ChannelPane = React.memo(function ChannelPane({
canResetThreadPanelWidth,
onCancelEdit,
onCancelThreadReply,
onBackFromAgentSession,
onCloseAgentSession,
onCloseChannelManagement,
onChannelManagementDeleted,
Expand Down Expand Up @@ -848,7 +849,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}
/>
Expand Down
6 changes: 6 additions & 0 deletions desktop/src/features/channels/ui/ChannelPane.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -578,8 +578,10 @@ export function ChannelScreen({
}, [setChannelManagementOpen, goHome]);
const {
agentSessionAgents,
backFromAgentSession: handleBackFromAgentSession,
channelAgentSessionAgents,
closeAgentSession: handleCloseAgentSession,
hasAgentSessionReturnTarget,
openAgentSession: handleOpenAgentSession,
openThreadAndCloseAgentSession: handleOpenThreadAndCloseAgentSession,
} = useChannelAgentSessions({
Expand All @@ -597,6 +599,7 @@ export function ChannelScreen({
handleOpenThread,
managedAgents: agentSessionCandidates,
openAgentSessionPubkey,
openThreadHeadId: effectiveOpenThreadHeadId,
profilePanelPubkey,
setChannelManagementOpen,
setExpandedThreadReplyIds,
Expand Down Expand Up @@ -913,6 +916,11 @@ export function ChannelScreen({
: undefined
}
onCloseAgentSession={handleCloseAgentSession}
onBackFromAgentSession={
hasAgentSessionReturnTarget
? handleBackFromAgentSession
: undefined
}
onCloseChannelManagement={handleCloseChannelManagement}
onCloseThread={handleCloseThread}
onDelete={
Expand Down
44 changes: 44 additions & 0 deletions desktop/src/features/channels/ui/agentSessionSelection.test.mjs
Original file line number Diff line number Diff line change
@@ -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,
);
});
36 changes: 36 additions & 0 deletions desktop/src/features/channels/ui/agentSessionSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
56 changes: 55 additions & 1 deletion desktop/src/features/channels/ui/useChannelAgentSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand All @@ -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;
Expand Down Expand Up @@ -162,6 +168,7 @@ export function useChannelAgentSessions({
handleOpenThread,
managedAgents,
openAgentSessionPubkey,
openThreadHeadId,
profilePanelPubkey = null,
setChannelManagementOpen,
setExpandedThreadReplyIds,
Expand All @@ -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);
Expand All @@ -199,6 +223,10 @@ export function useChannelAgentSessions({
setOpenAgentSessionChannelId(channelId ?? null);
},
[
isAgentSessionOpen,
openThreadHeadId,
profilePanelPubkey,
returnTarget,
setChannelManagementOpen,
setExpandedThreadReplyIds,
setOpenAgentSessionChannelId,
Expand All @@ -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);
Expand All @@ -219,13 +267,15 @@ export function useChannelAgentSessions({

const openThreadAndCloseAgentSession = React.useCallback(
(message: TimelineMessage) => {
returnTarget.clear();
setOpenAgentSessionPubkey(null);
setProfilePanelPubkey(null);
setChannelManagementOpen(false);
handleOpenThread(message);
},
[
handleOpenThread,
returnTarget,
setChannelManagementOpen,
setOpenAgentSessionPubkey,
setProfilePanelPubkey,
Expand All @@ -248,20 +298,24 @@ export function useChannelAgentSessions({
normalizePubkey(openAgentSessionPubkey),
)
) {
returnTarget.clear();
setOpenAgentSessionPubkey(null, { replace: true });
}
}, [
agentSessionAgents,
agentsLoaded,
openAgentSessionPubkey,
profilePanelPubkey,
returnTarget,
setOpenAgentSessionPubkey,
]);

return {
agentSessionAgents,
backFromAgentSession,
channelAgentSessionAgents,
closeAgentSession,
hasAgentSessionReturnTarget,
openAgentSession,
openAgentSessionPubkey,
openThreadAndCloseAgentSession,
Expand Down
40 changes: 40 additions & 0 deletions desktop/src/shared/hooks/usePanelReturnTarget.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Loading
Loading