mirror of
https://github.com/block/buzz.git
synced 2026-11-04 16:31:38 +00:00
feat(desktop): move the Project Canvas beside project chat
The Canvas preview sat above the message timeline as a fixed 25.2/28.8rem
strip, so it ate the top of every project channel's chat: the timeline
started below the fold, and a "Chat preview boundary" marker had to be
drawn across the full Canvas to show authors where that fold landed.
Put the preview in a right-hand pane instead. Chat keeps its full height,
and the Canvas gets a tall column, which suits a dashboard of stacked
widgets far better than a letterbox.
- `ChannelViewOverride` gains `mainColumnHeaderPlacement`
("top" | "right", default top). `ChannelPaneMainColumn` lays the header
out as a sibling column (`order-last`, `clamp(24rem, 38%, 28.8rem)`)
when it is "right" and keeps the existing `contents` pass-through
otherwise, so no other channel view changes. `ProjectChannelHome` asks
for "right" on the chat tab and "top" on the canvas tab, where the
surface still fills the pane.
- The pane is container-queried, not viewport-queried: it appears only
once the chat column itself is at least 52rem wide, so a narrow window
shows chat at full width and users reach the Canvas through its tab.
`ChannelPane` also passes `hideRightHeader` when a split auxiliary pane
is open, so a thread, profile, or agent panel takes that space rather
than competing with it. The frame stays mounted (`display: none`), so
opening and closing a thread never reloads the package or drops its
live subscriptions.
- The preview-boundary marker goes away with the fold it described, and
"Show full canvas" becomes a tooltipped icon button pinned
bottom-right, since a full-width labelled button no longer fits a 24rem
column. It follows the existing icon-button idiom: one `aria-label` on
the button, matching tooltip copy.
The e2e spec binds the new geometry — chat and composer both end at or
before the pane's left edge and the pane is taller than it is wide; the
narrow-viewport test now asserts the pane is hidden and the full tab is
the way in; and a new 1920px test opens a thread to check the pane
yields, chat takes the width, and the frame's `src` survives the round
trip.
Verified: desktop unit suite 5987 passing, project-channel-canvas 8
passing, the project and thread layout specs 6 passing, and tsc, biome,
px-text, pubkey-truncation, and the file-size gate clean. Three specs
fail on this branch independent of this change —
project-cold-start.spec.ts:76 and :107 plus
project-empty-state-alignment.spec.ts:48 look for
`project-home-context-panel`, whose component has had no callers since
the channel-first migration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
This commit is contained in:
@@ -623,7 +623,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
<ThreadRepliesErrorCard onRetry={onRetryHuddleThreadReplies} />
|
||||
</div>
|
||||
) : null}
|
||||
<ChannelPaneMainColumn>
|
||||
<ChannelPaneMainColumn hideRightHeader={hasSplitAuxiliaryPane}>
|
||||
<MessageTimeline
|
||||
ref={messageTimelineRef}
|
||||
channelId={activeChannel?.id}
|
||||
|
||||
@@ -11,11 +11,16 @@ const IN_FLOW_CHANNEL_CONTENT_STYLE = {
|
||||
|
||||
export function ChannelPaneMainColumn({
|
||||
children,
|
||||
hideRightHeader = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
hideRightHeader?: boolean;
|
||||
}) {
|
||||
const channelView = useChannelViewOverride();
|
||||
const mainColumnHeader = channelView?.mainColumnHeader;
|
||||
const headerOnRight =
|
||||
Boolean(mainColumnHeader) &&
|
||||
channelView?.mainColumnHeaderPlacement === "right";
|
||||
const className = cn(
|
||||
"relative isolate flex min-h-0 min-w-0 flex-1 flex-col",
|
||||
channelView?.mainContent && "hidden",
|
||||
@@ -32,12 +37,37 @@ export function ChannelPaneMainColumn({
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="relative flex min-h-0 min-w-0 flex-1 flex-col"
|
||||
className={cn(
|
||||
"relative flex min-h-0 min-w-0 flex-1",
|
||||
headerOnRight
|
||||
? "flex-row [container-type:inline-size]"
|
||||
: "flex-col",
|
||||
)}
|
||||
style={IN_FLOW_CHANNEL_CONTENT_STYLE}
|
||||
>
|
||||
{mainColumnHeader}
|
||||
<div
|
||||
className={channelView?.hideMainColumnBody ? "hidden" : "contents"}
|
||||
className={cn(
|
||||
headerOnRight
|
||||
? cn(
|
||||
"order-last min-h-0 shrink-0 [width:clamp(24rem,38%,28.8rem)]",
|
||||
hideRightHeader
|
||||
? "hidden"
|
||||
: "hidden [@container(min-width:52rem)]:flex",
|
||||
)
|
||||
: "contents",
|
||||
)}
|
||||
data-testid="channel-main-column-header"
|
||||
>
|
||||
{mainColumnHeader}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
channelView?.hideMainColumnBody
|
||||
? "hidden"
|
||||
: headerOnRight
|
||||
? "relative flex min-h-0 min-w-0 flex-1 flex-col"
|
||||
: "contents",
|
||||
)}
|
||||
data-testid="channel-main-column-body"
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -5,6 +5,7 @@ type ChannelViewOverride = {
|
||||
hideMainColumnBody?: boolean;
|
||||
isChannelViewActive: boolean;
|
||||
mainColumnHeader?: React.ReactNode;
|
||||
mainColumnHeaderPlacement?: "top" | "right";
|
||||
mainContent: React.ReactNode;
|
||||
onSelectChannelView: () => void;
|
||||
};
|
||||
|
||||
@@ -700,6 +700,8 @@ export function ProjectChannelHome({
|
||||
snapshots={canvasSnapshots}
|
||||
/>
|
||||
) : null,
|
||||
mainColumnHeaderPlacement:
|
||||
activeView === "chat" ? "right" : "top",
|
||||
mainContent,
|
||||
onSelectChannelView: () => selectView("chat"),
|
||||
}}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Maximize2 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { ProjectCanvasHost } from "./ProjectCanvasHost";
|
||||
import type { ProjectCanvasBroker } from "./projectCanvasBroker";
|
||||
import type { ProjectCanvasSnapshots } from "./projectCanvasProtocol";
|
||||
@@ -32,9 +33,7 @@ export function ProjectCanvasSurface({
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex min-h-0 flex-col overflow-hidden bg-background",
|
||||
full
|
||||
? "flex-1"
|
||||
: "h-[25.2rem] shrink-0 border-b border-border md:h-[28.8rem]",
|
||||
full ? "flex-1" : "h-full w-full border-l border-border",
|
||||
)}
|
||||
data-canvas-mode={full ? "full" : "preview"}
|
||||
data-testid="project-canvas-surface"
|
||||
@@ -60,32 +59,24 @@ export function ProjectCanvasSurface({
|
||||
snapshots={snapshots}
|
||||
/>
|
||||
</div>
|
||||
{full ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-[25.2rem] z-30 flex items-center gap-3 px-3 md:top-[28.8rem]"
|
||||
data-testid="project-canvas-preview-boundary"
|
||||
>
|
||||
<div className="h-px flex-1 border-t border-dotted border-primary/65" />
|
||||
<span className="rounded-sm bg-background/90 px-2 py-0.5 text-3xs font-medium text-muted-foreground shadow-sm backdrop-blur-sm">
|
||||
Chat preview boundary
|
||||
</span>
|
||||
<div className="h-px flex-1 border-t border-dotted border-primary/65" />
|
||||
</div>
|
||||
) : null}
|
||||
{!full ? (
|
||||
<div className="absolute inset-x-0 bottom-3 z-30 flex justify-center px-4">
|
||||
<Button
|
||||
className="border-border/80 bg-background/95 shadow-sm backdrop-blur-sm"
|
||||
data-testid="project-canvas-show-full"
|
||||
onClick={onShowFullCanvas}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
Show full canvas
|
||||
</Button>
|
||||
<div className="absolute bottom-3 right-3 z-50">
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label="Show full Canvas"
|
||||
className="h-8 w-8 border-border/80 bg-background/95 shadow-sm backdrop-blur-sm"
|
||||
data-testid="project-canvas-show-full"
|
||||
onClick={onShowFullCanvas}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Show full Canvas</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
import { installMockBridge } from "../helpers/bridge";
|
||||
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
||||
|
||||
async function openStarterProject(page: Page) {
|
||||
const projectRow = page.getByTestId("sidebar-project-buzz");
|
||||
@@ -12,6 +12,20 @@ async function openStarterProject(page: Page) {
|
||||
await projectRow.click();
|
||||
}
|
||||
|
||||
async function waitForMockLiveSubscription(page: Page, channelName: string) {
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
(name) =>
|
||||
window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({
|
||||
channelName: name,
|
||||
}) ?? false,
|
||||
channelName,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function expectCanvasReady(page: Page) {
|
||||
const iframe = page.getByTestId("project-canvas-frame");
|
||||
await expect(iframe).toHaveAttribute("sandbox", "allow-scripts");
|
||||
@@ -26,7 +40,7 @@ async function expectCanvasReady(page: Page) {
|
||||
return { iframe, root };
|
||||
}
|
||||
|
||||
test("project canvas uses one sandboxed frame across preview and full modes", async ({
|
||||
test("project canvas uses one sandboxed frame across right-pane and full modes", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
@@ -40,7 +54,22 @@ test("project canvas uses one sandboxed frame across preview and full modes", as
|
||||
const surface = page.getByTestId("project-canvas-surface");
|
||||
await expect(surface).toHaveAttribute("data-canvas-mode", "preview");
|
||||
await expect(page.getByTestId("project-canvas-show-full")).toBeVisible();
|
||||
await expect(page.getByTestId("channel-composer-overlay")).toBeVisible();
|
||||
const composer = page.getByTestId("channel-composer-overlay");
|
||||
await expect(composer).toBeVisible();
|
||||
|
||||
const bodyBox = await page
|
||||
.getByTestId("channel-main-column-body")
|
||||
.boundingBox();
|
||||
const composerBox = await composer.boundingBox();
|
||||
const surfaceBox = await surface.boundingBox();
|
||||
if (!bodyBox || !composerBox || !surfaceBox) {
|
||||
throw new Error("Project chat and Canvas pane were not visible.");
|
||||
}
|
||||
expect(surfaceBox.height).toBeGreaterThan(surfaceBox.width);
|
||||
expect(bodyBox.x + bodyBox.width).toBeLessThanOrEqual(surfaceBox.x + 1);
|
||||
expect(composerBox.x + composerBox.width).toBeLessThanOrEqual(
|
||||
surfaceBox.x + 1,
|
||||
);
|
||||
|
||||
const { iframe, root } = await expectCanvasReady(page);
|
||||
const initialSource = await iframe.getAttribute("src");
|
||||
@@ -87,9 +116,9 @@ test("project canvas uses one sandboxed frame across preview and full modes", as
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("project-canvas-preview-boundary"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("project-canvas-preview-boundary")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(page.getByTestId("channel-composer-overlay")).toBeHidden();
|
||||
await expect(iframe).toHaveAttribute("src", initialSource ?? "");
|
||||
await expect(root).toHaveAttribute("data-canvas-mode", "full");
|
||||
@@ -140,6 +169,47 @@ test("project canvas uses one sandboxed frame across preview and full modes", as
|
||||
.toBe(2);
|
||||
});
|
||||
|
||||
test("project canvas yields to a right-side panel at a wide viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ height: 900, width: 1920 });
|
||||
await installMockBridge(page);
|
||||
await page.goto("/");
|
||||
await openStarterProject(page);
|
||||
await expect(page.getByTestId("project-channel-home")).toBeVisible();
|
||||
await waitForMockLiveSubscription(page, "buzz");
|
||||
|
||||
const surface = page.getByTestId("project-canvas-surface");
|
||||
await expect(surface).toBeVisible();
|
||||
const { iframe } = await expectCanvasReady(page);
|
||||
const initialSource = await iframe.getAttribute("src");
|
||||
const rootId = await page.evaluate((pubkey) => {
|
||||
return (
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "buzz",
|
||||
content: "Project thread that opens beside chat.",
|
||||
pubkey,
|
||||
})?.id ?? null
|
||||
);
|
||||
}, TEST_IDENTITIES.tyler.pubkey);
|
||||
expect(rootId).not.toBeNull();
|
||||
|
||||
await page.getByTestId(`reply-message-${rootId}`).click({ force: true });
|
||||
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
|
||||
await expect(surface).toBeHidden();
|
||||
const bodyBox = await page
|
||||
.getByTestId("channel-main-column-body")
|
||||
.boundingBox();
|
||||
if (!bodyBox) throw new Error("Project chat was not visible beside thread.");
|
||||
expect(bodyBox.width).toBeGreaterThan(832);
|
||||
await expect(iframe).toHaveAttribute("src", initialSource ?? "");
|
||||
|
||||
await page.getByTestId("auxiliary-panel-close").click();
|
||||
await expect(page.getByTestId("message-thread-panel")).toHaveCount(0);
|
||||
await expect(surface).toBeVisible();
|
||||
await expect(iframe).toHaveAttribute("src", initialSource ?? "");
|
||||
});
|
||||
|
||||
test("Reload activates a new package revision and releases the old handle", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -252,7 +322,7 @@ test("a failed candidate commit restores the active Canvas", async ({
|
||||
.toBe(2);
|
||||
});
|
||||
|
||||
test("project canvas preview and full tab stay contained at a narrow viewport", async ({
|
||||
test("project canvas uses the full tab instead of squeezing narrow chat", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ height: 844, width: 390 });
|
||||
@@ -263,26 +333,26 @@ test("project canvas preview and full tab stay contained at a narrow viewport",
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
const surface = page.getByTestId("project-canvas-surface");
|
||||
const { iframe } = await expectCanvasReady(page);
|
||||
await expect(surface).toHaveAttribute("data-canvas-mode", "preview");
|
||||
await expect(page.getByTestId("project-canvas-show-full")).toBeVisible();
|
||||
await expect(surface).toBeHidden();
|
||||
await expect(page.getByTestId("project-canvas-show-full")).toBeHidden();
|
||||
await expect(page.getByTestId("channel-composer-overlay")).toBeVisible();
|
||||
|
||||
await page.getByTestId("project-channel-tab-canvas").click();
|
||||
await expect(surface).toHaveAttribute("data-canvas-mode", "full");
|
||||
await expect(surface).toBeVisible();
|
||||
await expect(page.getByTestId("channel-composer-overlay")).toBeHidden();
|
||||
const { iframe, root } = await expectCanvasReady(page);
|
||||
await expect(root).toHaveAttribute("data-canvas-mode", "full");
|
||||
const surfaceBox = await surface.boundingBox();
|
||||
const frameBox = await iframe.boundingBox();
|
||||
if (!surfaceBox || !frameBox)
|
||||
throw new Error("Canvas frame was not visible.");
|
||||
if (!surfaceBox || !frameBox) {
|
||||
throw new Error("Full Canvas frame was not visible.");
|
||||
}
|
||||
expect(frameBox.x).toBeGreaterThanOrEqual(surfaceBox.x);
|
||||
expect(frameBox.x + frameBox.width).toBeLessThanOrEqual(
|
||||
surfaceBox.x + surfaceBox.width,
|
||||
);
|
||||
|
||||
await page.getByTestId("project-canvas-show-full").click();
|
||||
await expect(surface).toHaveAttribute("data-canvas-mode", "full");
|
||||
await expect(page.getByTestId("channel-composer-overlay")).toBeHidden();
|
||||
await expect(
|
||||
page
|
||||
.frameLocator('[data-testid="project-canvas-frame"]')
|
||||
.locator("#canvas-root"),
|
||||
).toHaveAttribute("data-canvas-mode", "full");
|
||||
});
|
||||
|
||||
test("unexpected child navigation tears down the Canvas frame", async ({
|
||||
|
||||
Reference in New Issue
Block a user