Skip to content
Merged
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
5 changes: 5 additions & 0 deletions desktop/src/features/channels/ui/ChannelScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export function ChannelScreen({
setTopbarSearchHidden,
} = useAppShell();
const {
clearMessageRouteTarget,
openAgentSessionPubkey,
openThreadHeadId,
profilePanelPubkey,
Expand Down Expand Up @@ -493,6 +494,9 @@ export function ChannelScreen({
const handleThreadScrollTargetResolved = React.useCallback(() => {
setThreadScrollTargetId(null);
}, []);
const handleTargetReached = React.useCallback(() => {
clearMessageRouteTarget({ replace: true });
}, [clearMessageRouteTarget]);
React.useEffect(() => {
resetComposerTargets(activeChannelId);
}, [activeChannelId, resetComposerTargets]);
Expand Down Expand Up @@ -693,6 +697,7 @@ export function ChannelScreen({
handleThreadScrollTargetResolved
}
onThreadPanelResizeStart={handleThreadPanelResizeStart}
onTargetReached={handleTargetReached}
onToggleReaction={effectiveToggleReaction}
openAgentSessionPubkey={openAgentSessionPubkey}
openThreadHeadId={openThreadHeadId}
Expand Down
13 changes: 11 additions & 2 deletions desktop/src/features/channels/ui/useChannelPanelHistoryState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,21 @@ export type PanelValueSetter = (
options?: PanelSetterOptions,
) => void;

const PANEL_SEARCH_KEYS = [
const CHANNEL_SEARCH_KEYS = [
"agentSession",
"messageId",
"profile",
"profileView",
"thread",
"threadRootId",
] as const;

function asProfilePanelView(value: string | null): ProfilePanelView {
return value === "memories" || value === "channels" ? value : "summary";
}

export function useChannelPanelHistoryState() {
const { applyPatch, values } = useHistorySearchState(PANEL_SEARCH_KEYS);
const { applyPatch, values } = useHistorySearchState(CHANNEL_SEARCH_KEYS);

const setOpenThreadHeadId = React.useCallback<PanelValueSetter>(
(value, options) => applyPatch({ thread: value }, options),
Expand All @@ -61,7 +63,14 @@ export function useChannelPanelHistoryState() {
[applyPatch],
);

const clearMessageRouteTarget = React.useCallback(
(options?: PanelSetterOptions) =>
applyPatch({ messageId: null, threadRootId: null }, options),
[applyPatch],
);

return {
clearMessageRouteTarget,
openAgentSessionPubkey: values.agentSession,
openThreadHeadId: values.thread,
profilePanelPubkey: values.profile,
Expand Down
25 changes: 21 additions & 4 deletions desktop/src/features/channels/ui/useChannelRouteTarget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,27 @@ export function useChannelRouteTarget({
}

const targetMessage = timelineMessageById.get(targetMessageId) ?? null;
if (
!targetMessage?.parentId ||
isBroadcastReply(targetMessage.tags ?? [])
) {
if (!targetMessage) {
return;
}

if (!targetMessage.parentId) {
closeAgentSession();
// Root message links should open the reply panel for that root. The
// timeline scroll/highlight target alone is not enough: root links have
// no parent/thread metadata, so the reply-only branch below cannot infer
// a thread head.
setProfilePanelPubkey(null, { replace: true });
setEditTargetId(null);
setOpenThreadHeadId(targetMessage.id, { replace: true });
setThreadReplyTargetId(targetMessage.id);
setThreadScrollTargetId(null);
setExpandedThreadReplyIds(new Set());
handledThreadRouteTargetRef.current = targetKey;
return;
}

if (isBroadcastReply(targetMessage.tags ?? [])) {
return;
}

Expand Down
2 changes: 1 addition & 1 deletion desktop/src/features/messages/ui/MessageTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
showMessageList && !showIntro && "mt-auto",
)}
contentClassName={cn(
"flex flex-col gap-2",
"flex min-w-0 flex-col gap-2",
(showIntro || showGenericEmpty) && "min-h-full",
)}
loading={isLoading}
Expand Down
47 changes: 47 additions & 0 deletions desktop/tests/e2e/messaging.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1046,3 +1046,50 @@ test("ArrowUp edits your last thread reply right after sending it", async ({
await expect(editBanner).toContainText(reply);
await expect(threadInput).toHaveText(reply);
});

test("action bar stays within the timeline when the thread panel is open", async ({
page,
}) => {
// Narrow viewport + open thread panel => the timeline shrinks to a column.
// A long unbreakable token must not widen message rows past that column,
// or the right-anchored action bar is pushed offscreen (regression: #1081
// fixed the wrap but rows still expanded to content min-width).
await page.setViewportSize({ width: 1024, height: 800 });
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");

const timeline = page.getByTestId("message-timeline");
const input = page.getByTestId("message-input").first();
const send = page.getByTestId("send-message").first();

const longUrl = `https://example.com/${"a".repeat(180)}/path`;
await input.fill(longUrl);
await send.click();
await expect(timeline).toContainText("example.com");

const rootMessage = timeline.getByTestId("message-row").first();
await rootMessage.hover();
await rootMessage.getByRole("button", { name: "Reply" }).click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();

const wideRow = timeline.getByTestId("message-row").last();
await wideRow.scrollIntoViewIfNeeded();
await wideRow.hover();
const bar = wideRow.locator('[data-testid^="message-action-bar-"]');
await expect(bar).toBeVisible();

const timelineBox = await timeline.boundingBox();
const rowBox = await wideRow.boundingBox();
const barBox = await bar.boundingBox();
if (!timelineBox || !rowBox || !barBox) {
throw new Error("Expected timeline, row, and action bar to have geometry.");
}

expect(rowBox.x + rowBox.width).toBeLessThanOrEqual(
timelineBox.x + timelineBox.width + 1,
);
expect(barBox.x + barBox.width).toBeLessThanOrEqual(
timelineBox.x + timelineBox.width + 1,
);
});
71 changes: 71 additions & 0 deletions desktop/tests/e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,77 @@ test("settings is a route: section survives reload, closing returns to the previ
await expect(threadPanel).toBeVisible();
});

test("message links to visible root messages open the thread panel", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("message-timeline")).toContainText(
"Welcome to #general",
);

const link =
"buzz://message?channel=9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50&id=mock-general-welcome";
await page.getByTestId("message-input").fill(`Root link repro ${link}`);
await page.getByTestId("send-message").click();

const linkMessage = page
.getByTestId("message-row")
.filter({ hasText: "Root link repro" })
.last();
await expect(linkMessage).toBeVisible();
await linkMessage
.getByRole("button", { name: "Open message in general" })
.click();

const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
await expect(page).toHaveURL(/thread=mock-general-welcome/);
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
"Welcome to #general",
);
});

test("message links reopen a closed thread when the same messageId is already in the URL", async ({
page,
}) => {
await page.goto(
"/#/channels/9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50?messageId=mock-general-welcome",
);
await expect(page.getByTestId("chat-title")).toHaveText("general");

const threadPanel = page.getByTestId("message-thread-panel");
await expect(threadPanel).toBeVisible();
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
"Welcome to #general",
);

await threadPanel.getByRole("button", { name: "Close thread" }).click();
await expect(threadPanel).not.toBeVisible();

const link =
"buzz://message?channel=9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50&id=mock-general-welcome";
await page
.getByTestId("message-input")
.fill(`Reopen same root link repro ${link}`);
await page.getByTestId("send-message").click();

const linkMessage = page
.getByTestId("message-row")
.filter({ hasText: "Reopen same root link repro" })
.last();
await expect(linkMessage).toBeVisible();
await linkMessage
.getByRole("button", { name: "Open message in general" })
.click();

await expect(threadPanel).toBeVisible();
await expect(threadPanel.getByTestId("message-thread-head")).toContainText(
"Welcome to #general",
);
});

test("message deep links survive reload", async ({ page }) => {
await page.goto(
`/#/channels/${ENGINEERING_CHANNEL_ID}?messageId=mock-engineering-shipped`,
Expand Down