Keep Pulse on a single combined conversation layout

Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
Arjun Mahanti
2026-09-10 11:26:12 -07:00
parent f7bd603978
commit 2d620055e5
10 changed files with 169 additions and 325 deletions

View File

@@ -28,7 +28,6 @@ type PulseRouteSearch = ReturnType<typeof parseProjectDetailSearch> & {
view?: "create" | "edit" | "duplicate";
pane?: string;
feed?: string;
layout?: "combined" | "separate";
conversation?: string;
dm?: string;
channel?: string;
@@ -75,12 +74,6 @@ function validatePulseSearch(
].includes(String(search.feed))
? String(search.feed)
: undefined,
layout:
search.layout === "separate"
? "separate"
: search.layout === "combined"
? "combined"
: undefined,
conversation: stringValue("conversation"),
dm: stringValue("dm"),
channel: stringValue("channel"),

View File

@@ -29,15 +29,14 @@ highlight opens Search filtered to those conversations. Failures expose a retry
than pretending an excerpt is an AI summary. Browser tests mock the model endpoint;
live checks require the user-approved Codex connection.
The ellipsis menu at the top right of the window switches between **Separate feeds**
and **Combined conversations**. The latter has no top tabs: Search and For you
appear as circular-icon rows above All messages in the persistent sidebar, and
Agents is hidden. All messages opens the mixed DM and channel feed above
a single 220px list of joined DMs and channels, sorted by the
newest relay timestamp or loaded message. Selection is independent of list order,
so incoming activity does not switch the open conversation. The variation and
selected conversation live in the URL and survive reloads; both variations use
the same conversation detail and draft storage within the 960px container.
Messages uses a single combined conversation layout. Search and For you appear
as circular-icon rows above All messages in the persistent sidebar. All messages
opens the mixed DM and channel feed beside a 220px list of joined DMs and channels,
sorted by the newest relay timestamp or loaded message. Selection is independent
of list order, so incoming activity does not switch the open conversation. The
selected conversation lives in the URL and survives reloads; conversation detail
and draft storage stay within the 960px container. Older variation links open
this same combined layout.
Conversation detail uses message bubbles: incoming messages align left in gray,
and messages authored by the signed-in viewer align right in the selected theme’s accent color, with its matching contrast text. This is scoped

View File

@@ -27,10 +27,9 @@ export const CLEAR_WORKSPACE_PANELS = Object.fromEntries(
PULSE_WORKSPACE_KEYS.map((key) => [key, null]),
) as Record<(typeof PULSE_WORKSPACE_KEYS)[number], null>;
/** Reuse feature navigation while retaining Pulse's rail and selected variation. */
/** Reuse feature navigation while retaining Pulse's rail and selected conversation. */
export function workspaceNavigationTarget(
target: AppNavigationTarget,
layout: string | null,
conversation: string | null,
): AppNavigationTarget {
let feed: PulseView;
@@ -67,7 +66,6 @@ export function workspaceNavigationTarget(
to: "/pulse",
params: undefined,
search: {
layout: layout ?? undefined,
conversation: conversation ?? undefined,
...target.search,
feed,

View File

@@ -6,7 +6,6 @@ import { sortConversationsByRecency } from "../lib/conversationRecency";
import { PulseConversationSplitView } from "./PulseConversationSplitView";
export function PulseCombinedView({
grouped = false,
channels,
conversations,
currentPubkey,
@@ -15,7 +14,6 @@ export function PulseCombinedView({
view,
onSelectView,
}: {
grouped?: boolean;
channels: Channel[];
conversations: PulseConversation[];
currentPubkey?: string;
@@ -24,26 +22,16 @@ export function PulseCombinedView({
view: PulseView;
onSelectView: (view: PulseView) => void;
}) {
const sorted = React.useMemo(() => {
const recent = sortConversationsByRecency(channels, conversations);
return grouped
? [
...recent.filter((channel) => channel.channelType === "dm"),
...recent.filter((channel) => channel.channelType !== "dm"),
]
: recent;
}, [channels, conversations, grouped]);
const sorted = React.useMemo(
() => sortConversationsByRecency(channels, conversations),
[channels, conversations],
);
return (
<PulseConversationSplitView
channels={sorted}
currentPubkey={currentPubkey}
selectionKey="conversation"
grouped={grouped}
label={
grouped
? "Direct messages and channels"
: "Conversations by recent activity"
}
label="Conversations by recent activity"
testPrefix="pulse-combined"
allMessages={{ content: children, scrollRef }}
navigation={{ view, onSelectView }}

View File

@@ -45,7 +45,6 @@ const SPLIT_VIEW_SEARCH_KEYS = [
] as const;
export function PulseConversationSplitView({
grouped = false,
channels,
currentPubkey,
search = "",
@@ -55,7 +54,6 @@ export function PulseConversationSplitView({
allMessages,
navigation,
}: {
grouped?: boolean;
channels: Channel[];
currentPubkey?: string;
search?: string;
@@ -203,7 +201,7 @@ export function PulseConversationSplitView({
</>
)}
{visibleRows.length ? (
visibleRows.map((row, index) => {
visibleRows.map((row) => {
const participant = row.participants[0];
const working =
row.channel.channelType === "dm" &&
@@ -212,81 +210,70 @@ export function PulseConversationSplitView({
);
const unread = isUnread(row.channel.id);
return (
<React.Fragment key={row.channel.id}>
{grouped &&
index > 0 &&
(visibleRows[index - 1].channel.channelType === "dm") !==
(row.channel.channelType === "dm") && (
<div
aria-hidden="true"
data-testid="pulse-conversation-group-divider"
className="my-2 border-t border-border/40"
/>
)}
<button
type="button"
data-testid={`${testPrefix}-${row.channel.id}`}
data-channel-name={row.channel.name}
aria-description={
[working && "Agent working", unread && "Unread messages"]
.filter(Boolean)
.join(". ") || undefined
}
aria-label={
row.channel.channelType === "dm"
? `Open DM with ${row.name}`
: `Open channel ${row.name}`
}
aria-current={
selected?.id === row.channel.id ? "true" : undefined
}
onClick={() => selectConversation(row.channel)}
className={`mb-1 flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ${selected?.id === row.channel.id ? "bg-muted/70" : "hover:bg-muted/35"}`}
>
{row.channel.channelType !== "dm" ? (
<PulseChannelAvatar channel={row.channel} />
) : row.participants.length > 1 ? (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Users aria-hidden className="h-4 w-4" />
</span>
) : (
<ProfileAvatarWithStatus
avatarUrl={participant?.avatarUrl ?? null}
label={row.name}
avatarClassName="text-xs"
className="h-7 w-7 shrink-0"
geometry={AVATAR_STATUS_GEOMETRY}
size={AVATAR_SIZE}
status={
presence.data?.[
normalizePubkey(participant?.pubkey ?? "")
]
}
statusTestId="pulse-conversation-presence"
shape={participant?.isAgent ? "squircle" : "circle"}
/>
)}
<span
className={`min-w-0 truncate text-sm ${selected?.id === row.channel.id ? "font-semibold" : ""}`}
>
{row.name}
<button
type="button"
key={row.channel.id}
data-testid={`${testPrefix}-${row.channel.id}`}
data-channel-name={row.channel.name}
aria-description={
[working && "Agent working", unread && "Unread messages"]
.filter(Boolean)
.join(". ") || undefined
}
aria-label={
row.channel.channelType === "dm"
? `Open DM with ${row.name}`
: `Open channel ${row.name}`
}
aria-current={
selected?.id === row.channel.id ? "true" : undefined
}
onClick={() => selectConversation(row.channel)}
className={`mb-1 flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring ${selected?.id === row.channel.id ? "bg-muted/70" : "hover:bg-muted/35"}`}
>
{row.channel.channelType !== "dm" ? (
<PulseChannelAvatar channel={row.channel} />
) : row.participants.length > 1 ? (
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Users aria-hidden className="h-4 w-4" />
</span>
{working ? (
<span
aria-hidden="true"
data-testid="pulse-working-dots"
className="ml-auto flex shrink-0 items-center"
>
<TypingDots
className="gap-[3px] [--typing-dot-lift:-2px]"
dotClassName={PULSE_STATUS_DOT_CLASS}
/>
</span>
) : unread ? (
<PulseUnreadDot />
) : null}
</button>
</React.Fragment>
) : (
<ProfileAvatarWithStatus
avatarUrl={participant?.avatarUrl ?? null}
label={row.name}
avatarClassName="text-xs"
className="h-7 w-7 shrink-0"
geometry={AVATAR_STATUS_GEOMETRY}
size={AVATAR_SIZE}
status={
presence.data?.[
normalizePubkey(participant?.pubkey ?? "")
]
}
statusTestId="pulse-conversation-presence"
shape={participant?.isAgent ? "squircle" : "circle"}
/>
)}
<span
className={`min-w-0 truncate text-sm ${selected?.id === row.channel.id ? "font-semibold" : ""}`}
>
{row.name}
</span>
{working ? (
<span
aria-hidden="true"
data-testid="pulse-working-dots"
className="ml-auto flex shrink-0 items-center"
>
<TypingDots
className="gap-[3px] [--typing-dot-lift:-2px]"
dotClassName={PULSE_STATUS_DOT_CLASS}
/>
</span>
) : unread ? (
<PulseUnreadDot />
) : null}
</button>
);
})
) : (

View File

@@ -1,80 +0,0 @@
import { MoreHorizontal, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
export function PulseVariationMenu({
value,
onChange,
onRefresh,
refreshing,
}: {
value: "separate" | "combined";
onChange: (value: "separate" | "combined") => void;
onRefresh: () => void;
refreshing: boolean;
}) {
const [host, setHost] = useState<HTMLElement | null>(null);
useEffect(() => {
setHost(document.getElementById("app-top-chrome-content"));
}, []);
if (!host) return null;
return createPortal(
<div data-pulse-window-actions className="ml-auto flex items-center">
<Button
variant="ghost"
size="icon"
className="h-[28px] w-[28px] rounded-full [&_svg]:size-[14px]"
aria-label="Refresh messages"
title="Refresh messages"
onClick={onRefresh}
disabled={refreshing}
>
<RefreshCw
aria-hidden
className={
refreshing ? "animate-spin motion-reduce:animate-none" : undefined
}
/>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-[28px] w-[28px] shrink-0 rounded-full [&_svg]:size-[16px]"
aria-label="App variations"
title="App variations"
>
<MoreHorizontal aria-hidden className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>App variations</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={value}
onValueChange={(next) => {
if (next === "separate" || next === "combined") onChange(next);
}}
>
<DropdownMenuRadioItem value="separate">
Separate feeds
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="combined">
Combined conversations
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>,
host,
);
}

View File

@@ -0,0 +1,38 @@
import { RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { Button } from "@/shared/ui/button";
export function PulseWindowActions({
onRefresh,
refreshing,
}: {
onRefresh: () => void;
refreshing: boolean;
}) {
const [host, setHost] = useState<HTMLElement | null>(null);
useEffect(() => {
setHost(document.getElementById("app-top-chrome-content"));
}, []);
if (!host) return null;
return createPortal(
<div data-pulse-window-actions className="ml-auto flex items-center">
<Button
variant="ghost"
size="icon"
className="h-[28px] w-[28px] rounded-full [&_svg]:size-[14px]"
aria-label="Refresh messages"
title="Refresh messages"
onClick={onRefresh}
disabled={refreshing}
>
<RefreshCw
aria-hidden
className={
refreshing ? "animate-spin motion-reduce:animate-none" : undefined
}
/>
</Button>
</div>,
host,
);
}

View File

@@ -35,7 +35,6 @@ const WorkflowsRouteScreen = React.lazy(() =>
})),
);
const SEARCH_KEYS = [
"layout",
"conversation",
"channel",
...PULSE_WORKSPACE_KEYS,
@@ -52,9 +51,8 @@ export function PulseWorkspacePage({ page }: { page: WorkspacePage }) {
const resolveTarget = React.useCallback<
NonNullable<React.ContextType<typeof NavigationTargetContext>>
>(
(target) =>
workspaceNavigationTarget(target, values.layout, values.conversation),
[values.layout, values.conversation],
(target) => workspaceNavigationTarget(target, values.conversation),
[values.conversation],
);
const pane = parseWorkflowEditorPane(values.pane);
const hasOrigin = state.workflowEditorHasOrigin === true;

View File

@@ -22,7 +22,7 @@ import {
import { PulseAppNavigation, type PulseApp } from "./PulseAppNavigation";
import { PulseWorkspacePage } from "./PulseWorkspacePage";
import { PulseCombinedView } from "./PulseCombinedView";
import { PulseVariationMenu } from "./PulseVariationMenu";
import { PulseWindowActions } from "./PulseWindowActions";
import {
CLEAR_CONVERSATION_PANELS,
PULSE_CONVERSATION_KEYS,
@@ -36,7 +36,6 @@ import { buildSummaryInput } from "../lib/pulseSummary";
import { usePulseSummary } from "../usePulseSummary";
const FEED_SEARCH_KEYS = [
"feed",
"layout",
...PULSE_CONVERSATION_KEYS,
...PULSE_WORKSPACE_KEYS,
] as const;
@@ -61,7 +60,6 @@ export function UnifiedPulseView({
values.profilePersona ||
values.agentSession,
);
const combined = values.layout !== "separate";
const filter: PulseView = isPulseWorkspacePage(values.feed)
? values.feed
: values.feed === "search"
@@ -106,15 +104,6 @@ export function UnifiedPulseView({
if (app === activeApp) return;
setFilter(app === "messages" ? lastMessageView.current : app);
};
const setVariation = (next: "separate" | "combined") => {
if ((next === "combined") === combined) return;
if (!allowNavigation({ kind: "route", href: `/pulse?layout=${next}` }))
return;
applyPatch({
layout: next === "combined" ? null : "separate",
});
setBriefingFilter(null);
};
const [search, setSearch] = React.useState("");
const [scrollElement, setScrollElement] =
React.useState<HTMLDivElement | null>(null);
@@ -359,9 +348,7 @@ export function UnifiedPulseView({
)}
data-expanded={expanded}
>
<PulseVariationMenu
value={combined ? "combined" : "separate"}
onChange={setVariation}
<PulseWindowActions
onRefresh={() => void feed.refresh()}
refreshing={feed.query.isFetching}
/>
@@ -374,7 +361,6 @@ export function UnifiedPulseView({
<PulseWorkspacePage page={filter} />
) : (
<PulseCombinedView
grouped={!combined}
channels={feed.channels}
conversations={feed.conversations}
currentPubkey={currentPubkey}

View File

@@ -688,36 +688,17 @@ test("For you renders more than three summary rows", async ({ page }) => {
await expect(page.getByTestId("pulse-conversation")).toHaveCount(0);
});
test("app variations combine conversations by recency and preserve selection and drafts", async ({
test("combined conversations preserve recency, selection, and drafts for legacy links", async ({
page,
}) => {
await seed(page);
const menu = page.getByRole("button", {
name: "App variations",
exact: true,
});
await menu.focus();
await page.keyboard.press("Enter");
await expect(
page.getByRole("menuitemradio", {
name: "Combined conversations",
exact: true,
}),
).toHaveAttribute("aria-checked", "true");
await page
.getByRole("menuitemradio", { name: "Combined conversations", exact: true })
.click();
page.getByRole("button", { name: "App variations", exact: true }),
).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Refresh messages", exact: true }),
).toBeVisible();
await expect(page.getByTestId("pulse-tabs")).toHaveCount(0);
await expect(
page
.getByTestId("pulse-app-navigation")
.getByRole("button", { name: "Agents", exact: true }),
).toBeVisible();
await expect(
page
.getByTestId("app-top-chrome")
.getByRole("button", { name: "App variations" }),
).toBeVisible();
const list = page.getByTestId("pulse-combined-list");
const detail = page.getByTestId("pulse-combined-detail");
const all = list.getByRole("button", { name: "All messages", exact: true });
@@ -860,39 +841,21 @@ test("app variations combine conversations by recency and preserve selection and
await page.getByTestId("unified-pulse").screenshot({
path: "test-results/pulse-prototype/11-combined-conversations.png",
});
await menu.click();
await expect(
page.getByRole("menuitemradio", {
name: "Combined conversations",
exact: true,
}),
).toHaveAttribute("aria-checked", "true");
await page
.getByRole("menuitemradio", { name: "Separate feeds", exact: true })
.click();
await expect(page.getByTestId("pulse-tabs")).toHaveCount(0);
const legacyUrl = new URL(page.url());
const [route, query] = legacyUrl.hash.split("?");
const params = new URLSearchParams(query);
params.set("layout", "separate");
legacyUrl.hash = `${route}?${params}`;
await page.goto(legacyUrl.toString());
await expect(
page.getByTestId("pulse-conversation-group-divider"),
).toHaveCount(1);
await expect(detail).toHaveAttribute(
"data-channel-id",
engineeringId ?? "missing",
);
const divider = page.getByTestId("pulse-conversation-group-divider");
expect((await boundsOf(alice)).y).toBeLessThan((await boundsOf(divider)).y);
expect((await boundsOf(engineering)).y).toBeGreaterThan(
(await boundsOf(divider)).y,
);
await page.reload();
await expect(divider).toBeVisible();
await waitForAnimations(page);
await page
.getByTestId("unified-pulse")
.screenshot({ path: "test-results/pulse-prototype/18-separate-rail.png" });
await menu.click();
await page
.getByRole("menuitemradio", { name: "Combined conversations", exact: true })
.click();
).toHaveCount(0);
await expect(
page.getByRole("navigation", {
name: "Conversations by recent activity",
exact: true,
}),
).toBeVisible();
await expect(detail).toHaveAttribute(
"data-channel-id",
engineeringId ?? "missing",
@@ -1170,12 +1133,6 @@ test("live Pulse refresh and unread dots follow read state for channels and DMs"
page,
}) => {
await seed(page);
await page
.getByRole("button", { name: "App variations", exact: true })
.click();
await page
.getByRole("menuitemradio", { name: "Combined conversations", exact: true })
.click();
const list = page.getByTestId("pulse-combined-list");
const detail = page.getByTestId("pulse-combined-detail");
const all = list.getByRole("button", { name: "All messages", exact: true });
@@ -1248,23 +1205,17 @@ test("live Pulse refresh and unread dots follow read state for channels and DMs"
await expect(
page.getByRole("button", { name: "Refresh messages", exact: true }),
).toBeEnabled();
// The same read markers must drive the original, separate sidebars too.
// Later unread messages still clear independently in the combined list.
await page.evaluate((pubkey) => {
for (const channelName of ["engineering", "alice-tyler"]) {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName,
pubkey,
content: `Unread in separate ${channelName}`,
content: `More unread in ${channelName}`,
createdAt: Math.floor(Date.now() / 1000) + 3,
});
}
}, TEST_IDENTITIES.alice.pubkey);
await page
.getByRole("button", { name: "App variations", exact: true })
.click();
await page
.getByRole("menuitemradio", { name: "Separate feeds", exact: true })
.click();
await page.getByRole("button", { name: "All messages", exact: true }).click();
const channelRow = page
.getByTestId("pulse-combined-list")
@@ -1886,42 +1837,34 @@ test("workspace entrypoints keep projects, agents, and workflows in the main pan
0,
);
}
for (const variation of ["Combined conversations", "Separate feeds"]) {
await page
.getByRole("button", { name: "App variations", exact: true })
.click();
await page
.getByRole("menuitemradio", { name: variation, exact: true })
.click();
for (const [name, contentTestId] of [
["Projects", "projects-overview-layout"],
["Agents", "agents-page-content"],
["Workflows", "workflows-view"],
]) {
const entry = apps.getByRole("button", { name, exact: true });
await entry.focus();
await page.keyboard.press("Enter");
const panel = main.getByTestId(`pulse-workspace-${name.toLowerCase()}`);
await expect(panel.getByTestId(contentTestId)).toBeVisible();
await expect(entry).toHaveAttribute("aria-current", "page");
await expect(apps).toBeVisible();
await expect(main).toHaveCSS("max-width", "960px");
await expect(page).toHaveURL(
new RegExp(`#/pulse\\?.*feed=${name.toLowerCase()}`),
);
await expect(page.getByTestId("open-pulse-view")).toHaveCount(0);
await expect(rail).toHaveCount(0);
await expect(main.getByTestId("pulse-app-heading")).toHaveCount(0);
const appBox = await boundsOf(apps);
const mainBox = await boundsOf(main);
const panelBox = await boundsOf(panel);
expect(mainBox.x).toBeGreaterThanOrEqual(appBox.x + appBox.width + 8);
expect(panelBox.width).toBeCloseTo(mainBox.width, 0);
expect(mainBox.x + mainBox.width / 2).toBeCloseTo(720, 0);
await page.reload();
await expect(panel.getByTestId(contentTestId)).toBeVisible();
await expect(entry).toHaveAttribute("aria-current", "page");
}
for (const [name, contentTestId] of [
["Projects", "projects-overview-layout"],
["Agents", "agents-page-content"],
["Workflows", "workflows-view"],
]) {
const entry = apps.getByRole("button", { name, exact: true });
await entry.focus();
await page.keyboard.press("Enter");
const panel = main.getByTestId(`pulse-workspace-${name.toLowerCase()}`);
await expect(panel.getByTestId(contentTestId)).toBeVisible();
await expect(entry).toHaveAttribute("aria-current", "page");
await expect(apps).toBeVisible();
await expect(main).toHaveCSS("max-width", "960px");
await expect(page).toHaveURL(
new RegExp(`#/pulse\\?.*feed=${name.toLowerCase()}`),
);
await expect(page.getByTestId("open-pulse-view")).toHaveCount(0);
await expect(rail).toHaveCount(0);
await expect(main.getByTestId("pulse-app-heading")).toHaveCount(0);
const appBox = await boundsOf(apps);
const mainBox = await boundsOf(main);
const panelBox = await boundsOf(panel);
expect(mainBox.x).toBeGreaterThanOrEqual(appBox.x + appBox.width + 8);
expect(panelBox.width).toBeCloseTo(mainBox.width, 0);
expect(mainBox.x + mainBox.width / 2).toBeCloseTo(720, 0);
await page.reload();
await expect(panel.getByTestId(contentTestId)).toBeVisible();
await expect(entry).toHaveAttribute("aria-current", "page");
}
await main
.getByRole("button", { name: "Create Workflow", exact: true })
@@ -2245,12 +2188,6 @@ test("agent conversation rows replace unread dots with notification-sized workin
rowBounds.x + rowBounds.width - indicatorBounds.x - indicatorBounds.width,
).toBeCloseTo(12, 0);
await expect(humanRow.getByTestId("pulse-working-dots")).toHaveCount(0);
await page
.getByRole("button", { name: "App variations", exact: true })
.click();
await page
.getByRole("menuitemradio", { name: "Separate feeds", exact: true })
.click();
await expect(working).toBeVisible();
await page.emulateMedia({ reducedMotion: "reduce" });
await expect(dots.first()).toHaveCSS("animation-name", "none");