mirror of
https://github.com/block/buzz.git
synced 2026-10-29 05:21:31 +00:00
Keep Pulse on a single combined conversation layout
Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
38
desktop/src/features/pulse/ui/PulseWindowActions.tsx
Normal file
38
desktop/src/features/pulse/ui/PulseWindowActions.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user