Files
daily_console_web/src/components/shell/AssistantPanel.tsx
2026-09-25 12:31:57 +05:30

1010 lines
34 KiB
TypeScript

import { Fragment, useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { errorMessage } from '@/api/client';
import {
approveAssistant,
askAssistant,
assistantAvailable,
type AssistantAnswer,
} from '@/api/assistant';
import { useAssistantScope } from './assistantScope';
import { matchAssistantRoute } from './assistantContext';
import { ArrowUp, History, Maximize2, Minimize2, PanelRightClose } from 'lucide-react';
import {
DEFAULT_WIDTH,
expandedWidth,
MAX_WIDTH,
MIN_WIDTH,
useAssistantWidth,
} from './assistantWidth';
/** One question and what came back. `answer` and `error` are exclusive. */
interface Exchange {
question: string;
answer?: AssistantAnswer;
error?: string;
/**
* What became of a change Buddy proposed.
*
* `undefined` means the card is still on screen waiting. Once decided the
* card is replaced by its outcome and cannot be pressed again — a card that
* stayed live after approval is a second write waiting to happen.
*/
decision?: { state: 'approving' | 'done' | 'dismissed' | 'failed'; message?: string };
}
/** Replaces the last entry, which is always the one in flight. */
function replaceLast(thread: Exchange[], update: (entry: Exchange) => Exchange): Exchange[] {
if (thread.length === 0) return thread;
return thread.map((entry, index) => (index === thread.length - 1 ? update(entry) : entry));
}
/**
* What the composer says when it cannot be used, and why.
*
* Three different states with three different sentences. "Not connected yet"
* for everything would be true and useless: a person whose deployment has no
* model can do nothing, but a person on the Inventory page can move to Sales
* and get an answer today.
*/
export function composerHint(
available: boolean | null,
hasAgent: boolean,
isAsking: boolean,
): string {
if (isAsking) return 'Thinking…';
if (available === null) return 'Checking…';
if (available === false) return 'Not connected yet';
if (!hasAgent) return 'No assistant for this page yet';
return 'Ask about this page';
}
/**
* The line under the composer, or nothing.
*
* This used to be one hardcoded sentence — "Not connected yet — there is no
* assistant service behind this panel." — written before the panel had an API
* behind it and never removed once it did. It rendered on every page in every
* state, so the panel told everybody Buddy was off, permanently, including on a
* deployment where Buddy was answering questions. It also flatly contradicted
* the placeholder three lines above it, which was the only part telling the
* truth.
*
* The lesson worth keeping: a status message with no condition attached is not
* a status message. Each branch here reads the same two facts the composer
* itself is disabled by, so the two cannot drift apart again.
*/
export function composerNote(available: boolean | null, hasAgent: boolean): string {
// Still asking. A sentence that appears for 200ms and is replaced reads as a
// flicker, so the space stays empty until there is something true to put in it.
if (available === null) return '';
if (available === false) return 'Not connected yet — there is no assistant service behind this panel.';
if (!hasAgent) return 'No assistant for this page yet. Try Console or Sales.';
// Buddy is working. The caveat that matters then is not about connection: it
// is that an answer is a model reading live rows, and the rows are the thing
// to act on.
return 'Buddy reads your live data. Check anything you act on.';
}
/**
* A change waiting on the person.
*
* Framed and set apart from the reply on purpose. The sentence above it was
* written by a model; this was resolved by the server against the database, and
* the two must not read as one thing. What is shown here — the ids, the names,
* the quantity — is what the button actually agrees to.
*
* Once decided the buttons are gone, replaced by what happened. A card that
* stayed pressable after approval is a second write waiting for a double-click.
*/
function ApprovalCard({
proposal,
decision,
onDecide,
}: {
proposal: NonNullable<AssistantAnswer['awaiting']>;
decision: Exchange['decision'];
onDecide: (approve: boolean) => void;
}) {
const busy = decision?.state === 'approving';
const outcomeColour =
decision?.state === 'failed'
? 'var(--color-error, #d64545)'
: decision?.state === 'done'
? 'var(--color-success, #1f9d55)'
: 'var(--color-ink-3)';
return (
<div
style={{
border: '1px solid var(--color-line)',
borderRadius: 12,
padding: '11px 12px',
background: 'var(--color-surface)',
display: 'flex',
flexDirection: 'column',
gap: 8,
}}
>
<p style={{ margin: 0, fontSize: 12.5, fontWeight: 600, color: 'var(--color-ink-1)' }}>
{proposal.summary}
</p>
{proposal.details?.length ? (
<dl style={{ margin: 0, display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '3px 10px' }}>
{proposal.details.map((detail) => (
<Fragment key={detail.label}>
<dt style={{ fontSize: 11.5, color: 'var(--color-ink-3)' }}>{detail.label}</dt>
<dd style={{ margin: 0, fontSize: 11.5, color: 'var(--color-ink-1)' }}>
{detail.value}
</dd>
</Fragment>
))}
</dl>
) : null}
{proposal.warning ? (
<p
style={{
margin: 0,
fontSize: 11.5,
lineHeight: 1.5,
color: 'var(--color-warning, #b7860b)',
}}
>
{proposal.warning}
</p>
) : null}
{decision === undefined ? (
<div style={{ display: 'flex', gap: 8 }}>
<button
type="button"
onClick={() => onDecide(true)}
style={{
flex: 1,
padding: '7px 10px',
borderRadius: 8,
border: 0,
background: 'var(--color-brand)',
color: '#fff',
fontSize: 12.5,
fontWeight: 600,
cursor: 'pointer',
}}
>
Approve
</button>
{/* "Not now", not "Reject". Nothing is refused and nothing is
recorded — the card is simply left alone, and it expires unused. */}
<button
type="button"
onClick={() => onDecide(false)}
style={{
padding: '7px 12px',
borderRadius: 8,
border: '1px solid var(--color-line)',
background: 'transparent',
color: 'var(--color-ink-2)',
fontSize: 12.5,
cursor: 'pointer',
}}
>
Not now
</button>
</div>
) : (
<p style={{ margin: 0, fontSize: 11.5, lineHeight: 1.5, color: outcomeColour }}>
{busy
? 'Making the change…'
: decision.state === 'dismissed'
? 'Left alone. Nothing was changed.'
: (decision.message ?? (decision.state === 'done' ? 'Done.' : 'That did not go through.'))}
</p>
)}
</div>
);
}
/**
* One question and its answer.
*
* The answer shows what it ran, not just what it concluded. Buddy states things
* with the confidence of a sentence, and the only honest way to present that is
* beside the tools it used and a link to the page holding the same rows — so a
* person can disagree with it.
*/
function Exchange({
entry,
isLast,
isAsking,
onDecide,
}: {
entry: Exchange;
isLast: boolean;
isAsking: boolean;
onDecide: (approve: boolean) => void;
}) {
const waiting = isLast && isAsking && !entry.answer && !entry.error;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<p
style={{
margin: 0,
alignSelf: 'flex-end',
maxWidth: '85%',
padding: '7px 11px',
borderRadius: '14px 14px 4px 14px',
background: 'var(--color-brand-tint, rgba(102,37,130,.08))',
color: 'var(--color-ink-1)',
fontSize: 13,
lineHeight: 1.55,
}}
>
{entry.question}
</p>
{waiting ? (
<p style={{ margin: 0, fontSize: 12.5, color: 'var(--color-ink-3)' }}>Looking…</p>
) : null}
{entry.error ? (
<p style={{ margin: 0, fontSize: 12.5, lineHeight: 1.55, color: 'var(--color-error, #d64545)' }}>
{entry.error}
</p>
) : null}
{entry.answer ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<p
style={{
margin: 0,
fontSize: 13,
lineHeight: 1.6,
color: 'var(--color-ink-1)',
whiteSpace: 'pre-wrap',
}}
>
{entry.answer.reply}
</p>
{/* Said plainly, not as a subtle grey hint. A partial answer that
looks complete is the failure this flag exists to prevent. */}
{entry.answer.incomplete ? (
<p style={{ margin: 0, fontSize: 11.5, lineHeight: 1.5, color: 'var(--color-warning, #b7860b)' }}>
This answer is partial — Buddy ran out of room before finishing.
</p>
) : null}
{entry.answer.used?.length ? (
<p style={{ margin: 0, fontSize: 11, lineHeight: 1.5, color: 'var(--color-ink-4)' }}>
{entry.answer.used
.map((step) =>
step.outcome === 'ok'
? `${step.tool}${typeof step.rows === 'number' ? ` · ${step.rows}` : ''}`
: `${step.tool} · refused`,
)
.join(' ')}
</p>
) : null}
{entry.answer.awaiting ? (
<ApprovalCard
proposal={entry.answer.awaiting}
decision={entry.decision}
onDecide={onDecide}
/>
) : null}
{entry.answer.sources?.length ? (
<p style={{ margin: 0, fontSize: 11.5, lineHeight: 1.5 }}>
{entry.answer.sources.map((source) => (
<a
key={source}
href={source}
style={{ color: 'var(--color-brand)', textDecoration: 'none', marginRight: 10 }}
>
See the rows
</a>
))}
</p>
) : null}
</div>
) : null}
</div>
);
}
/**
* Typed against `CONTEXT`'s own shape so the two cannot drift.
*
* Without this the fallback has no `agent` key at all, and `routeContext` —
* which is one or the other — loses the field entirely. A page that fell
* through to the fallback would then be a compile error rather than simply a
* page with no assistant, which is what it is.
*/
/**
* Nearle Buddy, as a layout column.
*
* Not an overlay and not a floating bubble: the panel is part of the page on
* every supported route, so the page reflows beside it rather than being
* covered. It opens by stating the most useful thing about the page it is next
* to, says what it read, and offers prompts scoped to that page.
*
* 380px by default, sticky under the 56px header, and its own height is the
* viewport minus 104px so it never pushes the page taller than the screen.
*/
export function AssistantPanel({
onClose,
isStacked = false,
}: {
onClose: () => void;
/** Below md the panel sits under the page rather than beside it. */
isStacked?: boolean;
}) {
const { pathname } = useLocation();
const [draft, setDraft] = useState('');
const [isFocused, setIsFocused] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const threadRef = useRef<HTMLDivElement>(null);
const { width, setWidth, reset } = useAssistantWidth();
const [isDragging, setDragging] = useState(false);
const { key, context: routeContext } = matchAssistantRoute(pathname);
/**
* The heading follows the branch in view.
*
* A panel headed "Across your branches" sitting above a board showing one
* shop is a promise about scope that the page has already broken. Only the
* title moves; everything else about Buddy is untouched.
*/
const scopeLabel = useAssistantScope();
const context = scopeLabel ? { ...routeContext, title: scopeLabel } : routeContext;
/*
* Whether Buddy can answer here at all, and why not when it cannot.
*
* Two separate reasons, kept apart because they are two different facts and
* the person can act on one of them. `available === false` means this
* deployment has no model configured — nothing to be done from the browser.
* `agent === undefined` means this page has no assistant yet, which is about
* the page and not the deployment.
*
* `null` is "not asked yet": the composer stays disabled during the check, so
* it is never briefly live against a server that turns out to have no model.
*/
const [available, setAvailable] = useState<boolean | null>(null);
const [thread, setThread] = useState<Exchange[]>([]);
const [isAsking, setAsking] = useState(false);
const agent = routeContext.agent;
const canAsk = available === true && Boolean(agent) && !isAsking;
useEffect(() => {
let live = true;
void assistantAvailable().then((ok) => {
if (live) setAvailable(ok);
});
// Cancelled on unmount so a slow answer cannot set state on a closed panel.
return () => {
live = false;
};
}, []);
/*
* The thread is per-page and deliberately not persisted.
*
* An answer about Sales sitting above the Inventory page is worse than no
* answer: it reads as being about what is on screen. Clearing on navigation
* costs a person their history, which is the smaller loss.
*/
useEffect(() => {
setThread([]);
}, [key]);
/*
* Follow the conversation down, unless the person has scrolled away from it.
*
* A chat that does not follow leaves the newest answer below the fold, which
* reads as nothing having happened. One that follows unconditionally yanks
* somebody out of an earlier answer they were still reading the moment a
* reply lands — and a reply can land a while after the question, because a
* tool call and a model round trip are seconds, not milliseconds.
*
* So: only when they were already at the bottom. The 40px allowance covers
* fractional scroll heights, which browsers disagree about by a pixel or two
* at non-integer zoom levels — without it this silently stops following for
* anybody not at 100%.
*/
useEffect(() => {
const el = threadRef.current;
if (!el) return;
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
if (distanceFromBottom > 40) return;
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
}, [thread]);
/*
* Approving is its own call, with no question in it.
*
* Indexed rather than acting on the last exchange: a person can scroll up and
* approve an earlier card, and nothing about a card ties it to being the most
* recent thing said.
*/
const decide = async (index: number, approve: boolean) => {
const entry = thread[index];
const card = entry?.answer?.awaiting?.card;
if (!card || !agent || entry.decision) return;
if (!approve) {
// Dismissing is purely local: the server was never told, because there is
// nothing to undo. The card simply expires unused.
setThread((current) =>
current.map((e, i) => (i === index ? { ...e, decision: { state: 'dismissed' as const } } : e)),
);
return;
}
setThread((current) =>
current.map((e, i) => (i === index ? { ...e, decision: { state: 'approving' as const } } : e)),
);
try {
const done = await approveAssistant(agent, card);
setThread((current) =>
current.map((e, i) =>
i === index ? { ...e, decision: { state: 'done' as const, message: done.reply } } : e,
),
);
} catch (error) {
// "Somebody already approved that" arrives here, and it is an answer
// rather than a fault — shown on the card, which stays decided so the
// button cannot be pressed again into the same refusal.
setThread((current) =>
current.map((e, i) =>
i === index ? { ...e, decision: { state: 'failed' as const, message: errorMessage(error) } } : e,
),
);
}
};
const send = async (text: string) => {
const question = text.trim();
if (!question || !agent || !canAsk) return;
setDraft('');
setAsking(true);
setThread((current) => [...current, { question }]);
try {
const answer = await askAssistant(agent, question);
setThread((current) => replaceLast(current, (entry) => ({ ...entry, answer })));
} catch (error) {
// Shown in the thread rather than as a toast: the question is still on
// screen, and the failure belongs next to it.
setThread((current) =>
replaceLast(current, (entry) => ({ ...entry, error: errorMessage(error) })),
);
} finally {
setAsking(false);
}
};
/**
* Three widths, in priority order: stacked (the phone layout owns it),
* expanded (the one-click half-screen), then whatever the operator dragged
* it to. The inline width beats the media queries in `index.css`, which is
* the point — those are the default, not the rule.
*
* Height is deliberately NOT set here. It was, briefly — measured off the
* page so a short page got a short panel — but that put a strip of page
* background under the panel on every screen that was not full. The column
* is full height, always, and `.assistant` owns that.
*/
const sizeStyle: React.CSSProperties = isStacked
? {}
: isExpanded
? { width: expandedWidth(window.innerWidth), maxWidth: 'none' }
: { width, maxWidth: 'none' };
return (
/* No `position` here. `.assistant` is `position: sticky` from md up, and an
inline `relative` silently beat it — which is what made the panel scroll
away with the page. Sticky is itself a containing block, so the resize
handle's `absolute` still anchors to it. */
<aside aria-label="Nearle Buddy" className="assistant" style={sizeStyle}>
{isStacked ? null : (
<ResizeHandle
width={width}
isDragging={isDragging}
onDragStart={() => {
setDragging(true);
// Dragging is a deliberate size, so it leaves the half-screen
// preset rather than fighting it.
setIsExpanded(false);
}}
onDrag={(next) => setWidth(next, { persist: false })}
onDragEnd={(next) => {
setDragging(false);
setWidth(next);
}}
onNudge={(delta) => {
setIsExpanded(false);
setWidth(width + delta);
}}
onReset={reset}
/>
)}
<div
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
gap: 16,
padding: 16,
borderLeft: '1px solid var(--color-line)',
background: 'rgba(255,255,255,.72)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
boxShadow: '-4px 0 16px -2px rgb(16 24 32 / .06)',
}}
>
{/* Header: mark, name, and the current page as the subtitle. */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div
style={{
width: 32,
height: 32,
borderRadius: '50%',
background: 'var(--color-surface)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
overflow: 'hidden',
flex: 'none',
}}
>
<img src="/icon-192.png" alt="Nearle logo" style={{ width: 24, height: 24, objectFit: 'contain', borderRadius: '50%' }} />
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--color-ink-1)' }}>
Nearle Buddy
</div>
<div style={{ fontSize: 12, color: 'var(--color-ink-4)' }}>{context.page}</div>
</div>
{isStacked ? null : (
<>
<PanelIcon label="History">
<History size={14} />
</PanelIcon>
<PanelIcon label={isExpanded ? "Collapse" : "Expand"} onClick={() => setIsExpanded(!isExpanded)}>
{isExpanded ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</PanelIcon>
<PanelIcon label="Close Nearle Buddy" onClick={onClose}>
<PanelRightClose size={14} />
</PanelIcon>
</>
)}
</div>
{/*
The scrolling part, and the only one.
Everything above this is chrome that stays put; everything below it is
the composer, which must never leave the screen. Before this existed
the panel was one `height: 100%` column with no overflow anywhere: a
thread longer than the panel pushed the chips and the composer past
the bottom edge, and there was nothing to scroll — the box you type
into simply left the screen and the conversation could not be read
back.
`minHeight: 0` is the load-bearing half. A flex child defaults to
`min-height: auto`, which means it refuses to shrink below its content,
so `flex: 1` alone would let this grow to the height of the whole
conversation and overflow the panel exactly as before — the scrollbar
never appears and nothing looks wrong in the code.
*/}
<div
ref={threadRef}
style={{
flex: 1,
minHeight: 0,
overflowY: 'auto',
overscrollBehavior: 'contain',
display: 'flex',
flexDirection: 'column',
gap: 16,
}}
>
{/* Greeting — top-aligned, because it is the first thing in a
conversation, not a splash screen. */}
<div>
<p
style={{
margin: 0,
fontFamily: 'var(--font-display)',
fontSize: 15,
fontWeight: 600,
lineHeight: 1.35,
color: 'var(--color-ink-1)',
}}
>
{context.title}
</p>
<p
style={{
margin: '4px 0 0',
fontSize: 13,
lineHeight: 1.6,
color: 'var(--color-ink-2)',
}}
>
{context.greeting}
</p>
<p
style={{
margin: '10px 0 0',
paddingTop: 10,
borderTop: '1px solid var(--color-line)',
fontSize: 12,
lineHeight: 1.6,
color: 'var(--color-ink-3)',
}}
>
{context.reading}
</p>
</div>
{/* The conversation. Inside the scroller with the greeting above it, so
a short thread reads straight on from the greeting and a long one
scrolls under it — with the chips and the composer staying put. */}
{thread.length > 0 ? (
<div
role="log"
aria-label="Conversation with Nearle Buddy"
aria-busy={isAsking}
style={{ display: 'flex', flexDirection: 'column', gap: 14, marginTop: 16 }}
>
{thread.map((entry, index) => (
<Exchange
key={index}
entry={entry}
isLast={index === thread.length - 1}
isAsking={isAsking}
onDecide={(approve) => void decide(index, approve)}
/>
))}
</div>
) : null}
</div>
{/* Chips wrap, never scroll — a half-visible button at a scroller's edge
is a bug no amount of fade masking fixes. */}
<div role="group" aria-label="Suggested prompts" style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{context.prompts.map((prompt) => (
<Chip
key={prompt}
label={prompt}
/* A chip sends when Buddy can answer, and only fills the box when it
cannot — so a chip on a page with no assistant still shows what
could be asked rather than doing nothing on click. */
onSelect={() => (canAsk ? void send(prompt) : setDraft(prompt))}
/>
))}
</div>
{/* Live, at last — but only when something can actually answer.
This composer used to accept text and enable a brand-purple send
button the moment you typed, then swallow the submit, because no
assistant endpoint existed. It was disabled rather than left to lie.
It is enabled here exactly when a model is configured AND this page
has an agent, and the placeholder says which of the two is missing
otherwise. The rule it keeps is the old one: never accept a message
nothing will read. */}
<form
onSubmit={(event) => {
event.preventDefault();
void send(draft);
}}
style={{
display: 'flex',
alignItems: 'flex-end',
gap: 8,
padding: '8px 12px',
borderRadius: 20,
background: 'var(--color-surface)',
border: `1px solid ${isFocused ? 'rgba(102,37,130,.5)' : 'var(--color-line)'}`,
boxShadow: isFocused
? '0 8px 28px -8px rgba(102,37,130,.28)'
: '0 4px 16px -2px rgb(16 24 32 / .06)',
transition: 'all .2s cubic-bezier(.16,1,.3,1)',
}}
>
<textarea
ref={textareaRef}
rows={1}
value={draft}
onChange={(event) => {
setDraft(event.target.value);
const el = event.target;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 148)}px`;
}}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onKeyDown={(event) => {
// Enter sends, shift+Enter writes a second line. The composer is
// one line tall most of the time, so requiring the button would
// make every question a two-action job.
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
void send(draft);
}
}}
disabled={!canAsk}
placeholder={composerHint(available, Boolean(agent), isAsking)}
aria-label={`Ask Nearle Buddy — ${composerHint(available, Boolean(agent), isAsking)}`}
style={{
display: 'block',
width: '100%',
resize: 'none',
background: 'transparent',
border: 0,
outline: 'none',
padding: '2px 0',
fontSize: 13,
lineHeight: 1.6,
fontFamily: 'inherit',
color: 'var(--color-ink-1)',
maxHeight: 148,
}}
/>
<button
type="submit"
aria-label="Send message"
disabled={!canAsk || draft.trim() === ''}
style={{
width: 28,
height: 28,
borderRadius: 999,
border: 0,
flex: 'none',
background:
canAsk && draft.trim() !== '' ? 'var(--color-brand)' : 'var(--color-surface-sunken)',
color: canAsk && draft.trim() !== '' ? '#fff' : 'var(--color-ink-4)',
display: 'grid',
placeItems: 'center',
cursor: canAsk && draft.trim() !== '' ? 'pointer' : 'default',
transition: 'background .2s',
}}
>
<ArrowUp size={14} />
</button>
</form>
{composerNote(available, Boolean(agent)) ? (
<p
style={{
margin: 0,
fontSize: 10,
lineHeight: 1.4,
color: 'var(--color-ink-4)',
textAlign: 'center',
}}
>
{composerNote(available, Boolean(agent))}
</p>
) : null}
</div>
</aside>
);
}
/**
* A prompt chip.
*
* `14px` radius is pinned rather than `999px`: 14 is what a fully-rounded
* corner already resolves to on a one-line chip, and pinning it means a chip
* that wraps to two lines keeps the same corner instead of turning into
* something that reads as a card.
*/
function Chip({ label, onSelect }: { label: string; onSelect: () => void }) {
const [isHovered, setIsHovered] = useState(false);
return (
<button
type="button"
onClick={onSelect}
title={label}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
display: 'flex',
alignItems: 'center',
maxWidth: '100%',
borderRadius: 14,
border: `1px solid ${isHovered ? 'rgba(102,37,130,.4)' : 'var(--color-line)'}`,
background: isHovered ? 'var(--color-brand-tint)' : 'var(--color-surface)',
color: isHovered ? 'var(--color-brand)' : 'var(--color-ink-2)',
padding: '4px 10px',
fontSize: 12,
lineHeight: '18px',
fontWeight: 500,
textAlign: 'left',
cursor: 'pointer',
transform: isHovered ? 'translateY(-1px)' : 'none',
boxShadow: isHovered
? '0 2px 10px 0 rgb(16 24 32 / .04)'
: '0 1px 2px 0 rgb(16 24 32 / .03)',
transition: 'all .2s cubic-bezier(.16,1,.3,1)',
}}
>
{label}
</button>
);
}
/**
* The drag handle, on the panel's left edge.
*
* A real `separator` rather than a decorative strip: it takes focus, answers
* the arrow keys in 16px steps, Home and End go to the two limits, and Enter or
* a double-click restores the default. Resizing by mouse only would put the one
* control that fixes a cramped page out of reach of anyone not using one.
*
* The hit area is 10px wide while the line it draws is 2px — a 2px target is a
* target you miss, and the padding costs nothing because it sits in the gap
* between the page and the panel.
*/
function ResizeHandle({
width,
isDragging,
onDragStart,
onDrag,
onDragEnd,
onNudge,
onReset,
}: {
width: number;
isDragging: boolean;
onDragStart: () => void;
onDrag: (next: number) => void;
onDragEnd: (next: number) => void;
onNudge: (delta: number) => void;
onReset: () => void;
}) {
const [isHovered, setHovered] = useState(false);
const latest = useRef(width);
latest.current = width;
function onPointerDown(event: React.PointerEvent<HTMLDivElement>) {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
onDragStart();
// The width is measured from the right edge of the window, so the panel
// tracks the pointer exactly however the page is scrolled.
const move = (moveEvent: PointerEvent) => {
const next = window.innerWidth - moveEvent.clientX;
latest.current = next;
onDrag(next);
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
document.body.style.removeProperty('user-select');
document.body.style.removeProperty('cursor');
onDragEnd(latest.current);
};
// Without these two the drag selects the text it passes over and the
// cursor flickers back to a caret every time it leaves the handle.
document.body.style.setProperty('user-select', 'none');
document.body.style.setProperty('cursor', 'col-resize');
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
}
const isActive = isDragging || isHovered;
return (
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize Nearle Buddy"
aria-valuenow={Math.round(width)}
aria-valuemin={MIN_WIDTH}
aria-valuemax={MAX_WIDTH}
tabIndex={0}
onPointerDown={onPointerDown}
onDoubleClick={onReset}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onKeyDown={(event) => {
if (event.key === 'ArrowLeft') {
event.preventDefault();
onNudge(16);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
onNudge(-16);
} else if (event.key === 'Home') {
event.preventDefault();
onNudge(MAX_WIDTH);
} else if (event.key === 'End') {
event.preventDefault();
onNudge(-MAX_WIDTH);
} else if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onReset();
}
}}
title={`${Math.round(width)}px — drag to resize, double-click for ${DEFAULT_WIDTH}px`}
style={{
position: 'absolute',
top: 0,
left: -5,
width: 10,
height: '100%',
zIndex: 5,
cursor: 'col-resize',
display: 'flex',
justifyContent: 'center',
touchAction: 'none',
}}
>
<span
aria-hidden
style={{
width: 2,
height: '100%',
borderRadius: 2,
background: isActive ? 'var(--color-brand)' : 'transparent',
transition: isDragging ? 'none' : 'background .15s',
}}
/>
</div>
);
}
function PanelIcon({
label,
children,
onClick,
}: {
label: string;
children: React.ReactNode;
onClick?: () => void;
}) {
const [isHovered, setIsHovered] = useState(false);
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
width: 26,
height: 26,
borderRadius: 8,
border: 0,
display: 'grid',
placeItems: 'center',
background: isHovered ? 'var(--color-surface-sunken)' : 'transparent',
color: isHovered ? 'var(--color-ink-1)' : 'var(--color-ink-4)',
cursor: 'pointer',
flex: 'none',
transition: 'background .2s, color .2s',
}}
>
{children}
</button>
);
}