mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(native-chat): one set of rules for chat text size, colour, hover and focus (#26685)
* fix(native-chat): put chat text on one type scale that follows Text size Sizes were picked per component, so the same kind of row came out 12, 13 or 14px and the Text size setting reached only a few of them. * fix(native-chat): scale chat spacing and icons with Text size Icons and gaps stayed fixed while text grew, so at larger sizes they looked undersized and sat off-centre. * fix(native-chat): implement PR #26685 review fixes - Scale row gap with text size so controls don't overlap - Clamp 'Match terminal interface' to 12-20 for labels/controls - Use existing --status-warning token for warning color - Rewrite stale test for scaled typography tokens - Use app colors for in-progress tasks in composer dock * fix(native-chat): leave the task list's dock colour as it was * fix(native-chat): message text follows the terminal size; controls and spacing stay in 12-20 * test(native-chat): labels and spacing follow the bounded chat size; main's new notice test uses the chat colour --------- Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
This commit is contained in:
co-authored by
Brennan Benson
parent
1a1a2a165e
commit
53bf77682c
+16
-1
@@ -21,6 +21,10 @@
|
||||
{
|
||||
"name": "renderer-scrollbar-style",
|
||||
"specifier": "./config/oxlint-plugins/renderer-scrollbar-style.mjs"
|
||||
},
|
||||
{
|
||||
"name": "native-chat-design-tokens",
|
||||
"specifier": "./config/oxlint-plugins/native-chat-design-tokens.mjs"
|
||||
}
|
||||
],
|
||||
"categories": {
|
||||
@@ -181,6 +185,14 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/renderer/src/components/native-chat/**/*.{ts,tsx}"],
|
||||
"rules": {
|
||||
"native-chat-design-tokens/no-arbitrary-font-size": "error",
|
||||
"native-chat-design-tokens/no-softened-foreground": "error",
|
||||
"native-chat-design-tokens/no-weakened-focus-ring": "error"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["src/shared/**/*.ts"],
|
||||
"rules": {
|
||||
@@ -198,7 +210,10 @@
|
||||
"rules": {
|
||||
"react/globals": "off",
|
||||
"quadratic-buffer-concat/no-loop-carried-concat": "off",
|
||||
"renderer-scrollbar-style/require-styled-vertical-scrollbar": "off"
|
||||
"renderer-scrollbar-style/require-styled-vertical-scrollbar": "off",
|
||||
"native-chat-design-tokens/no-arbitrary-font-size": "off",
|
||||
"native-chat-design-tokens/no-softened-foreground": "off",
|
||||
"native-chat-design-tokens/no-weakened-focus-ring": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Design System
|
||||
|
||||
All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Most of it is linted: `pnpm run check:code-quality:changed` fails on new restyles of a `components/ui/` primitive, raw palette colors, and computed `className` strings; `pnpm lint` fails on any class Tailwind cannot generate. See the Enforcement section of the style guide before suppressing either. Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section.
|
||||
All UI work — layout, color, typography, spacing, component selection, UX behavior — must follow [`docs/STYLEGUIDE.md`](./docs/STYLEGUIDE.md). Most of it is linted: `pnpm run check:code-quality:changed` fails on new restyles of a `components/ui/` primitive, raw palette colors, and computed `className` strings; `pnpm lint` fails on any class Tailwind cannot generate. See the Enforcement section of the style guide before suppressing either. Use the tokens defined in `src/renderer/src/assets/main.css` (the canonical source) and the shadcn primitives in `src/renderer/src/components/ui/`. Don't invent new color values, font sizes, or shadow tiers when a documented one already covers the role. When STYLEGUIDE.md is silent, follow the resolution order in its final section. In `components/native-chat/`, the "Chat type scale" and "Chat text colour, hover and focus" sections are binding, and `pnpm lint` fails on arbitrary font sizes, softened `text-foreground`, and weakened focus rings there.
|
||||
|
||||
## Electron UI Validation
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { plainClassName } from './renderer-scrollbar-style.mjs'
|
||||
|
||||
const CHECKS = {
|
||||
'no-arbitrary-font-size': {
|
||||
pattern: /^text-\[(?:length:)?[\d.]+(?:px|rem|em)\]$/,
|
||||
message:
|
||||
'Use a chat type step (text-sm, text-xs, text-2xs, text-3xs, text-chat-code), not an arbitrary size. See "Chat type scale" in docs/STYLEGUIDE.md.'
|
||||
},
|
||||
'no-softened-foreground': {
|
||||
pattern: /^text-foreground\/\d+$/,
|
||||
message:
|
||||
"Don't soften text-foreground by hand: use text-chat-foreground on the chat canvas or text-muted-foreground on a lifted surface. See docs/STYLEGUIDE.md."
|
||||
},
|
||||
'no-weakened-focus-ring': {
|
||||
pattern: /^ring-ring\/\d+$/,
|
||||
message:
|
||||
'A focus ring at reduced opacity fails 3:1 contrast on the chat canvas: use ring-ring. See docs/STYLEGUIDE.md.'
|
||||
}
|
||||
}
|
||||
|
||||
function literalTexts(node) {
|
||||
if (node.type === 'Literal') {
|
||||
return typeof node.value === 'string' ? [node.value] : []
|
||||
}
|
||||
return node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw)
|
||||
}
|
||||
|
||||
function createRule({ pattern, message }) {
|
||||
return {
|
||||
create(context) {
|
||||
const check = (node) => {
|
||||
const offending = literalTexts(node)
|
||||
.flatMap((text) => text.split(/\s+/))
|
||||
.find((token) => pattern.test(plainClassName(token)))
|
||||
if (offending) {
|
||||
context.report({ node, message: `${offending}: ${message}` })
|
||||
}
|
||||
}
|
||||
return { Literal: check, TemplateLiteral: check }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
meta: { name: 'native-chat-design-tokens' },
|
||||
rules: Object.fromEntries(
|
||||
Object.entries(CHECKS).map(([name, check]) => [name, createRule(check)])
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import path from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { runOxlintPluginOnSource } from './oxlint-plugin-test-runner.mjs'
|
||||
|
||||
const pluginPath = path.resolve('config/oxlint-plugins/native-chat-design-tokens.mjs')
|
||||
|
||||
function lintSource(source) {
|
||||
return runOxlintPluginOnSource({
|
||||
pluginName: 'native-chat-design-tokens',
|
||||
pluginPath,
|
||||
source,
|
||||
rules: {
|
||||
'native-chat-design-tokens/no-arbitrary-font-size': 'warn',
|
||||
'native-chat-design-tokens/no-softened-foreground': 'warn',
|
||||
'native-chat-design-tokens/no-weakened-focus-ring': 'warn'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const violations = [
|
||||
['arbitrary px size', 'export const X = () => <p className="truncate text-[13px]" />'],
|
||||
['arbitrary rem size', 'export const X = () => <p className="text-[0.8125rem]" />'],
|
||||
['arbitrary size behind a variant', 'export const X = () => <p className="sm:text-[11px]" />'],
|
||||
['arbitrary size in a class constant', "const ROW = 'flex text-[10px]'"],
|
||||
[
|
||||
'arbitrary size in a composer',
|
||||
"export const X = () => <p className={cn(a && 'text-[12px]')} />"
|
||||
],
|
||||
['softened foreground', 'export const X = () => <p className="text-foreground/85" />'],
|
||||
[
|
||||
'weakened focus ring',
|
||||
'export const X = () => <button className="focus-visible:ring-2 focus-visible:ring-ring/70" />'
|
||||
]
|
||||
]
|
||||
|
||||
const accepted = [
|
||||
['named steps', 'export const X = () => <p className="text-sm text-xs text-2xs text-3xs" />'],
|
||||
['code step', 'export const X = () => <pre className="font-mono text-chat-code" />'],
|
||||
[
|
||||
'arbitrary colour',
|
||||
'export const X = () => <p className="text-[var(--git-decoration-added)]" />'
|
||||
],
|
||||
[
|
||||
'arbitrary non-font length',
|
||||
'export const X = () => <p className="min-h-[26px] max-w-[80%]" />'
|
||||
],
|
||||
[
|
||||
'chat and surface text colours',
|
||||
'export const X = () => <p className="text-chat-foreground text-foreground" />'
|
||||
],
|
||||
['softened state colour', 'export const X = () => <p className="text-destructive/80" />'],
|
||||
[
|
||||
'full focus ring',
|
||||
'export const X = () => <button className="focus-visible:ring-2 focus-visible:ring-ring" />'
|
||||
]
|
||||
]
|
||||
|
||||
describe('native-chat-design-tokens oxlint plugin', () => {
|
||||
it.each(violations)('reports %s', (_name, source) => {
|
||||
expect(lintSource(source)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each(accepted)('accepts %s', (_name, source) => {
|
||||
expect(lintSource(source)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -91,6 +91,29 @@ This keeps light/dark parity automatic.
|
||||
- 12px (sub-text, paths, secondary content)
|
||||
- 13px (sidebar items, dense list rows)
|
||||
- 14px (default body, button text in `default` size)
|
||||
- **Named small steps:** `text-2xs` (11px) and `text-3xs` (10px) are theme steps in `main.css`. Use them instead of `text-[11px]` / `text-[10px]`.
|
||||
|
||||
### Chat type scale
|
||||
|
||||
The native chat uses four steps and no others. Never write an arbitrary size (`text-[13px]`) inside `components/native-chat/`. `pnpm lint` enforces this, along with the softened-foreground and focus-ring rules below (`config/oxlint-plugins/native-chat-design-tokens.mjs`).
|
||||
|
||||
| Step | Default | Use it for |
|
||||
| ---------------- | ------- | ---------------------------------------------------------------------------------------------------------- |
|
||||
| `text-sm` | 14px | Anything the user reads: message prose, tool and agent activity rows, card titles and bodies, the composer |
|
||||
| `text-xs` | 12px | Labels, timestamps, counters, secondary lines under a row |
|
||||
| `text-2xs` | 11px | Captions, attribution, uppercase category labels |
|
||||
| `text-3xs` | 10px | Badges and mono pills |
|
||||
| `text-chat-code` | 12px | Code blocks, diffs, commands and tool output |
|
||||
|
||||
The chat root (`.native-chat-appearance`) redefines these steps as ratios of the Text size setting, so every tier scales together; `text-chat-code` follows the Code text size setting. The chat root also ties the spacing unit to the text size (4px at the default 14px), so icons, gaps, padding and row heights grow with the text. Use spacing-scale utilities (`size-3.5`, `min-h-6.5`, `pl-3.25`), not pixel lengths (`min-h-[26px]`), or the element stays fixed while the rest scales. Size an element with one of these utilities only: a CSS rule that sets `font-size` outside a layer silently overrides them.
|
||||
|
||||
### Chat text colour, hover and focus
|
||||
|
||||
- **Primary text on the chat canvas** uses `text-chat-foreground`, and `text-chat-foreground-strong` for titles and emphasis; both follow the Contrast setting. Don't soften `text-foreground` by hand (`text-foreground/85`). `text-foreground` is for text on a lifted surface inside the chat.
|
||||
- **Secondary text on the chat canvas** (transcript rows, notices, labels between messages) uses `text-chat-foreground-faint`. `text-muted-foreground` is for text on a lifted surface inside the chat: cards, menus, popovers and the docks above the composer.
|
||||
- **Row hover** in the transcript is `hover:bg-accent/20`. Buttons, menu items and card controls use `hover:bg-accent`.
|
||||
- **Focus rings:** full-width transcript rows use the inset ring (`focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring`) so it isn't clipped by the scroll container; standalone controls use `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`. Inside the chat `--ring` is `--chat-focus-ring`, which clears 3:1 on the canvas; don't lower its opacity.
|
||||
- **State colours in the chat:** warnings use `text-chat-warning`; diff lines use `text-chat-diff-added` / `text-chat-diff-removed`. They are mixed toward the foreground so they pass text contrast in light and dark. Don't use raw palette colours (`text-yellow-500`) or the git decoration tokens for text on a tinted row.
|
||||
|
||||
## Radius
|
||||
|
||||
|
||||
@@ -41,6 +41,16 @@
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Not inline: the chat root rescales these, so utilities must read the variables. */
|
||||
@theme {
|
||||
--text-2xs: 0.6875rem;
|
||||
--text-2xs--line-height: calc(1 / 0.6875);
|
||||
--text-3xs: 0.625rem;
|
||||
--text-3xs--line-height: calc(0.875 / 0.625);
|
||||
--text-chat-code: 0.75rem;
|
||||
--text-chat-code--line-height: calc(1 / 0.75);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
@@ -57,6 +67,9 @@
|
||||
--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-chat-warning: var(--chat-warning);
|
||||
--color-chat-diff-added: var(--chat-diff-added);
|
||||
--color-chat-diff-removed: var(--chat-diff-removed);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
@@ -611,6 +624,19 @@ body:has(> [data-slot='dialog-overlay'], > [data-slot='sheet-overlay']) {
|
||||
var(--chat-source-foreground) var(--chat-composer-border-mix),
|
||||
transparent
|
||||
);
|
||||
/* Mixed toward the foreground so each clears text contrast on the canvas in either scheme. */
|
||||
--chat-focus-ring: color-mix(in srgb, var(--chat-source-foreground) 50%, var(--chat-canvas));
|
||||
--chat-warning: color-mix(in srgb, var(--status-warning) 70%, var(--chat-source-foreground));
|
||||
--chat-diff-added: color-mix(
|
||||
in srgb,
|
||||
var(--git-decoration-added) 70%,
|
||||
var(--chat-source-foreground)
|
||||
);
|
||||
--chat-diff-removed: color-mix(
|
||||
in srgb,
|
||||
var(--git-decoration-deleted) 70%,
|
||||
var(--chat-source-foreground)
|
||||
);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@@ -30,7 +30,7 @@ export function NativeChatAgentMessageSenders({
|
||||
const owner = useNativeChatVisualOwner()
|
||||
const worktreeId = owner?.worktreeId ?? chatWorktreeId
|
||||
return (
|
||||
<div className="flex min-w-0 max-w-full flex-wrap items-center text-xs text-muted-foreground">
|
||||
<div className="flex min-w-0 max-w-full flex-wrap items-center text-xs text-chat-foreground-faint">
|
||||
<span>
|
||||
{queued
|
||||
? translate('components.native-chat.queuedMessages.fromSender', 'From')
|
||||
|
||||
@@ -106,14 +106,14 @@ export function NativeChatApprovalCard({
|
||||
<div
|
||||
data-native-chat-approval-content="true"
|
||||
tabIndex={0}
|
||||
className="min-h-0 max-h-72 shrink space-y-2 overflow-auto text-xs text-muted-foreground scrollbar-sleek focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
|
||||
className="min-h-0 max-h-72 shrink space-y-2 overflow-auto text-sm text-muted-foreground scrollbar-sleek focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
>
|
||||
{approval.description ? (
|
||||
<p className="whitespace-pre-wrap break-words">{approval.description}</p>
|
||||
) : null}
|
||||
{approval.decisionReason ? (
|
||||
<p className="whitespace-pre-wrap break-words">
|
||||
<span className="font-medium text-foreground/80">
|
||||
<span className="font-medium text-foreground">
|
||||
{translate('components.native-chat.approval.reason', 'Reason')}:{' '}
|
||||
</span>
|
||||
{approval.decisionReason}
|
||||
@@ -121,7 +121,7 @@ export function NativeChatApprovalCard({
|
||||
) : null}
|
||||
{neededPath ? (
|
||||
<p className="break-words">
|
||||
<span className="font-medium text-foreground/80">
|
||||
<span className="font-medium text-foreground">
|
||||
{translate('components.native-chat.approval.needsAccess', 'Needs access to')}
|
||||
:{' '}
|
||||
</span>
|
||||
@@ -141,7 +141,7 @@ export function NativeChatApprovalCard({
|
||||
/>
|
||||
{approval.subject.filePath ? (
|
||||
<p className="mt-2 break-all">
|
||||
<span className="font-medium text-foreground/80">
|
||||
<span className="font-medium text-foreground">
|
||||
{translate('components.native-chat.approval.plan.file', 'Plan file')}:{' '}
|
||||
</span>
|
||||
<span className="font-mono">{approval.subject.filePath}</span>
|
||||
@@ -152,7 +152,7 @@ export function NativeChatApprovalCard({
|
||||
<div
|
||||
data-native-chat-approval-detail="true"
|
||||
data-native-chat-code-content
|
||||
className="whitespace-pre-wrap break-words font-mono"
|
||||
className="whitespace-pre-wrap break-words font-mono text-chat-code"
|
||||
>
|
||||
{approval.detail}
|
||||
</div>
|
||||
|
||||
@@ -61,7 +61,6 @@ export function NativeChatAssistantMessageRow({
|
||||
data-native-chat-message-tone={isSystem ? 'faint' : undefined}
|
||||
className={cn(
|
||||
'group relative max-w-full select-text text-sm leading-relaxed text-chat-foreground',
|
||||
!isSystem && 'native-chat-message-text',
|
||||
isSystem && 'text-xs text-chat-foreground-faint'
|
||||
)}
|
||||
>
|
||||
@@ -75,7 +74,7 @@ export function NativeChatAssistantMessageRow({
|
||||
rowKey={message.id}
|
||||
content={words}
|
||||
variant="document"
|
||||
className="text-sm native-chat-message-text"
|
||||
className="text-sm"
|
||||
renderCodeBlock={isSystem ? NativeChatPlainCodeBlock : NativeChatCodeBlock}
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks={allowFileUriLinks}
|
||||
|
||||
@@ -149,7 +149,7 @@ function getPickerEmptyText(
|
||||
|
||||
function PickerGroupHeading({ kind }: { kind: 'commands' | 'skills' }): React.JSX.Element {
|
||||
return (
|
||||
<div className="px-2 pb-1 pt-1.5 text-[11px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
<div className="px-2 pb-1 pt-1.5 text-2xs font-semibold uppercase tracking-[0.05em] text-muted-foreground">
|
||||
{kind === 'commands'
|
||||
? translate('components.native-chat.composer.commands', 'Commands')
|
||||
: translate('components.native-chat.composer.skills', 'Skills')}
|
||||
@@ -201,7 +201,7 @@ function PickerOption({
|
||||
<span className="flex min-w-0 items-baseline gap-1.5">
|
||||
<span className="min-w-0 truncate font-mono font-medium">{item.token}</span>
|
||||
{item.kind === 'command' && item.argumentHint ? (
|
||||
<span className="min-w-0 truncate font-mono text-[11px] text-muted-foreground">
|
||||
<span className="min-w-0 truncate font-mono text-2xs text-muted-foreground">
|
||||
{item.argumentHint}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -210,11 +210,11 @@ function PickerOption({
|
||||
<span className="block truncate text-xs text-muted-foreground">{description}</span>
|
||||
) : null}
|
||||
{annotation ? (
|
||||
<span className="block truncate text-[11px] text-muted-foreground">{annotation}</span>
|
||||
<span className="block truncate text-2xs text-muted-foreground">{annotation}</span>
|
||||
) : null}
|
||||
</span>
|
||||
{item.kind === 'skill' ? (
|
||||
<span className="shrink-0 pt-0.5 text-[11px] text-muted-foreground">
|
||||
<span className="shrink-0 pt-0.5 text-2xs text-muted-foreground">
|
||||
{scopeLabel(item.sources[0]?.sourceKind)}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -269,7 +269,7 @@ function PickerOptionButton({
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-2 rounded-md border border-transparent px-2 py-1.5 text-left text-[13px] hover:bg-accent hover:text-accent-foreground',
|
||||
'flex w-full items-start gap-2 rounded-md border border-transparent px-2 py-1.5 text-left text-sm hover:bg-accent hover:text-accent-foreground',
|
||||
selected && 'border-border bg-accent text-accent-foreground'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { NativeChatToolRunIcon } from './NativeChatToolIcon'
|
||||
import { useNativeChatClippedLine } from './use-native-chat-clipped-line'
|
||||
|
||||
const ROW_CLASS_NAME =
|
||||
'flex min-h-6 w-full items-center gap-1.5 py-0.5 text-left text-sm leading-relaxed text-muted-foreground'
|
||||
'flex min-h-6 w-full items-center gap-1.5 py-0.5 text-left text-sm leading-relaxed text-chat-foreground-faint'
|
||||
|
||||
/**
|
||||
* The row a question tool call draws in place of its raw input. The agent is
|
||||
@@ -77,14 +77,17 @@ export function NativeChatAwaitingInputRow({
|
||||
)
|
||||
const header = (
|
||||
<>
|
||||
<NativeChatToolRunIcon iconName="message-square-more" className="text-muted-foreground" />
|
||||
<NativeChatToolRunIcon
|
||||
iconName="message-square-more"
|
||||
className="text-chat-foreground-faint"
|
||||
/>
|
||||
<span className={cn('shrink-0', pending && 'animate-pulse motion-reduce:animate-none')}>
|
||||
{label}
|
||||
</span>
|
||||
{question !== null && toggles && open ? null : (
|
||||
<span
|
||||
ref={question === null ? undefined : measureLine}
|
||||
className="min-w-0 truncate text-foreground/85"
|
||||
className="min-w-0 truncate text-chat-foreground"
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
@@ -93,7 +96,7 @@ export function NativeChatAwaitingInputRow({
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'size-3.5 shrink-0 text-muted-foreground transition-all motion-reduce:transition-none',
|
||||
'size-3.5 shrink-0 text-chat-foreground-faint transition-all motion-reduce:transition-none',
|
||||
open
|
||||
? 'rotate-90'
|
||||
: 'can-hover:opacity-0 group-hover/ask-row:opacity-100 group-focus-visible/ask-row:opacity-100'
|
||||
@@ -114,7 +117,7 @@ export function NativeChatAwaitingInputRow({
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
ROW_CLASS_NAME,
|
||||
'group/ask-row rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70'
|
||||
'group/ask-row rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring'
|
||||
)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
@@ -125,13 +128,13 @@ export function NativeChatAwaitingInputRow({
|
||||
)}
|
||||
{toggles && open && question !== null ? (
|
||||
// Indented to the label, past the icon slot and its gap.
|
||||
<p className="whitespace-pre-wrap break-words pl-5.5 text-sm leading-relaxed text-foreground/85">
|
||||
<p className="whitespace-pre-wrap break-words pl-5.5 text-sm leading-relaxed text-chat-foreground">
|
||||
{question}
|
||||
</p>
|
||||
) : null}
|
||||
{open && questions !== null ? (
|
||||
// Numbers hang in the icon slot so each question starts under the label.
|
||||
<ol className="list-decimal space-y-1 pl-5.5 text-sm leading-relaxed text-foreground/85 marker:text-muted-foreground">
|
||||
<ol className="list-decimal space-y-1 pl-5.5 text-sm leading-relaxed text-chat-foreground marker:text-chat-foreground-faint">
|
||||
{questions.map((entry) => (
|
||||
<li key={entry} className="whitespace-pre-wrap break-words">
|
||||
{entry}
|
||||
|
||||
@@ -55,7 +55,7 @@ export function NativeChatBackgroundTaskRun({
|
||||
duration
|
||||
].filter((part): part is string => part !== null)
|
||||
return (
|
||||
<div className="min-w-0 py-0.5 font-sans text-[13px] leading-relaxed text-chat-foreground-faint">
|
||||
<div className="min-w-0 py-0.5 font-sans text-sm leading-relaxed text-chat-foreground-faint">
|
||||
<div className="flex min-h-6 min-w-0 items-center gap-1.5">
|
||||
<Icon aria-hidden="true" className="size-3.5 shrink-0 text-chat-foreground-faint" />
|
||||
<AgentStateDot state={state} size="sm" title={null} />
|
||||
|
||||
@@ -86,7 +86,7 @@ function BackgroundTaskRow(props: {
|
||||
const stopId = backgroundTaskRowStopId(row, props.supportsTaskStop)
|
||||
return (
|
||||
<>
|
||||
<li className="flex h-6 min-w-0 items-center gap-2 text-foreground/80">
|
||||
<li className="flex h-6 min-w-0 items-center gap-2 text-foreground">
|
||||
<Icon aria-hidden="true" className={`size-3.5 shrink-0 ${kindIconTone(row.kind, false)}`} />
|
||||
{/* Every attention state states its reason on the row; `unverifiable` is never dropped. */}
|
||||
<AgentChildRowContent
|
||||
@@ -98,7 +98,7 @@ function BackgroundTaskRow(props: {
|
||||
dotTitle={null}
|
||||
/>
|
||||
{meta ? (
|
||||
<span className="shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground">
|
||||
<span className="shrink-0 font-mono text-3xs tabular-nums text-muted-foreground">
|
||||
{meta}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -192,7 +192,7 @@ export function NativeChatBackgroundTasksStatus(props: {
|
||||
<div className="flex h-8 items-center px-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-6 min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md px-1.5 text-left outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
className="flex h-6 min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md px-1.5 text-left outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-expanded={expanded}
|
||||
aria-controls={taskListId}
|
||||
aria-label={headerText}
|
||||
@@ -246,7 +246,7 @@ export function NativeChatBackgroundTasksStatus(props: {
|
||||
key={group.kind}
|
||||
className={index > 0 ? 'mt-1.5 border-t border-border/60 pt-1.5' : ''}
|
||||
>
|
||||
<p className="px-0.5 pb-1 font-mono text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<p className="px-0.5 pb-1 font-mono text-3xs uppercase tracking-wider text-muted-foreground">
|
||||
{backgroundTaskGroupLabel(group.kind)}
|
||||
</p>
|
||||
<ul
|
||||
|
||||
@@ -39,7 +39,7 @@ export function NativeChatCodeBlock({
|
||||
<pre
|
||||
data-native-chat-code-content
|
||||
className={cn(
|
||||
'scrollbar-sleek m-0 max-w-full overflow-x-auto font-mono text-[12px] text-chat-code-foreground',
|
||||
'scrollbar-sleek m-0 max-w-full overflow-x-auto font-mono text-chat-code text-chat-code-foreground',
|
||||
language ? 'px-3.5 pt-0.5 pb-3' : 'p-3 pr-10'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -352,7 +352,7 @@ 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 native-chat-message-text text-chat-foreground-strong 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-chat-foreground-faint disabled:cursor-not-allowed disabled:opacity-50'
|
||||
)}
|
||||
|
||||
@@ -107,7 +107,7 @@ function NoticeRow({ notice }: { notice: NativeChatComposerNotice }): React.JSX.
|
||||
</div>
|
||||
{notice.errorText ? (
|
||||
<div className="relative mb-2 ml-8 mr-2.5 rounded-md border border-border bg-muted">
|
||||
<pre className="scrollbar-sleek max-h-28 select-text overflow-auto whitespace-pre-wrap break-words py-1.5 pl-2 pr-8 font-mono text-[11px] text-foreground">
|
||||
<pre className="scrollbar-sleek max-h-28 select-text overflow-auto whitespace-pre-wrap break-words py-1.5 pl-2 pr-8 font-mono text-2xs text-foreground">
|
||||
{notice.errorText}
|
||||
</pre>
|
||||
<NativeChatCopyButton
|
||||
|
||||
@@ -193,7 +193,7 @@ export function NativeChatContextUsageRing({
|
||||
</ul>
|
||||
) : null}
|
||||
{usage.estimated ? (
|
||||
<p className="mt-3 text-[11px] text-muted-foreground">
|
||||
<p className="mt-3 text-2xs text-muted-foreground">
|
||||
{translate(
|
||||
'components.native-chat.contextUsage.estimated',
|
||||
'Estimated from the last response.'
|
||||
|
||||
@@ -94,8 +94,8 @@ function DiffRow({ line, gutterWidth }: { line: NativeChatEditLine; gutterWidth:
|
||||
<span
|
||||
className={cn(
|
||||
'w-3 shrink-0 select-none text-center',
|
||||
line.kind === 'add' && 'text-[var(--git-decoration-added)]',
|
||||
line.kind === 'del' && 'text-[var(--git-decoration-deleted)]'
|
||||
line.kind === 'add' && 'text-chat-diff-added',
|
||||
line.kind === 'del' && 'text-chat-diff-removed'
|
||||
)}
|
||||
aria-hidden
|
||||
>
|
||||
@@ -122,7 +122,7 @@ function DiffCardRows({
|
||||
<div
|
||||
data-native-chat-code-content
|
||||
tabIndex={0}
|
||||
className="ml-6 mt-1 max-h-72 overflow-auto rounded-lg border border-chat-code-border bg-chat-code-surface py-1 font-mono text-xs leading-relaxed text-chat-foreground scrollbar-sleek focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
|
||||
className="ml-6 mt-1 max-h-72 overflow-auto rounded-lg border border-chat-code-border bg-chat-code-surface py-1 font-mono text-chat-code leading-relaxed text-chat-foreground scrollbar-sleek focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
>
|
||||
{lines.map((line) => {
|
||||
const signature = `${line.kind}:${line.oldLineNumber}:${line.newLineNumber}:${line.text}`
|
||||
@@ -179,8 +179,8 @@ export function NativeChatDiffCard({
|
||||
type="button"
|
||||
onClick={() => hasBody && setExpanded(!expanded)}
|
||||
className={cn(
|
||||
'flex min-h-[26px] min-w-0 flex-1 items-center gap-2 text-left font-sans text-[13px]',
|
||||
hasBody ? 'cursor-pointer hover:bg-accent/30' : 'cursor-default'
|
||||
'flex min-h-6.5 min-w-0 flex-1 items-center gap-2 rounded-md text-left font-sans text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',
|
||||
hasBody ? 'cursor-pointer hover:bg-accent/20' : 'cursor-default'
|
||||
)}
|
||||
aria-expanded={hasBody ? expanded : undefined}
|
||||
>
|
||||
|
||||
@@ -8,17 +8,15 @@ export function NativeChatDiffView({ lines }: { lines: DiffLine[] }): React.JSX.
|
||||
return (
|
||||
<div
|
||||
data-native-chat-code-content
|
||||
className="overflow-hidden rounded-lg border border-chat-code-border bg-chat-code-surface py-1 font-mono text-xs leading-relaxed text-chat-foreground"
|
||||
className="overflow-hidden rounded-lg border border-chat-code-border bg-chat-code-surface py-1 font-mono text-chat-code leading-relaxed text-chat-foreground"
|
||||
>
|
||||
{lines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'whitespace-pre-wrap break-words px-2',
|
||||
line.kind === 'add' &&
|
||||
'bg-[var(--diff-added-ground)] text-[var(--git-decoration-added)]',
|
||||
line.kind === 'del' &&
|
||||
'bg-[var(--diff-removed-ground)] text-[var(--git-decoration-deleted)]',
|
||||
line.kind === 'add' && 'bg-[var(--diff-added-ground)] text-chat-diff-added',
|
||||
line.kind === 'del' && 'bg-[var(--diff-removed-ground)] text-chat-diff-removed',
|
||||
line.kind === 'meta' && 'text-chat-foreground-faint',
|
||||
line.kind === 'context' && 'text-chat-foreground'
|
||||
)}
|
||||
|
||||
@@ -36,9 +36,9 @@ export function NativeChatEmptyState({
|
||||
<MessageSquare className="size-6" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground">{copy.title}</p>
|
||||
<p className="text-sm font-medium text-chat-foreground-strong">{copy.title}</p>
|
||||
{copy.subtitle ? (
|
||||
<p className="max-w-sm text-balance text-xs text-muted-foreground">{copy.subtitle}</p>
|
||||
<p className="max-w-sm text-balance text-xs text-chat-foreground-faint">{copy.subtitle}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useNativeChatVisualDocument } from './use-native-chat-visual-document'
|
||||
|
||||
export function NativeChatVisualUnavailable(): React.JSX.Element {
|
||||
return (
|
||||
<p className="my-2 text-xs text-muted-foreground">
|
||||
<p className="my-2 text-xs text-chat-foreground-faint">
|
||||
{translate('components.native-chat.visualUnavailable', 'Visualization unavailable')}
|
||||
</p>
|
||||
)
|
||||
|
||||
@@ -11,7 +11,7 @@ export function NativeChatLoadingCue(): React.JSX.Element {
|
||||
data-native-chat-loading-cue="true"
|
||||
className="flex h-full w-full items-center justify-center animate-in fade-in delay-250 [--tw-animation-fill-mode:backwards]"
|
||||
>
|
||||
<LoadingSpinner className="size-5 text-muted-foreground" />
|
||||
<LoadingSpinner className="size-5 text-chat-foreground-faint" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -394,7 +394,7 @@ import type { NativeChatLiveSession } from './use-native-chat-live-session'
|
||||
fireEvent.click(toggle)
|
||||
rerender(transcript([first, active]))
|
||||
expect(within(toggle.parentElement!).getByText('Verify').closest('li')).toHaveClass(
|
||||
'text-foreground'
|
||||
'text-chat-foreground-strong'
|
||||
)
|
||||
expect(within(viewport as HTMLElement).getByText('Started Verify')).toBeInTheDocument()
|
||||
rerender(transcript([last]))
|
||||
|
||||
@@ -233,7 +233,8 @@ export function NativeChatMessageList({
|
||||
isVisible,
|
||||
// One pin serves both: revealing a diff and jumping from the rail are
|
||||
// mutually exclusive things to be doing.
|
||||
revealIndex: nativeChatSlotIndexOf(slots, railJump?.messageId ?? revealedDiff?.messageId)
|
||||
revealIndex: nativeChatSlotIndexOf(slots, railJump?.messageId ?? revealedDiff?.messageId),
|
||||
typography
|
||||
})
|
||||
const { showJump, onScroll, scrollToBottom, scrollMessageToTop, readerLeavesEnd } =
|
||||
useNativeChatTranscriptScroll({
|
||||
|
||||
@@ -95,7 +95,7 @@ describe('the row an Orca stop leaves', () => {
|
||||
])('names a %s and the machine, muted', (cause, sentence) => {
|
||||
renderStatus(orcaStopRow(cause), 'studio-mac')
|
||||
const row = screen.getByText(`${sentence} You can continue in this conversation.`)
|
||||
expect(row.parentElement?.parentElement).toHaveClass('text-muted-foreground')
|
||||
expect(row.parentElement?.parentElement).toHaveClass('text-chat-foreground-faint')
|
||||
expect(screen.queryByText(LEGACY_TEXT)).toBeNull()
|
||||
})
|
||||
|
||||
@@ -136,7 +136,7 @@ describe('the row an Orca stop leaves', () => {
|
||||
expect(
|
||||
screen.getByText('Orca on QA SSH stopped while this response was in progress.').parentElement
|
||||
?.parentElement
|
||||
).toHaveClass('text-muted-foreground')
|
||||
).toHaveClass('text-chat-foreground-faint')
|
||||
expect(screen.queryByText(/was closed/)).toBeNull()
|
||||
})
|
||||
|
||||
@@ -170,13 +170,13 @@ describe('the row an Orca stop leaves', () => {
|
||||
screen.getByText(
|
||||
'Orca on studio-mac stopped unexpectedly while this response was in progress.'
|
||||
).parentElement?.parentElement
|
||||
).toHaveClass('text-muted-foreground')
|
||||
).toHaveClass('text-chat-foreground-faint')
|
||||
cleanup()
|
||||
renderStatus(represented, null, true)
|
||||
expect(
|
||||
screen.getByText('This response was interrupted. You can continue in this conversation.')
|
||||
.parentElement?.parentElement
|
||||
).toHaveClass('text-muted-foreground')
|
||||
).toHaveClass('text-chat-foreground-faint')
|
||||
})
|
||||
|
||||
it('keeps the host words for a cause this build does not know', () => {
|
||||
@@ -187,7 +187,7 @@ describe('the row an Orca stop leaves', () => {
|
||||
it('keeps the host words when the chat has no machine to name, muted all the same', () => {
|
||||
renderStatus(orcaStopRow('update'), null)
|
||||
expect(screen.getByText(LEGACY_TEXT).parentElement?.parentElement).toHaveClass(
|
||||
'text-muted-foreground'
|
||||
'text-chat-foreground-faint'
|
||||
)
|
||||
})
|
||||
|
||||
@@ -347,7 +347,7 @@ describe('notice rows', () => {
|
||||
it('renders compaction as a centered separator', () => {
|
||||
renderStatus({ kind: 'status', text: 'Context compacted', presentation: 'compaction' })
|
||||
expect(screen.getByRole('separator', { name: 'Context compacted' })).toHaveClass(
|
||||
'text-muted-foreground'
|
||||
'text-chat-foreground-faint'
|
||||
)
|
||||
expect(
|
||||
screen.getByText('Context compacted').parentElement?.querySelectorAll('.bg-border')
|
||||
@@ -415,7 +415,7 @@ describe('notice rows', () => {
|
||||
['history-item-too-large', 'This part of the chat was too large to show.']
|
||||
])('words a %s row itself, as a muted status line', (presentation, words) => {
|
||||
renderStatus({ kind: 'status', text: 'Words an older host wrote', presentation })
|
||||
expect(screen.getByText(words)).toHaveClass('text-muted-foreground', 'text-sm')
|
||||
expect(screen.getByText(words)).toHaveClass('text-chat-foreground-faint', 'text-sm')
|
||||
expect(screen.queryByText('Words an older host wrote')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,7 +63,7 @@ export function NativeChatNoticeRow({
|
||||
<div
|
||||
role="separator"
|
||||
aria-label={label}
|
||||
className="flex items-center gap-3 py-2 text-xs text-muted-foreground"
|
||||
className="flex items-center gap-3 py-2 text-xs text-chat-foreground-faint"
|
||||
>
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
<span>{label}</span>
|
||||
@@ -74,7 +74,7 @@ export function NativeChatNoticeRow({
|
||||
if (block.presentation === 'command-output') {
|
||||
// Why: command output is laid out in columns; proportional type breaks its grid.
|
||||
return (
|
||||
<pre className="whitespace-pre-wrap break-words font-mono text-xs text-foreground">
|
||||
<pre className="whitespace-pre-wrap break-words font-mono text-xs text-chat-foreground">
|
||||
{block.text}
|
||||
</pre>
|
||||
)
|
||||
@@ -82,7 +82,7 @@ export function NativeChatNoticeRow({
|
||||
if (isAgentSessionHostStatusPresentation(block.presentation)) {
|
||||
// The look of any other host status line; only the words are the reader's.
|
||||
return (
|
||||
<p className="min-w-0 max-w-full select-text text-sm text-muted-foreground [overflow-wrap:anywhere]">
|
||||
<p className="min-w-0 max-w-full select-text text-sm text-chat-foreground-faint [overflow-wrap:anywhere]">
|
||||
{HOST_STATUS_WORDS[block.presentation]()}
|
||||
</p>
|
||||
)
|
||||
@@ -153,11 +153,11 @@ export function NativeChatNoticeRow({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'space-y-2 text-sm text-foreground',
|
||||
'space-y-2 text-sm text-chat-foreground',
|
||||
Icon && 'rounded-md border border-border bg-muted/20 p-3',
|
||||
tone === 'warning' && 'text-[color:var(--warning,#f59e0b)]',
|
||||
tone === 'warning' && 'text-chat-warning',
|
||||
tone === 'error' && 'text-destructive',
|
||||
tone === 'notice' && 'text-muted-foreground'
|
||||
tone === 'notice' && 'text-chat-foreground-faint'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
|
||||
@@ -35,7 +35,7 @@ export function NativeChatOlderHistoryRow({
|
||||
className="absolute inset-x-0 top-0 flex h-10 items-center justify-center"
|
||||
>
|
||||
{olderHistory.isAutoLoadEnabled ? (
|
||||
<span role="status" aria-live="polite" className="text-xs text-muted-foreground">
|
||||
<span role="status" aria-live="polite" className="text-xs text-chat-foreground-faint">
|
||||
{loadingEarlier ? <DelayedLoadingLabel /> : null}
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import type { CommentMarkdownLinkClickHandler } from '@/components/sidebar/CommentMarkdown'
|
||||
import { NativeChatPlainCodeBlock } from './NativeChatCodeBlock'
|
||||
import { NativeChatCodeBlock } from './NativeChatCodeBlock'
|
||||
import { NativeChatMarkdown } from './NativeChatMarkdown'
|
||||
|
||||
/** A reasoning block's text, under the live activity line or its finished row: quieter prose in the
|
||||
@@ -26,8 +26,8 @@ export function NativeChatReasoningBody({
|
||||
<NativeChatMarkdown
|
||||
content={markdown}
|
||||
variant="document"
|
||||
className="text-sm native-chat-message-text"
|
||||
renderCodeBlock={NativeChatPlainCodeBlock}
|
||||
className="text-sm"
|
||||
renderCodeBlock={NativeChatCodeBlock}
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks={allowFileUriLinks}
|
||||
linkifyFilePaths={onLinkClick !== undefined}
|
||||
|
||||
@@ -48,17 +48,17 @@ export function NativeChatReasoningRow({
|
||||
const headline = translatedHeadline(nativeChatReasoningHeadline(message, { live: turnIsWorking }))
|
||||
|
||||
return (
|
||||
<div className="min-w-0 text-sm text-muted-foreground">
|
||||
<div className="min-w-0 text-sm text-chat-foreground-faint">
|
||||
<Collapsible open={disclosure.open} onOpenChange={disclosure.setOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
{/* Laid out like a tool run's header, so its glyph sits in the same column. */}
|
||||
<button
|
||||
type="button"
|
||||
className="group/reasoning flex min-h-6 w-full min-w-0 items-center gap-1.5 rounded-md py-0.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
|
||||
className="group/reasoning flex min-h-6 w-full min-w-0 items-center gap-1.5 rounded-md py-0.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
>
|
||||
{headline === label ? null : <span className="sr-only">{label}: </span>}
|
||||
<NativeChatToolRunIcon iconName="brain" className="text-chat-foreground-faint" />
|
||||
<span className="min-w-0 truncate text-sm native-chat-message-text leading-relaxed text-chat-foreground-faint transition-colors group-hover/reasoning:text-chat-foreground">
|
||||
<span className="min-w-0 truncate text-sm leading-relaxed text-chat-foreground-faint transition-colors group-hover/reasoning:text-chat-foreground">
|
||||
{headline}
|
||||
</span>
|
||||
<NativeChatReasoningChevron />
|
||||
|
||||
@@ -44,7 +44,7 @@ export function NativeChatResolutionReceipt({
|
||||
const answers = nativeChatReceiptAnswers(body)
|
||||
return (
|
||||
<div
|
||||
className="space-y-1 border-l border-border pl-3 text-xs text-muted-foreground"
|
||||
className="space-y-1 border-l border-border pl-3 text-xs text-chat-foreground-faint"
|
||||
data-native-chat-receipt={body.kind}
|
||||
>
|
||||
{body.kind === 'question' ? (
|
||||
|
||||
@@ -102,7 +102,7 @@ export function NativeChatSubagentEntries({
|
||||
<StatusDot state={state} pulsing={state === 'working'} />
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 truncate font-sans text-[13px]',
|
||||
'min-w-0 truncate font-sans text-sm',
|
||||
state === 'idle' ? 'text-chat-foreground-faint' : 'text-chat-foreground'
|
||||
)}
|
||||
>
|
||||
@@ -123,7 +123,7 @@ export function NativeChatSubagentEntries({
|
||||
type="button"
|
||||
onClick={() => onSetSectionOpen?.(agent.id, !sectionOpen)}
|
||||
aria-expanded={sectionOpen}
|
||||
className="group/subagent-entry flex w-full items-center gap-1.5 rounded-md py-0.5 text-left hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
|
||||
className="group/subagent-entry flex w-full items-center gap-1.5 rounded-md py-0.5 text-left hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
>
|
||||
{entry}
|
||||
<ChevronRight
|
||||
@@ -189,7 +189,7 @@ export function NativeChatSubagentRun({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="group/subagent-run flex min-h-6 w-full items-center gap-1.5 rounded-md py-0.5 text-left font-sans text-[13px] native-chat-message-text leading-relaxed text-chat-foreground-faint hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
|
||||
className="group/subagent-run flex min-h-6 w-full items-center gap-1.5 rounded-md py-0.5 text-left font-sans text-sm leading-relaxed text-chat-foreground-faint hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
aria-expanded={open}
|
||||
aria-live="polite"
|
||||
>
|
||||
|
||||
@@ -25,7 +25,7 @@ export function NativeChatSubagentSectionHead({
|
||||
type="button"
|
||||
onClick={() => onSetOpen(agentId, !expanded)}
|
||||
aria-expanded={expanded}
|
||||
className="group/subagent-section flex min-h-6 w-full items-center gap-1.5 rounded-md py-0.5 text-left text-muted-foreground hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
|
||||
className="group/subagent-section flex min-h-6 w-full items-center gap-1.5 rounded-md py-0.5 text-left text-chat-foreground-faint hover:bg-accent/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
@@ -36,7 +36,7 @@ export function NativeChatSubagentSectionHead({
|
||||
/>
|
||||
<Bot aria-hidden className="size-3.5 shrink-0" />
|
||||
{state === null ? null : <StatusDot state={state} pulsing={state === 'working'} />}
|
||||
<code className="min-w-0 truncate font-mono text-[11px] text-foreground/80">
|
||||
<code className="min-w-0 truncate font-mono text-2xs text-chat-foreground">
|
||||
{entry?.label ?? translate('components.native-chat.subagents.unnamed', 'Subagent')}
|
||||
</code>
|
||||
</button>
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('NativeChatTaskList', () => {
|
||||
it('shows tri-state glyphs, progress, and activeForm in the first checklist', () => {
|
||||
const { container } = render(<NativeChatTaskList list={current} />)
|
||||
expect(screen.getByText('Read')).toHaveClass('line-through')
|
||||
expect(screen.getByText('Writing').closest('li')).toHaveClass('text-foreground')
|
||||
expect(screen.getByText('Writing').closest('li')).toHaveClass('text-chat-foreground-strong')
|
||||
expect(screen.getByText('Test')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('1 of 3 tasks completed')).toHaveTextContent('1/3')
|
||||
for (const glyph of ['circle', 'circle-dot', 'circle-check']) {
|
||||
|
||||
@@ -46,8 +46,8 @@ function TaskRow({ task, label }: { task: NativeChatTask; label?: string }): Rea
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
'flex items-start gap-1.5 text-xs text-muted-foreground',
|
||||
task.status === 'in_progress' && 'font-medium text-foreground'
|
||||
'flex items-start gap-1.5',
|
||||
task.status === 'in_progress' && 'font-medium text-chat-foreground-strong'
|
||||
)}
|
||||
>
|
||||
<Icon aria-hidden className="mt-0.5 size-3.5 shrink-0" />
|
||||
@@ -66,9 +66,7 @@ function TaskRow({ task, label }: { task: NativeChatTask; label?: string }): Rea
|
||||
|
||||
function Checklist({ list }: { list: TaskList }): React.JSX.Element {
|
||||
return list.tasks.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{translate('components.native-chat.taskList.empty', 'No tasks')}
|
||||
</p>
|
||||
<p className="text-xs">{translate('components.native-chat.taskList.empty', 'No tasks')}</p>
|
||||
) : (
|
||||
<ul
|
||||
aria-label={translate('components.native-chat.taskList.title', 'Tasks')}
|
||||
@@ -112,7 +110,7 @@ export function NativeChatTaskList({
|
||||
<ChevronRight aria-hidden className="size-3.5 group-data-[state=open]:rotate-90" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="max-h-40 overflow-y-auto px-3 pb-2 scrollbar-sleek">
|
||||
<div className="max-h-40 overflow-y-auto px-3 pb-2 text-xs text-muted-foreground scrollbar-sleek">
|
||||
<Checklist list={list} />
|
||||
{list.explanation ? (
|
||||
<p className="whitespace-pre-wrap break-words text-xs text-muted-foreground">
|
||||
@@ -126,8 +124,8 @@ export function NativeChatTaskList({
|
||||
}
|
||||
const changes = previous ? diffNativeChatTaskLists(previous, list) : null
|
||||
return (
|
||||
<div className="space-y-1 py-1">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<div className="space-y-1 py-1 text-sm text-chat-foreground-faint">
|
||||
<div className="flex items-center gap-1.5 text-xs text-chat-foreground-faint">
|
||||
<ListChecks aria-hidden className="size-4 shrink-0" />
|
||||
<span className="font-medium">
|
||||
{translate('components.native-chat.taskList.title', 'Tasks')}
|
||||
@@ -156,14 +154,19 @@ export function NativeChatTaskList({
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-chat-foreground-faint">
|
||||
{translate('components.native-chat.taskList.unchanged', 'Tasks unchanged')}
|
||||
</p>
|
||||
)}
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="group flex items-center gap-1 rounded py-0.5 text-xs text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<ChevronRight aria-hidden className="size-3.5 group-data-[state=open]:rotate-90" />
|
||||
{translate('components.native-chat.taskList.showAll', 'Full task list')}
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="group flex items-center gap-1 rounded py-0.5 text-xs text-chat-foreground-faint hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<ChevronRight aria-hidden className="size-3.5 group-data-[state=open]:rotate-90" />
|
||||
{translate('components.native-chat.taskList.showAll', 'Full task list')}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent animation="height" data-native-chat-member-detail>
|
||||
<Checklist list={list} />
|
||||
@@ -174,7 +177,7 @@ export function NativeChatTaskList({
|
||||
<Checklist list={list} />
|
||||
)}
|
||||
{list.explanation ? (
|
||||
<p className="whitespace-pre-wrap break-words text-xs text-muted-foreground">
|
||||
<p className="whitespace-pre-wrap break-words text-xs text-chat-foreground-faint">
|
||||
{list.explanation}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -214,7 +214,7 @@ describe('tool sentence rows', () => {
|
||||
'bg-chat-code-surface',
|
||||
'border-chat-code-border',
|
||||
'rounded-lg',
|
||||
'text-xs'
|
||||
'text-chat-code'
|
||||
)
|
||||
fireEvent.click(button)
|
||||
expect(screen.queryByText('command failed')).toBeNull()
|
||||
|
||||
@@ -59,7 +59,7 @@ function ToolLineDetail({
|
||||
{!diff && fullCommand ? (
|
||||
<pre
|
||||
data-native-chat-code-content
|
||||
className="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-chat-code-border bg-chat-code-surface p-2 font-mono text-xs text-chat-foreground scrollbar-sleek"
|
||||
className="max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-chat-code-border bg-chat-code-surface p-2 font-mono text-chat-code text-chat-foreground scrollbar-sleek"
|
||||
>
|
||||
{commandLanguage ? (
|
||||
<NativeChatHighlightedCode code={fullCommand} language={commandLanguage} />
|
||||
@@ -72,7 +72,7 @@ function ToolLineDetail({
|
||||
<pre
|
||||
data-native-chat-code-content
|
||||
className={cn(
|
||||
'max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-chat-code-border bg-chat-code-surface p-2 font-mono text-xs scrollbar-sleek',
|
||||
'max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-lg border border-chat-code-border bg-chat-code-surface p-2 font-mono text-chat-code scrollbar-sleek',
|
||||
body.isError ? 'text-destructive' : 'text-chat-foreground'
|
||||
)}
|
||||
>
|
||||
@@ -134,7 +134,7 @@ export function NativeChatToolLine({
|
||||
type="button"
|
||||
onClick={() => hasDetail && setExpanded(!expanded)}
|
||||
className={cn(
|
||||
'group/tool-line flex min-h-[26px] w-full items-center gap-2 text-left font-sans text-[13px]',
|
||||
'group/tool-line flex min-h-6.5 w-full items-center gap-2 rounded-md text-left font-sans text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',
|
||||
hasDetail ? 'cursor-pointer' : 'cursor-default'
|
||||
)}
|
||||
aria-expanded={hasDetail ? expanded : undefined}
|
||||
|
||||
@@ -49,7 +49,7 @@ import { i18n } from '@/i18n/i18n'
|
||||
)
|
||||
expect(style['--chat-content-max-width']).toBe('46rem')
|
||||
expect(style['--chat-font-family']).toContain('Menlo')
|
||||
const summary = container.querySelector('span.native-chat-message-text')
|
||||
const summary = container.querySelector('span.line-clamp-2')
|
||||
expect(summary).toHaveTextContent(live ? 'running 1 agent' : 'ran 1 agent')
|
||||
expect(summary).toHaveClass('min-w-0', 'line-clamp-2', 'whitespace-normal', 'break-words')
|
||||
expect(summary).not.toHaveClass('truncate', 'whitespace-nowrap', 'font-mono')
|
||||
@@ -74,7 +74,7 @@ import { i18n } from '@/i18n/i18n'
|
||||
</div>
|
||||
)
|
||||
|
||||
const summary = container.querySelector('span.native-chat-message-text')
|
||||
const summary = container.querySelector('span.line-clamp-2')
|
||||
expect(summary).toHaveClass('line-clamp-2', 'break-words')
|
||||
expect(summary?.textContent).toContain('printf')
|
||||
expect(summary?.textContent?.length).toBeLessThan(command.length)
|
||||
@@ -104,9 +104,8 @@ import { i18n } from '@/i18n/i18n'
|
||||
const header = screen.getByRole('button')
|
||||
// Top-aligned in the summary's own type, so `1lh` is one summary line.
|
||||
expect(header).toHaveClass('items-start', 'text-sm', 'leading-relaxed')
|
||||
expect(header).toHaveClass('native-chat-message-text')
|
||||
expect(header).not.toHaveClass('items-center')
|
||||
const summary = container.querySelector('span.native-chat-message-text')
|
||||
const summary = container.querySelector('span.line-clamp-2')
|
||||
expect(summary?.parentElement).toBe(header)
|
||||
expect(summary).toHaveClass('min-w-0', 'line-clamp-2')
|
||||
|
||||
|
||||
@@ -325,7 +325,7 @@ describe('NativeChatToolRun', () => {
|
||||
it('keeps the run in the transcript type, not a monospace dump', () => {
|
||||
const { container } = render(<NativeChatToolRun blocks={batch} expandSignal={false} />)
|
||||
|
||||
const label = runHeader(container).querySelector('span.native-chat-message-text')
|
||||
const label = runHeader(container).querySelector('span.line-clamp-2')
|
||||
expect(label).toHaveClass('text-sm')
|
||||
expect(label).not.toHaveClass('font-mono')
|
||||
})
|
||||
|
||||
@@ -229,7 +229,7 @@ export function NativeChatToolRun({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="group/tool-run flex min-h-6 w-full items-start gap-1.5 rounded-md py-0.5 text-left text-sm native-chat-message-text leading-relaxed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
|
||||
className="group/tool-run flex min-h-6 w-full items-start gap-1.5 rounded-md py-0.5 text-left text-sm leading-relaxed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
aria-expanded={open}
|
||||
aria-live="polite"
|
||||
data-native-chat-tool-run-state={live ? 'live' : 'settled'}
|
||||
@@ -245,7 +245,7 @@ export function NativeChatToolRun({
|
||||
{/* Keep the collapsed header bounded; the tool detail carries the full command. */}
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 line-clamp-2 whitespace-normal break-words text-sm native-chat-message-text leading-relaxed transition-colors',
|
||||
'min-w-0 line-clamp-2 whitespace-normal break-words text-sm leading-relaxed transition-colors',
|
||||
live
|
||||
? 'max-w-[72%] shrink-0 animate-pulse text-chat-foreground motion-reduce:animate-none'
|
||||
: 'min-w-0 text-chat-foreground-faint group-hover/tool-run:text-chat-foreground'
|
||||
|
||||
@@ -116,7 +116,7 @@ export function NativeChatToolRunMembers({
|
||||
ref={scrollerRef}
|
||||
onScroll={readPosition}
|
||||
className={cn(
|
||||
'scrollbar-sleek ml-[7px] mt-0.5 overflow-y-auto border-l border-chat-code-border pl-[13px]',
|
||||
'scrollbar-sleek ml-1.75 mt-0.5 overflow-y-auto border-l border-chat-code-border pl-3.25',
|
||||
// Contains the members' absolute sr-only labels, which otherwise lengthen the page.
|
||||
'relative',
|
||||
// Room for the list plus what the reader has opened in it, up to half the window.
|
||||
|
||||
@@ -135,7 +135,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-chat-canvas 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-chat-foreground-faint"
|
||||
title={label}
|
||||
>
|
||||
<ImageIcon className="size-3.5 shrink-0" />
|
||||
@@ -249,7 +249,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-chat-canvas 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-chat-foreground-faint"
|
||||
title={label}
|
||||
>
|
||||
<ImageIcon className="size-3.5 shrink-0" />
|
||||
@@ -325,12 +325,12 @@ export function ProviderFrameRow({
|
||||
}
|
||||
const frame = block.providerFrame
|
||||
return (
|
||||
<details className="group text-xs text-muted-foreground">
|
||||
<details className="group text-xs text-chat-foreground-faint">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 rounded-md px-2 py-1 font-mono hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<span className="transition-transform motion-reduce:transition-none group-open:rotate-90">
|
||||
›
|
||||
</span>
|
||||
<span className="font-medium text-foreground">{frame.provider}</span>
|
||||
<span className="font-medium text-chat-foreground-strong">{frame.provider}</span>
|
||||
<span className="truncate">{summary ?? nativeChatProviderFrameSummary(block)}</span>
|
||||
{frame.payload.truncated ? (
|
||||
<span>
|
||||
@@ -343,7 +343,7 @@ export function ProviderFrameRow({
|
||||
</summary>
|
||||
<pre
|
||||
data-native-chat-code-content
|
||||
className="scrollbar-sleek mt-1 max-h-64 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-muted p-2 font-mono text-xs text-foreground"
|
||||
className="scrollbar-sleek mt-1 max-h-64 overflow-auto whitespace-pre-wrap rounded-md border border-border bg-muted p-2 font-mono text-chat-code text-foreground"
|
||||
>
|
||||
{frame.payload.head}
|
||||
{frame.payload.truncated ? '\n…' : ''}
|
||||
|
||||
@@ -73,7 +73,7 @@ export const NativeChatTranscriptRow = memo(function NativeChatTranscriptRow({
|
||||
sections={slot.sections}
|
||||
onSetSectionOpen={context.subagentDisclosure.setSectionOpen}
|
||||
// The type its roster row's list inherits, so both halves of the list match.
|
||||
className="text-xs leading-relaxed text-muted-foreground"
|
||||
className="text-xs leading-relaxed text-chat-foreground-faint"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -57,14 +57,14 @@ export function NativeChatTurnActivityLine({
|
||||
{/* One element for every state of the line, so a screen reader hears each new label. The
|
||||
trigger overlays it, rather than wrapping it, and the body sits outside it. */}
|
||||
<div
|
||||
className="group/reasoning relative flex min-h-6 items-center gap-1.5 text-sm native-chat-message-text leading-relaxed text-muted-foreground"
|
||||
className="group/reasoning relative flex min-h-6 items-center gap-1.5 text-sm leading-relaxed text-chat-foreground-faint"
|
||||
data-native-chat-turn-activity="true"
|
||||
data-state={open ? 'open' : 'closed'}
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<LoadingSpinner className="size-4 shrink-0" />
|
||||
<span id={labelId} className="min-w-0 truncate text-foreground/85">
|
||||
<span id={labelId} className="min-w-0 truncate text-chat-foreground">
|
||||
{label}
|
||||
</span>
|
||||
{reasoning ? (
|
||||
@@ -74,7 +74,7 @@ export function NativeChatTurnActivityLine({
|
||||
<button
|
||||
type="button"
|
||||
aria-labelledby={labelId}
|
||||
className="absolute inset-0 rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
|
||||
className="absolute inset-0 rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
</>
|
||||
|
||||
@@ -100,7 +100,7 @@ export function NativeChatUserMessageRow({
|
||||
{/* A distinct surface separates the user's prompt from the assistant's prose. */}
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[80%] text-sm native-chat-message-text',
|
||||
'max-w-[80%] text-sm',
|
||||
from
|
||||
? 'select-text border-l-2 border-border/60 pl-3 text-chat-foreground'
|
||||
: 'rounded-xl border border-chat-user-border bg-chat-user-surface px-3.5 py-2.5 text-chat-foreground-strong'
|
||||
@@ -121,7 +121,7 @@ export function NativeChatUserMessageRow({
|
||||
<NativeChatMarkdown
|
||||
content={markdown}
|
||||
variant="document"
|
||||
className="text-sm native-chat-message-text"
|
||||
className="text-sm"
|
||||
renderCodeBlock={NativeChatCodeBlock}
|
||||
onLinkClick={onLinkClick}
|
||||
allowFileUriLinks={allowFileUriLinks}
|
||||
@@ -137,7 +137,7 @@ export function NativeChatUserMessageRow({
|
||||
)}
|
||||
</div>
|
||||
{message.sentAs === 'goal' ? (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1 text-xs text-chat-foreground-faint">
|
||||
<Goal className="size-3" aria-hidden />
|
||||
<span>{translate('components.native-chat.goal.sentAsGoal', 'Sent as goal')}</span>
|
||||
</div>
|
||||
@@ -149,7 +149,7 @@ export function NativeChatUserMessageRow({
|
||||
{...(rewind ? { rewind: { itemId: message.id, surface: rewind } } : {})}
|
||||
/>
|
||||
{deliveryNotice?.text !== undefined ? (
|
||||
<div className="flex max-w-[85%] items-center gap-2 text-[11px] text-destructive/80">
|
||||
<div className="flex max-w-[85%] items-center gap-2 text-2xs text-destructive/80">
|
||||
<span className="min-w-0 break-words">{deliveryNotice.text}</span>
|
||||
{deliveryNotice.onDismiss ? (
|
||||
<Button type="button" variant="ghost" size="xs" onClick={deliveryNotice.onDismiss}>
|
||||
|
||||
@@ -87,7 +87,7 @@ export function NativeChatWorkingStatus({
|
||||
data-native-chat-turn-status="settled"
|
||||
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'
|
||||
'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'
|
||||
)}
|
||||
aria-label={translate(
|
||||
'components.native-chat.status.toggleDetails',
|
||||
|
||||
@@ -156,16 +156,38 @@ describe('nativeChatAppearanceStyle terminal interface', () => {
|
||||
'--chat-foreground-mix': '56%'
|
||||
})
|
||||
})
|
||||
|
||||
it('lets message text follow any terminal size while controls and spacing stay in 12-20', () => {
|
||||
const at = (terminalFontSize: number) =>
|
||||
nativeChatAppearanceStyle(
|
||||
makeSettings({ terminalFontSize, nativeChatAppearance: { matchTerminalInterface: true } })
|
||||
)
|
||||
expect(at(24)).toMatchObject({
|
||||
'--chat-font-size': '24px',
|
||||
'--chat-code-font-size': '24px',
|
||||
'--chat-ui-font-size': '20px',
|
||||
'--chat-estimated-line-height': (22 * 24) / 14,
|
||||
'--chat-estimated-spacing-scale': 20 / 14
|
||||
})
|
||||
expect(at(10)).toMatchObject({
|
||||
'--chat-font-size': '10px',
|
||||
'--chat-ui-font-size': '12px',
|
||||
'--chat-estimated-spacing-scale': 12 / 14
|
||||
})
|
||||
expect(at(16)['--chat-ui-font-size']).toBe('16px')
|
||||
})
|
||||
})
|
||||
|
||||
describe('chat root appearance style', () => {
|
||||
it('keeps shared typography tokens independent of chat size and provides a relative code ratio', () => {
|
||||
it('scales typography tokens with chat size: text-sm, xs, 2xs, 3xs follow chat font size', () => {
|
||||
const style = nativeChatAppearanceStyle({
|
||||
nativeChatAppearance: { fontSize: 20, codeFontSize: 12 }
|
||||
})
|
||||
expect(style).not.toHaveProperty('--text-sm')
|
||||
expect(style).not.toHaveProperty('--text-xs')
|
||||
expect(style).not.toHaveProperty('fontSize')
|
||||
// The CSS redefines typography tokens: --text-sm becomes the chat font size,
|
||||
// and xs/2xs/3xs scale as ratios of it. The style object itself does not
|
||||
// carry these (they live in the CSS), but confirms the chat size is set.
|
||||
expect(style['--chat-font-size']).toBe('20px')
|
||||
expect(style['--chat-code-font-size']).toBe('12px')
|
||||
expect(style['--chat-inline-code-ratio']).toBe('0.6em')
|
||||
})
|
||||
it('derives wrap capacity from actual column width, including full-width panes', () => {
|
||||
|
||||
@@ -16,11 +16,13 @@ export const NATIVE_CHAT_TRANSCRIPT_COLUMN_CLASS =
|
||||
export type NativeChatAppearanceStyle = CSSProperties &
|
||||
Record<`--${string}`, string | number> & {
|
||||
'--chat-font-size': string
|
||||
'--chat-ui-font-size': string
|
||||
'--chat-code-font-size': string
|
||||
'--chat-content-max-width': string
|
||||
'--chat-inline-code-ratio': string
|
||||
'--chat-estimated-line-height': number
|
||||
'--chat-estimated-chars-per-line': number
|
||||
'--chat-estimated-spacing-scale': number
|
||||
}
|
||||
|
||||
// Width buckets keep a pixel-by-pixel resize from re-deriving the entire transcript.
|
||||
@@ -66,6 +68,8 @@ export function nativeChatAppearanceStyle(
|
||||
const terminalFontSize = settings?.terminalFontSize ?? 14
|
||||
const fontSize = matching ? terminalFontSize : appearance.fontSize
|
||||
const codeFontSize = matching ? terminalFontSize : appearance.codeFontSize
|
||||
// Message text follows the terminal (10-24); labels, controls and spacing stay in the chat's 12-20.
|
||||
const uiFontSize = Math.max(12, Math.min(20, fontSize))
|
||||
const maxWidthPx = width === 'wide' ? 960 : width === 'full' ? Number.POSITIVE_INFINITY : 736
|
||||
const measuredWidth = nativeChatColumnWidthBucket(measuredColumnWidthPx)
|
||||
const columnWidthPx = Math.min(
|
||||
@@ -75,6 +79,7 @@ export function nativeChatAppearanceStyle(
|
||||
const font = buildFontFamily(settings?.terminalFontFamily ?? '')
|
||||
const style: NativeChatAppearanceStyle = {
|
||||
'--chat-font-size': `${fontSize}px`,
|
||||
'--chat-ui-font-size': `${uiFontSize}px`,
|
||||
'--chat-code-font-size': `${codeFontSize}px`,
|
||||
'--chat-content-max-width': width === 'full' ? 'none' : width === 'wide' ? '60rem' : '46rem',
|
||||
'--chat-inline-code-ratio': `${codeFontSize / fontSize}em`,
|
||||
@@ -83,6 +88,7 @@ export function nativeChatAppearanceStyle(
|
||||
1,
|
||||
Math.floor((((96 * columnWidthPx) / 736) * 14) / fontSize)
|
||||
),
|
||||
'--chat-estimated-spacing-scale': uiFontSize / 14,
|
||||
'--chat-code-font-family': font
|
||||
}
|
||||
let light = settings?.theme === 'light' || (settings?.theme === 'system' && !systemPrefersDark)
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
/* Code size is opt-in so monospace metadata retains its existing typography. */
|
||||
.native-chat-appearance [data-native-chat-code-content],
|
||||
.native-chat-markdown pre {
|
||||
font-size: var(--chat-code-font-size, 12px);
|
||||
}
|
||||
|
||||
.native-chat-message-text {
|
||||
font-size: var(--chat-font-size, 14px);
|
||||
}
|
||||
|
||||
/* Text steps are ratios of the text size setting, so the tiers scale together. */
|
||||
.native-chat-appearance {
|
||||
--text-sm: var(--chat-font-size, 0.875rem);
|
||||
/* Labels and controls follow the chat's 12-20 range even when message text follows the terminal. */
|
||||
--chat-ui-size: var(--chat-ui-font-size, var(--text-sm));
|
||||
--text-xs: min(var(--text-sm), calc(var(--chat-ui-size) * 12 / 14));
|
||||
--text-2xs: min(var(--text-sm), calc(var(--chat-ui-size) * 11 / 14));
|
||||
/* Floored so badges stay legible at the smallest setting. */
|
||||
--text-3xs: max(9px, calc(var(--chat-ui-size) * 10 / 14));
|
||||
--text-chat-code: var(--chat-code-font-size, 0.75rem);
|
||||
--ring: var(--chat-focus-ring);
|
||||
/* Icons, gaps and row heights follow the text: 4px at the default 14px. */
|
||||
--spacing: calc(var(--chat-ui-size) / 3.5);
|
||||
font-family: var(--chat-font-family, var(--font-sans));
|
||||
}
|
||||
|
||||
.native-chat-markdown pre {
|
||||
font-size: var(--text-chat-code);
|
||||
}
|
||||
|
||||
.native-chat-appearance :is(.font-sans, textarea, [contenteditable='true']) {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ describe('transcript row height estimate', () => {
|
||||
const style = nativeChatAppearanceStyle({ nativeChatAppearance: { width: 'wide' } }, width)
|
||||
return nativeChatRowContentMetrics(prose, {
|
||||
lineHeightPx: style['--chat-estimated-line-height'],
|
||||
charsPerLine: style['--chat-estimated-chars-per-line']
|
||||
charsPerLine: style['--chat-estimated-chars-per-line'],
|
||||
spacingScale: style['--chat-estimated-spacing-scale']
|
||||
})
|
||||
}
|
||||
const initial = metricsAtWidth(800)
|
||||
@@ -60,7 +61,8 @@ describe('transcript row height estimate', () => {
|
||||
)
|
||||
const typography = {
|
||||
lineHeightPx: style['--chat-estimated-line-height'],
|
||||
charsPerLine: style['--chat-estimated-chars-per-line']
|
||||
charsPerLine: style['--chat-estimated-chars-per-line'],
|
||||
spacingScale: style['--chat-estimated-spacing-scale']
|
||||
}
|
||||
return estimateNativeChatRowHeight(
|
||||
nativeChatRowContentMetrics(prose, typography),
|
||||
@@ -234,7 +236,8 @@ describe('transcript row height estimate', () => {
|
||||
const style = nativeChatAppearanceStyle({ nativeChatAppearance: { fontSize } })
|
||||
const typography = {
|
||||
lineHeightPx: style['--chat-estimated-line-height'],
|
||||
charsPerLine: style['--chat-estimated-chars-per-line']
|
||||
charsPerLine: style['--chat-estimated-chars-per-line'],
|
||||
spacingScale: style['--chat-estimated-spacing-scale']
|
||||
}
|
||||
const heightFor = (command: string): number =>
|
||||
estimateNativeChatRowHeight(
|
||||
@@ -266,7 +269,8 @@ describe('transcript row height estimate', () => {
|
||||
const style = nativeChatAppearanceStyle({ nativeChatAppearance: { fontSize } })
|
||||
return estimateNativeChatRowHeight(reasoning, NO_CHROME, {
|
||||
lineHeightPx: style['--chat-estimated-line-height'],
|
||||
charsPerLine: style['--chat-estimated-chars-per-line']
|
||||
charsPerLine: style['--chat-estimated-chars-per-line'],
|
||||
spacingScale: style['--chat-estimated-spacing-scale']
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -42,8 +42,14 @@ export type NativeChatRowChromeMetrics = {
|
||||
export type NativeChatRowTypography = {
|
||||
lineHeightPx: number
|
||||
charsPerLine: number
|
||||
/** Spacing and controls relative to the default; bounded separately from message text. */
|
||||
spacingScale: number
|
||||
}
|
||||
export const DEFAULT_ROW_TYPOGRAPHY: NativeChatRowTypography = {
|
||||
lineHeightPx: 22,
|
||||
charsPerLine: 96,
|
||||
spacingScale: 1
|
||||
}
|
||||
const DEFAULT_ROW_TYPOGRAPHY: NativeChatRowTypography = { lineHeightPx: 22, charsPerLine: 96 }
|
||||
const PROSE_MIN_LINES = 1
|
||||
const USER_BUBBLE_CHROME_PX = 32
|
||||
const USER_FOLD_TOGGLE_PX = 24
|
||||
@@ -135,62 +141,56 @@ export function nativeChatWorkRunContentMetrics(
|
||||
}
|
||||
}
|
||||
|
||||
/** The trigger's headline is chat text, so it grows with the chat size above its `min-h-6` floor. */
|
||||
function collapsedReasoningHeight(typography: NativeChatRowTypography): number {
|
||||
return Math.max(
|
||||
COLLAPSED_REASONING_PX,
|
||||
(COLLAPSED_REASONING_PX * typography.lineHeightPx) / DEFAULT_ROW_TYPOGRAPHY.lineHeightPx
|
||||
)
|
||||
}
|
||||
|
||||
export function estimateNativeChatRowHeight(
|
||||
content: NativeChatRowContentMetrics,
|
||||
chrome: NativeChatRowChromeMetrics,
|
||||
typography = DEFAULT_ROW_TYPOGRAPHY
|
||||
): number {
|
||||
// Chat spacing and icons scale with the text size, so a row's fixed parts do too.
|
||||
const scale = typography.spacingScale
|
||||
let partCount = 0
|
||||
let height = 0
|
||||
if (chrome.hasReceipt) {
|
||||
height = RECEIPT_PX
|
||||
height = RECEIPT_PX * scale
|
||||
partCount = 1
|
||||
} else if (chrome.folded === true) {
|
||||
height = content.subagentGroupCount * SUBAGENT_ROW_PX
|
||||
height = content.subagentGroupCount * SUBAGENT_ROW_PX * scale
|
||||
partCount = height > 0 ? 1 : 0
|
||||
} else {
|
||||
height =
|
||||
content.role === 'reasoning'
|
||||
? content.textLines > 0
|
||||
? collapsedReasoningHeight(typography)
|
||||
? COLLAPSED_REASONING_PX * scale
|
||||
: 0
|
||||
: content.userFolds
|
||||
? NATIVE_CHAT_USER_MESSAGE_FOLDED_PX + USER_FOLD_TOGGLE_PX
|
||||
? (NATIVE_CHAT_USER_MESSAGE_FOLDED_PX + USER_FOLD_TOGGLE_PX) * scale
|
||||
: content.textLines * typography.lineHeightPx
|
||||
if (content.role === 'user' && content.textLines > 0) {
|
||||
height += USER_BUBBLE_CHROME_PX
|
||||
height += USER_BUBBLE_CHROME_PX * scale
|
||||
}
|
||||
if (
|
||||
chrome.inSubagentSection === true &&
|
||||
content.role === 'assistant' &&
|
||||
content.textLines > 0
|
||||
) {
|
||||
height += AGENT_CONTROLS_OVERHANG_PX
|
||||
height += AGENT_CONTROLS_OVERHANG_PX * scale
|
||||
}
|
||||
if (content.imageCount > 0) {
|
||||
height += IMAGE_STRIP_PX
|
||||
height += IMAGE_STRIP_PX * scale
|
||||
}
|
||||
if (content.toolCount > 0) {
|
||||
// A run is one collapsed header by default; its members only exist while open.
|
||||
height += TOOL_RUN_CHROME_PX + 2 * typography.lineHeightPx
|
||||
height += TOOL_RUN_CHROME_PX * scale + 2 * typography.lineHeightPx
|
||||
}
|
||||
height += content.subagentGroupCount * SUBAGENT_ROW_PX
|
||||
height += content.subagentGroupCount * SUBAGENT_ROW_PX * scale
|
||||
partCount = height > 0 ? 1 : 0
|
||||
}
|
||||
if (chrome.hasStatus) {
|
||||
height += STATUS_ROW_PX
|
||||
height += STATUS_ROW_PX * scale
|
||||
partCount += 1
|
||||
}
|
||||
if (chrome.hasTurnDiff) {
|
||||
height += TURN_DIFF_PX
|
||||
height += TURN_DIFF_PX * scale
|
||||
partCount += 1
|
||||
}
|
||||
height += Math.max(0, partCount - 1) * NATIVE_CHAT_ROW_GAP_PX
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const css = readFileSync(
|
||||
resolve('src/renderer/src/components/native-chat/native-chat-appearance.css'),
|
||||
'utf8'
|
||||
)
|
||||
const rootRule = /\.native-chat-appearance \{([^}]*)\}/.exec(css)?.[1] ?? ''
|
||||
|
||||
function declared(name: string): string {
|
||||
return new RegExp(`${name}:\\s*([^;]+);`).exec(rootRule)?.[1] ?? ''
|
||||
}
|
||||
|
||||
describe('chat type scale', () => {
|
||||
it('sizes the reading step from the text size setting', () => {
|
||||
expect(declared('--text-sm')).toContain('var(--chat-font-size')
|
||||
})
|
||||
|
||||
// Labels and controls follow the chat's own size, which only differs from --text-sm when matching a terminal.
|
||||
it('sizes labels and controls from the bounded chat size, falling back to the reading step', () => {
|
||||
expect(declared('--chat-ui-size')).toContain('var(--chat-ui-font-size')
|
||||
expect(declared('--chat-ui-size')).toContain('var(--text-sm)')
|
||||
})
|
||||
|
||||
// A step that stops deriving from the chat size stays fixed while the setting moves the rest.
|
||||
it.each(['--text-xs', '--text-2xs', '--text-3xs'])('scales %s with the chat size', (step) => {
|
||||
expect(declared(step)).toContain('var(--chat-ui-size)')
|
||||
})
|
||||
|
||||
// Labels never outgrow the message text when a small terminal size is matched.
|
||||
it.each(['--text-xs', '--text-2xs'])('caps %s at the reading step', (step) => {
|
||||
expect(declared(step)).toMatch(/^min\(var\(--text-sm\),/)
|
||||
})
|
||||
|
||||
it('scales spacing and icons with the chat size', () => {
|
||||
expect(declared('--spacing')).toContain('var(--chat-ui-size)')
|
||||
})
|
||||
|
||||
it('sizes code from the code text size setting', () => {
|
||||
expect(declared('--text-chat-code')).toContain('var(--chat-code-font-size')
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,10 @@ export function useNativeChatRowTypography(contentRef: RefObject<HTMLDivElement
|
||||
const style = useNativeChatStoreAppearanceStyle(columnWidthPx)
|
||||
const lineHeightPx = style['--chat-estimated-line-height']
|
||||
const charsPerLine = style['--chat-estimated-chars-per-line']
|
||||
const typography = useMemo(() => ({ lineHeightPx, charsPerLine }), [lineHeightPx, charsPerLine])
|
||||
const spacingScale = style['--chat-estimated-spacing-scale']
|
||||
const typography = useMemo(
|
||||
() => ({ lineHeightPx, charsPerLine, spacingScale }),
|
||||
[lineHeightPx, charsPerLine, spacingScale]
|
||||
)
|
||||
return { measureContent, typography }
|
||||
}
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { elementScroll, useVirtualizer, type VirtualItem } from '@tanstack/react-virtual'
|
||||
import { createProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks'
|
||||
import { NATIVE_CHAT_ROW_GAP_PX } from './native-chat-row-height-estimate'
|
||||
import {
|
||||
NATIVE_CHAT_ROW_GAP_PX,
|
||||
type NativeChatRowTypography,
|
||||
DEFAULT_ROW_TYPOGRAPHY
|
||||
} from './native-chat-row-height-estimate'
|
||||
import { nativeChatPinnedRowIndexes, nativeChatTranscriptRange } from './native-chat-pinned-rows'
|
||||
import { nativeChatSlotKey, type NativeChatTranscriptSlot } from './native-chat-transcript-slots'
|
||||
import {
|
||||
@@ -60,13 +64,16 @@ export function useNativeChatTranscriptWindow({
|
||||
scrollRef,
|
||||
slots,
|
||||
isVisible,
|
||||
revealIndex
|
||||
revealIndex,
|
||||
typography = DEFAULT_ROW_TYPOGRAPHY
|
||||
}: {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>
|
||||
slots: readonly NativeChatTranscriptSlot[]
|
||||
isVisible: boolean
|
||||
/** Slot the transcript was asked to reveal, or -1. */
|
||||
revealIndex: number
|
||||
/** Chat text scale; gap scales with the text size. */
|
||||
typography?: NativeChatRowTypography
|
||||
}): NativeChatTranscriptWindow {
|
||||
const sizerElementRef = useRef<HTMLDivElement | null>(null)
|
||||
const [scrollMargin, setScrollMargin] = useState(0)
|
||||
@@ -98,6 +105,7 @@ export function useNativeChatTranscriptWindow({
|
||||
[pinned]
|
||||
)
|
||||
|
||||
const scaledGapPx = NATIVE_CHAT_ROW_GAP_PX * typography.spacingScale
|
||||
const virtualizer = useVirtualizer({
|
||||
count: slots.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
@@ -105,7 +113,7 @@ export function useNativeChatTranscriptWindow({
|
||||
getItemKey,
|
||||
rangeExtractor,
|
||||
overscan: NATIVE_CHAT_WINDOW_OVERSCAN,
|
||||
gap: NATIVE_CHAT_ROW_GAP_PX,
|
||||
gap: scaledGapPx,
|
||||
scrollMargin,
|
||||
// A hidden pane has no boxes to measure; retain its last visible row sizes.
|
||||
useCachedMeasurements: !isVisible,
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
@@ -1,5 +1,10 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { extendTailwindMerge } from 'tailwind-merge'
|
||||
|
||||
// Unregistered, tailwind-merge reads the theme's extra text steps as colors and drops them.
|
||||
const twMerge = extendTailwindMerge({
|
||||
extend: { theme: { text: ['2xs', '3xs', 'chat-code'] } }
|
||||
})
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
|
||||
Reference in New Issue
Block a user