269 lines
12 KiB
JavaScript
269 lines
12 KiB
JavaScript
import * as React from 'react';
|
|
import { PanelLeftOpen } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import KrowAssistant from './KrowAssistant';
|
|
import { useAssistantPanel } from './AssistantPanelContext';
|
|
import { ResizeDivider } from './ResizeDivider';
|
|
import { useIsPhone, useViewportWidth, useVisualViewport } from './viewport';
|
|
import OwliverAvatar from '@/components/krow/OwliverAvatar';
|
|
|
|
const EXPANDED_WIDTH = 620;
|
|
/**
|
|
* Gutter separating the panel from the dashboard, split between the drag handle
|
|
* and the panel's own lead padding. Both halves are named because the track width
|
|
* is their sum plus the panel: get that arithmetic wrong and the column either
|
|
* overflows or leaves a strip of unexplained empty space at the shell's edge.
|
|
*/
|
|
const HANDLE_WIDTH = 12;
|
|
const LEAD_PADDING = 8;
|
|
const GUTTER = HANDLE_WIDTH + LEAD_PADDING;
|
|
/** Expanded must never dominate: the dashboard stays the primary experience. */
|
|
const MAX_VIEWPORT_SHARE = 0.42;
|
|
/**
|
|
* The collapsed state — a compact docked trigger.
|
|
*
|
|
* The earlier version was a full-height rail inside the layout column. It solved
|
|
* the wrong problem: it kept Owliver *findable*, but it also kept the column, so
|
|
* collapsing bought the dashboard 44px instead of the 400px the user was asking
|
|
* for, and it did it with rotated text down the side of the page.
|
|
*
|
|
* So collapsing now removes the column entirely — the layout goes to one column
|
|
* and `main` takes the whole shell — and the way back is this small docked pill.
|
|
* It is deliberately the only floating control in the product, and it exists only
|
|
* in the collapsed state, where there is nowhere else for it to live.
|
|
*/
|
|
function CollapsedTrigger({ page, onRestore }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onRestore}
|
|
aria-label={`Show the Owliver workspace for ${page}`}
|
|
aria-expanded={false}
|
|
/* `bottom` is a `max()` against the bottom safe-area inset rather than a
|
|
flat 20px: on a phone with a home indicator a flat offset puts the pill
|
|
under the gesture bar, where the tap belongs to the OS. `env()` is 0
|
|
everywhere else, so desktop keeps the offset it always had. */
|
|
className="group fixed bottom-[max(1.25rem,env(safe-area-inset-bottom))] right-[max(1.25rem,env(safe-area-inset-right))]
|
|
z-30 inline-flex items-center gap-2 rounded-full border border-border
|
|
bg-surface py-2 pl-2 pr-3.5 shadow-md transition-[box-shadow,border-color] duration-base
|
|
hover:border-krow-blue/40 hover:shadow-lg
|
|
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-krow-blue/50"
|
|
>
|
|
<OwliverAvatar className="h-7 w-7" rounded="rounded-full" />
|
|
<span className="text-body-sm font-semibold text-ink-1">Owliver</span>
|
|
<PanelLeftOpen className="h-3.5 w-3.5 shrink-0 text-ink-4 group-hover:text-krow-blue" aria-hidden="true" />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* MobileWorkspace — Owliver on a phone.
|
|
*
|
|
* The same `KrowAssistant`, the same context, the same runtime, the same
|
|
* conversation. What changes is only where it is mounted: on a phone the
|
|
* workspace is an overlay above the page rather than a column beside it.
|
|
*
|
|
* That is forced by arithmetic, not taste. The desktop workspace is
|
|
* `main + 400px`; at 375px the 400px track leaves `main` negative, so the two
|
|
* surfaces stop being a layout and start being a fight over the same pixels —
|
|
* which is exactly what the broken state was. An overlay takes the page out of
|
|
* that arithmetic entirely: the page stays `width: 100%` whether Owliver is
|
|
* open or closed, and there is never a reserved column standing empty.
|
|
*
|
|
* Three things this is deliberately not:
|
|
*
|
|
* - Not a second chat. Nothing about the assistant is re-implemented; this
|
|
* component is a positioned container and nothing else.
|
|
* - Not a takeover. It stops below the app header, so the reader can still see
|
|
* where they are and can still leave.
|
|
* - Not a fixed height. It is sized to `visualViewport` where that exists and
|
|
* to `100dvh` where it does not, so an open keyboard shortens the sheet
|
|
* instead of pushing the composer off the bottom of it.
|
|
*/
|
|
function MobileWorkspace({ context, onClose }) {
|
|
const viewport = useVisualViewport();
|
|
|
|
/* The page behind an overlay must not scroll: on a touch screen a drag that
|
|
starts on the scrim and lands on the page is otherwise indistinguishable
|
|
from scrolling the conversation, and the reader loses their place on both
|
|
surfaces at once. Restored exactly as found — another overlay may already
|
|
own it. */
|
|
React.useEffect(() => {
|
|
const { body } = document;
|
|
const previous = body.style.overflow;
|
|
body.style.overflow = 'hidden';
|
|
return () => { body.style.overflow = previous; };
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-x-0 top-0 z-50 h-[100dvh] md:hidden"
|
|
style={viewport ? { height: viewport.height, top: viewport.offsetTop } : undefined}
|
|
>
|
|
{/* Tapping the page dismisses, which is what a sheet over a page should
|
|
do. A button rather than a bare div so it is reachable without a
|
|
pointer. */}
|
|
<button
|
|
type="button"
|
|
aria-label="Close the Owliver workspace"
|
|
onClick={onClose}
|
|
className="absolute inset-0 bg-ink-1/30 backdrop-blur-[2px] motion-safe:animate-fade-in"
|
|
/>
|
|
|
|
{/* `top-14` is the header's own height: the sheet starts under the app
|
|
bar rather than over it, so navigation is never covered. `min-h-0` is
|
|
what lets the assistant's internal scroller own the overflow instead
|
|
of the sheet growing past the viewport. */}
|
|
<div
|
|
className="absolute inset-x-0 bottom-0 top-14 flex min-h-0 flex-col px-3
|
|
pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Owliver workspace"
|
|
>
|
|
<KrowAssistant
|
|
key={context.id}
|
|
context={context}
|
|
/* Expanded is a desktop-only width state; there is no wider to go
|
|
here, so the control is not offered rather than offered and inert. */
|
|
expanded={false}
|
|
onClose={onClose}
|
|
onExpand={null}
|
|
onRestore={null}
|
|
className="h-full min-h-0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* AssistantPanel — the Owliver workspace the Admin layout renders.
|
|
*
|
|
* One panel, two presentations, chosen by how much room there is beside the
|
|
* page rather than by what kind of device is asking:
|
|
*
|
|
* ≥ 768px a column in the layout. Owliver is part of the page on every
|
|
* supported route, so the dashboard reflows beside it instead of
|
|
* being covered, and collapsing restores the original layout
|
|
* exactly. This is the protected desktop geometry and everything
|
|
* below describes it.
|
|
* < 768px an overlay (`MobileWorkspace`), because a 400px track does not fit
|
|
* beside anything on a 375px phone. The page is `width: 100%` in
|
|
* both states and never participates in a two-column width
|
|
* calculation it cannot satisfy.
|
|
*
|
|
* Both presentations mount the same `KrowAssistant` with the same context and
|
|
* read the same open/collapsed state, so there is one assistant in the product
|
|
* and one set of actions that change it.
|
|
*
|
|
* Five structural details matter, and every one of them was a bug at some point:
|
|
*
|
|
* 1. Nothing above the sticky element clips. An ancestor with `overflow: hidden`
|
|
* becomes the nearest scroll container and silently disables
|
|
* `position: sticky` beneath it.
|
|
* 2. The aside stretches to the shell's full height (`self-stretch`), so its
|
|
* sticky context runs the whole page rather than ending partway down and
|
|
* leaving empty space beside a long dashboard.
|
|
* 3. `min-w-0` on the aside. A flex item's default `min-width: auto` refuses to
|
|
* go narrower than its content, so the track ignored its own width and stalled
|
|
* at the panel's old size on the way back from expanded.
|
|
* 4. Only the track has a width. The panel fills what the track leaves after the
|
|
* handle and the lead padding, so the two can never disagree — which is
|
|
* exactly what (3) was: two widths transitioning, one winning.
|
|
* 5. Collapsing removes the column outright and leaves a compact docked trigger.
|
|
* An earlier version kept a full-height rail in place, which bought the
|
|
* dashboard 44px when the user had asked for 400 — and spelled "Owliver" down
|
|
* the side of the page to do it.
|
|
*
|
|
* Expanding widens the same panel in place — an analysis workspace inside the
|
|
* application layout, never a fullscreen takeover.
|
|
*/
|
|
export function AssistantPanel({ stickyClassName, panelHeightClassName }) {
|
|
const {
|
|
context, isOpen, isExpanded, width, minWidth, maxWidth,
|
|
setWidth, resetWidth, open, close, expand, restore,
|
|
} = useAssistantPanel();
|
|
const viewportWidth = useViewportWidth();
|
|
const isPhone = useIsPhone();
|
|
|
|
// No assistant on this route: no column, no rail, no trace in the layout.
|
|
if (!context) return null;
|
|
|
|
/* Phones: Owliver is never a column, in either state.
|
|
Closed, the layout is one column and `main` has the whole viewport — there
|
|
is no reserved 400px gutter to leave a blank strip down the right. Open,
|
|
the workspace is an overlay, so the page keeps that full width underneath
|
|
rather than being asked to share it with a track wider than the phone.
|
|
Both states are rendered from the same panel state the desktop column uses,
|
|
so opening, collapsing and reopening are the same three actions here. */
|
|
if (isPhone) {
|
|
return isOpen
|
|
? <MobileWorkspace context={context} onClose={close} />
|
|
: <CollapsedTrigger page={context.page} onRestore={open} />;
|
|
}
|
|
|
|
/* Expanded overrides the dragged width; otherwise the user's own width wins.
|
|
Both are clamped against the viewport so the dashboard is never squeezed. */
|
|
const viewportCap = Math.round(viewportWidth * MAX_VIEWPORT_SHARE);
|
|
const panelWidth = isExpanded
|
|
? Math.max(width, Math.min(EXPANDED_WIDTH, viewportCap))
|
|
: Math.min(width, Math.max(minWidth, viewportCap));
|
|
|
|
/* Collapsed: no column at all. The layout falls back to a single column, `main`
|
|
takes the whole shell, and the only thing left is the docked trigger — so
|
|
there is never a reserved empty gutter where the panel used to be. */
|
|
if (!isOpen) {
|
|
return <CollapsedTrigger page={context.page} onRestore={open} />;
|
|
}
|
|
|
|
const trackWidth = panelWidth + GUTTER;
|
|
|
|
return (
|
|
<aside
|
|
aria-label="Owliver workspace"
|
|
style={{ width: trackWidth }}
|
|
/* No `overflow-hidden` here — see (1). `self-stretch` — see (2).
|
|
`min-w-0` — see (3).
|
|
|
|
A CSS transition rather than an animated width: the width is already a
|
|
pure function of state, so there is nothing for an animation library to
|
|
own, and the declarative version cannot get stranded mid-collapse the way
|
|
an interrupted JS animation can. Reduced motion is handled globally in
|
|
index.css. */
|
|
className="relative min-w-0 shrink-0 self-stretch transition-[width] duration-slow ease-out"
|
|
>
|
|
<div className={cn('sticky flex', stickyClassName)}>
|
|
{/* Drag handle. Sits in the gutter, so resizing never overlaps either
|
|
surface. Double-click restores the default width. */}
|
|
<ResizeDivider
|
|
width={panelWidth}
|
|
min={minWidth}
|
|
max={Math.min(maxWidth, viewportCap)}
|
|
onResize={setWidth}
|
|
onDoubleClick={resetWidth}
|
|
className={panelHeightClassName}
|
|
/>
|
|
|
|
{/* The panel takes whatever the track leaves after the handle and the
|
|
lead padding, which is `panelWidth` by construction — see (4). */}
|
|
<div className="min-w-0 flex-1 pl-2">
|
|
{/* Keyed on the context so switching pages gives a clean panel and
|
|
aborts any response still streaming for the last one. */}
|
|
<KrowAssistant
|
|
key={context.id}
|
|
context={context}
|
|
expanded={isExpanded}
|
|
onClose={close}
|
|
onExpand={expand}
|
|
onRestore={restore}
|
|
className={panelHeightClassName}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
export default AssistantPanel;
|