1010 lines
34 KiB
TypeScript
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>
|
|
);
|
|
}
|