Merge pull request #74746 from NousResearch/bb/chat-widget-chrome

Inline chat widgets share one shell
This commit is contained in:
brooklyn! 2026-07-30 04:54:33 -05:00 committed by GitHub
commit 1ee75fc473
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 91 additions and 49 deletions

View file

@ -91,10 +91,11 @@ for call-site shadow or border inventions.
| Token | Use |
| --- | --- |
| `--ui-stroke-primary…quaternary` | hairlines, in descending strength |
| `--ui-stroke-tertiary` | the default in-panel divider / list hairline |
| `--ui-stroke-tertiary` | the default in-panel divider / list hairline — and every bordered surface in the transcript |
| `--stroke-nous` | the overlay hairline (pairs with `shadow-nous`) |
| `--ui-text-primary / -secondary / -tertiary` | text hierarchy |
| `--ui-bg-quaternary` | soft control fill (secondary button) |
| `--ui-widget-surface-background` | fill for inline chat widgets (`WIDGET_SHELL_CLASS`) |
| `--chrome-action-hover` | hover fill for quiet controls |
| `--theme-primary`, `--ui-accent` | brand/accent |
@ -196,6 +197,14 @@ Notes:
existing components under `src/components/assistant-ui` and
`src/app/chat/composer`; do not fork a second markdown, message, tool-call, or
approval renderer for one feature.
- **Inline widgets** — a tool result that renders as a panel the user reads or
acts on (clarify, artifact card) wears `WIDGET_SHELL_CLASS`
(`src/components/chat/widget-shell.ts`): shared radius, the
`--ui-widget-surface-background` fill, no border. Its actions sit *outside*
the panel, below it. Don't give one widget its own radius or fill.
- Bordered surfaces in the transcript (tables, fences, callouts, attachments)
use `--ui-stroke-tertiary`. Not `border-border` — that's the app-wide
default and reads too hot against the thread.
- A tool result may expose an inline action that opens a preview. It must not
open the rail automatically.
- Install, onboarding, connecting, boot failure, and reauthentication are

View file

