fix(desktop): constrain message timeline width with min-w-0 (#1092)

Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
tlongwell-block
2026-06-17 17:53:20 -04:00
committed by GitHub
parent 22b47db8ec
commit 7de5517f1d
6 changed files with 156 additions and 7 deletions

View File

@@ -95,6 +95,7 @@ export function ChannelScreen({
setTopbarSearchHidden,
} = useAppShell();
const {
clearMessageRouteTarget,
openAgentSessionPubkey,
openThreadHeadId,
profilePanelPubkey,
@@ -495,6 +496,9 @@ export function ChannelScreen({
const handleThreadScrollTargetResolved = React.useCallback(() => {
setThreadScrollTargetId(null);
}, []);
const handleTargetReached = React.useCallback(() => {
clearMessageRouteTarget({ replace: true });
}, [clearMessageRouteTarget]);
React.useEffect(() => {
resetComposerTargets(activeChannelId);
}, [activeChannelId, resetComposerTargets]);
@@ -695,6 +699,7 @@ export function ChannelScreen({
handleThreadScrollTargetResolved
}
onThreadPanelResizeStart={handleThreadPanelResizeStart}
onTargetReached={handleTargetReached}
onToggleReaction={effectiveToggleReaction}
openAgentSessionPubkey={openAgentSessionPubkey}
openThreadHeadId={openThreadHeadId}

View File

@@ -23,11 +23,13 @@ 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 {
@@ -35,7 +37,7 @@ function asProfilePanelView(value: string | null): ProfilePanelView {
}
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),
@@ -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,

View File

@@ -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;
}

View File

@@ -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}

View File

@@ -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,
);
});

View File

@@ -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`,