Move Pulse app navigation to the window sidebar

Signed-off-by: Arjun Mahanti <arjun@squareup.com>
This commit is contained in:
Arjun Mahanti
2026-09-10 08:26:33 -07:00
parent 862422d94b
commit 7d5c8d5bef
9 changed files with 260 additions and 112 deletions

View File

@@ -103,6 +103,7 @@ export type { ProfilePanelTab, ProfilePanelView };
export function UserProfilePanel({
canResetWidth,
className,
currentPubkey,
isSinglePanelView = false,
layout = "standalone",
@@ -976,6 +977,7 @@ export function UserProfilePanel({
<UserProfilePanelFrame
addAgentToChannelDialog={addAgentToChannelDialog}
canResetWidth={canResetWidth}
className={className}
editAgentDialog={editAgentDialog}
headerActions={headerActions}
headerLeftContent={headerLeftContent}

View File

@@ -10,6 +10,7 @@ import { cn } from "@/shared/lib/cn";
type UserProfilePanelFrameProps = {
addAgentToChannelDialog: React.ReactNode;
canResetWidth?: boolean;
className?: string;
editAgentDialog: React.ReactNode;
headerActions: React.ReactNode;
headerLeftContent: React.ReactNode;
@@ -32,6 +33,7 @@ type UserProfilePanelFrameProps = {
export function UserProfilePanelFrame({
addAgentToChannelDialog,
canResetWidth,
className,
editAgentDialog,
headerActions,
headerLeftContent,
@@ -53,7 +55,7 @@ export function UserProfilePanelFrame({
return (
<AuxiliaryPanel
canResetWidth={canResetWidth}
className="relative"
className={cn("relative", className)}
isSinglePanelView={isSinglePanelView}
layout={isSplitLayout ? "split" : "standalone"}
onClose={onClose}

View File

@@ -97,6 +97,8 @@ export function profilePanelTargetKey(
export type UserProfilePanelProps = {
canResetWidth?: boolean;
/** Surface styling supplied by the host layout. */
className?: string;
currentPubkey?: string;
isSinglePanelView?: boolean;
layout?: "standalone" | "split";

View File

@@ -0,0 +1,51 @@
import { Bot, Folders, MessageCircle, Zap } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import type { PulseWorkspacePage } from "../lib/workspaceNavigation";
export type PulseApp = "messages" | PulseWorkspacePage;
const apps = {
messages: { label: "Messages", icon: MessageCircle },
projects: { label: "Projects", icon: Folders },
agents: { label: "Agents", icon: Bot },
workflows: { label: "Workflows", icon: Zap },
} as const;
/** Window-level app switching, separate from each app's own navigation. */
export function PulseAppNavigation({
active,
onSelect,
}: {
active: PulseApp;
onSelect: (app: PulseApp) => void;
}) {
return (
<nav
aria-label="Apps"
data-testid="pulse-app-navigation"
className="w-[64px] shrink-0 space-y-1 overflow-y-auto px-2 py-2 lg:w-[180px]"
>
{(Object.keys(apps) as PulseApp[]).map((app) => {
const Icon = apps[app].icon;
return (
<button
key={app}
type="button"
aria-label={apps[app].label}
aria-current={active === app ? "page" : undefined}
onClick={() => onSelect(app)}
className={cn(
"flex w-full items-center justify-center gap-3 rounded-xl px-3 py-3 text-left text-sm hover:bg-muted/35 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring lg:justify-start",
active === app
? "font-semibold text-foreground"
: "text-muted-foreground",
)}
>
<Icon aria-hidden="true" className="h-4 w-4 shrink-0" />
<span className="hidden lg:inline">{apps[app].label}</span>
</button>
);
})}
</nav>
);
}

View File

@@ -13,7 +13,6 @@ export function PulseCombinedView({
scrollRef,
children,
view,
workspaceContent,
onSelectView,
}: {
grouped?: boolean;
@@ -23,7 +22,6 @@ export function PulseCombinedView({
scrollRef: React.RefCallback<HTMLDivElement>;
children: React.ReactNode;
view: PulseView;
workspaceContent?: React.ReactNode;
onSelectView: (view: PulseView) => void;
}) {
const sorted = React.useMemo(() => {
@@ -49,7 +47,6 @@ export function PulseCombinedView({
testPrefix="pulse-combined"
allMessages={{ content: children, scrollRef }}
navigation={{ view, onSelectView }}
workspaceContent={workspaceContent}
/>
);
}

View File

@@ -6,15 +6,7 @@ import {
CLEAR_WORKSPACE_PANELS,
type PulseView,
} from "../lib/workspaceNavigation";
import {
Inbox,
Search,
MessageCircle,
Users,
Folders,
Bot,
Zap,
} from "lucide-react";
import { Inbox, Search, MessageCircle, Users } from "lucide-react";
import { allowNavigation } from "@/app/navigation/navigationGuard";
import { buildDirectMessageIntro } from "@/features/channels/lib/dmParticipantDisplay";
import { useUsersBatchQuery } from "@/features/profile/hooks";
@@ -62,7 +54,6 @@ export function PulseConversationSplitView({
testPrefix,
allMessages,
navigation,
workspaceContent,
}: {
grouped?: boolean;
channels: Channel[];
@@ -75,7 +66,6 @@ export function PulseConversationSplitView({
content: React.ReactNode;
scrollRef: React.RefCallback<HTMLDivElement>;
};
workspaceContent?: React.ReactNode;
navigation?: {
view: PulseView;
onSelectView: (view: PulseView) => void;
@@ -176,9 +166,6 @@ export function PulseConversationSplitView({
[
{ id: "search", label: "Search", icon: Search },
{ id: "all", label: "For you", icon: Inbox },
{ id: "projects", label: "Projects", icon: Folders },
{ id: "agents", label: "Agents", icon: Bot },
{ id: "workflows", label: "Workflows", icon: Zap },
] as const
).map(({ id, label, icon: Icon }) => (
<button
@@ -308,37 +295,36 @@ export function PulseConversationSplitView({
</p>
)}
</nav>
{workspaceContent ??
(!selected && allMessages ? (
<div
ref={allMessages.scrollRef}
className="min-h-0 min-w-0 flex-1 overflow-y-auto"
data-testid={
navigation?.view === "search"
? "pulse-search-feed"
: navigation?.view === "all"
? "pulse-briefing-feed"
: "pulse-all-messages-feed"
}
>
{allMessages.content}
</div>
) : (
<div
className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
data-testid={`${testPrefix}-detail`}
data-channel-id={selected?.id}
>
{selected ? (
<PulseChannelDetail channel={selected} />
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
<MessageCircle aria-hidden className="h-6 w-6" />
<p>Select a conversation</p>
</div>
)}
</div>
))}
{!selected && allMessages ? (
<div
ref={allMessages.scrollRef}
className="min-h-0 min-w-0 flex-1 overflow-y-auto"
data-testid={
navigation?.view === "search"
? "pulse-search-feed"
: navigation?.view === "all"
? "pulse-briefing-feed"
: "pulse-all-messages-feed"
}
>
{allMessages.content}
</div>
) : (
<div
className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
data-testid={`${testPrefix}-detail`}
data-channel-id={selected?.id}
>
{selected ? (
<PulseChannelDetail channel={selected} />
) : (
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
<MessageCircle aria-hidden className="h-6 w-6" />
<p>Select a conversation</p>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -75,6 +75,7 @@ export function PulseScreen() {
!(values.feed === "channel" && values.channel) ? (
<UserProfilePanel
canResetWidth={threadPanelWidth.canReset}
className="my-[12px] mr-[8px] h-auto overflow-hidden rounded-[24px] border-0"
currentPubkey={identityQuery.data?.pubkey}
onClose={handleCloseProfilePanel}
onOpenDm={handleOpenDm}

View File

@@ -19,6 +19,7 @@ import {
PULSE_WORKSPACE_KEYS,
CLEAR_WORKSPACE_PANELS,
} from "../lib/workspaceNavigation";
import { PulseAppNavigation, type PulseApp } from "./PulseAppNavigation";
import { PulseWorkspacePage } from "./PulseWorkspacePage";
import { PulseCombinedView } from "./PulseCombinedView";
import { PulseVariationMenu } from "./PulseVariationMenu";
@@ -92,6 +93,19 @@ export function UnifiedPulseView({
setBriefingFilter(null);
return true;
};
const activeApp: PulseApp = isPulseWorkspacePage(filter)
? filter
: "messages";
const lastMessageView = React.useRef<PulseView>(
values.conversation ? "conversation" : "all",
);
React.useEffect(() => {
if (!isPulseWorkspacePage(filter)) lastMessageView.current = filter;
}, [filter]);
const selectApp = (app: PulseApp) => {
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}` }))
@@ -332,56 +346,58 @@ export function UnifiedPulseView({
);
return (
<div
className={cn(
"flex min-h-0 flex-1 flex-col py-[12px]",
expanded && "px-[8px]",
)}
className="flex min-h-0 flex-1 gap-2 px-[8px] py-[12px]"
data-testid="unified-pulse"
>
<div
data-testid="pulse-main-container"
className={cn(
"relative mx-auto flex min-h-0 w-full flex-1 flex-col overflow-hidden rounded-[24px] bg-background",
!expanded && "max-w-[960px]",
)}
data-expanded={expanded}
>
<PulseVariationMenu
value={combined ? "combined" : "separate"}
onChange={setVariation}
onRefresh={() => void feed.refresh()}
refreshing={feed.query.isFetching}
/>
<div className="pulse-conversation-workspace flex min-h-0 flex-1">
<div
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
data-testid="pulse-scroll-area"
>
<PulseCombinedView
grouped={!combined}
channels={feed.channels}
conversations={feed.conversations}
currentPubkey={currentPubkey}
scrollRef={setScrollElement}
view={filter}
workspaceContent={
isPulseWorkspacePage(filter) ? (
<PulseWorkspacePage page={filter} />
) : undefined
}
onSelectView={setFilter}
<PulseAppNavigation active={activeApp} onSelect={selectApp} />
<div className="flex min-h-0 min-w-0 flex-1">
<div
data-testid="pulse-main-container"
className={cn(
"relative mx-auto flex min-h-0 w-full flex-1 flex-col overflow-hidden rounded-[24px] bg-background",
!expanded && "max-w-[960px]",
)}
data-expanded={expanded}
>
<PulseVariationMenu
value={combined ? "combined" : "separate"}
onChange={setVariation}
onRefresh={() => void feed.refresh()}
refreshing={feed.query.isFetching}
/>
<div className="pulse-conversation-workspace flex min-h-0 flex-1">
<div
className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
data-testid="pulse-scroll-area"
>
{content}
</PulseCombinedView>
</div>
<div
className="pulse-terminal-side-host"
data-testid="pulse-terminal-panel"
>
{terminal}
{isPulseWorkspacePage(filter) ? (
<PulseWorkspacePage page={filter} />
) : (
<PulseCombinedView
grouped={!combined}
channels={feed.channels}
conversations={feed.conversations}
currentPubkey={currentPubkey}
scrollRef={setScrollElement}
view={filter}
onSelectView={setFilter}
>
{content}
</PulseCombinedView>
)}
</div>
<div
className="pulse-terminal-side-host"
data-testid="pulse-terminal-panel"
>
{terminal}
</div>
</div>
</div>
</div>
{!expanded && (
<div aria-hidden="true" className="w-[64px] shrink-0 lg:w-[180px]" />
)}
</div>
);
}

View File

@@ -584,7 +584,7 @@ test("channel rail opens conversations and preserves drafts", async ({
await expect(list).toHaveCSS("width", "220px");
const all = list.getByRole("button", { name: "All messages", exact: true });
const aggregate = page.getByTestId("pulse-all-messages-feed");
await expect(list.getByRole("button").nth(5)).toHaveText("All messages");
await expect(list.getByRole("button").nth(2)).toHaveText("All messages");
await expect(all).toHaveAttribute("aria-current", "true");
await expect(
aggregate
@@ -710,7 +710,7 @@ test("app variations combine conversations by recency and preserve selection and
await expect(page.getByTestId("pulse-tabs")).toHaveCount(0);
await expect(
page
.getByTestId("pulse-combined-list")
.getByTestId("pulse-app-navigation")
.getByRole("button", { name: "Agents", exact: true }),
).toBeVisible();
await expect(
@@ -724,7 +724,7 @@ test("app variations combine conversations by recency and preserve selection and
const aggregate = page.getByTestId("pulse-all-messages-feed");
await expect(list.getByRole("button").nth(0)).toHaveText("Search");
await expect(list.getByRole("button").nth(1)).toHaveText("For you");
await expect(list.getByRole("button").nth(5)).toHaveText("All messages");
await expect(list.getByRole("button").nth(2)).toHaveText("All messages");
await list.getByRole("button", { name: "Search", exact: true }).click();
await expect(
page.getByRole("searchbox", { name: "Search loaded feed" }),
@@ -1347,7 +1347,7 @@ test("side panels expand Pulse and terminal docks beside the conversation", asyn
await expect(container).toHaveCSS("max-width", "none");
await expect
.poll(async () => (await boundsOf(container)).width)
.toBeGreaterThan(1400);
.toBeGreaterThan(1300);
const conversationBounds = await boundsOf(detail);
const terminalBounds = await boundsOf(terminal);
expect(terminalBounds.x).toBeGreaterThanOrEqual(
@@ -1355,8 +1355,9 @@ test("side panels expand Pulse and terminal docks beside the conversation", asyn
);
expect(terminalBounds.height).toBeGreaterThan(700);
const expandedBounds = await boundsOf(container);
expect(expandedBounds.x).toBe(8);
expect(expandedBounds.width).toBe(1584);
const appsBounds = await boundsOf(page.getByTestId("pulse-app-navigation"));
expect(expandedBounds.x).toBe(appsBounds.x + appsBounds.width + 8);
expect(expandedBounds.x + expandedBounds.width).toBe(1592);
const chatHeader = detail.getByTestId("chat-header");
const terminalHeader = terminal.getByTestId("terminal-header");
expect((await boundsOf(terminalHeader)).height).toBe(
@@ -1817,6 +1818,20 @@ test("workspace entrypoints keep projects, agents, and workflows in the main pan
await seed(page);
const rail = page.getByTestId("pulse-combined-list");
const main = page.getByTestId("pulse-main-container");
const apps = page.getByTestId("pulse-app-navigation");
await expect(main.getByTestId("pulse-app-heading")).toHaveCount(0);
await expect(
apps.getByRole("button", { name: "Messages", exact: true }),
).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(apps.locator("button > svg")).toHaveCount(4);
for (const width of [1600, 1280, 900]) {
await page.setViewportSize({ width, height: 960 });
const box = await boundsOf(main);
expect(box.x + box.width / 2).toBeCloseTo(width / 2, 0);
const navBox = await boundsOf(apps);
expect(box.x).toBeGreaterThanOrEqual(navBox.x + navBox.width);
}
await page.setViewportSize({ width: 1440, height: 960 });
await expect(
rail
.getByRole("button")
@@ -1824,8 +1839,16 @@ test("workspace entrypoints keep projects, agents, and workflows in the main pan
.first(),
).toHaveText("Search");
await expect(rail.getByRole("button").nth(1)).toHaveText("For you");
for (const [index, name] of ["Projects", "Agents", "Workflows"].entries()) {
await expect(rail.getByRole("button").nth(index + 2)).toHaveText(name);
await expect(apps.getByRole("button")).toHaveText([
"Messages",
"Projects",
"Agents",
"Workflows",
]);
for (const name of ["Projects", "Agents", "Workflows"]) {
await expect(rail.getByRole("button", { name, exact: true })).toHaveCount(
0,
);
}
for (const variation of ["Combined conversations", "Separate feeds"]) {
await page
@@ -1839,24 +1862,29 @@ test("workspace entrypoints keep projects, agents, and workflows in the main pan
["Agents", "agents-page-content"],
["Workflows", "workflows-view"],
]) {
const entry = rail.getByRole("button", { name, exact: true });
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", "true");
await expect(rail).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);
const railBox = await boundsOf(rail);
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(panelBox.x).toBeCloseTo(railBox.x + railBox.width, 0);
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", "true");
await expect(entry).toHaveAttribute("aria-current", "page");
}
}
await main
@@ -1865,11 +1893,11 @@ test("workspace entrypoints keep projects, agents, and workflows in the main pan
const editor = page.getByRole("dialog", { name: "Create workflow" });
await expect(editor).toBeVisible();
await expect(page).toHaveURL(/#\/pulse\?.*view=create/);
await expect(rail).toBeVisible();
await expect(apps).toBeVisible();
await page.goBack();
await expect(editor).toHaveCount(0);
await expect(main.getByTestId("workflows-view")).toBeVisible();
await rail.getByRole("button", { name: "Projects", exact: true }).click();
await apps.getByRole("button", { name: "Projects", exact: true }).click();
await main.getByTestId("projects-section-projects").click();
const project = main.locator(
'[data-testid="project-card-buzz"], [data-testid="project-row-buzz"]',
@@ -1878,20 +1906,32 @@ test("workspace entrypoints keep projects, agents, and workflows in the main pan
await project.click();
await expect(page).toHaveURL(/#\/pulse\?.*projectId=/);
await expect(main.getByTestId("pulse-workspace-projects")).toBeVisible();
await expect(rail).toBeVisible();
await expect(apps).toBeVisible();
await page.goBack();
await main.getByTestId("projects-section-projects").click();
await expect(project).toBeVisible();
await apps.getByRole("button", { name: "Messages", exact: true }).click();
await expect(rail).toBeVisible();
await rail.locator('[data-channel-name="bob-tyler"]').click();
await expect(main.getByTestId("message-input")).toBeVisible();
await expect(main.getByTestId("pulse-workspace-projects")).toHaveCount(0);
const composer = main.getByTestId("message-input");
await composer.fill("Keep this draft while switching apps");
await apps.getByRole("button", { name: "Agents", exact: true }).click();
await expect(rail).toHaveCount(0);
await apps.getByRole("button", { name: "Messages", exact: true }).click();
await expect(composer).toHaveText("Keep this draft while switching apps");
await waitForAnimations(page);
await page
.getByTestId("unified-pulse")
.screenshot({ path: "test-results/pulse-prototype/apps-messages.png" });
await rail.getByRole("button", { name: "For you", exact: true }).click();
await expect(main.getByTestId("pulse-briefing")).toBeVisible();
await rail.getByRole("button", { name: "Projects", exact: true }).click();
await apps.getByRole("button", { name: "Projects", exact: true }).click();
await main.getByTestId("projects-section-projects").click();
await expect(project).toBeVisible();
await waitForAnimations(page);
await main.screenshot({
await page.getByTestId("unified-pulse").screenshot({
path: "test-results/pulse-prototype/workspace-projects.png",
});
});
@@ -2141,3 +2181,54 @@ test("agent conversation rows replace unread dots with notification-sized workin
await expect(working).toHaveCount(0);
await expect(unread).toHaveCount(0);
});
test("workspace profile panels share the main container's frame and remain resizable", async ({
page,
}) => {
await seed(page);
await page
.getByTestId("pulse-app-navigation")
.getByRole("button", { name: "Projects", exact: true })
.click();
// Exercise a profile deep link through the real Pulse route and profile panel.
await page.evaluate((pubkey) => {
const [path, query] = location.hash.split("?");
const params = new URLSearchParams(query);
params.set("profile", pubkey);
location.hash = `${path}?${params}`;
}, TEST_IDENTITIES.bob.pubkey);
const panel = page.getByTestId("user-profile-panel");
const main = page.getByTestId("pulse-main-container");
await expect(panel).toBeVisible();
await expect(panel).toHaveCSS("border-radius", "24px");
await expect(panel).toHaveCSS("border-width", "0px");
await expect(main).toHaveAttribute("data-expanded", "true");
await waitForAnimations(page);
const panelBox = await boundsOf(panel);
const mainBox = await boundsOf(main);
expect(panelBox.y).toBe(mainBox.y);
expect(panelBox.height).toBe(mainBox.height);
expect(panelBox.x - mainBox.x - mainBox.width).toBeCloseTo(8, 0);
expect(panelBox.x + panelBox.width).toBe(1432);
await page.screenshot({
path: "test-results/pulse-prototype/workspace-profile-frame.png",
});
const handle = await boundsOf(
panel.getByTestId("user-profile-resize-handle"),
);
// Use the part of the resize target inside the rounded surface.
const x = panelBox.x + 2;
const y = handle.y + handle.height / 2;
await page.mouse.move(x, y);
await page.mouse.down();
await page.mouse.move(x - 40, y, { steps: 5 });
await page.mouse.up();
await expect
.poll(async () => (await boundsOf(panel)).width)
.toBeGreaterThan(panelBox.width + 20);
await panel.getByRole("button", { name: "Close panel", exact: true }).click();
await expect(panel).toHaveCount(0);
await expect(main).toHaveCSS("max-width", "960px");
const restored = await boundsOf(main);
expect(restored.x + restored.width / 2).toBeCloseTo(720, 0);
});