fix chatbox

This commit is contained in:
2026-08-06 15:43:46 +05:30
parent e5f8144fb3
commit b23ea760c8
15 changed files with 475 additions and 97 deletions

View File

@@ -1,7 +1,7 @@
'use client';
import {useState} from 'react';
import {useLoginForm} from '@/features/auth/hooks/useLoginForm';
import {LOGIN_ENDPOINT, useLoginForm} from '@/features/auth/hooks/useLoginForm';
import {GoogleIcon, MicrosoftIcon} from './ProviderIcons';
/**
@@ -27,6 +27,7 @@ export function LoginCredentialsForm() {
rememberMe,
errors,
isSubmitting,
next,
setEmail,
setPassword,
setRememberMe,
@@ -34,7 +35,35 @@ export function LoginCredentialsForm() {
} = useLoginForm();
return (
<form onSubmit={submit} noValidate className="space-y-4">
/**
* `method="post"` and `action` are the no-JavaScript safety net, and they
* are load-bearing rather than decorative.
*
* `submit` calls preventDefault, so on a hydrated page these attributes
* are never exercised. But hydration is not instantaneous: a form with
* neither attribute submits GET to its own URL, so a Continue pressed
* before React attached — or on a page whose bundle failed outright —
* navigated to `/login?email=…&password=…`, writing the password into the
* address bar, the history and every log along the way.
*
* With these set, that same early submit is an ordinary POST with the
* credentials in the body, and the endpoint answers it with a redirect.
* Sign-in therefore works with JavaScript disabled entirely.
*/
<form
onSubmit={submit}
method="post"
action={LOGIN_ENDPOINT}
noValidate
className="space-y-4"
>
{/*
The deep link the proxy preserved, carried through the native path —
the hydrated path reads it from the URL instead. Without it, a no-JS
sign-in from /login?next=/settings/billing would land on the dashboard.
*/}
{next && <input type="hidden" name="next" value={next} />}
<div>
<label
htmlFor="login-email"

View File

