refactor(ts-migration): Phase 11 batch 8 — the assistant panel components

The thirteen `.jsx` files under `src/components/ai-assistant/`, which
completes that directory: 24/24 files migrated and all 24 linted under
their new extensions.

39 errors, and converting 21 `/** @param {any} props */` hatches to
`: any` cleared 33 of them — the same pattern as batch 6, and the same
reason: without the JSDoc, TypeScript infers every destructured prop as
required, so `ResponseBlocks` and `AssistantMessage` alone produced 31
complaints about call sites that were always correct.

The remaining six were already there before this batch and are
unchanged.

13/13 erase byte-identically. `.jsx` -> `.tsx` is the move that can drop
unused imports through esbuild's loader difference; it did not here, and
the bundle hash is unchanged.

Measured against `3f835ee`:

  typecheck   6 errors, unchanged; no new error anywhere
  lint        exit 0, 0 errors, 289 warnings, 24/24 linted by name
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash 74d17e2d…
  type erasure  96/96 byte-identical across Phase 11 so far

No baseline artifact touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
2026-09-18 15:37:09 +05:30
parent 3f835eee93
commit 7a95d95151
13 changed files with 21 additions and 26 deletions

View File

@@ -0,0 +1,127 @@
import * as React from 'react';
import { ThumbsDown, ThumbsUp } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ResponseDocument } from './ResponseBlocks';
import OwliverAvatar from '@/components/krow/OwliverAvatar';
/**
* The thinking state, shown between sending and the first block arriving.
*
* Labelled as well as animated: three moving dots mean nothing to a screen
* reader, and the visually-hidden text is what tells one that a reply is coming.
*/
export function ThinkingIndicator() {
return (
<div className="flex items-center gap-2">
<OwliverAvatar className="h-6 w-6" rounded="rounded-full" />
<span className="inline-flex items-center gap-1 rounded-xl rounded-tl-sm border border-border bg-surface px-2.5 py-2 shadow-xs">
{[0, 1, 2].map((i) => (
<span
key={i}
className="h-1.5 w-1.5 animate-typing rounded-full bg-ink-4"
style={{ animationDelay: `${i * 0.18}s` }}
/>
))}
</span>
<span className="sr-only">Owliver is preparing a response</span>
</div>
);
}
/**
* One turn.
*
* The user's words sit in a filled bubble; the assistant's reply runs the full
* width of the panel. That asymmetry is deliberate — these replies are reports
* with tables and KPI tiles, and a chat bubble would waste a third of a 380px
* column on padding around them.
*/
/**
* How this conversation was rated.
*
* Offered on the latest answer only, because a rating belongs to the
* conversation rather than to a paragraph of it — thumbs on every turn would
* ask a reader to score an answer out of the exchange that produced it, and
* each one would set the same value anyway.
*
* Hidden until the turn is hovered or focused, the way History's delete control
* already is: a thread of reports should not carry two permanent buttons under
* every answer.
*/
function FeedbackControls({ feedback, onFeedback }) {
const rate = (rating) => onFeedback(feedback?.rating === rating ? null : rating);
return (
<div
className={cn(
'mt-2 flex items-center gap-0.5 transition-opacity',
/* Stays visible once rated, so a reader can see and change their own
answer rather than having to hunt for it again. */
feedback ? 'opacity-100' : 'opacity-0 group-hover/turn:opacity-100 group-focus-within/turn:opacity-100'
)}
>
{[
{ rating: 'up', Icon: ThumbsUp, label: 'This answer was useful' },
{ rating: 'down', Icon: ThumbsDown, label: 'This answer was not useful' },
].map(({ rating, Icon, label }) => (
<button
key={rating}
type="button"
onClick={() => rate(rating)}
aria-label={label}
aria-pressed={feedback?.rating === rating}
className={cn(
`rounded p-1 transition-colors focus-visible:outline-none
focus-visible:ring-2 focus-visible:ring-krow-blue/50`,
feedback?.rating === rating
? 'text-krow-blue'
: 'text-ink-4 hover:text-ink-2'
)}
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
</button>
))}
</div>
);
}
export const Message = React.memo(
({ role, text, blocks, streaming, stopped, onPrompt, onConfirm = null,
feedback = null, onFeedback = null }: any) => {
if (role === 'user') {
return (
<div className="flex justify-end">
<div className="max-w-[85%] animate-slide-up rounded-xl rounded-br-sm bg-krow-blue px-3 py-2 text-body-sm text-white">
{text}
</div>
</div>
);
}
return (
<div className="group/turn animate-slide-up">
<div className="mb-2 flex items-center gap-1.5">
<OwliverAvatar className="h-5 w-5" rounded="rounded-full" />
<span className="text-[10px] font-semibold uppercase tracking-wide text-ink-4">Owliver</span>
</div>
<ResponseDocument blocks={blocks} streaming={streaming} onPrompt={onPrompt} onConfirm={onConfirm} />
{stopped && <p className="mt-2 text-caption italic text-ink-4">Stopped early.</p>}
{/* Only where a handler was passed — a turn nobody is collecting feedback
for renders exactly as it did before this existed. */}
{onFeedback && !streaming && (
<FeedbackControls feedback={feedback} onFeedback={onFeedback} />
)}
</div>
);
});
Message.displayName = 'Message';
/** A subtle divider between turns, so a long thread stays readable. */
export function TurnDivider() {
return <div className="h-px bg-border/60" role="presentation" />;
}
export { cn };