@ -5,6 +5,7 @@ import { useEffect, useMemo } from 'react'
import { useSessionView } from '@/app/chat/session-view'
import { CodeCardIcon } from '@/components/chat/code-card'
import { WIDGET_SHELL_CLASS } from '@/components/chat/widget-shell'
import { useI18n } from '@/i18n'
import type { ArtifactDetection } from '@/lib/artifact-detect'
import { codiconForLanguage } from '@/lib/markdown-code'
@ -96,8 +97,9 @@ export function ArtifactCard({ code, detection, streaming = false }: ArtifactCar
return (
<button
className={cn(
'group/artifact my-1.5 flex w-full max-w-md items-center gap-2.5 overflow-hidden rounded-[0.625rem] border border-border px-3 py-2.5 text-left transition-colors',
streaming ? 'cursor-default' : 'cursor-pointer hover:bg-accent/40'
WIDGET_SHELL_CLASS,
'group/artifact my-1.5 flex w-full max-w-md items-center gap-2.5 overflow-hidden text-left',
streaming ? 'cursor-default' : 'cursor-pointer'
)}
data-slot="aui_artifact-card"
disabled={streaming}

View file

@ -16,6 +16,7 @@ import {
import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus'
import { useSessionView } from '@/app/chat/session-view'
import { ToolFallback } from '@/components/assistant-ui/tool/fallback'
import { WIDGET_SHELL_CLASS } from '@/components/chat/widget-shell'
import { Button } from '@/components/ui/button'
import { Kbd } from '@/components/ui/kbd'
import { Textarea } from '@/components/ui/textarea'
@ -93,7 +94,7 @@ const OPTION_ROW_CLASS =
const CLARIFY_TEXTAREA_CLASS = 'field-sizing-content max-h-40 min-h-0 resize-none'
const CLARIFY_SHELL_CLASS =
'my-1.5 rounded-md border border-primary/20 bg-(--ui-chat-surface-background) text-[length:var(--conversation-text-font-size)] text-(--ui-text-primary)'
`${WIDGET_SHELL_CLASS} text-[length:var(--conversation-text-font-size)] text-(--ui-text-primary)`
const CLARIFY_ICON_CLASS = 'mt-px size-4 shrink-0 text-(--ui-text-tertiary)'
@ -237,7 +238,7 @@ function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) {
)
return (
<ClarifyShell className="grid gap-1.5 px-2.5 py-2" data-clarify-settled="">
<ClarifyShell className="my-1.5 grid gap-1.5" data-clarify-settled="">
{question ? (
<ClarifyLine icon={MessageQuestion}>
<span className="whitespace-pre-wrap font-medium leading-(--conversation-line-height)">{question}</span>
@ -523,7 +524,7 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
return (
<ClarifyShell
aria-label={copy.loadingQuestion}
className="grid min-h-12 place-items-center px-2.5 py-3"
className="my-1.5 grid min-h-12 place-items-center"
role="status"
>
<Loader2 aria-hidden className="size-4 animate-spin text-(--ui-text-tertiary)" />
@ -548,13 +549,22 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
// exactly those and lets every other printable through to the composer —
// typing a real message instead of picking an option stays possible. The
// value is the choice count so the check needs no store access.
<ClarifyShell className="grid gap-2 px-2.5 py-2" data-clarify-choices={hasChoices ? choices.length : undefined}>
<div className="flex items-start gap-2">
<span className="flex-1 whitespace-pre-wrap font-medium leading-(--conversation-line-height)">{question}</span>
<MessageQuestion aria-hidden className="mt-px size-4 shrink-0 text-(--ui-text-tertiary)" />
</div>
//
// The form is the outer element so the actions can sit OUTSIDE the card and
// still submit it — the panel holds the question, the buttons ride below it.
<form
className="my-1.5 grid gap-4"
data-clarify-choices={hasChoices ? choices.length : undefined}
onSubmit={handleSubmit}
>
<ClarifyShell className="grid gap-2">
<div className="flex items-start gap-2">
<span className="flex-1 whitespace-pre-wrap font-medium leading-(--conversation-line-height)">
{question}
</span>
<MessageQuestion aria-hidden className="mt-px size-4 shrink-0 text-(--ui-text-tertiary)" />
</div>
<form className="grid gap-2" onSubmit={handleSubmit}>
{hasChoices ? (
<div className="grid gap-px" role="group">
{choices.map((choice, index) => (
@ -616,25 +626,25 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) {
value={draft}
/>
)}
</ClarifyShell>
<div className="flex items-center justify-end gap-1">
<Button disabled={submitting} onClick={() => void respond('')} size="xs" type="button" variant="text">
{copy.skip}
</Button>
<Button disabled={submitting || !pendingAnswer} size="xs" type="submit">
{submitting ? (
<Loader2 className="size-3 animate-spin" />
) : (
<>
{copy.continueLabel}
<span aria-hidden className="ml-0.5 text-[0.625rem] opacity-70">
</span>
</>
)}
</Button>
</div>
</form>
</ClarifyShell>
<div className="flex items-center justify-end gap-1">
<Button disabled={submitting} onClick={() => void respond('')} size="xs" type="button" variant="text">
{copy.skip}
</Button>
<Button disabled={submitting || !pendingAnswer} size="xs" type="submit">
{submitting ? (
<Loader2 className="size-3 animate-spin" />
) : (
<>
{copy.continueLabel}
<span aria-hidden className="ml-0.5 text-[0.625rem] opacity-70">
</span>
</>
)}
</Button>
</div>
</form>
)
}

View file

@ -418,7 +418,7 @@ export function DirectiveContent({ text }: { text: string }) {
{images.map((src, index) => (
<ZoomableImage
alt=""
className="max-h-48 max-w-full rounded-lg border border-border/60 object-contain"
className="max-h-48 max-w-full rounded-lg border border-(--ui-stroke-tertiary) object-contain"
draggable={false}
key={`img-${index}`}
slot="aui_embedded-image"
@ -481,7 +481,7 @@ const DirectiveImage: FC<{ id: string; label: string }> = ({ id, label }) => {
return (
<ZoomableImage
alt={label}
className="max-h-48 max-w-full rounded-lg border border-border/60 object-contain"
className="max-h-48 max-w-full rounded-lg border border-(--ui-stroke-tertiary) object-contain"
draggable={false}
slot="aui_directive-image"
src={src}

View file

@ -112,7 +112,7 @@ export function MarkdownAlert({ children, type }: { children: ReactNode; type: A
return (
<div
className="my-2 rounded-lg border border-border bg-muted/25 px-3 py-2 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
className="my-2 rounded-lg border border-(--ui-stroke-tertiary) bg-muted/25 px-3 py-2 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
data-slot="aui_markdown-alert"
>
<div className={cn('mb-1 flex items-center gap-1.5 text-[0.8125rem] font-semibold', style.accent)}>

View file

@ -197,7 +197,7 @@ function MediaAttachment({ path }: { path: string }) {
if (kind === 'audio' && src) {
return (
<span className="my-3 block max-w-md rounded-xl border border-border bg-muted/35 p-3">
<span className="my-3 block max-w-md rounded-xl border border-(--ui-stroke-tertiary) bg-muted/35 p-3">
<span className="mb-2 block truncate text-xs font-medium text-muted-foreground">{name}</span>
<audio className="block w-full" controls onError={() => setFailed(true)} preload="metadata" src={src} />
{failed && <OpenMediaButton kind="audio" path={path} />}
@ -207,7 +207,7 @@ function MediaAttachment({ path }: { path: string }) {
if (kind === 'video' && src) {
return (
<span className="my-3 block max-w-2xl rounded-xl border border-border bg-muted/35 p-3">
<span className="my-3 block max-w-2xl rounded-xl border border-(--ui-stroke-tertiary) bg-muted/35 p-3">
<span className="mb-2 block truncate text-xs font-medium text-muted-foreground">{name}</span>
<video
className="block max-h-112 w-full rounded-lg bg-black"
@ -440,7 +440,7 @@ function HugeTextFallback({ containerClassName, text }: { containerClassName?: s
return (
<div
className={cn(
'aui-md w-full max-w-none overflow-hidden rounded-[0.625rem] border border-border font-mono text-[0.7rem] leading-relaxed text-foreground/90',
'aui-md w-full max-w-none overflow-hidden rounded-[0.625rem] border border-(--ui-stroke-tertiary) font-mono text-[0.7rem] leading-relaxed text-foreground/90',
containerClassName
)}
>
@ -527,7 +527,7 @@ function MarkdownTextSurface({
return (
<blockquote
className={cn('border-s-2 border-border ps-3 text-muted-foreground italic', className)}
className={cn('border-s-2 border-(--ui-stroke-tertiary) ps-3 text-muted-foreground italic', className)}
dir="auto"
{...props}
>
@ -545,10 +545,10 @@ function MarkdownTextSurface({
<li className={cn('leading-(--dt-line-height)', className)} {...props} />
),
table: ({ className, ...props }: ComponentProps<'table'>) => (
<div className="aui-md-table my-2 max-w-full overflow-x-auto rounded-[0.375rem] border border-border">
<div className="aui-md-table my-2 max-w-full overflow-x-auto rounded-[0.375rem] border border-(--ui-stroke-tertiary)">
<table
className={cn(
'm-0 w-full min-w-[18rem] border-collapse text-[0.8125rem] [&_tr]:border-b [&_tr]:border-border last:[&_tr]:border-0',
'm-0 w-full min-w-[18rem] border-collapse text-[0.8125rem] [&_tr]:border-b [&_tr]:border-(--ui-stroke-tertiary) last:[&_tr]:border-0',
className
)}
{...props}

View file

@ -104,7 +104,7 @@ export const UserMessageText: FC<UserMessageTextProps> = ({ className, text }) =
if (segment.kind === 'fence') {
return (
<pre
className="my-1.5 max-w-full overflow-x-auto rounded-md border border-border/45 bg-[color-mix(in_srgb,currentColor_5%,transparent)] px-2.5 py-2 font-mono text-[0.86em] leading-snug"
className="my-1.5 max-w-full overflow-x-auto rounded-md border border-(--ui-stroke-tertiary) bg-[color-mix(in_srgb,currentColor_5%,transparent)] px-2.5 py-2 font-mono text-[0.86em] leading-snug"
data-slot="aui_user-fence"
key={`fence-${segmentIndex}`}
>

View file

@ -11,16 +11,16 @@ import { cn } from '@/lib/utils'
// instead of being dumped as raw text.
const TAG_CLASSES = {
blockquote: 'mt-2 mb-2 border-l-2 border-border/70 pl-2.5 italic text-muted-foreground/85',
blockquote: 'mt-2 mb-2 border-l-2 border-(--ui-stroke-tertiary) pl-2.5 italic text-muted-foreground/85',
h1: 'mt-3 mb-1.5 text-sm font-semibold tracking-tight text-foreground first:mt-0',
h2: 'mt-3 mb-1.5 text-[0.82rem] font-semibold tracking-tight text-foreground first:mt-0',
h3: 'mt-2.5 mb-1 text-[0.78rem] font-semibold text-foreground first:mt-0',
h4: 'mt-2 mb-1 text-[0.74rem] font-semibold text-foreground first:mt-0',
hr: 'my-2 border-border/50',
hr: 'my-2 border-(--ui-stroke-tertiary)',
li: 'marker:text-muted-foreground/60',
ol: 'mb-2 list-decimal pl-5 last:mb-0',
p: 'mb-1.5 leading-relaxed last:mb-0',
pre: 'mb-2 overflow-x-auto rounded-md border border-border/60 bg-background/70 p-2 font-mono text-[0.7rem] leading-[1.55] last:mb-0',
pre: 'mb-2 overflow-x-auto rounded-md border border-(--ui-stroke-tertiary) bg-background/70 p-2 font-mono text-[0.7rem] leading-[1.55] last:mb-0',
td: 'px-2 py-1 align-top leading-snug',
th: 'px-2 py-1 text-left text-[0.62rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground/80',
thead: 'bg-muted/40',
@ -66,10 +66,10 @@ function MarkdownCode({ className, ...rest }: ComponentProps<'code'>) {
function MarkdownTable({ className, ...rest }: ComponentProps<'table'>) {
return (
<div className="mb-2 max-w-full overflow-x-auto rounded-md border border-border/60 last:mb-0">
<div className="mb-2 max-w-full overflow-x-auto rounded-md border border-(--ui-stroke-tertiary) last:mb-0">
<table
className={cn(
'w-full border-collapse text-[0.72rem] [&_tr]:border-b [&_tr]:border-border/50 last:[&_tr]:border-0',
'w-full border-collapse text-[0.72rem] [&_tr]:border-b [&_tr]:border-(--ui-stroke-tertiary) last:[&_tr]:border-0',
className
)}
{...rest}

View file

@ -95,7 +95,7 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev
}
return (
<div className="flex w-full max-w-160 items-center gap-2 rounded-lg border border-border/55 bg-card/55 px-2.5 py-1.5 text-sm">
<div className="flex w-full max-w-160 items-center gap-2 rounded-lg border border-(--ui-stroke-tertiary) bg-card/55 px-2.5 py-1.5 text-sm">
<span className="grid size-6 shrink-0 place-items-center rounded-md bg-muted/55 text-muted-foreground/85">
<MonitorPlay className="size-3.5" />
</span>
@ -103,7 +103,7 @@ export function PreviewAttachment({ source = 'manual', target }: { source?: Prev
{name}
</span>
<button
className="shrink-0 rounded-md border border-border/55 bg-background/40 px-2 py-1 text-[0.7rem] font-medium text-muted-foreground transition-colors hover:bg-accent/55 hover:text-foreground disabled:opacity-50"
className="shrink-0 rounded-md border border-(--ui-stroke-tertiary) bg-background/40 px-2 py-1 text-[0.7rem] font-medium text-muted-foreground transition-colors hover:bg-accent/55 hover:text-foreground disabled:opacity-50"
disabled={opening}
onClick={() => void togglePreview()}
type="button"

View file

@ -0,0 +1,12 @@
/**
* Inline transcript widgets the few tool results that render as a panel the
* user reads or acts on (a clarify question, an artifact card) rather than as
* a scaffold line.
*
* They share one shell so they cannot drift apart. They used to each pick their
* own: clarify sat on a 2px radius over the chat backdrop's own color (a card
* you could only see by its hairline), the artifact card on a hardcoded 10px
* over nothing. One radius one rung above the composer, one mode-derived fill,
* no border the surface reads as a surface on the fill alone.
*/
export const WIDGET_SHELL_CLASS = 'rounded-3xl bg-(--ui-widget-surface-background) px-3.5 py-3'

View file

@ -327,6 +327,10 @@
--ui-sidebar-surface-background: var(--ui-bg-sidebar);
--ui-chat-surface-background: var(--ui-bg-chrome);
--ui-editor-surface-background: var(--ui-bg-chrome);
/* Inline chat widgets (clarify, artifact card) the card fill in light
mode, nudged down in dark so a widget settles into the transcript
instead of glowing above it (see the `.dark` override). */
--ui-widget-surface-background: var(--ui-bg-editor);
--ui-chat-bubble-background: color-mix(
in srgb,
var(--theme-bubble-seed) var(--theme-mix-bubble),
@ -512,6 +516,11 @@
--ui-inline-code-background: color-mix(in srgb, #ffffff 7%, transparent);
--ui-inline-code-foreground: color-mix(in srgb, #ffffff 88%, transparent);
--ui-selection-background: color-mix(in srgb, #ffd24a 38%, transparent);
/* A dark card sits ABOVE the chrome (#161618 over #0d0d0e), so an inline
widget wearing the raw card fill reads as a lit panel. Take it down a
touch still clearly a surface, no longer the brightest thing in the
transcript. Derived from the card so every skin follows its own seed. */
--ui-widget-surface-background: color-mix(in srgb, var(--ui-bg-editor) 88%, #000);
}
* {
@ -1485,7 +1494,7 @@ text-* variant utilities. */ .btn-arc {
}
[data-slot='aui_assistant-message-content'] .aui-md .aui-md-table thead {
border-bottom-color: var(--dt-border) !important;
border-bottom-color: var(--ui-stroke-tertiary) !important;
}
/* Tool / thinking blocks are scaffolding around the model's reply, so we