@@ -4,9 +4,20 @@ import {useCallback, useState} from 'react';
import {useRouter, useSearchParams} from 'next/navigation';
import {useToast} from '@astryxdesign/core/Toast';
import {useSession} from '@/features/auth/providers/SessionProvider';
import {
LOGIN_ERROR_PARAM,
loginErrorFromCode,
} from '@/features/auth/services/loginErrorCodes';
import {resolveRedirectTarget} from '@/features/auth/services/redirectTarget';
import type {LoginError} from '@/features/auth/types/auth';
/**
* Where credentials are POSTed. Exported because the form element itself needs
* it for `action` — the no-JavaScript path submits straight here, bypassing
* this hook, authService and the repository entirely.
*/
export const LOGIN_ENDPOINT = '/api/auth/login';
/**
* Every behaviour of the sign-in form, with no markup attached.
*
@@ -39,12 +50,30 @@ export function useLoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [rememberMe, setRememberMe] = useState(false);
const [errors, setErrors] = useState<LoginFormState['errors']>({});
/**
* Seeded from `?error=` so a sign-in that failed on the no-JavaScript path
* still shows its message once the page hydrates. The code is looked up in a
* fixed table — an unrecognised one yields nothing, so the URL cannot be
* used to write arbitrary text onto the sign-in screen.
*
* Seeding in useState rather than an effect keeps the message present in the
* first render, server and client alike, so there is no flash and no
* hydration mismatch to patch up.
*/
const [errors, setErrors] = useState<LoginFormState['errors']>(() => {
const seeded = loginErrorFromCode(searchParams.get(LOGIN_ERROR_PARAM));
return seeded ? {[seeded.field]: seeded.message} : {};
});
const [isSubmitting, setIsSubmitting] = useState(false);
// Raw, for the hidden field the native form path posts back. Unvalidated
// here on purpose: the server validates it through resolveRedirectTarget,
// which is the only side that can be trusted to.
const next = searchParams.get('next');
// Shared with GuestGuard, which redirects on the same event — see
// resolveRedirectTarget for why that matters and how `next` is validated.
const destination = resolveRedirectTarget(searchParams.get('next'));
const destination = resolveRedirectTarget(next);
const submit = useCallback(
async (event: React.FormEvent) => {
@@ -106,6 +135,7 @@ export function useLoginForm() {
rememberMe,
errors,
isSubmitting,
next,
setEmail: updateEmail,
setPassword: updatePassword,
setRememberMe,

View File

@@ -0,0 +1,37 @@
import type {LoginError} from '@/features/auth/types/auth';
/**
* The no-JavaScript sign-in path's error vocabulary.
*
* When the form posts natively (see LoginCredentialsForm), the server cannot
* hand the failure back in a JSON body — it has to redirect, and the only
* channel a redirect has is the URL. So failures travel as a short CODE that
* is looked up here, never as the message itself.
*
* That indirection is the point. A `?message=` parameter is attacker-authored
* text rendered inside the sign-in screen: enough to forge "Your account was
* suspended — call this number." A code that is not in this table renders
* nothing at all, so the worst an attacker can put on the page is silence.
*
* Note what is absent: the email, the password, and any echo of user input.
* The redirect carries the fact of a failure and nothing else.
*/
export const LOGIN_ERROR_PARAM = 'error';
/** Code → the field it belongs to and what the user reads. */
const LOGIN_ERRORS: Record<string, LoginError> = {
email_required: {field: 'email', message: 'Invalid email or password.'},
email_invalid: {field: 'email', message: 'Invalid email or password.'},
password_required: {field: 'password', message: 'Invalid email or password.'},
invalid_credentials: {field: 'form', message: 'Invalid email or password.'},
malformed: {field: 'form', message: 'Sign-in failed. Please try again.'},
};
export type LoginErrorCode = keyof typeof LOGIN_ERRORS;
/** Unknown or absent codes resolve to null — render nothing, never guess. */
export function loginErrorFromCode(code: string | null): LoginError | null {
if (!code) return null;
return LOGIN_ERRORS[code] ?? null;
}

View File

@@ -43,7 +43,42 @@ export function MessageBubble({message}: {message: ChatMessage}) {
) : undefined
}
>
<ChatMessageBubble variant={isAssistant ? 'ghost' : 'filled'}>
<ChatMessageBubble
variant={isAssistant ? 'ghost' : 'filled'}
/**
* The assistant bubble must have a DEFINITE width. This is the fix for
* report titles rendering one character per line.
*
* ChatMessage lays its row out with `display:flex; align-items:
* flex-start`, so the bubble is a flex item on the main axis and sizes
* shrink-to-fit — its width is asked of its contents. ResponseRenderer
* then sets `container-type: inline-size` to drive the report's
* container queries, and inline-axis size containment means the report
* contributes ZERO to that intrinsic measurement. The bubble asked how
* wide its content wanted to be, containment answered "nothing", and
* the bubble collapsed to its min-content width.
*
* Astryx's own `word-break: break-word` on the bubble is what made the
* collapse land on a single GLYPH rather than a single word: that
* value is defined as `word-break: normal` plus `overflow-wrap:
* anywhere`, and `anywhere` is the one wrapping mode that lets a break
* opportunity count against min-content size. Min-content therefore
* became the widest character — "S / t / o / r / e".
*
* Stating the width breaks the cycle: the bubble no longer measures
* its contents, so containment has nothing to poison.
*
* `max-w-full` is deliberate too. `styles.content` applies
* `max-width: max(80%, 280px)` to EVERY bubble including `ghost`,
* so an assistant report in the 440px rail was being capped around
* 302px — despite this component's contract that ghost turns are
* full-width. Tailwind's utilities layer is declared after
* `astryx-base` in globals.css, so these win over StyleX's
* `:not(#\#)` specificity hack by cascade layer rather than by
* out-specifying it.
*/
className={isAssistant ? 'w-full max-w-full min-w-0' : undefined}
>
{isThinking ? (
<TypingIndicator />
) : (

View File

@@ -14,35 +14,38 @@ export function ChartBlock({charts}: {charts: ChartPartSpec[]}) {
{charts.map((c, idx) => (
<div
key={c.title || idx}
className="rounded-xl border border-border bg-card p-4 shadow-sm space-y-3"
className="rounded-xl border border-border bg-card p-4 shadow-sm space-y-3 overflow-hidden"
>
<div>
<Heading level={4} className="text-sm font-semibold text-primary">
<Heading level={4} className="text-base font-bold text-white">
{c.title}
</Heading>
{c.subtitle ? (
<p className="text-xs text-secondary">{c.subtitle}</p>
<p className="text-sm font-medium text-zinc-300 mt-0.5">{c.subtitle}</p>
) : null}
</div>
<div className="h-48 sm:h-56 w-full pt-2">
<div className="h-56 @sm/report:h-64 w-full max-w-full pt-2">
{c.chartType === 'area' ? (
<AreaChartView
data={c.data}
xKey={c.xKey}
series={c.series}
height="100%"
/>
) : c.chartType === 'bar' ? (
<BarChartView
data={c.data}
xKey={c.xKey}
series={c.series}
height="100%"
/>
) : (
<LineChartView
data={c.data}
xKey={c.xKey}
series={c.series}
height="100%"
/>
)}
</div>

View File

@@ -48,16 +48,22 @@ export function InsightCardsBlock({insights}: {insights: ReportInsight[]}) {
key={insight.id || insight.title}
className={`rounded-xl border p-4 shadow-sm space-y-2 transition-colors ${meta.borderClass}`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="text-base">{meta.icon}</span>
<span className="text-sm font-semibold text-primary">
{/*
`flex-wrap` so the label and the badges drop to their own lines
rather than compressing each other. Without it, a narrow panel
set the title and "87% confidence" against a Badge that will not
shrink, and the title absorbed the entire deficit.
*/}
<div className="flex flex-wrap items-start justify-between gap-x-2 gap-y-1.5">
<div className="flex items-start gap-2 min-w-0">
<span className="text-lg shrink-0">{meta.icon}</span>
<span className="text-base font-bold text-white whitespace-normal break-words min-w-0">
{insight.title}
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 shrink-0">
{insight.confidence ? (
<span className="text-xs text-secondary font-mono">
<span className="text-sm font-semibold text-zinc-300 font-mono tabular-nums whitespace-nowrap">
{insight.confidence}% confidence
</span>
) : null}
@@ -65,14 +71,16 @@ export function InsightCardsBlock({insights}: {insights: ReportInsight[]}) {
</div>
</div>
<p className="text-xs text-secondary leading-relaxed">
<p className="text-sm font-medium text-zinc-200 leading-relaxed whitespace-normal break-words">
{insight.explanation}
</p>
{insight.recommendedAction ? (
<div className="pt-1.5 flex items-center gap-2 text-xs text-primary font-medium">
<span className="text-secondary font-normal">Action:</span>
<span>{insight.recommendedAction}</span>
<div className="pt-2 flex flex-wrap items-baseline gap-x-2 gap-y-1 text-sm font-semibold">
<span className="text-amber-300 font-bold shrink-0">Action:</span>
<span className="text-white whitespace-normal break-words min-w-0">
{insight.recommendedAction}
</span>
</div>
) : null}
</div>

View File

@@ -38,7 +38,26 @@ export function KpiGridBlock({kpis}: {kpis: ReportKpi[]}) {
if (!kpis || kpis.length === 0) return null;
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 w-full">
/**
* Intrinsic sizing, not a column count.
*
* This was `grid-cols-2 sm:grid-cols-3`, and `sm:` is a VIEWPORT query —
* always true on desktop — so three columns were forced into a 440px
* panel and each card landed at ~117px. The grid now states the only
* thing that is actually true of a KPI card (it needs ~180px to be
* legible) and lets the browser fit as many as the panel allows: two in
* the default rail, five or six when it is dragged wide. Cards reflow to
* the next row instead of shrinking past readability.
*
* `min(180px, 100%)` rather than a bare `180px` is what keeps this from
* trading one overflow for another: at a container narrower than the
* track minimum — the mobile slide-over, or a panel dragged to its 420px
* floor with deep padding — a rigid 180px track would push the grid wider
* than its parent and reintroduce horizontal scrolling. The `min()` lets
* the track collapse to the container in exactly that case and behave as
* a fixed minimum everywhere else.
*/
<div className="grid w-full max-w-full gap-3 grid-cols-[repeat(auto-fit,minmax(min(180px,100%),1fr))]">
{kpis.map((kpi) => {
const isUp = kpi.trendDirection === 'up' || (kpi.trend && kpi.trend.startsWith('+'));
const isDown = kpi.trendDirection === 'down' || (kpi.trend && (kpi.trend.startsWith('-') || kpi.trend.startsWith('−')));
@@ -46,25 +65,47 @@ export function KpiGridBlock({kpis}: {kpis: ReportKpi[]}) {
return (
<div
key={kpi.id || kpi.title}
className="rounded-xl border border-border bg-card p-3.5 flex flex-col justify-between shadow-sm hover:border-border-strong transition-colors"
// `min-w-0` so the card may size to its grid track. A grid item
// defaults to `min-width: auto` — its min-CONTENT width — which
// means one long unbroken value silently widens the whole track
// and pushes the row past the panel.
className="rounded-xl border border-border bg-card p-3.5 flex flex-col justify-between gap-1 min-w-0 shadow-sm hover:border-border-strong transition-colors"
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-medium text-secondary truncate">
<div className="flex items-start justify-between gap-2">
{/*
`truncate` used to hide the consequence of the squeeze rather
than fix it — a card too narrow for "Indiranagar footfall"
simply clipped it to "Indiran…". With a real minimum width the
label has room, and on the rare long one it wraps by WORD:
`break-words` sets overflow-wrap without touching word-break,
so a long token can still break as a last resort but ordinary
prose never breaks mid-word.
*/}
<span className="text-xs font-semibold text-zinc-300 whitespace-normal break-words min-w-0 flex-1">
{kpi.title}
</span>
{kpi.iconName ? (
<Icon icon={kpi.iconName as any} size="xsm" color="secondary" />
<span className="shrink-0">
<Icon icon={kpi.iconName as any} size="xsm" color="secondary" />
</span>
) : null}
</div>
<div className="mt-2 flex items-baseline justify-between gap-2">
<span className="text-lg sm:text-xl font-semibold tracking-tight text-primary">
<div className="mt-2 flex flex-wrap items-baseline justify-between gap-x-2 gap-y-1 min-w-0 w-full">
{/*
`tabular-nums` so a streaming figure does not jitter as digits
land, and the size step is a CONTAINER query — the panel's
width decides whether there is room for the larger setting,
which is the question `sm:` was answering with the window's.
*/}
<span className="text-xl @xs/report:text-2xl font-bold tracking-tight text-white tabular-nums whitespace-nowrap shrink-0">
{kpi.value}
</span>
{kpi.trend ? (
<span
className={`text-xs font-medium ${
isUp ? 'text-emerald-400' : isDown ? 'text-rose-400' : 'text-secondary'
title={kpi.trend}
className={`text-xs font-bold truncate max-w-full min-w-0 ${
isUp ? 'text-emerald-400' : isDown ? 'text-rose-400' : 'text-zinc-300'
}`}
>
{kpi.trend}

View File

@@ -19,7 +19,46 @@ export function ResponseRenderer({
const textContent = report.summary || report.title || '';
return (
<div className="space-y-4 w-full my-1">
/**
* `@container/report` is the fix for the squeezed-report bug, and it is
* the whole reason the blocks below can be written once.
*
* The report renders inside a resizable panel (440px by default, 420px
* minimum, draggable to 85vw) that sits inside a desktop viewport. Every
* block underneath used to size itself with VIEWPORT breakpoints — `sm:`
* and friends — which on any desktop are unconditionally true. So a report
* in a 440px rail was laid out as though it had 640px+ to work with:
* KpiGridBlock took `sm:grid-cols-3` and split ~376px of usable width into
* three ~117px cards, of which padding ate 28px. A figure like
* "₹12,45,600" cannot set in 89px, so it wrapped down the card.
*
* A container query asks the right question. `width: 100%` was never the
* problem — the blocks always filled the panel; they were choosing their
* COLUMN COUNT from the window instead of from the space they were given.
* Named (`/report`) rather than anonymous so a future nested container —
* a two-up comparison, say — cannot silently retarget these variants.
*/
/*
* The wrapping trio overrides Astryx's `word-break: break-word` on the
* chat bubble (that value means `word-break: normal` + `overflow-wrap:
* anywhere`, and `anywhere` lets break opportunities shrink an element's
* min-content width down to a single glyph). Restating it as `normal` +
* `break-word` keeps the useful half — one unbreakable token, a long ID
* or URL, still breaks rather than overflowing — while making min-content
* the longest WORD. Both properties inherit, so this covers every title,
* cell and label below.
*
* What it does NOT do is protect against a collapsed ancestor, and that
* is worth stating because it is tempting to assume otherwise. Measured
* in a headless replica of this exact chain: with the bubble left
* shrink-to-fit, adding `word-break: normal` here changed nothing — the
* title still stacked one character per line, because inline-axis size
* containment makes the report contribute ZERO to the intrinsic width no
* matter how its text wraps. Only the definite width on the bubble (see
* MessageBubble) prevents that. This declaration guards the different
* case of a merely NARROW container, not a collapsed one.
*/
<div className="@container/report space-y-4 w-full max-w-full min-w-0 my-1 whitespace-normal [word-break:normal] [overflow-wrap:break-word]">
{/* 1. Summary / Title */}
{report.summary ? (
<SummaryCard

View File

@@ -13,7 +13,7 @@ export function SummaryCard({
isStreaming?: boolean;
}) {
return (
<div className="rounded-xl border border-border bg-card p-4 sm:p-5 shadow-sm space-y-3">
<div className="rounded-xl border border-border bg-card p-4 @sm/report:p-5 shadow-sm space-y-3 w-full max-w-full">
{title ? (
<Heading level={3} className="text-base font-semibold text-primary">
{title}

View File

@@ -16,20 +16,33 @@ export function TableBlock({tables}: {tables: TablePartSpec[]}) {
>
{t.title ? (
<div className="p-4 border-b border-border bg-card">
<Heading level={4} className="text-sm font-semibold text-primary">
<Heading level={4} className="text-base font-bold text-white whitespace-normal break-words">
{t.title}
</Heading>
</div>
) : null}
<div className="overflow-x-auto w-full">
<table className="w-full text-left text-xs border-collapse">
{/*
The scroll container is what lets the table below refuse to be
crushed. `w-full` alone made the table a prisoner of the panel: at
five columns in ~376px the browser had to honour width:100%, so it
drove every column to its min-content width and long labels came
apart. Pairing a readable floor (`min-w-[34rem]`) with horizontal
scroll means a narrow panel scrolls a legible table instead of
displaying an illegible one — and a wide panel never scrolls,
because `w-full` still stretches it past the floor.
*/}
<div className="overflow-x-auto w-full max-w-full">
<table
data-ai-report
className="w-full min-w-full table-auto text-left text-sm border-collapse"
>
<thead>
<tr className="border-b border-border bg-muted/40 text-secondary font-medium">
<tr className="border-b border-zinc-700 bg-zinc-800/80 text-white font-bold">
{t.columns.map((col) => (
<th
key={col.key}
className={`px-4 py-3 ${
className={`px-3.5 py-3.5 whitespace-nowrap text-sm font-bold text-white ${
col.align === 'end'
? 'text-right'
: col.align === 'center'
@@ -42,9 +55,9 @@ export function TableBlock({tables}: {tables: TablePartSpec[]}) {
))}
</tr>
</thead>
<tbody className="divide-y divide-border/60">
<tbody className="divide-y divide-zinc-800/80">
{t.rows.map((row, rIdx) => (
<tr key={rIdx} className="hover:bg-white/[0.02] transition-colors">
<tr key={rIdx} className="hover:bg-white/[0.04] transition-colors">
{t.columns.map((col) => {
const val = row[col.key];
const valStr = String(val ?? '');
@@ -53,12 +66,12 @@ export function TableBlock({tables}: {tables: TablePartSpec[]}) {
return (
<td
key={col.key}
className={`px-4 py-3 text-primary ${
className={`px-3.5 py-3.5 text-white font-semibold align-middle text-sm ${
col.align === 'end'
? 'text-right'
? 'text-right tabular-nums whitespace-nowrap'
: col.align === 'center'
? 'text-center'
: 'text-left'
: 'text-left whitespace-normal break-words'
}`}
>
{isStatus ? (

View File

@@ -25,10 +25,10 @@ export function buildStoresTemplate(entities: ExtractedEntities): ReportPart {
{key: 'status', header: 'Status', align: 'center'},
],
rows: [
{store: 'Indiranagar Flagship', visitors: '12,400', revenue: '₹9,10,000', conversion: '23.8%', status: 'active'},
{store: 'Koramangala 80ft', visitors: '7,800', revenue: '₹5,20,000', conversion: '21.4%', status: 'active'},
{store: 'Jayanagar 4th Block', visitors: '6,100', revenue: '₹4,00,000', conversion: '20.9%', status: 'active'},
{store: 'Whitefield Main', visitors: '4,200', revenue: '₹3,30,000', conversion: '18.2%', status: 'warning'},
{store: 'Indiranagar Flagship', visitors: '12,400', revenue: '₹9.1L', conversion: '23.8%', status: 'active'},
{store: 'Koramangala 80ft', visitors: '7,800', revenue: '₹5.2L', conversion: '21.4%', status: 'active'},
{store: 'Jayanagar 4th Block', visitors: '6,100', revenue: '₹4.0L', conversion: '20.9%', status: 'active'},
{store: 'Whitefield Main', visitors: '4,200', revenue: '₹3.3L', conversion: '18.2%', status: 'warning'},
],
},
],