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
18 changes: 18 additions & 0 deletions docs/relay-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,24 @@ inserting isolated rows into channel history. Explicit denial revokes the owning
channel; access loss, cache clear and disposal purge the view. There is no separate
reader owner, subscription or persistence.

An open thread can call `view.retarget(messageId, signal)` to select another
message without reallocating its reader. The same bounded exact-read path verifies
the candidate's signed ancestry against the already-known root; navigation hints
never establish identity. A same-root selection preserves traversal rows/cursors
and publishes only after its overlays are complete. A different root, unavailable
message or read failure leaves the prior selection intact. Selection requests
supersede each other independently of history paging and repair; cancellation,
access loss, cache clear and disposal fence late results. All evidence remains
inside the existing view budget and session authority.

Channel thread panels opt into this retained lifetime. Same-thread links preserve
the mounted editor, reply target and expansion state; a different verified thread
still replaces the current view. Failed link selection keeps the existing reply and
shows one notice in the thread's status area. A message that no longer exists has
no retry; other failures offer Try again, which stays focused while a fresh
navigation request runs. Other embedded/preview consumers
retain their previous lifetime. This does not yet add main-pane tabs or dragging.

## Global message search and public previews

Global message searches (`kinds: [9, 40002]`, no `#h`) keep the relay's order.
Expand Down
18 changes: 15 additions & 3 deletions src/bundled/channels/ChannelActivityPopover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
useState,
useSyncExternalStore,
type ReactElement,
type ComponentProps,
} from "react";
import { selectProfiles } from "../../features/relay/profile-selection";
import type { Profile } from "../../features/relay/contracts";
Expand Down Expand Up @@ -196,6 +197,10 @@ export function ChannelActivityPopover({
const agentIds = activeAgents.join(",");
const [open, setOpen] = useState(false);
const triggerPointerType = useRef<string | null>(null);
const actions =
useRef<
NonNullable<ComponentProps<typeof PopoverRoot>["actionsRef"]>["current"]
>(null);
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!open || !activeAgents.length) return;
Expand Down Expand Up @@ -236,10 +241,17 @@ export function ChannelActivityPopover({
if (!hasActivity) setOpen(false);
}, [hasActivity]);
const stale = items.length > 0 && snapshot.freshness === "stale";
// Row actions close through Base UI so its hover/press state resets; the
// sidebar trigger stays mounted after the navigation they start.
const close = () => {
if (actions.current) actions.current.close();
else setOpen(false);
};
return (
<PopoverRoot
modal={false}
open={open}
actionsRef={actions}
onOpenChange={(next, details) => {
if (next && !hasActivity) return;
if (details.reason === "trigger-press") {
Expand Down Expand Up @@ -320,13 +332,13 @@ export function ChannelActivityPopover({
}
openLabel={`Open ${messageId ? "thread" : "conversation"} for ${name} in ${channelName}`}
onOpen={() => {
setOpen(false);
close();
onOpenWorkingAgent?.(channelId, agent, messageId);
}}
onOpenActivity={
openAgentActivity
? () => {
setOpen(false);
close();
openAgentActivity(channelId, agent);
}
: undefined
Expand All @@ -348,7 +360,7 @@ export function ChannelActivityPopover({
session={session}
agents={activeAgents}
onOpen={(selected) => {
setOpen(false);
close();
onOpenThread(selected);
}}
/>
Expand Down
4 changes: 4 additions & 0 deletions src/bundled/channels/ChannelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1619,6 +1619,10 @@ function ChannelWorkspace({
},
content: (
<ThreadPanel
retainThread
onRetryNavigation={() =>
void navigator?.retry()
}
sessionConversation={
current?.channelType === "session"
}
Expand Down
1 change: 1 addition & 0 deletions src/features/conversation/BuzzLinkPreview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ function setup(snapshot: ThreadSnapshot, messageId = root.id) {
subscribe: () => () => {},
refresh: vi.fn(async () => {}),
loadMore: vi.fn(async () => {}),
retarget: vi.fn<ThreadView["retarget"]>(async () => "selected"),
dispose: vi.fn(),
} satisfies ThreadView;
const thread = vi.fn(() => view);
Expand Down
171 changes: 171 additions & 0 deletions src/features/messages/ThreadPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ function messagesHarness(
listeners: Set<() => void>;
refresh: ReturnType<typeof vi.fn<() => Promise<void>>>;
loadMore: ReturnType<typeof vi.fn<() => Promise<void>>>;
retarget: ReturnType<typeof vi.fn<ThreadView["retarget"]>>;
dispose: ReturnType<typeof vi.fn>;
})[] = [];
const makeView = () => {
Expand All @@ -169,6 +170,7 @@ function messagesHarness(
},
refresh: vi.fn(async () => {}),
loadMore: vi.fn(async () => {}),
retarget: vi.fn<ThreadView["retarget"]>(async () => "selected"),
dispose: vi.fn(),
listeners,
};
Expand Down Expand Up @@ -1141,3 +1143,172 @@ it("passes the Me recipient, readiness and direct Activity contract through to t
expect(composer).not.toHaveAttribute("data-personal");
expect(composer).not.toHaveAttribute("data-direct-activity");
});

it("retained navigation keeps the mounted editor when another message belongs to the open thread", async () => {
const controller = new AbortController();
const first = { ...ordinaryNavigation(), signal: controller.signal };
const h = messagesHarness(first);
const mounted = render(<ThreadPanel {...h.props} retainThread />);
const editor = within(mounted.container).getByRole("region", {
name: "Composer",
});
const current = h.view;
let release!: (value: "selected") => void;
current.retarget.mockImplementation(
() =>
new Promise((resolve) => {
release = resolve;
}),
);
const next = {
...ordinaryNavigation(),
entryId: "next",
signal: new AbortController().signal,
};
next.target.messageId = "c".repeat(64);
act(() => controller.abort());
mounted.rerender(
<ThreadPanel
{...h.props}
retainThread
messageId={next.target.messageId}
navigation={next}
/>,
);
expect(
within(mounted.container).getByRole("region", { name: "Composer" }),
).toBe(editor);
await act(async () => release("selected"));
expect(
within(mounted.container).getByRole("region", { name: "Composer" }),
).toBe(editor);
expect(current.dispose).not.toHaveBeenCalled();
mounted.unmount();
expect(current.dispose).toHaveBeenCalledOnce();
});

it.each(["unavailable", "error", "cancelled"] as const)(
"retained target %s keeps the same thread and reply editor",
async (result) => {
const h = messagesHarness(ordinaryNavigation());
const retry = vi.fn();
const mounted = render(
<ThreadPanel {...h.props} retainThread onRetryNavigation={retry} />,
);
const editor = screen.getByRole("region", { name: "Composer" });
const reader = h.view;
reader.retarget.mockResolvedValue(result);
const next = { ...ordinaryNavigation(), entryId: "next" };
next.target.messageId = "c".repeat(64);
await act(async () =>
mounted.rerender(
<ThreadPanel
{...h.props}
retainThread
onRetryNavigation={retry}
messageId={next.target.messageId}
navigation={next}
/>,
),
);
expect(screen.getByRole("region", { name: "Composer" })).toBe(editor);
expect(reader.dispose).not.toHaveBeenCalled();
expect(h.thread).toHaveBeenCalledOnce();
expect(screen.getAllByRole("alert")).toHaveLength(1);
expect(screen.getByRole("alert")).toHaveTextContent(
result === "unavailable"
? "That message is no longer available."
: "Couldn’t open that message.",
);
expect(next.complete).toHaveBeenCalledWith({
status: "failed",
reason: result === "unavailable" ? "not-found" : "unavailable",
});
if (result === "unavailable") {
// Retrying can't restore a message that no longer exists.
expect(screen.queryByRole("button", { name: "Try again" })).toBeNull();
return;
}
const again = screen.getByRole("button", { name: "Try again" });
fireEvent.click(again);
expect(retry).toHaveBeenCalledOnce();
// The focused control stays mounted while the fresh attempt runs.
let release!: (value: "selected") => void;
reader.retarget.mockImplementation(
() =>
new Promise((resolve) => {
release = resolve;
}),
);
const retried = { ...ordinaryNavigation(), entryId: "retried" };
retried.target.messageId = next.target.messageId;
await act(async () =>
mounted.rerender(
<ThreadPanel
{...h.props}
retainThread
onRetryNavigation={retry}
messageId={retried.target.messageId}
navigation={retried}
/>,
),
);
try {
expect(screen.getByRole("button", { name: "Try again" })).toBe(again);
expect(again).toHaveAttribute("aria-busy", "true");
} finally {
await act(async () => release("selected"));
}
expect(screen.queryByRole("alert")).toBeNull();
expect(screen.getByRole("region", { name: "Composer" })).toBe(editor);
expect(reader.dispose).not.toHaveBeenCalled();
},
);

it("a link into a different thread replaces the reader and its editor", async () => {
const h = messagesHarness(ordinaryNavigation());
const mounted = render(<ThreadPanel {...h.props} retainThread />);
const editor = screen.getByRole("region", { name: "Composer" });
const previous = h.view;
previous.retarget.mockResolvedValue("different");
const next = { ...ordinaryNavigation(), entryId: "other-thread" };
next.target.messageId = "d".repeat(64);
await act(async () =>
mounted.rerender(
<ThreadPanel
{...h.props}
retainThread
messageId={next.target.messageId}
navigation={next}
/>,
),
);
expect(previous.dispose).toHaveBeenCalledOnce();
expect(h.thread).toHaveBeenCalledTimes(2);
expect(h.view).not.toBe(previous);
expect(screen.getByRole("region", { name: "Composer" })).not.toBe(editor);
expect(screen.queryByRole("alert")).toBeNull();
});

it("an unrouted retained opening retries allocation locally", () => {
const h = messagesHarness();
const navigationRetry = vi.fn();
h.thread
.mockReset()
.mockImplementationOnce(() => {
throw new Error("capacity");
})
.mockReturnValue(h.view);
render(
<ThreadPanel
{...h.props}
retainThread
onRetryNavigation={navigationRetry}
/>,
);
expect(screen.getByRole("alert")).toHaveTextContent("capacity");
fireEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(screen.getByRole("region", { name: "Composer" })).toBeVisible();
expect(navigationRetry).not.toHaveBeenCalled();
expect(h.thread).toHaveBeenCalledTimes(2);
});
Loading
Loading