Soften native chat colors and code surfaces (#25651)

* feat(native-chat): add chat-scoped color tokens

* feat(native-chat): soften transcript and composer appearance

* fix(native-chat): refine code spacing and faint text styling

* fix(native-chat): wrap prose links at word boundaries

* test(native-chat): refresh background task strip snapshots
This commit is contained in:
Brennan Benson
2026-10-05 21:40:55 -07:00
committed by GitHub
parent bfd1e9c579
commit b58f8197dc
36 changed files with 255 additions and 80 deletions
+41
View File
@@ -43,6 +43,19 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-chat-canvas: var(--chat-canvas);
--color-chat-foreground: var(--chat-foreground);
--color-chat-foreground-strong: var(--chat-foreground-strong);
--color-chat-foreground-faint: var(--chat-foreground-faint);
--color-chat-user-surface: var(--chat-user-surface);
--color-chat-user-border: var(--chat-user-border);
--color-chat-code-foreground: var(--chat-code-foreground);
--color-chat-code-surface: var(--chat-code-surface);
--color-chat-code-border: var(--chat-code-border);
--color-chat-inline-code-surface: var(--chat-inline-code-surface);
--color-chat-inline-code-border: var(--chat-inline-code-border);
--color-chat-composer-surface: var(--chat-composer-surface);
--color-chat-composer-border: var(--chat-composer-border);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
@@ -160,6 +173,20 @@
--background: #fff;
--editor-surface: #ffffff;
--foreground: #0a0a0a;
--chat-canvas: var(--background);
--chat-foreground: color-mix(in srgb, var(--foreground) 82%, var(--chat-canvas));
--chat-foreground-strong: color-mix(in srgb, var(--foreground) 92%, var(--chat-canvas));
--chat-foreground-faint: var(--muted-foreground);
--chat-user-surface: color-mix(in srgb, var(--foreground) 4%, var(--chat-canvas));
--chat-user-border: color-mix(in srgb, var(--foreground) 7%, transparent);
--chat-code-foreground: color-mix(in srgb, var(--foreground) 85%, var(--chat-canvas));
--chat-code-surface: color-mix(in srgb, var(--foreground) 2%, var(--chat-canvas));
--chat-code-border: color-mix(in srgb, var(--foreground) 7%, transparent);
--chat-inline-code-surface: color-mix(in srgb, var(--foreground) 4%, var(--chat-canvas));
--chat-inline-code-border: color-mix(in srgb, var(--foreground) 9%, transparent);
--chat-composer-surface: var(--chat-canvas);
--chat-composer-border: color-mix(in srgb, var(--foreground) 11%, transparent);
--chat-content-max-width: 46rem;
--card: #fff;
--card-foreground: #0a0a0a;
--popover: #fff;
@@ -283,6 +310,20 @@
--background: #0a0a0a;
--editor-surface: #1e1e1e;
--foreground: #fafafa;
--chat-canvas: color-mix(in srgb, var(--foreground) 5%, var(--background));
--chat-foreground: color-mix(in srgb, var(--foreground) 78%, var(--chat-canvas));
--chat-foreground-strong: color-mix(in srgb, var(--foreground) 90%, var(--chat-canvas));
--chat-foreground-faint: color-mix(in srgb, var(--muted-foreground) 83%, var(--chat-canvas));
--chat-user-surface: color-mix(in srgb, var(--foreground) 7%, var(--chat-canvas));
--chat-user-border: color-mix(in srgb, var(--foreground) 6%, transparent);
--chat-code-foreground: color-mix(in srgb, var(--foreground) 85%, var(--chat-canvas));
--chat-code-surface: color-mix(in srgb, var(--foreground) 3.5%, transparent);
--chat-code-border: color-mix(in srgb, var(--foreground) 7%, transparent);
--chat-inline-code-surface: color-mix(in srgb, var(--foreground) 6%, transparent);
--chat-inline-code-border: color-mix(in srgb, var(--foreground) 8%, transparent);
--chat-composer-surface: color-mix(in srgb, var(--foreground) 4%, transparent);
--chat-composer-border: color-mix(in srgb, var(--foreground) 9%, transparent);
--chat-content-max-width: 46rem;
--card: #171717;
--card-foreground: #fafafa;
--popover: #171717;
@@ -2,9 +2,8 @@ import { useEffect, useRef } from 'react'
import { ShieldQuestion, X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import CommentMarkdown, {
type CommentMarkdownLinkClickHandler
} from '@/components/sidebar/CommentMarkdown'
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
import { NativeChatMarkdown } from './NativeChatMarkdown'
import {
isNewerApprovalSubject,
isPlanApprovalSubject
@@ -55,8 +54,8 @@ export function NativeChatApprovalCard({
}, [shouldFocus])
return (
<div className="min-h-0 shrink overflow-hidden bg-background">
<div className="mx-auto flex h-full min-h-0 max-h-full w-full max-w-4xl px-3 pt-2 pb-1 sm:px-4">
<div className="min-h-0 shrink overflow-hidden bg-chat-canvas">
<div className="mx-auto flex h-full min-h-0 max-h-full w-full max-w-(--chat-content-max-width) px-3 pt-2 pb-1 sm:px-4">
<div
ref={cardRef}
data-native-chat-approval-card="true"
@@ -129,10 +128,10 @@ export function NativeChatApprovalCard({
) : null}
{isPlanApprovalSubject(approval.subject) ? (
<div data-native-chat-approval-plan="true">
<CommentMarkdown
<NativeChatMarkdown
content={approval.subject.text}
variant="document"
className="text-sm"
className="text-sm text-chat-foreground"
renderCodeBlock={NativeChatCodeBlock}
{...(onLinkClick ? { onLinkClick } : {})}
allowFileUriLinks={allowFileUriLinks}
@@ -201,10 +201,10 @@ export function NativeChatBackgroundTasksStatus(props: {
return (
<div
data-native-chat-background-tasks="true"
className="group/tasks shrink-0 bg-background px-3 pt-2 sm:px-4"
className="group/tasks shrink-0 bg-chat-canvas px-3 pt-2 sm:px-4"
>
{/* When the goal tab is the next sibling, take its width and share its top edge. */}
<div className="mx-auto w-full max-w-4xl [[data-native-chat-background-tasks]:has(+[data-native-chat-thread-goal])_&]:px-2">
<div className="mx-auto w-full max-w-(--chat-content-max-width) [[data-native-chat-background-tasks]:has(+[data-native-chat-thread-goal])_&]:px-2">
<div
ref={stripRef}
className="overflow-hidden rounded-lg border border-border bg-muted/50 text-xs text-muted-foreground shadow-xs [[data-native-chat-background-tasks]:has(+[data-native-chat-thread-goal])_&]:rounded-b-none [[data-native-chat-background-tasks]:has(+[data-native-chat-thread-goal])_&]:shadow-none"
@@ -1,15 +1,28 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { NativeChatCodeBlock } from './NativeChatCodeBlock'
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
describe('NativeChatCodeBlock', () => {
it('shows a plain language label without a header icon or divider', () => {
const { container } = render(<NativeChatCodeBlock language="typescript" />)
const label = screen.getByText('TypeScript').closest('[data-code-language]')
const header = label?.parentElement
expect(label).toHaveClass('font-sans', 'text-xs', 'text-chat-foreground-faint')
expect(header).toHaveClass('h-7.5')
expect(header).not.toHaveClass('border-b', 'border-border/60')
expect(header?.querySelector('svg')).toBeNull()
expect(container.querySelector('pre')).toHaveClass('px-3.5', 'pt-0.5', 'pb-3')
})
it('copies only the fenced code and confirms success', async () => {
const writeClipboardText = vi.fn().mockResolvedValue(undefined)
Object.assign(window, { api: { ui: { writeClipboardText } } })
@@ -1,5 +1,4 @@
import React from 'react'
import { Code2 } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import { getCodeBlockLanguageLabel } from '@/components/editor/rich-markdown-code-block-languages'
@@ -16,14 +15,13 @@ export function NativeChatCodeBlock({
const code = extractCodeText(children)
return (
<div className="group/code relative my-3 min-w-0 max-w-full overflow-hidden rounded-md bg-accent">
<div className="group/code relative my-3 min-w-0 max-w-full overflow-hidden rounded-lg border border-chat-code-border bg-chat-code-surface">
{language ? (
<div className="flex h-9 items-center justify-between border-b border-border/60 px-3">
<div className="flex h-7.5 items-center justify-between px-3">
<span
data-code-language={language}
className="flex min-w-0 items-center gap-1.5 font-mono text-[11px] text-muted-foreground"
className="min-w-0 font-sans text-xs text-chat-foreground-faint"
>
<Code2 className="size-3.5 shrink-0" />
<span className="truncate">{getCodeBlockLanguageLabel(language)}</span>
</span>
{code ? (
@@ -36,9 +34,10 @@ export function NativeChatCodeBlock({
</div>
) : null}
<pre
data-native-chat-code-content
className={cn(
'scrollbar-sleek m-0 max-h-80 max-w-full overflow-x-auto p-3 font-mono text-[12px]',
!language && 'pr-10'
'scrollbar-sleek m-0 max-h-80 max-w-full overflow-x-auto font-mono text-[12px] text-chat-code-foreground',
language ? 'px-3.5 pt-0.5 pb-3' : 'p-3 pr-10'
)}
>
{children}
@@ -95,7 +95,7 @@ export function NativeChatComposerActions({
)
return (
<div className="flex w-full items-center justify-between gap-2">
<div className="flex w-full items-center justify-between gap-2 text-chat-foreground-faint">
<div className="flex min-w-0 items-center gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
@@ -179,10 +179,10 @@ export function NativeChatComposerField({
}
return (
<div className="shrink-0 bg-background">
<div className="shrink-0 bg-chat-canvas">
{/* Extra bottom padding keeps the input box off the window rim. */}
<div className="px-3 pt-2 pb-4 sm:px-4">
<div className="relative mx-auto w-full max-w-4xl">
<div className="relative mx-auto w-full max-w-(--chat-content-max-width)">
{autocomplete.mode === 'slash' ? (
<NativeChatPickerMenu
autocomplete={autocomplete}
@@ -208,8 +208,8 @@ export function NativeChatComposerField({
// Why: always-on hairline (token-level border, not focus ring) —
// no focus/click border flash. The box is a container, not a
// focus target.
'rounded-lg border border-border p-1.5 shadow-xs',
'bg-muted/50 dark:bg-input/40',
'rounded-xl border border-chat-composer-border p-1.5 shadow-xs',
'bg-chat-composer-surface',
// Why (#10481): the native caret blink invalidates paint up to the
// nearest containment boundary; without this the whole transcript
// re-rasterizes twice a second. Pickers are siblings and every menu
@@ -285,9 +285,9 @@ export function NativeChatComposerField({
// keeps that gutter off the heavy native scrollbar. Both are layout-driven,
// so re-wrap on window/pane resize is handled without a measure pass.
className={cn(
'min-h-12 w-full bg-transparent px-2 py-1 text-sm outline-none pointer-coarse:min-h-14',
'min-h-12 w-full bg-transparent px-2 py-1 text-sm text-chat-foreground-strong outline-none pointer-coarse:min-h-14',
'max-h-[calc(8lh+0.5rem)] overflow-y-auto scrollbar-sleek',
'placeholder:text-muted-foreground/60 disabled:cursor-not-allowed disabled:opacity-50'
'placeholder:text-chat-foreground-faint disabled:cursor-not-allowed disabled:opacity-50'
)}
/>
<div className="flex flex-wrap items-center gap-2 pt-0.5">
@@ -58,7 +58,7 @@ export function NativeChatCopyButton({
aria-label={label}
title={label}
className={cn(
'flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'flex size-6 shrink-0 items-center justify-center rounded-md text-chat-foreground-faint transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
copied && 'text-status-success',
className
)}
@@ -45,7 +45,7 @@ function RemoveAttachmentButton({ onRemove }: { onRemove: () => void }): React.J
'components.native-chat.composer.removeAttachment',
'Remove attachment'
)}
className="absolute -right-1.5 -top-1.5 flex size-4 items-center justify-center rounded-full border border-border bg-background text-muted-foreground shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="absolute -right-1.5 -top-1.5 flex size-4 items-center justify-center rounded-full border border-border bg-chat-canvas text-muted-foreground shadow-xs transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X className="size-3" />
</button>
@@ -155,7 +155,7 @@ function NativeChatImageThumbnail({ attachment, onRemove }: Props): React.JSX.El
aria-busy={isPending}
title={label}
onClick={() => setIsOpen(true)}
className="flex size-full items-center justify-center overflow-hidden rounded-md border border-border bg-background transition-colors hover:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="flex size-full items-center justify-center overflow-hidden rounded-md border border-border bg-chat-canvas transition-colors hover:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{thumbnailSrc ? (
<img
@@ -168,7 +168,7 @@ function NativeChatImageThumbnail({ attachment, onRemove }: Props): React.JSX.El
)}
</button>
{isPending ? (
<span className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md bg-background/50">
<span className="pointer-events-none absolute inset-0 flex items-center justify-center rounded-md bg-chat-canvas/50">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</span>
) : null}
@@ -40,7 +40,7 @@ export function NativeChatLaunchRetry({
)
: ''
return (
<div className="mx-auto flex w-full max-w-4xl items-center justify-between gap-3 px-4 py-1 text-xs text-destructive">
<div className="mx-auto flex w-full max-w-(--chat-content-max-width) items-center justify-between gap-3 px-4 py-1 text-xs text-destructive">
<span className="min-w-0 break-words">
{cause ? joinSentences([message, cause]) : message}
</span>
@@ -0,0 +1,46 @@
// @vitest-environment happy-dom
import '@testing-library/jest-dom/vitest'
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { NativeChatMarkdown } from './NativeChatMarkdown'
afterEach(cleanup)
describe('chat markdown isolation', () => {
it('opts chat into appearance styles without applying them to ordinary comments', () => {
render(
<>
<NativeChatMarkdown content="Chat message" className="text-sm" data-testid="chat" />
<CommentMarkdown content="Sidebar comment" data-testid="comment" />
</>
)
expect(screen.getByTestId('chat')).toHaveClass('native-chat-markdown', 'text-sm')
expect(screen.getByTestId('comment')).not.toHaveClass('native-chat-markdown')
expect(screen.getByText('Chat message')).toBeInTheDocument()
expect(screen.getByText('Sidebar comment')).toBeInTheDocument()
})
it('scopes word-boundary link wrapping to chat links', () => {
render(
<>
<NativeChatMarkdown
content="See [AppearancePane.tsx](https://example.com/chat)"
variant="document"
/>
<CommentMarkdown
content="See [SidebarPane.tsx](https://example.com/comment)"
variant="document"
/>
</>
)
expect(
screen.getByRole('link', { name: 'AppearancePane.tsx' }).matches('.native-chat-markdown a')
).toBe(true)
expect(
screen.getByRole('link', { name: 'SidebarPane.tsx' }).matches('.native-chat-markdown a')
).toBe(false)
})
})
@@ -0,0 +1,11 @@
import type { ComponentProps } from 'react'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { cn } from '@/lib/utils'
import './native-chat-markdown.css'
export function NativeChatMarkdown({
className,
...props
}: ComponentProps<typeof CommentMarkdown>): React.JSX.Element {
return <CommentMarkdown {...props} className={cn('native-chat-markdown', className)} />
}
@@ -195,7 +195,9 @@ describe('jumping from the rail while following the end', () => {
configurable: true,
get: () =>
TOP_GUTTER_PX +
(screen.queryByRole('button', { name: /load earlier messages/i })?.closest('.max-w-4xl')
(screen
.queryByRole('button', { name: /load earlier messages/i })
?.closest('[data-native-chat-transcript-column]')
? OLDER_HISTORY_ROW_PX
: 0)
})
@@ -421,7 +421,7 @@ describe('NativeChatMessageList childless spawn group', () => {
/** Every slot the transcript column lays out — one per row that mounted. */
function emptySlots(container: HTMLElement): Element[] {
const column = container.querySelector('.max-w-4xl')
const column = container.querySelector('[data-native-chat-transcript-column]')
expect(column).not.toBeNull()
return Array.from(column!.children).filter((slot) => slot.textContent === '')
}
@@ -360,7 +360,7 @@ export function NativeChatMessageList({
return (
<NativeChatDisclosureContext.Provider value={disclosures}>
<div className="relative flex min-h-0 flex-1 flex-col">
<div className="relative flex min-h-0 flex-1 flex-col bg-chat-canvas">
<div className="relative min-h-0 flex-1">
<div
ref={scrollRef}
@@ -388,9 +388,10 @@ export function NativeChatMessageList({
<div className="px-3 pt-10 pb-4 sm:px-4">
<div
ref={contentRef}
// Why: matches composer column (max-w-4xl) with 5px horizontal inset
data-native-chat-transcript-column
// Why: matches composer width with 5px horizontal inset
// on each side so content is slightly narrower than the input box.
className="mx-auto flex w-full max-w-4xl flex-col gap-5 px-[5px]"
className="mx-auto flex w-full max-w-(--chat-content-max-width) flex-col gap-5 px-[5px]"
>
<NativeChatTranscriptItems
slots={slots}
@@ -430,7 +431,10 @@ export function NativeChatMessageList({
</div>
{taskListState.list && taskListState.list.tasks.length > 0 ? (
<div className="shrink-0 px-3 pb-2 sm:px-4">
<div className="mx-auto w-full max-w-4xl" style={{ zoom: fontScale }}>
<div
className="mx-auto w-full max-w-(--chat-content-max-width)"
style={{ zoom: fontScale }}
>
<NativeChatTaskList
key={session.sessionId}
list={taskListState.list}
@@ -108,7 +108,7 @@ export function stubLayout({
}
// The transcript column: as tall as the window it wraps, plus what sits
// under it. This is the element the list observes for streamed growth.
return this.classList.contains('max-w-4xl')
return this.hasAttribute('data-native-chat-transcript-column')
? reservedTranscriptHeight(this) + belowTranscriptPx
: 0
}
@@ -249,7 +249,7 @@ describe('transcript with a hidden scroll root', () => {
expect(container.querySelector('[data-native-chat-window]')).toBeInTheDocument()
expect(container.querySelectorAll('[data-index]')).toHaveLength(0)
expect(screen.queryByText(/^marker-/)).toBeNull()
const column = container.querySelector('.max-w-4xl')
const column = container.querySelector('[data-native-chat-transcript-column]')
expect(column?.children).toHaveLength(1)
viewportHeight = VIEWPORT_PX
@@ -131,6 +131,20 @@ describe('MessageRow control visibility', () => {
expect(screen.queryByRole('time')).toBeNull()
expect(screen.queryByRole('button')).toBeNull()
})
it.each(['reasoning', 'system'] as const)(
'keeps %s rows upright and scopes their faint text',
(role) => {
const { container } = renderMessage(role)
const row = container.querySelector('[data-native-chat-message-tone="faint"]')
expect(row).toHaveClass('text-chat-foreground-faint')
expect(row).not.toHaveClass('italic')
expect(row).toContainElement(screen.getByText('Message text'))
if (role === 'reasoning') {
expect(row).toHaveClass('border-l-2')
}
}
)
})
describe('MessageRow send mode', () => {
@@ -211,7 +225,7 @@ describe('what a user message says about its delivery', () => {
const sending = screen.getByText('Sending…')
const copy = screen.getByRole('button', { name: 'Copy message' })
expect(sending).toHaveClass('text-xs', 'text-muted-foreground')
expect(sending).toHaveClass('text-xs', 'text-chat-foreground-faint')
expect(Array.from(sending.parentElement!.children)).toEqual([copy, sending])
expect(sending.parentElement).not.toHaveClass('can-hover:opacity-0')
expect(sending.parentElement!.parentElement).toHaveClass('group')
@@ -1,9 +1,8 @@
import { memo, useCallback, useRef } from 'react'
import { Goal, RotateCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import CommentMarkdown, {
type CommentMarkdownLinkClickHandler
} from '@/components/sidebar/CommentMarkdown'
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
import { NativeChatMarkdown } from './NativeChatMarkdown'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import type {
@@ -59,7 +58,7 @@ function UserMessageMeta({
<NativeChatCopyButton text={markdown} className={sending ? USER_META_REVEAL : undefined} />
) : null}
{sending ? (
<span className="text-xs whitespace-nowrap text-muted-foreground">
<span className="text-xs whitespace-nowrap text-chat-foreground-faint">
{translate('components.native-chat.messageSending', 'Sending…')}
</span>
) : (
@@ -178,9 +177,8 @@ export const MessageRow = memo(function MessageRow({
if (isUser) {
return (
<div ref={rowRef} className="group relative flex flex-col items-end gap-0.5">
{/* User turns get a distinct muted fill (not the card/canvas color) so
the prompt reads apart from the assistant's body copy. */}
<div className="max-w-[85%] rounded-lg rounded-tr-sm bg-muted px-3.5 py-2.5 text-sm text-foreground">
{/* A distinct surface separates the user's prompt from the assistant's prose. */}
<div className="max-w-[80%] rounded-xl border border-chat-user-border bg-chat-user-surface px-3.5 py-2.5 text-sm text-chat-foreground-strong">
{markdown ? (
<>
<NativeChatImageAttachments
@@ -188,7 +186,7 @@ export const MessageRow = memo(function MessageRow({
runtimeContext={runtimeContext}
enablePreview={runtimeContext !== undefined}
/>
<CommentMarkdown
<NativeChatMarkdown
content={markdown}
variant="document"
className="text-sm"
@@ -246,11 +244,12 @@ export const MessageRow = memo(function MessageRow({
return (
<div
ref={rowRef}
data-native-chat-message-tone={isReasoning || isSystem ? 'faint' : undefined}
className={cn(
'group relative max-w-full select-text text-sm leading-relaxed text-foreground',
// Reasoning is the agent thinking aloud — quieter, italic, like an aside.
isReasoning && 'border-l-2 border-border/60 pl-3 italic text-muted-foreground',
isSystem && 'text-xs text-muted-foreground'
'group relative max-w-full select-text text-sm leading-relaxed text-chat-foreground',
// Reasoning stays quieter while keeping the same upright text as prose.
isReasoning && 'border-l-2 border-border/60 pl-3 text-chat-foreground-faint',
isSystem && 'text-xs text-chat-foreground-faint'
)}
>
<NativeChatImageAttachments
@@ -259,7 +258,7 @@ export const MessageRow = memo(function MessageRow({
enablePreview={runtimeContext !== undefined}
/>
{markdown ? (
<CommentMarkdown
<NativeChatMarkdown
content={markdown}
variant="document"
className="text-sm"
@@ -45,7 +45,7 @@ export function NativeChatMessageTimestamp({
aria-label={formatters.full.format(date)}
tabIndex={focusable ? 0 : undefined}
className={cn(
'rounded-md text-xs whitespace-nowrap text-muted-foreground tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'rounded-md text-xs whitespace-nowrap text-chat-foreground-faint tabular-nums focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
className
)}
>
@@ -1,7 +1,7 @@
import { AlertCircle, AlertTriangle, Info } from 'lucide-react'
import CommentMarkdown, {
type CommentMarkdownLinkClickHandler
} from '@/components/sidebar/CommentMarkdown'
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
import { NativeChatMarkdown } from './NativeChatMarkdown'
import { NativeChatCodeBlock } from './NativeChatCodeBlock'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
@@ -74,10 +74,11 @@ export function NativeChatNoticeRow({
</CardTitle>
</CardHeader>
<CardContent className="px-4 text-sm leading-relaxed text-foreground">
<CommentMarkdown
<NativeChatMarkdown
content={block.text}
variant="document"
className="text-sm"
renderCodeBlock={NativeChatCodeBlock}
className="text-sm text-chat-foreground"
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
linkifyFilePaths={onLinkClick !== undefined}
@@ -99,7 +99,7 @@ function NativeChatPaneFileDropOverlay(): React.JSX.Element {
return (
<div
data-native-chat-drop-overlay="true"
className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-background/80"
className="pointer-events-none absolute inset-0 z-30 flex items-center justify-center bg-chat-canvas/80"
>
<div className="flex flex-col items-center gap-1 rounded-xl border border-dashed border-foreground/30 bg-card px-8 py-5 text-center shadow-floating">
<span className="mb-1 flex size-9 items-center justify-center rounded-full bg-foreground/10">
@@ -80,7 +80,7 @@ export function NativeChatPromptEditor({
role: 'textbox',
'aria-multiline': 'true',
'aria-label': placeholder,
class: `${className ?? ''} whitespace-pre-wrap break-words [&_p]:m-0 [&_p.is-editor-empty:first-child]:before:content-[attr(data-placeholder)] [&_p.is-editor-empty:first-child]:before:text-muted-foreground/60 [&_p.is-editor-empty:first-child]:before:float-left [&_p.is-editor-empty:first-child]:before:h-0 [&_p.is-editor-empty:first-child]:before:pointer-events-none`,
class: `${className ?? ''} whitespace-pre-wrap break-words [&_p]:m-0 [&_p.is-editor-empty:first-child]:before:content-[attr(data-placeholder)] [&_p.is-editor-empty:first-child]:before:text-chat-foreground-faint [&_p.is-editor-empty:first-child]:before:float-left [&_p.is-editor-empty:first-child]:before:h-0 [&_p.is-editor-empty:first-child]:before:pointer-events-none`,
...Object.fromEntries(Object.entries(events).filter(([key]) => key.startsWith('aria-')))
},
// Clipboard input is always literal text; only the picker creates skill nodes.
@@ -158,8 +158,8 @@ export function NativeChatQuestionCard({
// Part of the composer: docked in the bottom input region, matching the
// composer's width and padding, rendered as the "ask" dialog card directly
// above the text input. Its free-text row is the answer input.
<div className="shrink-0 bg-background" aria-busy={isSubmitting}>
<div className="mx-auto w-full max-w-4xl px-3 pt-2 pb-4 sm:px-4">
<div className="shrink-0 bg-chat-canvas" aria-busy={isSubmitting}>
<div className="mx-auto w-full max-w-(--chat-content-max-width) px-3 pt-2 pb-4 sm:px-4">
{total > 1 ? (
<div className="mb-2 flex gap-1 overflow-x-auto pb-1 scrollbar-sleek">
{prompt.questions.map((qq, i) => (
@@ -39,7 +39,7 @@ export function NativeChatQueuedMessageList({
return (
<div aria-live="polite">
{cards.length > 0 ? (
<div ref={queueRef} className="mx-auto w-full max-w-4xl px-4 py-1">
<div ref={queueRef} className="mx-auto w-full max-w-(--chat-content-max-width) px-4 py-1">
{/* One box: the pause row, when shown, is its first row, and each card a row below it. */}
<div className="divide-y divide-border rounded-md border border-border bg-card text-card-foreground">
{pause ? (
@@ -348,7 +348,7 @@ export function NativeChatResolvedView({
onMouseUpCapture={contextMenu.onSelectionCapture}
onKeyUpCapture={contextMenu.onSelectionCapture}
onContextMenuCapture={contextMenu.onContextMenuCapture}
className="flex h-full min-h-0 w-full flex-col bg-background focus:outline-none"
className="flex h-full min-h-0 w-full flex-col bg-chat-canvas focus:outline-none"
>
<div className="flex min-h-0 flex-1 flex-col">
{viewState.kind === 'loading' ? (
@@ -87,7 +87,7 @@ function PickerTrigger(props: {
variant="ghost"
size="xs"
aria-label={accessibleName}
className="max-w-48 text-muted-foreground"
className="max-w-48"
>
<span className="truncate">{props.label}</span>
<ChevronDown className="size-3" />
@@ -249,7 +249,7 @@ function NativeChatSessionOptionPickersInner({
: null
return (
<div className="flex min-w-0 items-center gap-0.5">
<div className="flex min-w-0 items-center gap-0.5 text-chat-foreground-faint">
<DropdownMenu
key={`model:${requestedModelSequence ?? 'idle'}`}
// Read only when a request remounts the menu: one made while pending is spent shut.
@@ -248,7 +248,7 @@ export function NativeChatStructuredSession(
onKeyUpCapture={paneCommands.onSelectionCapture}
onKeyDownCapture={paneCommands.onKeyDownCapture}
onContextMenuCapture={paneCommands.onContextMenuCapture}
className="flex h-full min-h-0 w-full flex-col bg-background focus:outline-none"
className="flex h-full min-h-0 w-full flex-col bg-chat-canvas focus:outline-none"
>
<div className="flex min-h-0 flex-1 flex-col">
{viewState.kind === 'loading' ? (
@@ -62,12 +62,12 @@ export function NativeChatStructuredSessionStatus(props: {
return (
<>
{props.reconnecting && !props.error ? (
<p className="mx-auto w-full max-w-4xl px-4 py-1 text-xs text-muted-foreground">
<p className="mx-auto w-full max-w-(--chat-content-max-width) px-4 py-1 text-xs text-muted-foreground">
{translate('components.native-chat.state.reconnecting', 'Reconnecting to this chat…')}
</p>
) : null}
{props.error || props.composerError ? (
<p className="mx-auto w-full max-w-4xl px-4 py-1 text-xs text-destructive">
<p className="mx-auto w-full max-w-(--chat-content-max-width) px-4 py-1 text-xs text-destructive">
{props.error ?? props.composerError}
</p>
) : null}
@@ -73,7 +73,7 @@ export function NativeChatThreadGoalBanner(props: {
className="group/goal relative -mb-2 shrink-0 px-3 sm:px-4"
data-native-chat-thread-goal={goal.status}
>
<div className="mx-auto w-full max-w-4xl px-2">
<div className="mx-auto w-full max-w-(--chat-content-max-width) px-2">
{/* Right after the task strip, that strip's bottom border is this tab's top edge. */}
<div className="flex items-start gap-2 rounded-t-md border border-b-0 border-border bg-muted/30 py-1 pr-1 pl-3 text-xs text-muted-foreground group-[[data-native-chat-background-tasks]+&]/goal:rounded-t-none group-[[data-native-chat-background-tasks]+&]/goal:border-t-0">
<Goal aria-hidden className="mt-1 size-3.5 shrink-0" />
@@ -129,7 +129,7 @@ function TranscriptImagePreview({
const viewImageLabel = translate('components.native-chat.composer.viewAttachment', 'View image')
const fallback = (
<div
className="flex max-w-full items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1 text-xs text-muted-foreground"
className="flex max-w-full items-center gap-1.5 rounded-md border border-border bg-chat-canvas px-2 py-1 text-xs text-muted-foreground"
title={label}
>
<ImageIcon className="size-3.5 shrink-0" />
@@ -172,7 +172,7 @@ function TranscriptImagePreview({
aria-label={`${viewImageLabel}: ${label}`}
title={label}
onClick={() => setOpen(true)}
className="flex size-full items-center justify-center overflow-hidden rounded-md border border-border bg-background transition-colors hover:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="flex size-full items-center justify-center overflow-hidden rounded-md border border-border bg-chat-canvas transition-colors hover:border-ring focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<img
src={displaySrc}
@@ -238,7 +238,7 @@ export function NativeChatImageAttachments({
return (
<div
key={`${imageKeyBase}-${occurrence}`}
className="flex max-w-full items-center gap-1.5 rounded-md border border-border bg-background px-2 py-1 text-xs text-muted-foreground"
className="flex max-w-full items-center gap-1.5 rounded-md border border-border bg-chat-canvas px-2 py-1 text-xs text-muted-foreground"
title={label}
>
<ImageIcon className="size-3.5 shrink-0" />
@@ -291,7 +291,7 @@ export function NativeChatAgentControls({
'Scroll this message to top'
)}
title={translate('components.native-chat.scrollMessageToTop', 'Scroll this message to top')}
className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="flex size-6 shrink-0 items-center justify-center rounded-md text-chat-foreground-faint transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<ArrowUp className="size-3.5" />
</button>
@@ -1,5 +1,6 @@
import { ChevronRight } from 'lucide-react'
import { translate } from '@/i18n/i18n'
import { cn } from '@/lib/utils'
import {
describeNativeChatTurnStatus,
formatNativeChatDuration,
@@ -68,11 +69,11 @@ export function NativeChatWorkingStatus({
const label = turnStatusLabel(key, duration)
// `tabular-nums`: the live clock reflows its own label every second otherwise.
const className =
'flex min-h-8 items-center gap-1 border-b border-border text-sm text-muted-foreground tabular-nums'
'flex min-h-8 items-center gap-1 border-b border-border text-sm text-chat-foreground-faint tabular-nums'
const caret =
workedSeconds != null && onToggleExpanded ? (
<ChevronRight
className={`size-3.5 transition-transform${expanded ? ' rotate-90' : ''}`}
className={cn('size-3.5 transition-transform', expanded && 'rotate-90')}
aria-hidden="true"
/>
) : null
@@ -81,7 +82,10 @@ export function NativeChatWorkingStatus({
<button
type="button"
data-native-chat-turn-status="settled"
className={`${className} w-full text-left hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70`}
className={cn(
className,
'w-full text-left hover:text-chat-foreground-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70'
)}
aria-label={translate(
'components.native-chat.status.toggleDetails',
NATIVE_CHAT_TURN_STATUS_COPY.toggleDetails
@@ -0,0 +1,42 @@
/* Shared markdown also renders sidebar comments, so chat styling stays opt-in. */
.native-chat-markdown :is(h1, h2, h3, h4, h5, h6, [role='heading'], strong) {
color: var(--chat-foreground-strong);
}
.native-chat-markdown a {
word-break: normal;
overflow-wrap: anywhere;
color: var(--chat-foreground-strong);
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
}
.native-chat-markdown code {
border: 1px solid var(--chat-inline-code-border);
border-radius: var(--radius-sm, 0.375rem);
background: var(--chat-inline-code-surface);
padding: 0.0625rem 0.3125rem;
color: var(--chat-code-foreground);
font-size: 0.86em;
}
.native-chat-markdown pre:not([data-native-chat-code-content]) {
border: 1px solid var(--chat-code-border);
border-radius: var(--radius);
background: var(--chat-code-surface);
color: var(--chat-code-foreground);
}
.native-chat-markdown pre code {
border: none;
border-radius: 0;
background: transparent;
padding: 0;
color: inherit;
font-size: inherit;
}
[data-native-chat-message-tone='faint']
.native-chat-markdown
:is(h1, h2, h3, h4, h5, h6, [role='heading'], strong, a, code, pre) {
color: inherit;
}
@@ -146,7 +146,7 @@ export function stubLayout({
}
// The transcript column: as tall as the window it wraps, plus what sits
// under it. This is the element the list observes for streamed growth.
return this.classList.contains('max-w-4xl')
return this.hasAttribute('data-native-chat-transcript-column')
? reservedTranscriptHeight(this) + layout.belowTranscriptPx
: 0
}
File diff suppressed because one or more lines are too long
@@ -18,7 +18,7 @@ export function NativeChatPaneCover({
<div
ref={coverRef}
tabIndex={-1}
className="native-chat-pane-shell absolute inset-0 z-10 flex min-h-0 min-w-0 bg-background focus:outline-none"
className="native-chat-pane-shell absolute inset-0 z-10 flex min-h-0 min-w-0 bg-chat-canvas focus:outline-none"
onPaste={(event) => {
// Chat inputs claim their pastes first; one that reaches here found no chat input.
if (event.defaultPrevented || isEditableTarget(event.target)) {