mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 08:02:21 +00:00
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:
@@ -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)} />
|
||||
}
|
||||
+3
-1
@@ -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}
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
+2
-2
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)) {
|
||||
|
||||
Reference in New Issue
Block a user