Files
krow_talent_app/src/components/ai-assistant/AssistantPanel.jsx
2026-08-24 13:16:15 +05:30

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;