mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-30 19:09:28 +00:00
Merge pull request #74746 from NousResearch/bb/chat-widget-chrome
Inline chat widgets share one shell
This commit is contained in:
commit
1ee75fc473
11 changed files with 91 additions and 49 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)}>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
12
apps/desktop/src/components/chat/widget-shell.ts
Normal file
12
apps/desktop/src/components/chat/widget-shell.ts
Normal 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'
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue