From eddcf772c6374294271c9f7a5bd0fdb7e674df82 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Mon, 31 Aug 2026 08:29:56 -0700 Subject: [PATCH 1/3] Let a person collapse the sidebar, and reach the roster on a phone The sidebar could always collapse. The primitive has held the state, the width transition and a Cmd/Ctrl+B listener since it was vendored in, and nothing ever rendered a trigger for any of it. The only affordance was SidebarRail, a 16px transparent strip carrying tabIndex={-1}, so the eye could not find it and the keyboard could not reach it. Under 768px the same sidebar is a Sheet that starts closed, and with no trigger nothing could open it. The roster is this app's navigation, so on a phone every channel sat behind a control that did not exist. The toggle is drawn in the chrome each screen already has, and in both states: one that lived inside the sidebar could not bring back what it hid. PageShell's back-button bar becomes unconditional so the control sits at the pane's left edge rather than 400px into a centred prose column, and the three screens that draw no header of their own get the same 48px band. The preference now survives a reload. The primitive wrote a sidebar_state cookie for a server-rendered shell to read on its first byte; nothing renders this app on a server and nothing ever read it back, so that write is gone and lib/sidebar.ts holds the answer instead, in the shape of the theme preference beside it. Mobile is deliberately excluded: an overlay that covers the screen it overlays has no business being open before anybody asked. --- app/src/components/layout/page-shell.tsx | 19 +- app/src/components/layout/sidebar-shell.tsx | 62 +++++++ app/src/components/layout/sidebar-toggle.tsx | 84 +++++++++ app/src/components/ui/sidebar.tsx | 14 +- app/src/lib/sidebar.ts | 33 ++++ app/src/routes/_authed/_app.tsx | 14 +- app/src/routes/_authed/_app/agents/index.tsx | 2 + app/src/routes/_authed/_app/bot.tsx | 2 + .../_authed/_app/channel/$channelId.tsx | 2 + app/src/routes/_authed/_app/channel/new.tsx | 2 + app/src/routes/_authed/_app/index.tsx | 166 +++++++++--------- app/src/routes/_authed/admin/route.tsx | 17 +- app/src/routes/_authed/settings/route.tsx | 17 +- app/tests/sidebar-preference.test.ts | 56 ++++++ 14 files changed, 360 insertions(+), 130 deletions(-) create mode 100644 app/src/components/layout/sidebar-shell.tsx create mode 100644 app/src/components/layout/sidebar-toggle.tsx create mode 100644 app/src/lib/sidebar.ts create mode 100644 app/tests/sidebar-preference.test.ts diff --git a/app/src/components/layout/page-shell.tsx b/app/src/components/layout/page-shell.tsx index 3663a6676..108836173 100644 --- a/app/src/components/layout/page-shell.tsx +++ b/app/src/components/layout/page-shell.tsx @@ -3,6 +3,7 @@ import { Link, type LinkProps } from "@tanstack/react-router"; import type * as React from "react"; import { cn } from "@/lib/utils"; import { Button } from "../ui/button"; +import { SidebarToggle } from "./sidebar-toggle"; /** * The frame every configuration screen sits in. @@ -58,8 +59,15 @@ export function PageShell({ }) { return ( <> - {!!backButton && ( -
+ {/* + * The bar is unconditional. The sidebar toggle has to sit at the pane's left edge in both + * states, and the prose column is centred — a toggle inside it would be 400px from the edge it + * belongs to on a wide screen. Six screens already drew exactly this bar for their Back link, + * so this is that bar always rendered rather than a second one above it. + */} +
+ + {!!backButton && ( -
- )} + )} +
+ parseStoredSidebarOpen(window.localStorage.getItem(SIDEBAR_STORAGE_KEY)), + ); + + useEffect(() => { + applySidebarOpen(open, { + setStoredValue: (key, value) => window.localStorage.setItem(key, value), + }); + }, [open]); + + return ( + + {children} + + ); +} diff --git a/app/src/components/layout/sidebar-toggle.tsx b/app/src/components/layout/sidebar-toggle.tsx new file mode 100644 index 000000000..1820dca26 --- /dev/null +++ b/app/src/components/layout/sidebar-toggle.tsx @@ -0,0 +1,84 @@ +import { IconLayoutSidebar } from "@tabler/icons-react"; + +import { Button } from "@/components/ui/button"; +import { useSidebar } from "@/components/ui/sidebar"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +/** + * ⌘ on Apple platforms, Ctrl everywhere else. The primitive's listener accepts either modifier, so + * this only decides which of the two to name. + */ +const SHORTCUT_LABEL = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent) + ? "⌘B" + : "Ctrl+B"; + +/** + * The control that opens and closes the shell's sidebar. + * + * WHAT THIS IS FIXING. The sidebar could always collapse — the primitive has had the state, the + * width transition and a ⌘B shortcut since it was vendored in. Nothing ever rendered a trigger for + * it. The only affordance was `SidebarRail`, a 16px transparent strip carrying `tabIndex={-1}`, so + * the eye could not find it and the keyboard could not reach it; and under 768px, where the sidebar + * becomes a Sheet that starts closed, there was no way to open the roster at all. + * + * It is therefore drawn in the chrome each screen already has, and in BOTH states, rather than + * inside the sidebar it hides — a trigger that disappears along with the sidebar cannot bring it + * back. + * + * Built from `Button` rather than the primitive's `SidebarTrigger` because that component hardcodes + * its own children after the prop spread, including an `sr-only` "Toggle Sidebar" that would + * contradict the label below. The state still belongs to the primitive: `toggleSidebar` is its hook. + */ +export function SidebarToggle({ className }: { className?: string }) { + const { isMobile, open, toggleSidebar } = useSidebar(); + /* + * The label says what the click will do, not what is on screen. Below 768px the sidebar is an + * overlay Sheet with its own open state, and `open` describes the desktop pane — reading it there + * would name the wrong action. + */ + const label = isMobile || !open ? "Show sidebar" : "Hide sidebar"; + + return ( + + + + + } + /> + {/* An accelerator nobody is told about is not a feature. */} + + {label} + {SHORTCUT_LABEL} + + + ); +} + +/** + * The toggle on a screen that draws no header of its own. + * + * Three `_app` screens open straight into their content, and the toggle still has to land in the + * same 48px band it occupies everywhere else — a control that moves between screens is a control + * somebody has to look for each time. No bottom border: a divider under an otherwise empty bar is a + * line with nothing to divide. + */ +export function SidebarToggleBar() { + return ( +
+ +
+ ); +} diff --git a/app/src/components/ui/sidebar.tsx b/app/src/components/ui/sidebar.tsx index b639c56fc..90608ea81 100644 --- a/app/src/components/ui/sidebar.tsx +++ b/app/src/components/ui/sidebar.tsx @@ -25,8 +25,6 @@ import { } from "@/components/ui/tooltip"; import { IconLayoutSidebar } from "@tabler/icons-react"; -const SIDEBAR_COOKIE_NAME = "sidebar_state"; -const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; const SIDEBAR_WIDTH = "16rem"; const SIDEBAR_WIDTH_MOBILE = "18rem"; const SIDEBAR_WIDTH_ICON = "3rem"; @@ -53,6 +51,15 @@ function useSidebar() { return context; } +/** + * Sidebar state, uncontrolled by default. + * + * This vendored file used to write a `sidebar_state` cookie on every toggle, so a server-rendered + * shell could paint the right width on its first byte. Nothing renders this app on a server and + * nothing ever read the cookie back, so the preference lives in `lib/sidebar.ts` and the shells + * drive `open`/`onOpenChange` through `layout/sidebar-shell.tsx` instead. Re-adding the cookie would + * stand a second, staler answer beside that one. + */ function SidebarProvider({ defaultOpen = true, open: openProp, @@ -81,9 +88,6 @@ function SidebarProvider({ } else { _setOpen(openState); } - - // This sets the cookie to keep the sidebar state. - document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`; }, [setOpenProp, open], ); diff --git a/app/src/lib/sidebar.ts b/app/src/lib/sidebar.ts new file mode 100644 index 000000000..8a0d7fa89 --- /dev/null +++ b/app/src/lib/sidebar.ts @@ -0,0 +1,33 @@ +/** + * Whether the shell's sidebar is open, remembered across reloads. + * + * The primitive under `components/ui/sidebar.tsx` writes a `sidebar_state` cookie of its own, which + * exists so a server-rendered shell can paint the right width on the first byte. Nothing renders + * this app on a server, and nothing ever read that cookie back — so the preference lives here + * instead, in the same shape and the same storage as the theme preference next door. + */ +export const SIDEBAR_STORAGE_KEY = "openbot-sidebar"; + +/** + * Only the exact stored `collapsed` starts the sidebar closed. + * + * Everything else opens it: a key never written, a value from an older build, a value somebody + * else's script left behind. The roster is this app's navigation, and the cost of the two mistakes + * is not symmetric — opening a sidebar somebody wanted shut costs them one click, while shutting one + * on a guess hides every channel they have behind an affordance they have not found yet. + */ +export function parseStoredSidebarOpen(value: string | null) { + return value !== "collapsed"; +} + +type SidebarEffects = { + setStoredValue: (key: string, value: string) => void; +}; + +/** + * Records the state in the vocabulary the primitive already uses for it — `expanded` and + * `collapsed`, the two values of its `data-state` attribute — rather than inventing a second pair. + */ +export function applySidebarOpen(open: boolean, effects: SidebarEffects) { + effects.setStoredValue(SIDEBAR_STORAGE_KEY, open ? "expanded" : "collapsed"); +} diff --git a/app/src/routes/_authed/_app.tsx b/app/src/routes/_authed/_app.tsx index 7b46a4900..dcf51a53d 100644 --- a/app/src/routes/_authed/_app.tsx +++ b/app/src/routes/_authed/_app.tsx @@ -1,6 +1,6 @@ import { createFileRoute, Outlet } from "@tanstack/react-router"; import { AppSidebar } from "@/components/app-sidebar/app-sidebar"; -import { SidebarProvider } from "@/components/ui/sidebar"; +import { SidebarShell } from "@/components/layout/sidebar-shell"; export const Route = createFileRoute("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/_authed/_app")({ component: RouteComponent, @@ -10,19 +10,11 @@ function RouteComponent() { return ( // One viewport, never scrolls: panes scroll inside it. A growable shell lets the transcript's // scroller size against the page, grow it, and grow again. - +
-
+ ); } diff --git a/app/src/routes/_authed/_app/agents/index.tsx b/app/src/routes/_authed/_app/agents/index.tsx index aa4b42162..7ff455ff5 100644 --- a/app/src/routes/_authed/_app/agents/index.tsx +++ b/app/src/routes/_authed/_app/agents/index.tsx @@ -6,6 +6,7 @@ import { AgentCard } from "@/components/agents/agent-card"; import { AgentProfile as AgentProfileDetail } from "@/components/agents/agent-profile"; import { NewAgent } from "@/components/agents/new-agent"; import { DetailPanel } from "@/components/layout/detail-panel"; +import { SidebarToggleBar } from "@/components/layout/sidebar-toggle"; import { StaggerItem } from "@/components/layout/stagger"; import { Button } from "@/components/ui/button"; import { Empty, EmptyHeader, EmptyTitle } from "@/components/ui/empty"; @@ -58,6 +59,7 @@ function AgentsScreen() { ) : null } > +
diff --git a/app/src/routes/_authed/_app/bot.tsx b/app/src/routes/_authed/_app/bot.tsx index 7eed66aa8..20e925f5a 100644 --- a/app/src/routes/_authed/_app/bot.tsx +++ b/app/src/routes/_authed/_app/bot.tsx @@ -2,6 +2,7 @@ import { CopilotChat } from "@copilotkit/react-core/v2"; import { IconPlus } from "@tabler/icons-react"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; +import { SidebarToggleBar } from "@/components/layout/sidebar-toggle"; import { Button } from "@/components/ui/button"; import { agentListQueryOptions } from "@/lib/agents/queries"; import { useActiveBot } from "@/lib/copilot/active-bot"; @@ -76,6 +77,7 @@ function BotChat({ agentId, name }: { agentId: string; name: string }) { return (
+
{/* diff --git a/app/src/routes/_authed/_app/channel/$channelId.tsx b/app/src/routes/_authed/_app/channel/$channelId.tsx index 1c282746d..d1bf998b5 100644 --- a/app/src/routes/_authed/_app/channel/$channelId.tsx +++ b/app/src/routes/_authed/_app/channel/$channelId.tsx @@ -17,6 +17,7 @@ import { ActivityLog } from "@/components/computer/activity-log"; import { ComputerView } from "@/components/computer/computer-view"; import { useNeedsYou } from "@/components/computer/needs-you"; import { DetailPanel } from "@/components/layout/detail-panel"; +import { SidebarToggle } from "@/components/layout/sidebar-toggle"; import { Button } from "@/components/ui/button"; import { markChannelReadMutationOptions } from "@/lib/channels/mutations"; import { @@ -177,6 +178,7 @@ function RouteComponent() {
{/* Keyed on the displayed name so cold channel loads animate the resolved name, not the id. */}
+
+ To: -
-

- {appConfig.brand.productName} -

-

- Start a new channel -

-
-
- { - // A channel is pinned to one coworker for the life of its thread, so the coworker is - // chosen now, before it is created. An `@` is an explicit choice and is honoured as-is. - // With no `@`, the message is routed to the coworker it is for; if that routing cannot - // run, it falls back to the same default the composer used to always use. - setError(null); - try { - let agentId: string | undefined = draft.agentId ?? undefined; - if (agentId) { - /* - * Told to the server so the choice is recorded, and its answer thrown away: the - * person already decided and nothing here may change that. Failing to write the - * audit row must not stop the conversation, so a rejection is swallowed whole. - */ - await routeMessage(draft.text, agentId).catch(() => undefined); - } else { - try { - agentId = (await routeMessage(draft.text)).agentId; - } catch { - agentId = fallback?.id; + <> + +
+
+

+ {appConfig.brand.productName} +

+

+ Start a new channel +

+
+
+ { + // A channel is pinned to one coworker for the life of its thread, so the coworker is + // chosen now, before it is created. An `@` is an explicit choice and is honoured as-is. + // With no `@`, the message is routed to the coworker it is for; if that routing cannot + // run, it falls back to the same default the composer used to always use. + setError(null); + try { + let agentId: string | undefined = draft.agentId ?? undefined; + if (agentId) { + /* + * Told to the server so the choice is recorded, and its answer thrown away: the + * person already decided and nothing here may change that. Failing to write the + * audit row must not stop the conversation, so a rejection is swallowed whole. + */ + await routeMessage(draft.text, agentId).catch( + () => undefined, + ); + } else { + try { + agentId = (await routeMessage(draft.text)).agentId; + } catch { + agentId = fallback?.id; + } } + if (!agentId) return; + await start(agentId, draft.text); + } catch (caught) { + setError( + caught instanceof Error + ? caught.message + : "Could not start the conversation.", + ); + throw caught; } - if (!agentId) return; - await start(agentId, draft.text); - } catch (caught) { - setError( - caught instanceof Error - ? caught.message - : "Could not start the conversation.", - ); - throw caught; - } - }} - pending={pending} - /> - {fallback ? ( - // Said out loud: a message that silently reaches somebody you did not choose is the - // kind of surprise that costs trust the first time it happens. -

- Sent to the coworker it is for. Type @ to choose one - yourself. -

- ) : null} - {error ? ( -

- {error} -

- ) : null} -
-
-

Explore agents

-
- {!!explore?.length && - explore.map((agent) => ( - - - - ))} + }} + pending={pending} + /> + {fallback ? ( + // Said out loud: a message that silently reaches somebody you did not choose is the + // kind of surprise that costs trust the first time it happens. +

+ Sent to the coworker it is for. Type @ to choose one + yourself. +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} +
+
+

Explore agents

+
+ {!!explore?.length && + explore.map((agent) => ( + + + + ))} +
-
+ ); } diff --git a/app/src/routes/_authed/admin/route.tsx b/app/src/routes/_authed/admin/route.tsx index e92175c39..94acef922 100644 --- a/app/src/routes/_authed/admin/route.tsx +++ b/app/src/routes/_authed/admin/route.tsx @@ -1,6 +1,6 @@ import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"; import { AdminSidebar } from "@/components/admin/admin-sidebar"; -import { SidebarProvider } from "@/components/ui/sidebar"; +import { SidebarShell } from "@/components/layout/sidebar-shell"; import { currentUserQueryOptions } from "../../../lib/auth/queries"; export const Route = createFileRoute("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/_authed/admin")({ @@ -17,22 +17,11 @@ export const Route = createFileRoute("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/_authed/admin")({ function RouteComponent() { return ( - +
-
+ ); } diff --git a/app/src/routes/_authed/settings/route.tsx b/app/src/routes/_authed/settings/route.tsx index 93e3d935f..9a753bd73 100644 --- a/app/src/routes/_authed/settings/route.tsx +++ b/app/src/routes/_authed/settings/route.tsx @@ -1,6 +1,6 @@ import { createFileRoute, Outlet } from "@tanstack/react-router"; +import { SidebarShell } from "@/components/layout/sidebar-shell"; import { SettingsSidebar } from "@/components/settings/settings-sidebar"; -import { SidebarProvider } from "@/components/ui/sidebar"; export const Route = createFileRoute("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/_authed/settings")({ component: RouteComponent, @@ -8,22 +8,11 @@ export const Route = createFileRoute("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/_authed/settings")({ function RouteComponent() { return ( - +
-
+ ); } diff --git a/app/tests/sidebar-preference.test.ts b/app/tests/sidebar-preference.test.ts new file mode 100644 index 000000000..9b934a55d --- /dev/null +++ b/app/tests/sidebar-preference.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { + applySidebarOpen, + parseStoredSidebarOpen, + SIDEBAR_STORAGE_KEY, +} from "../src/lib/sidebar"; + +describe("sidebar preference", () => { + test("only the stored collapsed value starts the sidebar closed", () => { + expect(parseStoredSidebarOpen("collapsed")).toBe(false); + expect(parseStoredSidebarOpen("expanded")).toBe(true); + expect(parseStoredSidebarOpen(null)).toBe(true); + }); + + /* + * The roster is this app's navigation, so an unreadable value opens the sidebar rather than + * guessing it shut. A shell that hides its own navigation on a stale key is a shell with no way + * back. + */ + test("an unrecognised stored value leaves the sidebar open", () => { + expect(parseStoredSidebarOpen("")).toBe(true); + expect(parseStoredSidebarOpen("false")).toBe(true); + expect(parseStoredSidebarOpen("Collapsed")).toBe(true); + }); + + test("persists both states under the key the reader parses", () => { + const writes: Array<[string, string]> = []; + const effects = { + setStoredValue: (key: string, value: string) => writes.push([key, value]), + }; + + applySidebarOpen(false, effects); + applySidebarOpen(true, effects); + + expect(writes).toEqual([ + [SIDEBAR_STORAGE_KEY, "collapsed"], + [SIDEBAR_STORAGE_KEY, "expanded"], + ]); + }); + + /* + * The test that matters: the writer and the reader are two functions and nothing but this holds + * their vocabulary together. Drift here does not throw, it silently stops restoring the state. + */ + test("a round trip through storage preserves the state", () => { + for (const open of [true, false]) { + let stored: string | null = null; + applySidebarOpen(open, { + setStoredValue: (_key, value) => { + stored = value; + }, + }); + expect(parseStoredSidebarOpen(stored)).toBe(open); + } + }); +}); From 782c34f3b2caf5c8ddef315868c8102a19c78636 Mon Sep 17 00:00:00 2001 From: Jerel John Velarde Date: Mon, 31 Aug 2026 09:36:49 -0700 Subject: [PATCH 2/3] Draw no toggle where there is no sidebar, and cover the playground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps found by auditing every screen under a shell rather than trusting the list of files the feature commit happened to touch. The admin playground drew no toggle at all, so collapsing the sidebar there left no way back. It is the one admin page that keeps its own geometry — an editor beside a live preview, as its comment says — so the toggle goes inline in its header rather than in a band of its own, which would take 48px from the thing being previewed. `PageShell` is not guaranteed to be inside a shell. Every one of the twenty screens that draws it is inside one today, so a toggle reading `useSidebar` unconditionally works here; a screen that renders `PageShell` outside a provider is a reasonable layout choice, and it would have met a thrown error during render rather than a missing button. `useSidebar` throwing is correct for a part OF a sidebar, where its absence is a wiring bug. A control that merely offers to toggle one reads the context optionally and draws nothing when there is none. --- app/src/components/layout/sidebar-toggle.tsx | 7 +++++-- app/src/components/ui/sidebar.tsx | 13 +++++++++++++ app/src/routes/_authed/admin/playground.tsx | 19 +++++++++++++------ 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/app/src/components/layout/sidebar-toggle.tsx b/app/src/components/layout/sidebar-toggle.tsx index 1820dca26..b7cda50eb 100644 --- a/app/src/components/layout/sidebar-toggle.tsx +++ b/app/src/components/layout/sidebar-toggle.tsx @@ -1,7 +1,7 @@ import { IconLayoutSidebar } from "@tabler/icons-react"; import { Button } from "@/components/ui/button"; -import { useSidebar } from "@/components/ui/sidebar"; +import { useOptionalSidebar } from "@/components/ui/sidebar"; import { Tooltip, TooltipContent, @@ -35,7 +35,10 @@ const SHORTCUT_LABEL = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent) * contradict the label below. The state still belongs to the primitive: `toggleSidebar` is its hook. */ export function SidebarToggle({ className }: { className?: string }) { - const { isMobile, open, toggleSidebar } = useSidebar(); + const sidebar = useOptionalSidebar(); + // No sidebar in scope, so nothing to toggle and nothing to draw. + if (!sidebar) return null; + const { isMobile, open, toggleSidebar } = sidebar; /* * The label says what the click will do, not what is on screen. Below 768px the sidebar is an * overlay Sheet with its own open state, and `open` describes the desktop pane — reading it there diff --git a/app/src/components/ui/sidebar.tsx b/app/src/components/ui/sidebar.tsx index 90608ea81..ceec714c1 100644 --- a/app/src/components/ui/sidebar.tsx +++ b/app/src/components/ui/sidebar.tsx @@ -51,6 +51,18 @@ function useSidebar() { return context; } +/** + * The sidebar's state, or null where there is no sidebar. + * + * `useSidebar` throwing is right for a part OF a sidebar, where its absence is a wiring bug. A + * control that merely offers to toggle one is the other case: `PageShell` sits inside the three + * shells today, and a screen that renders it outside one is a layout choice rather than a defect. + * Throwing there would take a whole screen down over a button that should simply not be drawn. + */ +function useOptionalSidebar() { + return React.useContext(SidebarContext); +} + /** * Sidebar state, uncontrolled by default. * @@ -723,5 +735,6 @@ export { SidebarRail, SidebarSeparator, SidebarTrigger, + useOptionalSidebar, useSidebar, }; diff --git a/app/src/routes/_authed/admin/playground.tsx b/app/src/routes/_authed/admin/playground.tsx index 667b7655e..3945891fb 100644 --- a/app/src/routes/_authed/admin/playground.tsx +++ b/app/src/routes/_authed/admin/playground.tsx @@ -2,6 +2,7 @@ import { OpenGenerativeUIActivityRenderer } from "@copilotkit/react-core/v2"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; import { useId, useState } from "react"; +import { SidebarToggle } from "@/components/layout/sidebar-toggle"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -127,12 +128,18 @@ function PlaygroundPage() { */
-
-

Playground

-

- Write a component here and publish it without a deployment. What you - edit is a draft; a conversation only ever draws what is published. -

+ {/* Inline rather than in a band of its own: this screen is an editor beside a live preview + and every 48px of height is taken from the thing being previewed. */} +
+ +
+

Playground

+

+ Write a component here and publish it without a deployment. What + you edit is a draft; a conversation only ever draws what is + published. +

+
- )} -
+ {bar ? ( +
+ {hasSidebar ? : null} + {!!backButton && ( + + )} +
+ ) : null}