diff --git a/app/src/components/layout/page-shell.tsx b/app/src/components/layout/page-shell.tsx index 3663a6676..ef8b1bbbe 100644 --- a/app/src/components/layout/page-shell.tsx +++ b/app/src/components/layout/page-shell.tsx @@ -3,6 +3,8 @@ 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 { useOptionalSidebar } from "../ui/sidebar"; +import { SidebarToggle } from "./sidebar-toggle"; /** * The frame every configuration screen sits in. @@ -56,23 +58,42 @@ export function PageShell({ label: string; }; }) { + /* + * Whether this screen has a sidebar at all. `/assist` and `/link/slack` draw PageShell directly + * under `_authed`, which mounts no provider, so there is nothing for a toggle to act on there. + */ + const hasSidebar = useOptionalSidebar() !== null; + /* + * The bar carries the toggle and the Back link, and is drawn when it has at least one of them. + * The screens with a Back link already drew exactly this bar, so for them nothing changes; what + * changed is that a sidebar is now reason enough on its own, because the toggle has to sit at the + * pane's left edge in both states and the prose column is centred — a control inside it would be + * 400px from the edge it belongs to on a wide screen. Drawing it with neither would be a 56px + * band holding nothing, which reads as a layout bug rather than as chrome. + */ + const bar = hasSidebar || !!backButton; + return ( <> - {!!backButton && ( -
- + {bar ? ( +
+ {hasSidebar ? : null} + {!!backButton && ( + + )}
- )} + ) : null}
+ 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..672f90931 --- /dev/null +++ b/app/src/components/layout/sidebar-toggle.tsx @@ -0,0 +1,89 @@ +import { IconLayoutSidebar } from "@tabler/icons-react"; + +import { Button } from "@/components/ui/button"; +import { useOptionalSidebar } 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 sidebar = useOptionalSidebar(); + // No sidebar in scope, so nothing to toggle and nothing to draw. + if (!sidebar) return null; + const { isMobile, open, openMobile, 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 + * would name the wrong action. + */ + const label = (isMobile ? openMobile : open) + ? "Hide sidebar" + : "Show 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..ceec714c1 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,27 @@ 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. + * + * 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 +100,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], ); @@ -719,5 +735,6 @@ export { SidebarRail, SidebarSeparator, SidebarTrigger, + useOptionalSidebar, useSidebar, }; 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/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. +

+