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:
Matt Toohey
2026-09-02 16:42:24 +10:00
parent 91c3f8da8f
commit cde664e728
6 changed files with 146 additions and 52 deletions

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ type ChannelViewOverride = {
hideMainColumnBody?: boolean;
isChannelViewActive: boolean;
mainColumnHeader?: React.ReactNode;
mainColumnHeaderPlacement?: "top" | "right";
mainContent: React.ReactNode;
onSelectChannelView: () => void;
};

View File

@@ -700,6 +700,8 @@ export function ProjectChannelHome({
snapshots={canvasSnapshots}
/>
) : null,
mainColumnHeaderPlacement:
activeView === "chat" ? "right" : "top",
mainContent,
onSelectChannelView: () => selectView("chat"),
}}

View File

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

View File

@@ -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 ({