mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 08:02:37 +00:00
Add chat contrast and "Match terminal interface" (#25659)
* Add chat contrast and terminal interface matching * Fix matching chat palettes and contrast controls * Verify rebased appearance settings and preserve slider focus * Memoize chat appearance and narrow settings subscriptions * Wrap native chat tool-run summaries in the terminal font * Use the matched chat foreground for inherited controls * fix(native-chat): bound collapsed tool summaries * Restore code foreground token for matched chat themes * Reset all chat appearance controls through serialized writer * Make terminal matching control chat size and contrast * Verify terminal match controls on Chat settings page * Share native chat appearance store selection * Align matched chat palette test with full-strength foreground * Keep tool-run header marks on the summary's first line A wrapped two-line summary centered the leading icon, failure count, check, and chevron between its lines. The header now top-aligns its items and gives each mark a one-line-tall slot, so marks sit on line 1 and line 2 wraps under the text. * Explain terminal-matched chat controls on hover While Match terminal interface is on, Contrast, Text size and Code text size are disabled. Hovering any of them now shows a tooltip saying the chat is matching the terminal interface and how to change it. Disabled controls drop hover events, so an enabled wrapper carries the tooltip; with matching off the controls render exactly as before. * Name the tool-run alignment test case by summary length
This commit is contained in:
@@ -24,7 +24,8 @@
|
||||
unicode-range: U+E000-F8FF, U+F0000-FFFFD, U+100000-10FFFD;
|
||||
}
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
/* A matching chat can use a different scheme from the document; portaled menus stay outside it. */
|
||||
@custom-variant dark (&:is(.dark *:not(:where([data-native-chat-scheme='light'], [data-native-chat-scheme='light'] *)), [data-native-chat-scheme='dark'], [data-native-chat-scheme='dark'] *));
|
||||
|
||||
/* Why: hover-reveal controls (sidebar/row action buttons) hide with `opacity-0`
|
||||
and fade in via `group-hover`. Touch devices have no hover, so those controls
|
||||
@@ -173,19 +174,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-canvas-mix: 0%;
|
||||
--chat-foreground-mix: 82%;
|
||||
--chat-strong-mix: 92%;
|
||||
--chat-faint-mix: 100%;
|
||||
--chat-user-mix: 4%;
|
||||
--chat-user-border-mix: 7%;
|
||||
--chat-code-mix: 2%;
|
||||
--chat-code-base: var(--chat-canvas);
|
||||
--chat-inline-code-mix: 4%;
|
||||
--chat-inline-code-base: var(--chat-canvas);
|
||||
--chat-inline-code-border-mix: 9%;
|
||||
--chat-composer-mix: 0%;
|
||||
--chat-composer-base: var(--chat-canvas);
|
||||
--chat-composer-border-mix: 11%;
|
||||
--chat-content-max-width: 46rem;
|
||||
--card: #fff;
|
||||
--card-foreground: #0a0a0a;
|
||||
@@ -199,8 +201,6 @@
|
||||
--muted-foreground: #737373;
|
||||
--accent: #f5f5f5;
|
||||
--accent-foreground: #171717;
|
||||
--destructive: #e40014;
|
||||
--destructive-foreground: #fcf3f3;
|
||||
--border: #e5e5e5;
|
||||
--input: #e5e5e5;
|
||||
--ring: #a1a1a1;
|
||||
@@ -212,14 +212,6 @@
|
||||
--agent-question: var(--color-orange-600);
|
||||
/* Legible weight for text/glyphs on a tinted --agent-question surface. */
|
||||
--agent-question-text: var(--color-orange-700);
|
||||
--status-success: #15803d;
|
||||
--status-success-background: color-mix(in srgb, var(--status-success) 10%, transparent);
|
||||
--status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent);
|
||||
/* Needs-attention state that is not an error: a resume that did not carry on, an update that
|
||||
failed. Yellow, so it stays apart from destructive red and from the orange working state. */
|
||||
--status-warning: #ca8a04;
|
||||
--status-warning-background: color-mix(in srgb, var(--status-warning) 10%, transparent);
|
||||
--status-warning-border: color-mix(in srgb, var(--status-warning) 25%, transparent);
|
||||
/* Workspace PR-state tones: same hue in both themes so a status keeps its
|
||||
identity when the theme flips. */
|
||||
--workspace-status-done: #c7a594;
|
||||
@@ -258,17 +250,6 @@
|
||||
--terminal-pane-title-on-light-button-hover-fg: rgb(24 24 27);
|
||||
--terminal-pane-title-on-light-input-bg: rgb(24 24 27 / 0.05);
|
||||
--terminal-pane-title-on-light-separator: rgb(24 24 27 / 0.1);
|
||||
--git-decoration-added: #587c0c;
|
||||
--git-decoration-modified: #895503;
|
||||
--git-decoration-deleted: #ad0707;
|
||||
--git-decoration-renamed: #007acc;
|
||||
--git-decoration-untracked: #007100;
|
||||
--git-decoration-copied: #007acc;
|
||||
--git-decoration-ignored: #8c8c8c;
|
||||
--diff-added-ground: color-mix(in srgb, var(--git-decoration-added) 13%, transparent);
|
||||
--diff-added-gutter: color-mix(in srgb, var(--git-decoration-added) 26%, transparent);
|
||||
--diff-removed-ground: color-mix(in srgb, var(--git-decoration-deleted) 11%, transparent);
|
||||
--diff-removed-gutter: color-mix(in srgb, var(--git-decoration-deleted) 22%, transparent);
|
||||
--git-graph-ref: #007acc;
|
||||
--git-graph-remote-ref: #b66dff;
|
||||
--git-graph-base-ref: #ea5c00;
|
||||
@@ -310,19 +291,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-canvas-mix: 5%;
|
||||
--chat-foreground-mix: 78%;
|
||||
--chat-strong-mix: 90%;
|
||||
--chat-faint-mix: 83%;
|
||||
--chat-user-mix: 7%;
|
||||
--chat-user-border-mix: 6%;
|
||||
--chat-code-mix: 3.5%;
|
||||
--chat-code-base: transparent;
|
||||
--chat-inline-code-mix: 6%;
|
||||
--chat-inline-code-base: transparent;
|
||||
--chat-inline-code-border-mix: 8%;
|
||||
--chat-composer-mix: 4%;
|
||||
--chat-composer-base: transparent;
|
||||
--chat-composer-border-mix: 9%;
|
||||
--chat-content-max-width: 46rem;
|
||||
--card: #171717;
|
||||
--card-foreground: #fafafa;
|
||||
@@ -336,8 +318,6 @@
|
||||
--muted-foreground: #a1a1a1;
|
||||
--accent: #404040;
|
||||
--accent-foreground: #fafafa;
|
||||
--destructive: #ff6568;
|
||||
--destructive-foreground: #df2225;
|
||||
--border: rgb(255 255 255 / 0.07);
|
||||
--input: rgb(255 255 255 / 0.15);
|
||||
--ring: #737373;
|
||||
@@ -345,12 +325,6 @@
|
||||
--ai-action-accent: var(--color-violet-400);
|
||||
--agent-question: var(--color-orange-500);
|
||||
--agent-question-text: var(--color-orange-300);
|
||||
--status-success: #86efac;
|
||||
--status-success-background: color-mix(in srgb, var(--status-success) 10%, transparent);
|
||||
--status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent);
|
||||
--status-warning: #eab308;
|
||||
--status-warning-background: color-mix(in srgb, var(--status-warning) 10%, transparent);
|
||||
--status-warning-border: color-mix(in srgb, var(--status-warning) 25%, transparent);
|
||||
--workspace-status-done: #c7a594;
|
||||
--workspace-status-review: #16a34a;
|
||||
--workspace-status-progress: #d4a300;
|
||||
@@ -391,17 +365,6 @@
|
||||
--terminal-pane-title-on-light-button-hover-fg: rgb(24 24 27);
|
||||
--terminal-pane-title-on-light-input-bg: rgb(24 24 27 / 0.05);
|
||||
--terminal-pane-title-on-light-separator: rgb(24 24 27 / 0.1);
|
||||
--git-decoration-added: #81b88b;
|
||||
--git-decoration-modified: #e2c08d;
|
||||
--git-decoration-deleted: #c74e39;
|
||||
--git-decoration-renamed: #73c991;
|
||||
--git-decoration-untracked: #73c991;
|
||||
--git-decoration-copied: #73c991;
|
||||
--git-decoration-ignored: #6e6e6e;
|
||||
--diff-added-ground: color-mix(in srgb, var(--git-decoration-added) 16%, transparent);
|
||||
--diff-added-gutter: color-mix(in srgb, var(--git-decoration-added) 30%, transparent);
|
||||
--diff-removed-ground: color-mix(in srgb, var(--git-decoration-deleted) 18%, transparent);
|
||||
--diff-removed-gutter: color-mix(in srgb, var(--git-decoration-deleted) 32%, transparent);
|
||||
--git-graph-ref: #3794ff;
|
||||
--git-graph-remote-ref: #b66dff;
|
||||
--git-graph-base-ref: #ea5c00;
|
||||
@@ -418,6 +381,54 @@
|
||||
--tab-group-split-divider-strong: #a1a1aa;
|
||||
}
|
||||
|
||||
/* Status and diff colors follow the chat scheme without copying the app palette. */
|
||||
:root,
|
||||
.native-chat-appearance[data-native-chat-scheme='light'] {
|
||||
--destructive: #e40014;
|
||||
--destructive-foreground: #fcf3f3;
|
||||
--status-success: #15803d;
|
||||
--status-success-background: color-mix(in srgb, var(--status-success) 10%, transparent);
|
||||
--status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent);
|
||||
/* Needs-attention states use yellow, separate from errors and working states. */
|
||||
--status-warning: #ca8a04;
|
||||
--status-warning-background: color-mix(in srgb, var(--status-warning) 10%, transparent);
|
||||
--status-warning-border: color-mix(in srgb, var(--status-warning) 25%, transparent);
|
||||
--git-decoration-added: #587c0c;
|
||||
--git-decoration-modified: #895503;
|
||||
--git-decoration-deleted: #ad0707;
|
||||
--git-decoration-renamed: #007acc;
|
||||
--git-decoration-untracked: #007100;
|
||||
--git-decoration-copied: #007acc;
|
||||
--git-decoration-ignored: #8c8c8c;
|
||||
--diff-added-ground: color-mix(in srgb, var(--git-decoration-added) 13%, transparent);
|
||||
--diff-added-gutter: color-mix(in srgb, var(--git-decoration-added) 26%, transparent);
|
||||
--diff-removed-ground: color-mix(in srgb, var(--git-decoration-deleted) 11%, transparent);
|
||||
--diff-removed-gutter: color-mix(in srgb, var(--git-decoration-deleted) 22%, transparent);
|
||||
}
|
||||
|
||||
.dark,
|
||||
.native-chat-appearance[data-native-chat-scheme='dark'] {
|
||||
--destructive: #ff6568;
|
||||
--destructive-foreground: #df2225;
|
||||
--status-success: #86efac;
|
||||
--status-success-background: color-mix(in srgb, var(--status-success) 10%, transparent);
|
||||
--status-success-border: color-mix(in srgb, var(--status-success) 25%, transparent);
|
||||
--status-warning: #eab308;
|
||||
--status-warning-background: color-mix(in srgb, var(--status-warning) 10%, transparent);
|
||||
--status-warning-border: color-mix(in srgb, var(--status-warning) 25%, transparent);
|
||||
--git-decoration-added: #81b88b;
|
||||
--git-decoration-modified: #e2c08d;
|
||||
--git-decoration-deleted: #c74e39;
|
||||
--git-decoration-renamed: #73c991;
|
||||
--git-decoration-untracked: #73c991;
|
||||
--git-decoration-copied: #73c991;
|
||||
--git-decoration-ignored: #6e6e6e;
|
||||
--diff-added-ground: color-mix(in srgb, var(--git-decoration-added) 16%, transparent);
|
||||
--diff-added-gutter: color-mix(in srgb, var(--git-decoration-added) 30%, transparent);
|
||||
--diff-removed-ground: color-mix(in srgb, var(--git-decoration-deleted) 18%, transparent);
|
||||
--diff-removed-gutter: color-mix(in srgb, var(--git-decoration-deleted) 32%, transparent);
|
||||
}
|
||||
|
||||
.plugin-security-chrome {
|
||||
/* Why: plugin themes may style the app, but provenance and consent must
|
||||
retain host-owned contrast so a pack cannot disguise a trust decision. */
|
||||
@@ -529,6 +540,72 @@ body:has(> [data-slot='dialog-overlay'], > [data-slot='sheet-overlay']) {
|
||||
|
||||
/* ── Base Layer ──────────────────────────────────────── */
|
||||
|
||||
/* Re-evaluate chat colors where the appearance producer supplies their source colors. */
|
||||
:root,
|
||||
.dark,
|
||||
.native-chat-appearance {
|
||||
--chat-source-background: var(--background);
|
||||
--chat-source-foreground: var(--foreground);
|
||||
--chat-source-muted-foreground: var(--muted-foreground);
|
||||
--chat-canvas: color-mix(
|
||||
in srgb,
|
||||
var(--chat-source-foreground) var(--chat-canvas-mix),
|
||||
var(--chat-source-background)
|
||||
);
|
||||
--chat-foreground: color-mix(
|
||||
in srgb,
|
||||
var(--chat-source-foreground) var(--chat-foreground-mix),
|
||||
var(--chat-canvas)
|
||||
);
|
||||
--chat-foreground-strong: color-mix(
|
||||
in srgb,
|
||||
var(--chat-source-foreground) var(--chat-strong-mix),
|
||||
var(--chat-canvas)
|
||||
);
|
||||
--chat-foreground-faint: color-mix(
|
||||
in srgb,
|
||||
var(--chat-source-muted-foreground) var(--chat-faint-mix),
|
||||
var(--chat-canvas)
|
||||
);
|
||||
--chat-user-surface: color-mix(
|
||||
in srgb,
|
||||
var(--chat-source-foreground) var(--chat-user-mix),
|
||||
var(--chat-canvas)
|
||||
);
|
||||
--chat-user-border: color-mix(
|
||||
in srgb,
|
||||
var(--chat-source-foreground) var(--chat-user-border-mix),
|
||||
transparent
|
||||
);
|
||||
--chat-code-foreground: color-mix(in srgb, var(--chat-source-foreground) 85%, var(--chat-canvas));
|
||||
--chat-code-surface: color-mix(
|
||||
in srgb,
|
||||
var(--chat-source-foreground) var(--chat-code-mix),
|
||||
var(--chat-code-base)
|
||||
);
|
||||
--chat-code-border: color-mix(in srgb, var(--chat-source-foreground) 7%, transparent);
|
||||
--chat-inline-code-surface: color-mix(
|
||||
in srgb,
|
||||
var(--chat-source-foreground) var(--chat-inline-code-mix),
|
||||
var(--chat-inline-code-base)
|
||||
);
|
||||
--chat-inline-code-border: color-mix(
|
||||
in srgb,
|
||||
var(--chat-source-foreground) var(--chat-inline-code-border-mix),
|
||||
transparent
|
||||
);
|
||||
--chat-composer-surface: color-mix(
|
||||
in srgb,
|
||||
var(--chat-source-foreground) var(--chat-composer-mix),
|
||||
var(--chat-composer-base)
|
||||
);
|
||||
--chat-composer-border: color-mix(
|
||||
in srgb,
|
||||
var(--chat-source-foreground) var(--chat-composer-border-mix),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
*,
|
||||
*::before,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
NATIVE_CHAT_APPEARANCE_ROOT_CLASS,
|
||||
useNativeChatAppearanceStyle
|
||||
} from './native-chat-appearance-style'
|
||||
import { NATIVE_CHAT_APPEARANCE_ROOT_CLASS } from './native-chat-appearance-style'
|
||||
import { useNativeChatStoreAppearanceStyle } from './use-native-chat-store-appearance-style'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNativeChatComposerRevealFocus } from './use-native-chat-composer-reveal-focus'
|
||||
import { useAppStore } from '../../store'
|
||||
@@ -327,8 +325,7 @@ export function NativeChatResolvedView({
|
||||
|
||||
// Only the focused conversation accepts chat text-size shortcuts.
|
||||
useNativeChatFontSize(isConversation && isVisible && isFocusedGroup, rootRef)
|
||||
const appearanceSettings = useAppStore((state) => state.settings?.nativeChatAppearance)
|
||||
const appearanceStyle = useNativeChatAppearanceStyle({ nativeChatAppearance: appearanceSettings })
|
||||
const appearanceStyle = useNativeChatStoreAppearanceStyle()
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -371,6 +368,7 @@ export function NativeChatResolvedView({
|
||||
'flex h-full min-h-0 w-full flex-col focus:outline-none'
|
||||
)}
|
||||
style={appearanceStyle}
|
||||
data-native-chat-scheme={appearanceStyle.colorScheme}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{viewState.kind === 'loading' ? (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as terminalThemeSelection from '../../../../shared/terminal-theme-selection'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
@@ -38,7 +39,17 @@ describe('NativeChatStructuredSession', () => {
|
||||
useAppStore.setState({
|
||||
settings: {
|
||||
...getDefaultSettings('/tmp'),
|
||||
nativeChatAppearance: { fontSize: 18, codeFontSize: 11, width: 'wide' }
|
||||
theme: 'dark',
|
||||
nativeChatAppearance: {
|
||||
fontSize: 18,
|
||||
codeFontSize: 11,
|
||||
width: 'wide',
|
||||
matchTerminalInterface: true,
|
||||
contrast: 120
|
||||
},
|
||||
terminalFontFamily: 'Consolas',
|
||||
terminalFontSize: 16,
|
||||
terminalColorOverrides: { background: '#112233', foreground: '#ddeeff' }
|
||||
}
|
||||
})
|
||||
const { container } = render(
|
||||
@@ -52,22 +63,84 @@ describe('NativeChatStructuredSession', () => {
|
||||
/>
|
||||
)
|
||||
const root = container.querySelector<HTMLElement>('[data-native-chat-root]')
|
||||
expect(root?.style.getPropertyValue('--chat-font-size')).toBe('18px')
|
||||
expect(root?.style.getPropertyValue('--chat-code-font-size')).toBe('11px')
|
||||
expect(root?.style.colorScheme).toBe('dark')
|
||||
expect(root?.style.color).toBe('var(--foreground)')
|
||||
expect(root?.dataset.nativeChatScheme).toBe('dark')
|
||||
expect(root?.style.getPropertyValue('--chat-source-background')).toBe('#112233')
|
||||
expect(root?.style.getPropertyValue('--chat-source-foreground')).toBe('#ddeeff')
|
||||
expect(root?.style.getPropertyValue('--chat-code-font-family')).toContain('Consolas')
|
||||
expect(root?.style.getPropertyValue('--chat-font-family')).toContain('Consolas')
|
||||
expect(root?.style.getPropertyValue('--chat-foreground-mix')).toBe('100%')
|
||||
expect(root?.style.getPropertyValue('--chat-font-size')).toBe('16px')
|
||||
expect(root?.style.getPropertyValue('--chat-code-font-size')).toBe('16px')
|
||||
expect(root?.style.getPropertyValue('--chat-content-max-width')).toBe('60rem')
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
settings: { ...getDefaultSettings('/tmp'), nativeChatAppearance: { width: 'full' } }
|
||||
settings: {
|
||||
...getDefaultSettings('/tmp'),
|
||||
theme: 'dark',
|
||||
nativeChatAppearance: { width: 'full' }
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(root?.style.colorScheme).toBe('')
|
||||
expect(root?.style.color).toBe('')
|
||||
expect(root?.dataset.nativeChatScheme).toBeUndefined()
|
||||
expect(root?.style.getPropertyValue('--chat-source-background')).toBe('')
|
||||
expect(root?.style.getPropertyValue('--chat-source-foreground')).toBe('')
|
||||
expect(root?.style.getPropertyValue('--chat-font-family')).toBe('')
|
||||
expect(root?.style.getPropertyValue('--chat-foreground-mix')).toBe('78%')
|
||||
expect(root?.style.getPropertyValue('--chat-font-size')).toBe('14px')
|
||||
expect(root?.style.getPropertyValue('--chat-content-max-width')).toBe('none')
|
||||
act(() => useAppStore.setState({ settings: original }))
|
||||
})
|
||||
|
||||
it('skips appearance work on unrelated settings writes and updates the root for appearance changes', () => {
|
||||
const original = useAppStore.getState().settings
|
||||
const settings = {
|
||||
...getDefaultSettings('/tmp'),
|
||||
theme: 'dark' as const,
|
||||
nativeChatAppearance: { matchTerminalInterface: true }
|
||||
}
|
||||
useAppStore.setState({ settings })
|
||||
const resolveColors = vi.spyOn(terminalThemeSelection, 'resolveConfiguredTerminalColors')
|
||||
const view = render(
|
||||
<NativeChatStructuredSession
|
||||
isVisible
|
||||
isFocusedGroup
|
||||
tabId="appearance-subscription-tab"
|
||||
sessionId="appearance-subscription-session"
|
||||
target={{ kind: 'local' }}
|
||||
agent="codex"
|
||||
/>
|
||||
)
|
||||
const initialResolutions = resolveColors.mock.calls.length
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
settings: { ...settings, terminalFontSize: settings.terminalFontSize + 1 }
|
||||
})
|
||||
)
|
||||
expect(resolveColors).toHaveBeenCalledTimes(initialResolutions + 1)
|
||||
expect(
|
||||
view.container
|
||||
.querySelector<HTMLElement>('[data-native-chat-root]')
|
||||
?.style.getPropertyValue('--chat-font-size')
|
||||
).toBe(`${settings.terminalFontSize + 1}px`)
|
||||
act(() => useAppStore.setState({ settings: { ...settings, terminalFontFamily: 'Menlo' } }))
|
||||
expect(resolveColors).toHaveBeenCalledTimes(initialResolutions + 2)
|
||||
expect(
|
||||
view.container
|
||||
.querySelector<HTMLElement>('[data-native-chat-root]')
|
||||
?.style.getPropertyValue('--chat-code-font-family')
|
||||
).toContain('Menlo')
|
||||
view.unmount()
|
||||
useAppStore.setState({ settings: original })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
resetStructuredSessionMocks()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("gives the composer this pane shows the conversation's own draft, and Stop returns text there", () => {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
NATIVE_CHAT_APPEARANCE_ROOT_CLASS,
|
||||
useNativeChatAppearanceStyle
|
||||
} from './native-chat-appearance-style'
|
||||
import { NATIVE_CHAT_APPEARANCE_ROOT_CLASS } from './native-chat-appearance-style'
|
||||
import { useNativeChatStoreAppearanceStyle } from './use-native-chat-store-appearance-style'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { agentSessionPromptQuestions } from '../../../../shared/agent-session-question-answer'
|
||||
import { dispatchStructuredAgentSessionComposerCommand } from '../../../../shared/structured-agent-session-composer'
|
||||
@@ -145,8 +143,7 @@ export function NativeChatStructuredSession(
|
||||
viewState.kind === 'ready' && props.isVisible && props.isFocusedGroup,
|
||||
rootRef
|
||||
)
|
||||
const appearanceSettings = useAppStore((state) => state.settings?.nativeChatAppearance)
|
||||
const appearanceStyle = useNativeChatAppearanceStyle({ nativeChatAppearance: appearanceSettings })
|
||||
const appearanceStyle = useNativeChatStoreAppearanceStyle()
|
||||
const imageRuntimeContext = useNativeChatImageRuntimeContext(props.tabId)
|
||||
const { onLinkClick, linkActionRequest, closeLinkActions } = useNativeChatLinkActions(
|
||||
fileLinkContext,
|
||||
@@ -265,6 +262,7 @@ export function NativeChatStructuredSession(
|
||||
'flex h-full min-h-0 w-full flex-col focus:outline-none'
|
||||
)}
|
||||
style={appearanceStyle}
|
||||
data-native-chat-scheme={appearanceStyle.colorScheme}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{viewState.kind === 'loading' ? (
|
||||
|
||||
@@ -18,7 +18,10 @@ import { NativeChatDiffView } from './NativeChatDiffView'
|
||||
import { nativeChatToolLineLabel } from './native-chat-tool-line-label'
|
||||
import { diffFromText, diffFromToolCall, type DiffLine } from './native-chat-diff'
|
||||
import { useNativeChatDisclosure } from './native-chat-disclosure-store'
|
||||
import { createToolInputDisplay, truncateToolDetail } from './native-chat-tool-summary'
|
||||
import {
|
||||
createToolInputDisplay,
|
||||
truncateToolDetail
|
||||
} from './native-chat-tool-summary'
|
||||
|
||||
/** A tool sentence with input and output behind its own remembered disclosure. */
|
||||
export function NativeChatToolLine({
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// @vitest-environment happy-dom
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { NativeChatBlock } from '../../../../shared/native-chat-types'
|
||||
import { NativeChatToolRun } from './NativeChatToolRun'
|
||||
import { nativeChatAppearanceStyle } from './native-chat-appearance-style'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const blocks: NativeChatBlock[] = [
|
||||
{ type: 'tool-call', name: 'Bash', input: { command: 'test' }, state: 'failed' },
|
||||
{ type: 'tool-result', output: 'exit 1', isError: true },
|
||||
{ type: 'tool-call', name: 'Read', input: { file_path: 'a.ts' }, state: 'completed' },
|
||||
{ type: 'tool-call', name: 'Write', input: { file_path: 'a.ts' }, state: 'completed' },
|
||||
{ type: 'tool-call', name: 'Grep', input: { pattern: 'todo' }, state: 'completed' },
|
||||
{ type: 'tool-call', name: 'Task', input: { description: 'Review' }, state: 'completed' }
|
||||
]
|
||||
|
||||
describe('tool-run summary in a matching chat', () => {
|
||||
it.each([false, true])('caps the summary at two wrapped lines, live=%s', (live) => {
|
||||
const style = nativeChatAppearanceStyle({
|
||||
terminalFontFamily: 'Menlo',
|
||||
nativeChatAppearance: { matchTerminalInterface: true }
|
||||
})
|
||||
const { container } = render(
|
||||
<div className="native-chat-appearance" style={style}>
|
||||
<div className="max-w-(--chat-content-max-width)">
|
||||
<NativeChatToolRun blocks={blocks} expandSignal={false} activeTurnIsWorking={live} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
expect(style['--chat-content-max-width']).toBe('46rem')
|
||||
expect(style['--chat-font-family']).toContain('Menlo')
|
||||
const summary = container.querySelector('span.native-chat-message-text')
|
||||
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')
|
||||
const failure = container.querySelector('[aria-label="Failed tool calls: 1"]')
|
||||
expect(failure).toHaveTextContent('1 failed')
|
||||
expect(failure).toHaveClass('shrink-0')
|
||||
expect(failure?.parentElement).toHaveClass('flex', 'h-[1lh]', 'items-center')
|
||||
})
|
||||
|
||||
it('keeps a long command available in the expanded detail', () => {
|
||||
const command = `printf ${'x'.repeat(5000)}`
|
||||
const style = nativeChatAppearanceStyle({
|
||||
terminalFontFamily: 'Menlo',
|
||||
nativeChatAppearance: { matchTerminalInterface: true }
|
||||
})
|
||||
const { container } = render(
|
||||
<div className="native-chat-appearance" style={style}>
|
||||
<NativeChatToolRun
|
||||
blocks={[{ type: 'tool-call', name: 'shell', input: { command }, state: 'completed' }]}
|
||||
expandSignal={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
const summary = container.querySelector('span.native-chat-message-text')
|
||||
expect(summary).toHaveClass('line-clamp-2', 'break-words')
|
||||
expect(summary?.textContent).toContain('printf')
|
||||
expect(summary?.textContent?.length).toBeLessThan(command.length)
|
||||
expect(container.querySelector('pre')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
expect(container.querySelector('pre')).toHaveTextContent(command)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['wrapped', `QA inert command for display only: ${'x'.repeat(400)}`],
|
||||
['single-line', 'pnpm test']
|
||||
])('pins the marks to the first line of a %s summary', (_summaryLength, command) => {
|
||||
const { container } = render(
|
||||
<NativeChatToolRun
|
||||
blocks={[
|
||||
{ type: 'tool-call', name: 'shell', input: { command }, state: 'completed' },
|
||||
{ type: 'tool-result', output: 'done' }
|
||||
]}
|
||||
expandSignal={false}
|
||||
activeTurnIsWorking={false}
|
||||
/>
|
||||
)
|
||||
|
||||
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')
|
||||
expect(summary?.parentElement).toBe(header)
|
||||
expect(summary).toHaveClass('min-w-0', 'line-clamp-2')
|
||||
|
||||
const slots = Array.from(header.children).filter((child) => child !== summary)
|
||||
expect(slots.length).toBeGreaterThanOrEqual(3)
|
||||
for (const slot of slots) {
|
||||
expect(slot).toHaveClass('flex', 'h-[1lh]', 'items-center')
|
||||
}
|
||||
// The icon leads, so a wrapped second line starts under the text, not under it.
|
||||
expect(header.firstElementChild?.querySelector('svg')).toBeInTheDocument()
|
||||
expect(header.children[1]).toBe(summary)
|
||||
expect(header.querySelector('.lucide-check')?.parentElement).toHaveClass('h-[1lh]')
|
||||
expect(header.querySelector('.lucide-chevron-right')?.parentElement).toHaveClass('h-[1lh]')
|
||||
})
|
||||
})
|
||||
@@ -320,7 +320,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.truncate')
|
||||
const label = runHeader(container).querySelector('span.native-chat-message-text')
|
||||
expect(label).toHaveClass('text-sm')
|
||||
expect(label).not.toHaveClass('font-mono')
|
||||
})
|
||||
|
||||
@@ -51,8 +51,7 @@ import { NativeChatToolRunCallCounts } from './NativeChatToolRunCallCounts'
|
||||
const NO_SUBAGENT_GROUPS: NativeChatSubagentGroupBlock[] = []
|
||||
const NO_BACKGROUND_TASKS: NativeChatBackgroundTaskBlock[] = []
|
||||
|
||||
/** A run of a message's tool calls/results, collapsed to a one-line summary that
|
||||
* expands to the individual inline tool lines. */
|
||||
/** A run of tool calls/results with a summary that expands to the individual tool lines. */
|
||||
export function NativeChatToolRun({
|
||||
blocks,
|
||||
previousTodoWrite,
|
||||
@@ -254,27 +253,28 @@ export function NativeChatToolRun({
|
||||
// One element for the run's whole life. Live and settled are states of
|
||||
// this button, not two buttons: a header that remounted as a call started
|
||||
// and again as it ended lost its hover, its mark, and its count each time.
|
||||
// It carries the summary's type so each `h-[1lh]` slot is one summary line
|
||||
// tall: marks sit on line 1 and a wrapped line 2 starts under the text.
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="group/tool-run flex min-h-6 w-full 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/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"
|
||||
aria-expanded={open}
|
||||
aria-live="polite"
|
||||
data-native-chat-tool-run-state={live ? 'live' : 'settled'}
|
||||
>
|
||||
{settledHeaderIcon ? (
|
||||
<NativeChatToolRunIcon
|
||||
iconName={settledHeaderIcon}
|
||||
className="text-chat-foreground-faint"
|
||||
/>
|
||||
<span className="flex h-[1lh] shrink-0 items-center">
|
||||
<NativeChatToolRunIcon
|
||||
iconName={settledHeaderIcon}
|
||||
className="text-chat-foreground-faint"
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
{/* The run in words, in the transcript's own type. Present tense while
|
||||
live, past once settled; the text changes in place and nothing
|
||||
around it moves. While live it keeps its width and the preview
|
||||
beside it is what gives way. */}
|
||||
{/* Keep the collapsed header bounded; the tool detail carries the full command. */}
|
||||
<span
|
||||
className={cn(
|
||||
'truncate text-sm native-chat-message-text leading-relaxed transition-colors',
|
||||
'min-w-0 line-clamp-2 whitespace-normal break-words text-sm native-chat-message-text 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'
|
||||
@@ -290,28 +290,34 @@ export function NativeChatToolRun({
|
||||
and never while live: between two calls nothing is running, and a
|
||||
mark that appeared then would flash on every call. */}
|
||||
{!live && runSucceeded ? (
|
||||
<Check aria-hidden className="size-3 shrink-0 text-chat-foreground-faint" />
|
||||
<span className="flex h-[1lh] shrink-0 items-center">
|
||||
<Check aria-hidden className="size-3 shrink-0 text-chat-foreground-faint" />
|
||||
</span>
|
||||
) : null}
|
||||
{latestCallLabel ? (
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 truncate font-sans text-xs text-chat-foreground-faint',
|
||||
latestCall &&
|
||||
nativeChatToolLineLabel(latestCall).command &&
|
||||
'rounded-md border border-chat-inline-code-border bg-chat-inline-code-surface px-1.5 font-mono text-chat-foreground'
|
||||
)}
|
||||
>
|
||||
{latestCallLabel}
|
||||
<span className="flex h-[1lh] min-w-0 items-center">
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 truncate font-sans text-xs text-chat-foreground-faint',
|
||||
latestCall &&
|
||||
nativeChatToolLineLabel(latestCall).command &&
|
||||
'rounded-md border border-chat-inline-code-border bg-chat-inline-code-surface px-1.5 font-mono text-chat-foreground'
|
||||
)}
|
||||
>
|
||||
{latestCallLabel}
|
||||
</span>
|
||||
</span>
|
||||
) : null}
|
||||
{/* Revealed on hover of this header alone — see NativeChatToolLine on
|
||||
why the group is named — and points down when open. */}
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3.5 shrink-0 text-chat-foreground-faint transition-all',
|
||||
open ? 'rotate-90 opacity-100' : 'opacity-0 group-hover/tool-run:opacity-100'
|
||||
)}
|
||||
/>
|
||||
<span className="flex h-[1lh] shrink-0 items-center">
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'size-3.5 shrink-0 text-chat-foreground-faint transition-all',
|
||||
open ? 'rotate-90 opacity-100' : 'opacity-0 group-hover/tool-run:opacity-100'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{open && showsHeader ? (
|
||||
|
||||
@@ -47,13 +47,16 @@ export function NativeChatToolRunCallCounts({
|
||||
/* Outside the truncating member list, so the one thing the reader cannot
|
||||
afford to miss survives a pane too narrow to print it. Quiet text in the
|
||||
header's own type, not a destructive tint or a swapped glyph: a tool error
|
||||
is routine work, and the line's own detail is one click away. */
|
||||
<span
|
||||
aria-label={marks.map((mark) => mark.label).join(', ')}
|
||||
className="shrink-0 font-sans text-xs tabular-nums text-chat-foreground-faint transition-colors group-hover/tool-run:text-chat-foreground"
|
||||
>
|
||||
<span aria-hidden> · </span>
|
||||
{marks.map((mark) => mark.text).join(', ')}
|
||||
is routine work, and the line's own detail is one click away. One header
|
||||
line tall so it stays on a wrapped summary's first line. */
|
||||
<span className="flex h-[1lh] shrink-0 items-center">
|
||||
<span
|
||||
aria-label={marks.map((mark) => mark.label).join(', ')}
|
||||
className="shrink-0 font-sans text-xs tabular-nums text-chat-foreground-faint transition-colors group-hover/tool-run:text-chat-foreground"
|
||||
>
|
||||
<span aria-hidden> · </span>
|
||||
{marks.map((mark) => mark.text).join(', ')}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { compile } from 'tailwindcss'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture'
|
||||
import * as terminalThemeSelection from '../../../../shared/terminal-theme-selection'
|
||||
import { nativeChatAppearanceStyle } from './native-chat-appearance-style'
|
||||
|
||||
const css = readFileSync(resolve('src/renderer/src/assets/main.css'), 'utf8')
|
||||
// Happy DOM only needs the ordinary rules; Tailwind directives are compiled separately below.
|
||||
const paletteCss = [
|
||||
...css.matchAll(/(?:^|\n)(:root[^{}]*|\.dark[^{}]*|\.native-chat-appearance[^{}]*)\{([^{}]*)\}/g)
|
||||
]
|
||||
.map((match) => match[0])
|
||||
.join('\n')
|
||||
|
||||
afterEach(() => {
|
||||
document.head.innerHTML = ''
|
||||
document.body.innerHTML = ''
|
||||
document.documentElement.className = ''
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
function createChat(appScheme: 'light' | 'dark', terminalScheme: 'light' | 'dark'): HTMLElement {
|
||||
document.documentElement.classList.toggle('dark', appScheme === 'dark')
|
||||
const sheet = document.createElement('style')
|
||||
sheet.textContent = paletteCss
|
||||
document.head.append(sheet)
|
||||
const chat = document.createElement('div')
|
||||
chat.className = 'native-chat-appearance'
|
||||
const style = nativeChatAppearanceStyle(
|
||||
createGlobalSettingsFixture({
|
||||
theme: appScheme,
|
||||
terminalColorOverrides:
|
||||
terminalScheme === 'light'
|
||||
? { background: '#ffffff', foreground: '#000000' }
|
||||
: { background: '#000000', foreground: '#ffffff' },
|
||||
nativeChatAppearance: { matchTerminalInterface: true }
|
||||
})
|
||||
)
|
||||
chat.dataset.nativeChatScheme = style.colorScheme
|
||||
chat.style.color = style.color ?? ''
|
||||
for (const [key, value] of Object.entries(style)) {
|
||||
if (key.startsWith('--')) {
|
||||
chat.style.setProperty(key, String(value))
|
||||
}
|
||||
}
|
||||
document.body.append(chat)
|
||||
return chat
|
||||
}
|
||||
|
||||
const statusRoles = [
|
||||
'--destructive',
|
||||
'--status-success',
|
||||
'--status-warning',
|
||||
'--git-decoration-added',
|
||||
'--git-decoration-deleted',
|
||||
'--diff-added-ground',
|
||||
'--diff-added-gutter',
|
||||
'--diff-removed-ground',
|
||||
'--diff-removed-gutter'
|
||||
]
|
||||
|
||||
describe('matching chat surfaces with opposite app schemes', () => {
|
||||
it.each([
|
||||
['dark', 'light'],
|
||||
['light', 'dark']
|
||||
] as const)('uses the %s app with a %s terminal palette', (appScheme, terminalScheme) => {
|
||||
const chat = createChat(appScheme, terminalScheme)
|
||||
// These roles cover transcript payloads, question badges, inline completion menus and controls.
|
||||
const matched = getComputedStyle(chat)
|
||||
const terminalForegroundAtFullStrength = `${terminalScheme === 'light' ? '#000000' : '#ffffff'} 100%`
|
||||
expect(chat.style.color).toBe('var(--foreground)')
|
||||
expect(matched.getPropertyValue('--chat-foreground')).toContain(
|
||||
terminalForegroundAtFullStrength
|
||||
)
|
||||
expect(matched.getPropertyValue('--chat-foreground-strong')).toContain(
|
||||
terminalForegroundAtFullStrength
|
||||
)
|
||||
expect(matched.getPropertyValue('--foreground')).toContain(terminalForegroundAtFullStrength)
|
||||
expect(matched.getPropertyValue('--muted')).toContain('7%')
|
||||
expect(matched.getPropertyValue('--muted-foreground')).toContain('62%')
|
||||
expect(matched.getPropertyValue('--popover')).toContain('4%')
|
||||
expect(matched.getPropertyValue('--popover-foreground')).toContain(
|
||||
terminalForegroundAtFullStrength
|
||||
)
|
||||
expect(matched.getPropertyValue('--accent')).toContain('9%')
|
||||
expect(matched.getPropertyValue('--input')).toContain('7%')
|
||||
expect(matched.getPropertyValue('--secondary')).toContain('7%')
|
||||
expect(matched.getPropertyValue('--ring')).toContain('44%')
|
||||
expect(matched.getPropertyValue('--chat-source-background')).toBe(
|
||||
terminalScheme === 'light' ? '#ffffff' : '#000000'
|
||||
)
|
||||
expect(matched.getPropertyValue('--chat-source-foreground')).toBe(
|
||||
terminalScheme === 'light' ? '#000000' : '#ffffff'
|
||||
)
|
||||
const codeForeground = matched.getPropertyValue('--chat-code-foreground')
|
||||
expect(codeForeground).toContain(`${terminalScheme === 'light' ? '#000000' : '#ffffff'} 85%`)
|
||||
expect(codeForeground).toContain(terminalScheme === 'light' ? '#ffffff' : '#000000')
|
||||
expect(codeForeground).not.toContain(appScheme === 'dark' ? '#fafafa' : '#0a0a0a')
|
||||
const outside = getComputedStyle(document.documentElement)
|
||||
const appStatus = statusRoles.map((role) => outside.getPropertyValue(role))
|
||||
const chatStatus = statusRoles.map((role) => matched.getPropertyValue(role))
|
||||
expect(chatStatus.every(Boolean)).toBe(true)
|
||||
expect(chatStatus).not.toEqual(appStatus)
|
||||
document.documentElement.classList.toggle('dark', terminalScheme === 'dark')
|
||||
expect(
|
||||
statusRoles.map((role) => getComputedStyle(document.documentElement).getPropertyValue(role))
|
||||
).toEqual(chatStatus)
|
||||
})
|
||||
|
||||
it('resolves an incomplete terminal palette without a local background variable cycle', () => {
|
||||
vi.spyOn(terminalThemeSelection, 'resolveConfiguredTerminalColors').mockReturnValue({})
|
||||
const chat = createChat('dark', 'light')
|
||||
expect(chat.dataset.nativeChatScheme).toBe('dark')
|
||||
const styles = getComputedStyle(chat)
|
||||
expect(styles.getPropertyValue('--chat-canvas')).toContain('#000000')
|
||||
expect(styles.getPropertyValue('--chat-source-foreground')).toBe('#fafafa')
|
||||
})
|
||||
})
|
||||
|
||||
describe('chat scheme and inherited dark utilities', () => {
|
||||
it('compiles the custom variant and excludes light chats and enables dark chats', async () => {
|
||||
const declaration = css.match(/^@custom-variant dark \((.+)\);$/m)
|
||||
expect(declaration).not.toBeNull()
|
||||
if (!declaration) {
|
||||
throw new Error('Missing dark variant')
|
||||
}
|
||||
const compiler = await compile(
|
||||
`${declaration[0]}\n@theme { --color-input: #123456; }\n@tailwind utilities;`
|
||||
)
|
||||
expect(compiler.build(['dark:bg-input/30'])).toContain('data-native-chat-scheme')
|
||||
expect(declaration[1]).toContain(
|
||||
".dark *:not(:where([data-native-chat-scheme='light'], [data-native-chat-scheme='light'] *))"
|
||||
)
|
||||
expect(declaration[1]).toContain(
|
||||
", [data-native-chat-scheme='dark'], [data-native-chat-scheme='dark'] *"
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,162 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { nativeChatAppearanceStyle } from './native-chat-appearance-style'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture'
|
||||
import { buildFontFamily } from '@/lib/monospace-font-family'
|
||||
import * as terminalThemeSelection from '../../../../shared/terminal-theme-selection'
|
||||
import { resolveConfiguredTerminalColors } from '../../../../shared/terminal-theme-selection'
|
||||
import { nativeChatAppearanceStyle, nativeChatContrastMix } from './native-chat-appearance-style'
|
||||
|
||||
const makeSettings = createGlobalSettingsFixture
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('nativeChatContrastMix', () => {
|
||||
it.each([
|
||||
[50, false, 56],
|
||||
[100, false, 78],
|
||||
[150, false, 100],
|
||||
[50, true, 64],
|
||||
[100, true, 82],
|
||||
[150, true, 100],
|
||||
[-100, false, 56],
|
||||
[200, true, 100],
|
||||
[Number.NaN, false, 78]
|
||||
])('maps %s in light mode %s to %s', (contrast, light, expected) => {
|
||||
expect(nativeChatContrastMix(contrast, light)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nativeChatAppearanceStyle terminal interface', () => {
|
||||
it('follows the terminal code font while keeping default body font and colors', () => {
|
||||
const style = nativeChatAppearanceStyle(makeSettings({ terminalFontFamily: 'Menlo' }))
|
||||
expect(style['--chat-code-font-family']).toBe(buildFontFamily('Menlo'))
|
||||
expect(style['--chat-font-family']).toBeUndefined()
|
||||
expect(style['--chat-source-foreground']).toBeUndefined()
|
||||
expect(style['--chat-source-background']).toBeUndefined()
|
||||
expect(style['--chat-foreground-mix']).toBe('78%')
|
||||
})
|
||||
|
||||
it('updates the code font and matching body font from the current terminal setting', () => {
|
||||
for (const terminalFontFamily of ['', 'Menlo', 'Consolas']) {
|
||||
const settings = makeSettings({
|
||||
terminalFontFamily,
|
||||
nativeChatAppearance: { matchTerminalInterface: true }
|
||||
})
|
||||
const style = nativeChatAppearanceStyle(settings)
|
||||
expect(style['--chat-font-family']).toBe(buildFontFamily(terminalFontFamily))
|
||||
expect(style['--chat-code-font-family']).toBe(buildFontFamily(terminalFontFamily))
|
||||
expect(style['--chat-code-font-size']).toBe(`${settings.terminalFontSize}px`)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the selected theme and live overrides as the two palette sources', () => {
|
||||
const settings = makeSettings({
|
||||
nativeChatAppearance: { matchTerminalInterface: true, contrast: 150 },
|
||||
terminalColorOverrides: { background: '#122033', foreground: '#ddeeff' }
|
||||
})
|
||||
const style = nativeChatAppearanceStyle(settings)
|
||||
expect(style['--chat-source-background']).toBe('#122033')
|
||||
expect(style['--chat-source-foreground']).toBe('#ddeeff')
|
||||
expect(style['--chat-canvas-mix']).toBe('0%')
|
||||
expect(style['--chat-foreground-mix']).toBe('100%')
|
||||
expect(style['--chat-code-base']).toBe('transparent')
|
||||
})
|
||||
|
||||
it('resolves custom themes and falls back when the selected theme was removed', () => {
|
||||
const settings = makeSettings({
|
||||
terminalThemeDark: 'custom:manual:sample',
|
||||
terminalCustomThemes: [
|
||||
{
|
||||
id: 'manual:sample',
|
||||
name: 'Sample',
|
||||
source: 'manual',
|
||||
mode: 'dark',
|
||||
terminal: { background: '#122033', foreground: '#ddeeff', black: '#000001' },
|
||||
importedAt: '2026-10-05T00:00:00.000Z'
|
||||
}
|
||||
],
|
||||
nativeChatAppearance: { matchTerminalInterface: true }
|
||||
})
|
||||
expect(nativeChatAppearanceStyle(settings)['--chat-source-background']).toBe('#122033')
|
||||
const missing = { ...settings, terminalCustomThemes: [] }
|
||||
expect(nativeChatAppearanceStyle(missing)['--chat-source-background']).toBe(
|
||||
resolveConfiguredTerminalColors(missing, true).background
|
||||
)
|
||||
})
|
||||
|
||||
it('uses light formulas for a light terminal inside a dark app', () => {
|
||||
const settings = makeSettings({
|
||||
theme: 'dark',
|
||||
terminalThemeDark: 'Builtin Tango Light',
|
||||
nativeChatAppearance: { matchTerminalInterface: true }
|
||||
})
|
||||
const style = nativeChatAppearanceStyle(settings)
|
||||
const colors = resolveConfiguredTerminalColors(settings, true)
|
||||
expect(style['--chat-source-background']).toBe(colors.background)
|
||||
expect(style['--chat-source-foreground']).toBe(colors.foreground)
|
||||
expect(style['--chat-foreground-mix']).toBe('100%')
|
||||
expect(style['--chat-code-base']).toBe('var(--chat-canvas)')
|
||||
expect(style['--chat-strong-mix']).toBe('100%')
|
||||
})
|
||||
|
||||
it('follows the separate light theme and system theme changes', () => {
|
||||
const settings = makeSettings({
|
||||
theme: 'system',
|
||||
terminalUseSeparateLightTheme: true,
|
||||
terminalThemeLight: 'Builtin Tango Light',
|
||||
nativeChatAppearance: { matchTerminalInterface: true }
|
||||
})
|
||||
const dark = nativeChatAppearanceStyle(settings, undefined, true)
|
||||
const light = nativeChatAppearanceStyle(settings, undefined, false)
|
||||
expect(light['--chat-source-background']).toBe(
|
||||
resolveConfiguredTerminalColors(settings, false).background
|
||||
)
|
||||
expect(light['--chat-source-background']).not.toBe(dark['--chat-source-background'])
|
||||
expect(light['--chat-foreground-mix']).toBe('100%')
|
||||
})
|
||||
|
||||
it('uses app light contrast without matching and removes matching overrides when off', () => {
|
||||
const style = nativeChatAppearanceStyle(
|
||||
makeSettings({
|
||||
theme: 'light',
|
||||
nativeChatAppearance: { matchTerminalInterface: false, contrast: 50 }
|
||||
})
|
||||
)
|
||||
expect(style['--chat-foreground-mix']).toBe('64%')
|
||||
expect(style['--chat-font-family']).toBeUndefined()
|
||||
expect(style['--chat-canvas-mix']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses live terminal size and full foreground while matching, then restores saved chat sizes and contrast', () => {
|
||||
const settings = makeSettings({
|
||||
terminalFontSize: 17,
|
||||
nativeChatAppearance: {
|
||||
fontSize: 20,
|
||||
codeFontSize: 10,
|
||||
contrast: 50,
|
||||
matchTerminalInterface: true
|
||||
}
|
||||
})
|
||||
const matched = nativeChatAppearanceStyle(settings, 384)
|
||||
expect(matched).toMatchObject({
|
||||
'--chat-font-size': '17px',
|
||||
'--chat-code-font-size': '17px',
|
||||
'--chat-inline-code-ratio': '1em',
|
||||
'--chat-foreground-mix': '100%',
|
||||
'--chat-estimated-line-height': (22 * 17) / 14
|
||||
})
|
||||
expect(
|
||||
nativeChatAppearanceStyle({ ...settings, terminalFontSize: 18 })['--chat-font-size']
|
||||
).toBe('18px')
|
||||
const restored = nativeChatAppearanceStyle({
|
||||
...settings,
|
||||
nativeChatAppearance: { ...settings.nativeChatAppearance, matchTerminalInterface: false }
|
||||
})
|
||||
expect(restored).toMatchObject({
|
||||
'--chat-font-size': '20px',
|
||||
'--chat-code-font-size': '10px',
|
||||
'--chat-foreground-mix': '56%'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('chat root appearance style', () => {
|
||||
it('keeps shared typography tokens independent of chat size and provides a relative code ratio', () => {
|
||||
@@ -30,6 +187,21 @@ describe('chat root appearance style', () => {
|
||||
'--chat-estimated-chars-per-line': 96
|
||||
})
|
||||
})
|
||||
it.each(['light', 'dark', 'system'] as const)(
|
||||
'leaves native controls app-owned in %s mode when matching is off',
|
||||
(theme) => {
|
||||
for (const nativeChatAppearance of [
|
||||
undefined,
|
||||
{ matchTerminalInterface: false },
|
||||
{ contrast: 150 }
|
||||
]) {
|
||||
const style = nativeChatAppearanceStyle(makeSettings({ theme, nativeChatAppearance }))
|
||||
expect(style.colorScheme).toBeUndefined()
|
||||
expect(style.color).toBeUndefined()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('clamps sizes on read and derives the column width', () => {
|
||||
expect(
|
||||
nativeChatAppearanceStyle({
|
||||
@@ -47,3 +219,49 @@ describe('chat root appearance style', () => {
|
||||
).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('contrast hierarchy and incomplete terminal palettes', () => {
|
||||
it.each(['light', 'dark'] as const)(
|
||||
'keeps strong text above body in %s mode up to the cap',
|
||||
(theme) => {
|
||||
for (const matching of [false, true]) {
|
||||
for (let contrast = 50; contrast <= 150; contrast++) {
|
||||
const style = nativeChatAppearanceStyle(
|
||||
makeSettings({
|
||||
theme,
|
||||
terminalColorOverrides:
|
||||
theme === 'light'
|
||||
? { background: '#ffffff', foreground: '#000000' }
|
||||
: { background: '#000000', foreground: '#ffffff' },
|
||||
nativeChatAppearance: { contrast, matchTerminalInterface: matching }
|
||||
})
|
||||
)
|
||||
const body = Number.parseFloat(String(style['--chat-foreground-mix']))
|
||||
const strong = Number.parseFloat(String(style['--chat-strong-mix']))
|
||||
expect(strong).toBe(Math.min(100, body + (theme === 'light' ? 10 : 12)))
|
||||
if (body < 100) {
|
||||
expect(strong).toBeGreaterThan(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it.each([{}, { background: '#ffffff' }, { foreground: '#000000' }])(
|
||||
'uses the fallback source pair for an incomplete palette %j',
|
||||
(colors) => {
|
||||
vi.spyOn(terminalThemeSelection, 'resolveConfiguredTerminalColors').mockReturnValue(colors)
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
const style = nativeChatAppearanceStyle(
|
||||
makeSettings({
|
||||
theme,
|
||||
nativeChatAppearance: { matchTerminalInterface: true }
|
||||
})
|
||||
)
|
||||
expect(style['--chat-source-background']).toBe('#000000')
|
||||
expect(style['--chat-source-foreground']).toBe('#fafafa')
|
||||
expect(style.colorScheme).toBe('dark')
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import './native-chat-appearance.css'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { useMemo, type CSSProperties } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import { resolveNativeChatAppearanceSettings } from '../../../../shared/native-chat-appearance-settings'
|
||||
import { resolveConfiguredTerminalColors } from '../../../../shared/terminal-theme-selection'
|
||||
import { buildFontFamily } from '@/lib/monospace-font-family'
|
||||
import { getSystemPrefersDark, isTerminalBackgroundLight } from '@/lib/terminal-theme'
|
||||
import { useSystemPrefersDark } from '../terminal-pane/use-system-prefers-dark'
|
||||
|
||||
export const NATIVE_CHAT_APPEARANCE_ROOT_CLASS = 'native-chat-appearance bg-chat-canvas'
|
||||
export const NATIVE_CHAT_TRANSCRIPT_OUTER_CLASS = 'px-3 pt-10 pb-4 sm:px-4'
|
||||
export const NATIVE_CHAT_TRANSCRIPT_COLUMN_CLASS =
|
||||
'mx-auto flex w-full max-w-(--chat-content-max-width) flex-col gap-5 px-[5px]'
|
||||
|
||||
export type NativeChatAppearanceStyle = CSSProperties & {
|
||||
'--chat-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
|
||||
}
|
||||
export type NativeChatAppearanceStyle = CSSProperties &
|
||||
Record<`--${string}`, string | number> & {
|
||||
'--chat-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
|
||||
}
|
||||
|
||||
// Width buckets keep a pixel-by-pixel resize from re-deriving the entire transcript.
|
||||
export function nativeChatColumnWidthBucket(width: number | null | undefined): number | null {
|
||||
@@ -24,20 +30,50 @@ export function nativeChatColumnWidthBucket(width: number | null | undefined): n
|
||||
: null
|
||||
}
|
||||
|
||||
export function selectNativeChatAppearanceSettings(
|
||||
settings: Partial<GlobalSettings> | null | undefined
|
||||
) {
|
||||
return {
|
||||
theme: settings?.theme,
|
||||
terminalThemeDark: settings?.terminalThemeDark,
|
||||
terminalThemeLight: settings?.terminalThemeLight,
|
||||
terminalUseSeparateLightTheme: settings?.terminalUseSeparateLightTheme,
|
||||
terminalCustomThemes: settings?.terminalCustomThemes,
|
||||
terminalColorOverrides: settings?.terminalColorOverrides,
|
||||
terminalFontFamily: settings?.terminalFontFamily,
|
||||
terminalFontSize: settings?.nativeChatAppearance?.matchTerminalInterface
|
||||
? settings.terminalFontSize
|
||||
: undefined,
|
||||
nativeChatAppearance: settings?.nativeChatAppearance
|
||||
}
|
||||
}
|
||||
|
||||
export function nativeChatContrastMix(contrast: number, light: boolean): number {
|
||||
const value = Number.isFinite(contrast) ? Math.min(150, Math.max(50, contrast)) : 100
|
||||
return light
|
||||
? Math.min(100, Math.max(55, 82 + (value - 100) * 0.36))
|
||||
: Math.min(100, Math.max(50, 78 + (value - 100) * 0.44))
|
||||
}
|
||||
|
||||
export function nativeChatAppearanceStyle(
|
||||
settings: Pick<GlobalSettings, 'nativeChatAppearance'> | null | undefined,
|
||||
measuredColumnWidthPx?: number | null
|
||||
settings: Partial<GlobalSettings> | null | undefined,
|
||||
measuredColumnWidthPx?: number | null,
|
||||
systemPrefersDark = getSystemPrefersDark()
|
||||
): NativeChatAppearanceStyle {
|
||||
const { fontSize, codeFontSize, width } = resolveNativeChatAppearanceSettings(
|
||||
settings?.nativeChatAppearance
|
||||
)
|
||||
const appearance = resolveNativeChatAppearanceSettings(settings?.nativeChatAppearance)
|
||||
const { width } = appearance
|
||||
const matching = appearance.matchTerminalInterface
|
||||
const terminalFontSize = settings?.terminalFontSize ?? 14
|
||||
const fontSize = matching ? terminalFontSize : appearance.fontSize
|
||||
const codeFontSize = matching ? terminalFontSize : appearance.codeFontSize
|
||||
const maxWidthPx = width === 'wide' ? 960 : width === 'full' ? Number.POSITIVE_INFINITY : 736
|
||||
const measuredWidth = nativeChatColumnWidthBucket(measuredColumnWidthPx)
|
||||
const columnWidthPx = Math.min(
|
||||
measuredWidth ? measuredWidth : width === 'wide' ? 960 : 736,
|
||||
maxWidthPx
|
||||
)
|
||||
return {
|
||||
const font = buildFontFamily(settings?.terminalFontFamily ?? '')
|
||||
const style: NativeChatAppearanceStyle = {
|
||||
'--chat-font-size': `${fontSize}px`,
|
||||
'--chat-code-font-size': `${codeFontSize}px`,
|
||||
'--chat-content-max-width': width === 'full' ? 'none' : width === 'wide' ? '60rem' : '46rem',
|
||||
@@ -46,12 +82,82 @@ export function nativeChatAppearanceStyle(
|
||||
'--chat-estimated-chars-per-line': Math.max(
|
||||
1,
|
||||
Math.floor((((96 * columnWidthPx) / 736) * 14) / fontSize)
|
||||
)
|
||||
),
|
||||
'--chat-code-font-family': font
|
||||
}
|
||||
let light = settings?.theme === 'light' || (settings?.theme === 'system' && !systemPrefersDark)
|
||||
if (matching) {
|
||||
const colors = resolveConfiguredTerminalColors(
|
||||
{
|
||||
theme: settings?.theme ?? 'dark',
|
||||
terminalThemeDark: settings?.terminalThemeDark ?? '',
|
||||
terminalThemeLight: settings?.terminalThemeLight ?? '',
|
||||
terminalUseSeparateLightTheme: settings?.terminalUseSeparateLightTheme ?? false,
|
||||
terminalCustomThemes: settings?.terminalCustomThemes,
|
||||
terminalColorOverrides: settings?.terminalColorOverrides
|
||||
},
|
||||
systemPrefersDark
|
||||
)
|
||||
const hasThemeColors = Boolean(colors.background && colors.foreground)
|
||||
const background = hasThemeColors ? colors.background : '#000000'
|
||||
const foreground = hasThemeColors ? colors.foreground : '#fafafa'
|
||||
light = isTerminalBackgroundLight(background)
|
||||
style.colorScheme = light ? 'light' : 'dark'
|
||||
// Ghost controls inherit a color, so remapping the token alone leaves the app's color in place.
|
||||
style.color = 'var(--foreground)'
|
||||
Object.assign(style, {
|
||||
'--chat-font-family': font,
|
||||
'--background': 'var(--chat-canvas)',
|
||||
'--foreground': 'var(--chat-foreground-strong)',
|
||||
'--muted-foreground': 'var(--chat-source-muted-foreground)',
|
||||
'--accent': 'color-mix(in srgb, var(--chat-source-foreground) 9%, var(--chat-canvas))',
|
||||
'--muted': 'color-mix(in srgb, var(--chat-source-foreground) 7%, var(--chat-canvas))',
|
||||
'--popover': 'color-mix(in srgb, var(--chat-source-foreground) 4%, var(--chat-canvas))',
|
||||
'--popover-foreground': 'var(--chat-foreground-strong)',
|
||||
'--accent-foreground': 'var(--chat-foreground-strong)',
|
||||
'--border': 'color-mix(in srgb, var(--chat-source-foreground) 7%, var(--chat-canvas))',
|
||||
'--input': 'color-mix(in srgb, var(--chat-source-foreground) 7%, var(--chat-canvas))',
|
||||
'--card': 'color-mix(in srgb, var(--chat-source-foreground) 4%, var(--chat-canvas))',
|
||||
'--card-foreground': 'var(--chat-foreground-strong)',
|
||||
'--primary': 'var(--chat-foreground-strong)',
|
||||
'--primary-foreground': 'var(--chat-canvas)',
|
||||
'--secondary': 'var(--muted)',
|
||||
'--secondary-foreground': 'var(--chat-foreground-strong)',
|
||||
'--ring': 'color-mix(in srgb, var(--chat-source-foreground) 44%, var(--chat-canvas))',
|
||||
'--chat-source-background': background,
|
||||
'--chat-source-foreground': foreground,
|
||||
'--chat-source-muted-foreground':
|
||||
'color-mix(in srgb, var(--chat-source-foreground) 62%, var(--chat-canvas))',
|
||||
'--chat-canvas-mix': '0%',
|
||||
'--chat-faint-mix': light ? '100%' : '83%',
|
||||
'--chat-user-mix': light ? '4%' : '7%',
|
||||
'--chat-user-border-mix': light ? '7%' : '6%',
|
||||
'--chat-code-mix': light ? '2%' : '3.5%',
|
||||
'--chat-code-base': light ? 'var(--chat-canvas)' : 'transparent',
|
||||
'--chat-inline-code-mix': light ? '4%' : '6%',
|
||||
'--chat-inline-code-base': light ? 'var(--chat-canvas)' : 'transparent',
|
||||
'--chat-inline-code-border-mix': light ? '9%' : '8%',
|
||||
'--chat-composer-mix': light ? '0%' : '4%',
|
||||
'--chat-composer-base': light ? 'var(--chat-canvas)' : 'transparent',
|
||||
'--chat-composer-border-mix': light ? '11%' : '9%'
|
||||
})
|
||||
}
|
||||
const bodyMix = matching ? 100 : nativeChatContrastMix(appearance.contrast, light)
|
||||
style['--chat-foreground-mix'] = `${bodyMix}%`
|
||||
style['--chat-strong-mix'] = `${Math.min(100, bodyMix + (light ? 10 : 12))}%`
|
||||
return style
|
||||
}
|
||||
|
||||
export function useNativeChatAppearanceStyle(
|
||||
settings: Pick<GlobalSettings, 'nativeChatAppearance'> | null | undefined
|
||||
settings: Partial<GlobalSettings> | null | undefined,
|
||||
measuredColumnWidthPx?: number | null
|
||||
): NativeChatAppearanceStyle {
|
||||
return nativeChatAppearanceStyle(settings)
|
||||
const selectInputs = useShallow(selectNativeChatAppearanceSettings)
|
||||
const inputs = selectInputs(settings)
|
||||
const systemPrefersDark = useSystemPrefersDark()
|
||||
const measuredWidth = nativeChatColumnWidthBucket(measuredColumnWidthPx)
|
||||
return useMemo(
|
||||
() => nativeChatAppearanceStyle(inputs, measuredWidth, systemPrefersDark),
|
||||
[inputs, measuredWidth, systemPrefersDark]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('chat appearance write queue', () => {
|
||||
mock.state.settings = { ...mock.state.settings!, ...updates }
|
||||
})
|
||||
mock.state.updateSettings = updateSettings
|
||||
const fromNewerVersion = { fontSize: 14, contrast: 151 }
|
||||
const fromNewerVersion = { fontSize: 14, contrast: 151, futureSetting: 'keep' }
|
||||
mock.state.settings = {
|
||||
...getDefaultSettings('/tmp'),
|
||||
nativeChatAppearance: fromNewerVersion
|
||||
@@ -49,7 +49,8 @@ describe('chat appearance write queue', () => {
|
||||
|
||||
expect(updateSettings).toHaveBeenCalledTimes(2)
|
||||
expect(mock.state.settings?.nativeChatAppearance).toEqual({
|
||||
contrast: 151,
|
||||
contrast: 150,
|
||||
futureSetting: 'keep',
|
||||
codeFontSize: 16,
|
||||
fontSize: 15
|
||||
})
|
||||
@@ -73,4 +74,35 @@ describe('chat appearance write queue', () => {
|
||||
expect(updateSettings).toHaveBeenCalledTimes(2)
|
||||
expect(mock.state.settings?.nativeChatAppearance).toEqual({ fontSize: 20 })
|
||||
})
|
||||
|
||||
it('consumes chat size actions without changing saved size while terminal matching is active', async () => {
|
||||
const updateSettings = vi.fn(async (updates: Partial<GlobalSettings>) => {
|
||||
mock.state.settings = { ...mock.state.settings!, ...updates }
|
||||
})
|
||||
mock.state.updateSettings = updateSettings
|
||||
mock.state.settings = {
|
||||
...getDefaultSettings('/tmp'),
|
||||
nativeChatAppearance: {
|
||||
fontSize: 18,
|
||||
codeFontSize: 16,
|
||||
contrast: 125,
|
||||
matchTerminalInterface: true
|
||||
}
|
||||
}
|
||||
await writeNativeChatFontSize('increase')
|
||||
await writeNativeChatFontSize('decrease')
|
||||
await writeNativeChatFontSize('reset')
|
||||
expect(updateSettings).not.toHaveBeenCalled()
|
||||
expect(mock.state.settings.nativeChatAppearance?.fontSize).toBe(18)
|
||||
mock.state.settings = {
|
||||
...mock.state.settings,
|
||||
nativeChatAppearance: {
|
||||
...mock.state.settings.nativeChatAppearance,
|
||||
matchTerminalInterface: false
|
||||
}
|
||||
}
|
||||
await writeNativeChatFontSize('increase')
|
||||
expect(updateSettings).toHaveBeenCalledTimes(1)
|
||||
expect(mock.state.settings.nativeChatAppearance?.fontSize).toBe(19)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,3 +7,15 @@
|
||||
.native-chat-message-text {
|
||||
font-size: var(--chat-font-size, 14px);
|
||||
}
|
||||
|
||||
.native-chat-appearance {
|
||||
font-family: var(--chat-font-family, var(--font-sans));
|
||||
}
|
||||
|
||||
.native-chat-appearance :is(.font-sans, textarea, [contenteditable='true']) {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.native-chat-appearance :is(.font-mono, code, pre) {
|
||||
font-family: var(--chat-code-font-family, var(--font-mono));
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ export function chatFontSizeForAction(
|
||||
appearance: NativeChatAppearanceSettings | undefined,
|
||||
action: Exclude<ChatFontSizeAction, null>
|
||||
): NativeChatAppearanceSettings | undefined {
|
||||
if (appearance?.matchTerminalInterface) {
|
||||
return appearance
|
||||
}
|
||||
const { fontSize } = resolveNativeChatAppearanceSettings(appearance)
|
||||
return normalizeNativeChatAppearanceSettings({
|
||||
...appearance,
|
||||
|
||||
@@ -216,4 +216,50 @@ describe('transcript row height estimate', () => {
|
||||
estimateNativeChatRowHeight(prose, NO_CHROME)
|
||||
)
|
||||
})
|
||||
|
||||
it('reserves a bounded two-line tool header across supported text sizes', () => {
|
||||
const estimates = [12, 14, 20].map((fontSize) => {
|
||||
const style = nativeChatAppearanceStyle({ nativeChatAppearance: { fontSize } })
|
||||
const typography = {
|
||||
lineHeightPx: style['--chat-estimated-line-height'],
|
||||
charsPerLine: style['--chat-estimated-chars-per-line']
|
||||
}
|
||||
const heightFor = (command: string): number =>
|
||||
estimateNativeChatRowHeight(
|
||||
nativeChatRowContentMetrics({
|
||||
id: command,
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'tool-call', name: 'shell', input: { command }, state: 'completed' }],
|
||||
timestamp: 1,
|
||||
source: 'transcript'
|
||||
}),
|
||||
NO_CHROME,
|
||||
typography
|
||||
)
|
||||
const estimate = heightFor('x'.repeat(5000))
|
||||
|
||||
expect(estimate).toBe(heightFor('ls'))
|
||||
expect(estimate).toBeGreaterThan(2 * typography.lineHeightPx)
|
||||
expect(estimate).toBeLessThan(2 * typography.lineHeightPx + 24)
|
||||
return estimate
|
||||
})
|
||||
|
||||
expect(estimates[0]).toBeLessThan(estimates[1])
|
||||
expect(estimates[1]).toBeLessThan(estimates[2])
|
||||
})
|
||||
|
||||
it('grows the collapsed reasoning trigger with chat text size above its floor', () => {
|
||||
const reasoning = nativeChatRowContentMetrics(message('thinking', 'reasoning'))
|
||||
const [small, standard, large] = [12, 14, 20].map((fontSize) => {
|
||||
const style = nativeChatAppearanceStyle({ nativeChatAppearance: { fontSize } })
|
||||
return estimateNativeChatRowHeight(reasoning, NO_CHROME, {
|
||||
lineHeightPx: style['--chat-estimated-line-height'],
|
||||
charsPerLine: style['--chat-estimated-chars-per-line']
|
||||
})
|
||||
})
|
||||
|
||||
expect(small).toBe(24)
|
||||
expect(standard).toBe(24)
|
||||
expect(large).toBeGreaterThan(standard)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -41,8 +41,9 @@ const DEFAULT_ROW_TYPOGRAPHY: NativeChatRowTypography = { lineHeightPx: 22, char
|
||||
const PROSE_MIN_LINES = 1
|
||||
const USER_BUBBLE_CHROME_PX = 32
|
||||
const IMAGE_STRIP_PX = 88
|
||||
const TOOL_RUN_PX = 40
|
||||
/** A reasoning row draws only its `xs` trigger button until opened; opening remeasures it. */
|
||||
/** Header margin and vertical padding; its summary can occupy two text lines. */
|
||||
const TOOL_RUN_CHROME_PX = 16
|
||||
/** A reasoning row draws only its one-line trigger (`min-h-6`) until opened; opening remeasures it. */
|
||||
const COLLAPSED_REASONING_PX = 24
|
||||
const SUBAGENT_ROW_PX = 32
|
||||
/** The one-line head that names a subagent above its own rows. */
|
||||
@@ -110,6 +111,14 @@ export function nativeChatRowContentMetrics(
|
||||
return metrics
|
||||
}
|
||||
|
||||
/** 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,
|
||||
@@ -127,7 +136,7 @@ export function estimateNativeChatRowHeight(
|
||||
height =
|
||||
content.role === 'reasoning'
|
||||
? content.textLines > 0
|
||||
? COLLAPSED_REASONING_PX
|
||||
? collapsedReasoningHeight(typography)
|
||||
: 0
|
||||
: content.textLines * typography.lineHeightPx
|
||||
if (content.role === 'user' && content.textLines > 0) {
|
||||
@@ -145,7 +154,7 @@ export function estimateNativeChatRowHeight(
|
||||
}
|
||||
if (content.toolCount > 0) {
|
||||
// A run is one collapsed header by default; its members only exist while open.
|
||||
height += TOOL_RUN_PX
|
||||
height += TOOL_RUN_CHROME_PX + 2 * typography.lineHeightPx
|
||||
}
|
||||
height += content.subagentGroupCount * SUBAGENT_ROW_PX
|
||||
partCount = height > 0 ? 1 : 0
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import * as terminalThemeSelection from '../../../../shared/terminal-theme-selection'
|
||||
import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture'
|
||||
import { resetSystemPrefersDarkSubscriptionForTests } from '../terminal-pane/use-system-prefers-dark'
|
||||
import { useNativeChatAppearanceStyle } from './native-chat-appearance-style'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
resetSystemPrefersDarkSubscriptionForTests()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('shared chat appearance hook', () => {
|
||||
it('updates every consumer from one system-scheme subscription', () => {
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const matches = vi.spyOn(media, 'matches', 'get').mockReturnValue(true)
|
||||
vi.spyOn(window, 'matchMedia').mockReturnValue(media)
|
||||
const addListener = vi.spyOn(media, 'addEventListener')
|
||||
const removeListener = vi.spyOn(media, 'removeEventListener')
|
||||
const settings = createGlobalSettingsFixture({
|
||||
theme: 'system',
|
||||
terminalUseSeparateLightTheme: true,
|
||||
terminalThemeDark: 'Builtin Tango Dark',
|
||||
terminalThemeLight: 'Builtin Tango Light',
|
||||
nativeChatAppearance: { matchTerminalInterface: true }
|
||||
})
|
||||
const chat = renderHook(() => useNativeChatAppearanceStyle(settings))
|
||||
const anotherChat = renderHook(() => useNativeChatAppearanceStyle(settings))
|
||||
expect(addListener).toHaveBeenCalledTimes(1)
|
||||
expect(chat.result.current.colorScheme).toBe('dark')
|
||||
const darkBackground = chat.result.current['--chat-source-background']
|
||||
act(() => {
|
||||
matches.mockReturnValue(false)
|
||||
media.dispatchEvent(new MediaQueryListEvent('change', { matches: false }))
|
||||
})
|
||||
expect(chat.result.current.colorScheme).toBe('light')
|
||||
expect(chat.result.current['--chat-source-background']).not.toBe(darkBackground)
|
||||
expect(chat.result.current['--chat-foreground-mix']).toBe('100%')
|
||||
expect(anotherChat.result.current).toEqual(chat.result.current)
|
||||
chat.unmount()
|
||||
expect(removeListener).not.toHaveBeenCalled()
|
||||
anotherChat.unmount()
|
||||
expect(removeListener).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('keeps the style and palette resolution stable across unrelated settings and render changes', () => {
|
||||
const resolveColors = vi.spyOn(terminalThemeSelection, 'resolveConfiguredTerminalColors')
|
||||
const settings = createGlobalSettingsFixture({
|
||||
theme: 'dark',
|
||||
nativeChatAppearance: { matchTerminalInterface: true },
|
||||
terminalFontFamily: 'Consolas'
|
||||
})
|
||||
const { result, rerender } = renderHook(
|
||||
({ settings, width }: { settings: GlobalSettings; width?: number }) =>
|
||||
useNativeChatAppearanceStyle(settings, width),
|
||||
{ initialProps: { settings, width: 736 } }
|
||||
)
|
||||
const style = result.current
|
||||
expect(resolveColors).toHaveBeenCalledTimes(1)
|
||||
rerender({
|
||||
settings: { ...settings, terminalFontSize: settings.terminalFontSize + 1 },
|
||||
width: 736
|
||||
})
|
||||
expect(result.current).not.toBe(style)
|
||||
expect(result.current['--chat-font-size']).toBe(`${settings.terminalFontSize + 1}px`)
|
||||
expect(resolveColors).toHaveBeenCalledTimes(2)
|
||||
rerender({ settings: { ...settings, terminalFontFamily: 'Menlo' }, width: 736 })
|
||||
expect(result.current['--chat-code-font-family']).toContain('Menlo')
|
||||
expect(resolveColors).toHaveBeenCalledTimes(3)
|
||||
rerender({ settings: { ...settings, terminalFontFamily: 'Menlo' }, width: 384 })
|
||||
expect(result.current['--chat-estimated-chars-per-line']).toBe(50)
|
||||
expect(resolveColors).toHaveBeenCalledTimes(4)
|
||||
const narrower = result.current
|
||||
rerender({ settings: { ...settings, terminalFontFamily: 'Menlo' }, width: 399 })
|
||||
expect(result.current).toBe(narrower)
|
||||
expect(resolveColors).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('invalidates the style for each appearance input', () => {
|
||||
const settings = createGlobalSettingsFixture({
|
||||
theme: 'dark',
|
||||
terminalThemeDark: 'Builtin Tango Dark',
|
||||
terminalThemeLight: '',
|
||||
nativeChatAppearance: { matchTerminalInterface: true }
|
||||
})
|
||||
const updates: Partial<GlobalSettings>[] = [
|
||||
{ theme: 'light' },
|
||||
{ terminalThemeDark: 'Builtin Tango Light' },
|
||||
{ terminalThemeLight: 'Builtin Tango Light' },
|
||||
{ terminalUseSeparateLightTheme: !settings.terminalUseSeparateLightTheme },
|
||||
{ terminalCustomThemes: [] },
|
||||
{ terminalColorOverrides: { background: '#122033', foreground: '#ddeeff' } },
|
||||
{ terminalFontFamily: 'Menlo' },
|
||||
{ terminalFontSize: settings.terminalFontSize + 1 },
|
||||
{ nativeChatAppearance: { matchTerminalInterface: true, contrast: 120 } }
|
||||
]
|
||||
const { result, rerender } = renderHook(
|
||||
(settings: GlobalSettings) => useNativeChatAppearanceStyle(settings),
|
||||
{ initialProps: settings }
|
||||
)
|
||||
for (const update of updates) {
|
||||
rerender(settings)
|
||||
const style = result.current
|
||||
rerender({ ...settings, ...update })
|
||||
expect(result.current, Object.keys(update).join(',')).not.toBe(style)
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores terminal size changes while matching is off', () => {
|
||||
const settings = createGlobalSettingsFixture({ nativeChatAppearance: { fontSize: 18 } })
|
||||
const { result, rerender } = renderHook(
|
||||
(current: GlobalSettings) => useNativeChatAppearanceStyle(current),
|
||||
{ initialProps: settings }
|
||||
)
|
||||
const style = result.current
|
||||
rerender({ ...settings, terminalFontSize: settings.terminalFontSize + 1 })
|
||||
expect(result.current).toBe(style)
|
||||
})
|
||||
})
|
||||
@@ -19,7 +19,7 @@ import { useNativeChatFontSize } from './use-native-chat-font-size'
|
||||
vi.mock('@/lib/web-client-location', () => ({ isWebClientLocation: () => mocks.web }))
|
||||
import { isMacPlatform } from './native-chat-shortcut'
|
||||
|
||||
function key(key: string, target: EventTarget = window): void {
|
||||
function key(key: string, target: EventTarget = window): KeyboardEvent {
|
||||
const code =
|
||||
key === '+'
|
||||
? 'Equal'
|
||||
@@ -28,17 +28,17 @@ function key(key: string, target: EventTarget = window): void {
|
||||
: key === '0'
|
||||
? 'Digit0'
|
||||
: `Key${key.toUpperCase()}`
|
||||
target.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key,
|
||||
code,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
metaKey: isMacPlatform(),
|
||||
ctrlKey: !isMacPlatform(),
|
||||
shiftKey: key === '+' || key === '_'
|
||||
})
|
||||
)
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key,
|
||||
code,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
metaKey: isMacPlatform(),
|
||||
ctrlKey: !isMacPlatform(),
|
||||
shiftKey: key === '+' || key === '_'
|
||||
})
|
||||
target.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
@@ -49,6 +49,21 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('persisted chat font-size shortcuts', () => {
|
||||
it('consumes matching chat size keys without writing the saved font size', async () => {
|
||||
mocks.settings.nativeChatAppearance = { fontSize: 18, matchTerminalInterface: true }
|
||||
renderHook(() => useNativeChatFontSize(true))
|
||||
const events: KeyboardEvent[] = []
|
||||
act(() => {
|
||||
events.push(key('+'), key('-'), key('0'))
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(events.every((event) => event.defaultPrevented)).toBe(true)
|
||||
expect(mocks.updateSettings).not.toHaveBeenCalled()
|
||||
expect(mocks.settings.nativeChatAppearance).toEqual({
|
||||
fontSize: 18,
|
||||
matchTerminalInterface: true
|
||||
})
|
||||
})
|
||||
it('serializes quick repeats against the latest stored size, clamps, and resets only text size', async () => {
|
||||
mocks.settings.nativeChatAppearance = { fontSize: 19, codeFontSize: 16, width: 'wide' }
|
||||
renderHook(() => useNativeChatFontSize(true))
|
||||
|
||||
@@ -2,45 +2,108 @@
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as terminalThemeSelection from '../../../../shared/terminal-theme-selection'
|
||||
import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture'
|
||||
import { useAppStore } from '../../store'
|
||||
import { resetSystemPrefersDarkSubscriptionForTests } from '../terminal-pane/use-system-prefers-dark'
|
||||
import { useNativeChatRowTypography } from './use-native-chat-row-typography'
|
||||
|
||||
const measurements = vi.hoisted((): { onWidth?: (width: number | null) => void } => ({}))
|
||||
vi.mock('../right-sidebar/right-sidebar-measured-width', () => ({
|
||||
useMeasuredWidth: (onWidth: (width: number | null) => void) => {
|
||||
measurements.onWidth = onWidth
|
||||
return () => undefined
|
||||
}
|
||||
}))
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: (selector: (state: { settings: object }) => unknown) => selector({ settings: {} })
|
||||
}))
|
||||
afterEach(cleanup)
|
||||
const originalSettings = useAppStore.getState().settings
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
useAppStore.setState({ settings: originalSettings })
|
||||
resetSystemPrefersDarkSubscriptionForTests()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('memoized transcript row typography', () => {
|
||||
it('does no theme work on message renders or unrelated settings writes and follows text size and measured width', () => {
|
||||
const settings = createGlobalSettingsFixture({
|
||||
theme: 'dark',
|
||||
nativeChatAppearance: { matchTerminalInterface: true }
|
||||
})
|
||||
useAppStore.setState({ settings })
|
||||
const resolveColors = vi.spyOn(terminalThemeSelection, 'resolveConfiguredTerminalColors')
|
||||
const matchMedia = vi.spyOn(window, 'matchMedia')
|
||||
const rendered = vi.fn()
|
||||
const { result, rerender } = renderHook(() => {
|
||||
rendered()
|
||||
return useNativeChatRowTypography(useRef<HTMLDivElement>(null))
|
||||
})
|
||||
const initial = result.current.typography
|
||||
expect(resolveColors).toHaveBeenCalledTimes(1)
|
||||
const initialMediaReads = matchMedia.mock.calls.length
|
||||
rerender()
|
||||
rerender()
|
||||
expect(result.current.typography).toBe(initial)
|
||||
expect(resolveColors).toHaveBeenCalledTimes(1)
|
||||
expect(matchMedia).toHaveBeenCalledTimes(initialMediaReads)
|
||||
const renders = rendered.mock.calls.length
|
||||
const terminalSizeSettings = { ...settings, terminalFontSize: settings.terminalFontSize + 1 }
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
settings: terminalSizeSettings
|
||||
})
|
||||
)
|
||||
expect(rendered).toHaveBeenCalledTimes(renders + 1)
|
||||
expect(result.current.typography.lineHeightPx).toBe((22 * 15) / 14)
|
||||
expect(resolveColors).toHaveBeenCalledTimes(2)
|
||||
act(() =>
|
||||
useAppStore.setState({
|
||||
settings: {
|
||||
...terminalSizeSettings,
|
||||
nativeChatAppearance: { matchTerminalInterface: true, fontSize: 20 }
|
||||
}
|
||||
})
|
||||
)
|
||||
expect(result.current.typography.lineHeightPx).toBe((22 * 15) / 14)
|
||||
expect(resolveColors).toHaveBeenCalledTimes(3)
|
||||
const node = document.createElement('div')
|
||||
vi.spyOn(node, 'getBoundingClientRect').mockReturnValue(DOMRect.fromRect({ width: 384 }))
|
||||
act(() => result.current.measureContent(node))
|
||||
expect(result.current.typography.charsPerLine).toBe(46)
|
||||
expect(resolveColors).toHaveBeenCalledTimes(4)
|
||||
const measured = result.current.typography
|
||||
vi.mocked(node.getBoundingClientRect).mockReturnValue(DOMRect.fromRect({ width: 399 }))
|
||||
act(() => result.current.measureContent(node))
|
||||
expect(result.current.typography).toBe(measured)
|
||||
expect(resolveColors).toHaveBeenCalledTimes(4)
|
||||
act(() => result.current.measureContent(null))
|
||||
})
|
||||
|
||||
describe('chat row typography during resizing', () => {
|
||||
it('preserves the slot memo on default-width mount and pixel resizes, then refreshes for a new bucket', () => {
|
||||
useAppStore.setState({ settings: createGlobalSettingsFixture() })
|
||||
let slotBuilds = 0
|
||||
const { result } = renderHook(() => {
|
||||
const ref = useRef<HTMLDivElement | null>(null)
|
||||
const { typography } = useNativeChatRowTypography(ref)
|
||||
const { typography, measureContent } = useNativeChatRowTypography(ref)
|
||||
const slots = useMemo(() => {
|
||||
slotBuilds += 1
|
||||
return { typography }
|
||||
}, [typography])
|
||||
return slots
|
||||
return { slots, measureContent }
|
||||
})
|
||||
const initial = result.current
|
||||
act(() => measurements.onWidth?.(736))
|
||||
expect(result.current).toBe(initial)
|
||||
const node = document.createElement('div')
|
||||
const bounds = vi.spyOn(node, 'getBoundingClientRect')
|
||||
const measure = (width: number) => {
|
||||
bounds.mockReturnValue(DOMRect.fromRect({ width }))
|
||||
act(() => result.current.measureContent(node))
|
||||
}
|
||||
const initial = result.current.slots
|
||||
measure(736)
|
||||
expect(result.current.slots).toBe(initial)
|
||||
expect(slotBuilds).toBe(1)
|
||||
act(() => measurements.onWidth?.(735))
|
||||
const narrower = result.current
|
||||
act(() => measurements.onWidth?.(734))
|
||||
act(() => measurements.onWidth?.(733))
|
||||
expect(result.current).toBe(narrower)
|
||||
measure(735)
|
||||
const narrower = result.current.slots
|
||||
measure(734)
|
||||
measure(733)
|
||||
expect(result.current.slots).toBe(narrower)
|
||||
expect(slotBuilds).toBe(2)
|
||||
expect(narrower).not.toBe(initial)
|
||||
act(() => measurements.onWidth?.(672))
|
||||
measure(672)
|
||||
expect(slotBuilds).toBe(3)
|
||||
expect(result.current).not.toBe(narrower)
|
||||
expect(result.current.slots).not.toBe(narrower)
|
||||
act(() => result.current.measureContent(null))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { useCallback, useMemo, useState, type RefObject } from 'react'
|
||||
import { useMeasuredWidth } from '../right-sidebar/right-sidebar-measured-width'
|
||||
import { useAppStore } from '../../store'
|
||||
import {
|
||||
nativeChatAppearanceStyle,
|
||||
nativeChatColumnWidthBucket
|
||||
} from './native-chat-appearance-style'
|
||||
import { nativeChatColumnWidthBucket } from './native-chat-appearance-style'
|
||||
import { useNativeChatStoreAppearanceStyle } from './use-native-chat-store-appearance-style'
|
||||
|
||||
export function useNativeChatRowTypography(contentRef: RefObject<HTMLDivElement | null>) {
|
||||
const appearance = useAppStore((state) => state.settings?.nativeChatAppearance)
|
||||
const [columnWidthPx, setColumnWidthPx] = useState<number | null>(null)
|
||||
const commitWidth = useCallback((width: number | null) => {
|
||||
setColumnWidthPx(nativeChatColumnWidthBucket(width))
|
||||
@@ -20,7 +16,7 @@ export function useNativeChatRowTypography(contentRef: RefObject<HTMLDivElement
|
||||
},
|
||||
[contentRef, measureWidth]
|
||||
)
|
||||
const style = nativeChatAppearanceStyle({ nativeChatAppearance: appearance }, columnWidthPx)
|
||||
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])
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useAppStore } from '../../store'
|
||||
import {
|
||||
selectNativeChatAppearanceSettings,
|
||||
useNativeChatAppearanceStyle
|
||||
} from './native-chat-appearance-style'
|
||||
|
||||
export function useNativeChatStoreAppearanceStyle(measuredColumnWidthPx?: number | null) {
|
||||
const settings = useAppStore(
|
||||
useShallow((state) => selectNativeChatAppearanceSettings(state.settings))
|
||||
)
|
||||
return useNativeChatAppearanceStyle(settings, measuredColumnWidthPx)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// @vitest-environment happy-dom
|
||||
import type { ComponentProps } from 'react'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Slider } from '../ui/slider'
|
||||
import { resolveNativeChatAppearanceSettings } from '../../../../shared/native-chat-appearance-settings'
|
||||
import { AppearanceChatContrastControls } from './AppearanceChatContrastControls'
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: (selector: (state: { settingsSearchQuery: string }) => unknown) =>
|
||||
selector({ settingsSearchQuery: '' })
|
||||
}))
|
||||
vi.mock('../ui/slider', () => ({
|
||||
Slider: ({ value, onValueChange, onValueCommit }: ComponentProps<typeof Slider>) => (
|
||||
<input
|
||||
aria-label="Contrast"
|
||||
type="range"
|
||||
min={50}
|
||||
max={150}
|
||||
value={value?.[0]}
|
||||
onChange={(event) => onValueChange?.([Number(event.target.value)])}
|
||||
onPointerUp={(event) => onValueCommit?.([Number(event.currentTarget.value)])}
|
||||
/>
|
||||
)
|
||||
}))
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('contrast drag persistence', () => {
|
||||
it('updates the displayed draft during dragging and saves once on commit', () => {
|
||||
const onChange = vi.fn()
|
||||
render(
|
||||
<AppearanceChatContrastControls
|
||||
appearance={resolveNativeChatAppearanceSettings(undefined)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
const slider = screen.getByRole('slider', { name: 'Contrast' })
|
||||
for (const value of [110, 120, 130]) {
|
||||
fireEvent.change(slider, { target: { value } })
|
||||
expect(screen.getByText(String(value))).toBeTruthy()
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
}
|
||||
fireEvent.pointerUp(slider)
|
||||
expect(onChange).toHaveBeenCalledExactlyOnceWith({ contrast: 130 })
|
||||
})
|
||||
|
||||
it('resets the draft when settings are changed externally', () => {
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<AppearanceChatContrastControls
|
||||
appearance={resolveNativeChatAppearanceSettings(undefined)}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
fireEvent.change(screen.getByRole('slider'), { target: { value: 130 } })
|
||||
rerender(
|
||||
<AppearanceChatContrastControls
|
||||
appearance={resolveNativeChatAppearanceSettings({ contrast: 80 })}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('slider').getAttribute('value')).toBe('80')
|
||||
expect(screen.getByText('80')).toBeTruthy()
|
||||
expect(onChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment happy-dom
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createGlobalSettingsFixture } from '../../../../shared/global-settings-test-fixture'
|
||||
import { AppearanceChatSection } from './AppearanceChatSection'
|
||||
|
||||
const mock = vi.hoisted(
|
||||
(): { state: { settingsSearchQuery: string; settings: GlobalSettings | null } } => ({
|
||||
state: { settingsSearchQuery: '', settings: null }
|
||||
})
|
||||
)
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useAppStore: Object.assign(
|
||||
(selector: (state: typeof mock.state) => unknown) => selector(mock.state),
|
||||
{ getState: () => mock.state }
|
||||
)
|
||||
}))
|
||||
beforeEach(() => {
|
||||
mock.state.settings = null
|
||||
})
|
||||
afterEach(cleanup)
|
||||
|
||||
function persistInMock(settings: GlobalSettings) {
|
||||
mock.state.settings = settings
|
||||
return vi.fn(async (updates: Partial<GlobalSettings>) => {
|
||||
mock.state.settings = { ...mock.state.settings!, ...updates }
|
||||
})
|
||||
}
|
||||
|
||||
describe('chat contrast controls', () => {
|
||||
it('renders a switch and saves the opt-in choice', async () => {
|
||||
const settings = createGlobalSettingsFixture()
|
||||
const updateSettings = persistInMock(settings)
|
||||
render(<AppearanceChatSection settings={settings} updateSettings={updateSettings} />)
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Match terminal interface' }))
|
||||
await waitFor(() =>
|
||||
expect(updateSettings).toHaveBeenCalledWith({
|
||||
nativeChatAppearance: { matchTerminalInterface: true }
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the clamped contrast and supports slider keyboard input', async () => {
|
||||
const settings = createGlobalSettingsFixture({ nativeChatAppearance: { contrast: 999 } })
|
||||
const updateSettings = persistInMock(settings)
|
||||
render(<AppearanceChatSection settings={settings} updateSettings={updateSettings} />)
|
||||
const slider = screen.getByRole('slider', { name: 'Contrast' })
|
||||
expect(slider.getAttribute('aria-valuenow')).toBe('150')
|
||||
fireEvent.keyDown(slider, { key: 'ArrowLeft' })
|
||||
await waitFor(() =>
|
||||
expect(updateSettings).toHaveBeenCalledWith({ nativeChatAppearance: { contrast: 149 } })
|
||||
)
|
||||
expect(screen.getByText('Softer')).toBeTruthy()
|
||||
expect(screen.getByText('Sharper')).toBeTruthy()
|
||||
})
|
||||
it('keeps keyboard focus when a committed value reaches settings', async () => {
|
||||
const settings = createGlobalSettingsFixture({ nativeChatAppearance: { contrast: 120 } })
|
||||
const updateSettings = persistInMock(settings)
|
||||
const { rerender } = render(
|
||||
<AppearanceChatSection settings={settings} updateSettings={updateSettings} />
|
||||
)
|
||||
const slider = screen.getByRole('slider', { name: 'Contrast' })
|
||||
slider.focus()
|
||||
fireEvent.keyDown(slider, { key: 'ArrowLeft' })
|
||||
await waitFor(() =>
|
||||
expect(updateSettings).toHaveBeenLastCalledWith({ nativeChatAppearance: { contrast: 119 } })
|
||||
)
|
||||
rerender(
|
||||
<AppearanceChatSection
|
||||
settings={{ ...settings, nativeChatAppearance: { contrast: 119 } }}
|
||||
updateSettings={updateSettings}
|
||||
/>
|
||||
)
|
||||
expect(document.activeElement).toBe(slider)
|
||||
fireEvent.keyDown(slider, { key: 'ArrowLeft' })
|
||||
await waitFor(() =>
|
||||
expect(updateSettings).toHaveBeenLastCalledWith({ nativeChatAppearance: { contrast: 118 } })
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { useState } from 'react'
|
||||
import type { NativeChatAppearanceSettings } from '../../../../shared/native-chat-appearance-settings'
|
||||
import {
|
||||
chatTerminalControlledHint,
|
||||
chatTerminalControlledTooltip,
|
||||
getChatContrastEntriesByKey
|
||||
} from './chat-appearance-search'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { Slider } from '../ui/slider'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { SettingsDisabledControlTooltip } from './SettingsDisabledControlTooltip'
|
||||
import { SettingsRow, SettingsSwitchRow } from './SettingsFormControls'
|
||||
|
||||
type ChatContrastControlsProps = {
|
||||
appearance: Required<NativeChatAppearanceSettings>
|
||||
onChange: (updates: NativeChatAppearanceSettings) => void
|
||||
forceVisiblePrimary?: boolean
|
||||
}
|
||||
|
||||
export function AppearanceChatContrastControls({
|
||||
appearance,
|
||||
onChange,
|
||||
forceVisiblePrimary
|
||||
}: ChatContrastControlsProps): React.JSX.Element {
|
||||
const entries = getChatContrastEntriesByKey()
|
||||
const { contrast, matchTerminalInterface: matching } = appearance
|
||||
return (
|
||||
<>
|
||||
<SearchableSetting
|
||||
id={entries.matchTerminalInterface.targetSectionId}
|
||||
{...entries.matchTerminalInterface}
|
||||
forceVisible={forceVisiblePrimary}
|
||||
>
|
||||
<SettingsSwitchRow
|
||||
label={entries.matchTerminalInterface.title}
|
||||
description={entries.matchTerminalInterface.description}
|
||||
checked={matching}
|
||||
onChange={() => onChange({ matchTerminalInterface: !matching })}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
<SearchableSetting
|
||||
id={entries.contrast.targetSectionId}
|
||||
{...entries.contrast}
|
||||
forceVisible={forceVisiblePrimary}
|
||||
>
|
||||
<SettingsRow
|
||||
label={entries.contrast.title}
|
||||
description={matching ? chatTerminalControlledHint() : entries.contrast.description}
|
||||
control={
|
||||
<ChatContrastSlider contrast={contrast} onChange={onChange} disabled={matching} />
|
||||
}
|
||||
/>
|
||||
</SearchableSetting>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatContrastSlider({
|
||||
contrast,
|
||||
onChange,
|
||||
disabled
|
||||
}: Pick<ChatContrastControlsProps, 'onChange'> & {
|
||||
contrast: number
|
||||
disabled: boolean
|
||||
}): React.JSX.Element {
|
||||
const [draft, setDraft] = useState({ savedContrast: contrast, value: contrast })
|
||||
// Keep the thumb mounted so committed keyboard changes retain focus.
|
||||
if (draft.savedContrast !== contrast) {
|
||||
setDraft({ savedContrast: contrast, value: contrast })
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{translate('settings.appearance.chat.softer', 'Softer')}
|
||||
</span>
|
||||
<SettingsDisabledControlTooltip
|
||||
reason={disabled ? chatTerminalControlledTooltip() : undefined}
|
||||
>
|
||||
<div className="w-40">
|
||||
<Slider
|
||||
disabled={disabled}
|
||||
min={50}
|
||||
max={150}
|
||||
step={1}
|
||||
value={[draft.value]}
|
||||
thumbLabels={[translate('settings.appearance.chat.contrast', 'Contrast')]}
|
||||
onValueChange={([value]) => setDraft({ savedContrast: contrast, value })}
|
||||
onValueCommit={([value]) => onChange({ contrast: value })}
|
||||
/>
|
||||
</div>
|
||||
</SettingsDisabledControlTooltip>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{translate('settings.appearance.chat.sharper', 'Sharper')}
|
||||
</span>
|
||||
<span className="w-8 text-right text-xs text-muted-foreground tabular-nums">
|
||||
{draft.value}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultSettings } from '../../../../shared/constants'
|
||||
import { TooltipProvider } from '../ui/tooltip'
|
||||
import { AppearanceChatSection } from './AppearanceChatSection'
|
||||
import {
|
||||
getChatAppearanceEntriesByKey,
|
||||
@@ -132,7 +133,9 @@ describe('chat appearance settings controls', () => {
|
||||
fontSize: 18,
|
||||
codeFontSize: 16,
|
||||
width: 'wide' as const,
|
||||
contrast: 151
|
||||
contrast: 130,
|
||||
matchTerminalInterface: false,
|
||||
futureSetting: 'keep'
|
||||
}
|
||||
}
|
||||
const updateSettings = persistInMock(settings)
|
||||
@@ -142,12 +145,15 @@ describe('chat appearance settings controls', () => {
|
||||
fireEvent.blur(text)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset' }))
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(2))
|
||||
expect(mocks.state.settings?.nativeChatAppearance).toEqual({ contrast: 151 })
|
||||
expect(mocks.state.settings?.nativeChatAppearance).toEqual({ futureSetting: 'keep' })
|
||||
})
|
||||
it('indexes each row and width choice in Chat settings search', () => {
|
||||
const entries = getChatAppearanceSearchEntries()
|
||||
for (const query of [
|
||||
'Chat',
|
||||
'Match terminal interface',
|
||||
'Contrast',
|
||||
'brighter look',
|
||||
'Code text size',
|
||||
'tool output',
|
||||
'Comfortable',
|
||||
@@ -158,4 +164,103 @@ describe('chat appearance settings controls', () => {
|
||||
expect(matchesSettingsSearch(query, entries), query).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('disables only terminal-controlled rows while matching and restores saved values off', async () => {
|
||||
const settings = {
|
||||
...getDefaultSettings('/tmp'),
|
||||
nativeChatAppearance: {
|
||||
fontSize: 18,
|
||||
codeFontSize: 16,
|
||||
contrast: 125,
|
||||
matchTerminalInterface: true
|
||||
}
|
||||
}
|
||||
const updateSettings = persistInMock(settings)
|
||||
const { rerender } = render(
|
||||
<AppearanceChatSection settings={settings} updateSettings={updateSettings} />,
|
||||
{ wrapper: TooltipProvider }
|
||||
)
|
||||
expect(screen.getAllByText('Set by terminal interface.')).toHaveLength(3)
|
||||
expect(screen.getByRole('spinbutton', { name: 'Text size' }).hasAttribute('disabled')).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('spinbutton', { name: 'Code text size' }).hasAttribute('disabled')
|
||||
).toBe(true)
|
||||
expect(screen.getByRole('slider', { name: 'Contrast' }).hasAttribute('data-disabled')).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
screen.getByRole('switch', { name: 'Match terminal interface' }).hasAttribute('disabled')
|
||||
).toBe(false)
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Wide' }))
|
||||
await waitFor(() => expect(mocks.state.settings?.nativeChatAppearance?.width).toBe('wide'))
|
||||
expect(mocks.state.settings?.nativeChatAppearance).toMatchObject({
|
||||
fontSize: 18,
|
||||
codeFontSize: 16,
|
||||
contrast: 125
|
||||
})
|
||||
rerender(
|
||||
<AppearanceChatSection settings={mocks.state.settings!} updateSettings={updateSettings} />
|
||||
)
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Match terminal interface' }))
|
||||
await waitFor(() =>
|
||||
expect(mocks.state.settings?.nativeChatAppearance?.matchTerminalInterface).toBeUndefined()
|
||||
)
|
||||
rerender(
|
||||
<AppearanceChatSection settings={mocks.state.settings!} updateSettings={updateSettings} />
|
||||
)
|
||||
expect(screen.queryByText('Set by terminal interface.')).toBeNull()
|
||||
expect(screen.getByRole('spinbutton', { name: 'Text size' }).getAttribute('value')).toBe('18')
|
||||
expect(screen.getByRole('spinbutton', { name: 'Code text size' }).getAttribute('value')).toBe(
|
||||
'16'
|
||||
)
|
||||
expect(screen.getByRole('slider', { name: 'Contrast' }).getAttribute('aria-valuenow')).toBe(
|
||||
'125'
|
||||
)
|
||||
expect(screen.getByRole('radio', { name: 'Wide' }).getAttribute('aria-checked')).toBe('true')
|
||||
})
|
||||
|
||||
const terminalTooltip =
|
||||
'Matching your terminal interface. Turn off Match terminal interface to change this.'
|
||||
const controlledControls = [
|
||||
['spinbutton', 'Text size'],
|
||||
['spinbutton', 'Code text size'],
|
||||
['slider', 'Contrast']
|
||||
] as const
|
||||
|
||||
it.each(controlledControls)(
|
||||
'explains a matched %s "%s" on hover of its wrapper',
|
||||
async (role, name) => {
|
||||
const settings = {
|
||||
...getDefaultSettings('/tmp'),
|
||||
nativeChatAppearance: { matchTerminalInterface: true }
|
||||
}
|
||||
render(
|
||||
<AppearanceChatSection settings={settings} updateSettings={persistInMock(settings)} />,
|
||||
{ wrapper: TooltipProvider }
|
||||
)
|
||||
const trigger = screen.getByRole(role, { name }).closest('[data-slot="tooltip-trigger"]')
|
||||
expect(trigger).not.toBeNull()
|
||||
fireEvent.pointerMove(trigger!, { pointerType: 'mouse' })
|
||||
expect((await screen.findByRole('tooltip')).textContent).toBe(terminalTooltip)
|
||||
// Width stays editable and unexplained.
|
||||
expect(
|
||||
screen.getByRole('radio', { name: 'Wide' }).closest('[data-slot="tooltip-trigger"]')
|
||||
).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('gives the controls no tooltip while matching is off', () => {
|
||||
const settings = getDefaultSettings('/tmp')
|
||||
render(<AppearanceChatSection settings={settings} updateSettings={persistInMock(settings)} />, {
|
||||
wrapper: TooltipProvider
|
||||
})
|
||||
for (const [role, name] of controlledControls) {
|
||||
const control = screen.getByRole(role, { name })
|
||||
expect(control.closest('[data-slot="tooltip-trigger"]')).toBeNull()
|
||||
fireEvent.pointerMove(control, { pointerType: 'mouse' })
|
||||
}
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,10 +7,16 @@ import {
|
||||
import { useAppStore } from '../../store'
|
||||
import { formatPrimaryShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { AppearanceChatContrastControls } from './AppearanceChatContrastControls'
|
||||
import { Button } from '../ui/button'
|
||||
import { SearchableSetting } from './SearchableSetting'
|
||||
import { NumberField, SettingsRow, SettingsSegmentedControl } from './SettingsFormControls'
|
||||
import { getChatAppearanceEntriesByKey, getChatWidthOptions } from './chat-appearance-search'
|
||||
import {
|
||||
chatTerminalControlledHint,
|
||||
chatTerminalControlledTooltip,
|
||||
getChatAppearanceEntriesByKey,
|
||||
getChatWidthOptions
|
||||
} from './chat-appearance-search'
|
||||
import { writeNativeChatAppearance } from '../native-chat/native-chat-appearance-write'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
|
||||
@@ -26,6 +32,7 @@ export function AppearanceChatSection({
|
||||
forceVisiblePrimary = false
|
||||
}: AppearanceChatSectionProps): React.JSX.Element {
|
||||
const appearance = resolveNativeChatAppearanceSettings(settings.nativeChatAppearance)
|
||||
const matching = appearance.matchTerminalInterface
|
||||
const keybindings = useAppStore((state) => state.keybindings)
|
||||
const increase = formatPrimaryShortcutLabel('zoom.in', keybindings)
|
||||
const decrease = formatPrimaryShortcutLabel('zoom.out', keybindings)
|
||||
@@ -38,6 +45,11 @@ export function AppearanceChatSection({
|
||||
}
|
||||
return (
|
||||
<div className="divide-y divide-border/40">
|
||||
<AppearanceChatContrastControls
|
||||
appearance={appearance}
|
||||
onChange={update}
|
||||
forceVisiblePrimary={forceVisiblePrimary}
|
||||
/>
|
||||
<SearchableSetting
|
||||
id={entries.textSize.targetSectionId}
|
||||
{...entries.textSize}
|
||||
@@ -45,9 +57,11 @@ export function AppearanceChatSection({
|
||||
>
|
||||
<NumberField
|
||||
label={entries.textSize.title}
|
||||
description={entries.textSize.description}
|
||||
description={matching ? chatTerminalControlledHint() : entries.textSize.description}
|
||||
value={appearance.fontSize}
|
||||
defaultValue={14}
|
||||
defaultValue={matching ? undefined : 14}
|
||||
disabled={matching}
|
||||
disabledReason={chatTerminalControlledTooltip()}
|
||||
min={12}
|
||||
max={20}
|
||||
integer
|
||||
@@ -62,9 +76,11 @@ export function AppearanceChatSection({
|
||||
>
|
||||
<NumberField
|
||||
label={entries.codeTextSize.title}
|
||||
description={entries.codeTextSize.description}
|
||||
description={matching ? chatTerminalControlledHint() : entries.codeTextSize.description}
|
||||
value={appearance.codeFontSize}
|
||||
defaultValue={12}
|
||||
defaultValue={matching ? undefined : 12}
|
||||
disabled={matching}
|
||||
disabledReason={chatTerminalControlledTooltip()}
|
||||
min={10}
|
||||
max={18}
|
||||
integer
|
||||
|
||||
@@ -144,4 +144,25 @@ describe('Chat settings page', () => {
|
||||
expect(getSettingsScrollTarget(entry.targetSectionId ?? '', container)).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Match terminal interface', 'chat-match-terminal-interface', 'switch'],
|
||||
['Contrast', 'chat-contrast', 'slider']
|
||||
])('searches %s and resolves its Chat deep link', (title, targetSectionId, role) => {
|
||||
state.settingsSearchQuery = title
|
||||
const { container } = renderChat(true)
|
||||
expect(screen.getByRole(role, { name: title })).toBeTruthy()
|
||||
const sections = buildSettingsNavigationMetadata({
|
||||
isMac: true,
|
||||
isWindows: false,
|
||||
isWebClient: false,
|
||||
experimentalStructuredNativeChat: true,
|
||||
repos: []
|
||||
})
|
||||
const result = buildCmdJSettingsResults(sections).find(
|
||||
(entry) => entry.sectionId === 'chat' && entry.title === title
|
||||
)
|
||||
expect(result?.targetSectionId).toBe(targetSectionId)
|
||||
expect(getSettingsScrollTarget(targetSectionId, container)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type React from 'react'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
|
||||
/** Explains why a settings control is disabled; renders the control untouched without a reason. */
|
||||
export function SettingsDisabledControlTooltip({
|
||||
reason,
|
||||
children
|
||||
}: {
|
||||
reason?: string
|
||||
children: React.ReactNode
|
||||
}): React.JSX.Element {
|
||||
if (!reason) {
|
||||
return <>{children}</>
|
||||
}
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{/* Disabled controls drop hover events, so an enabled wrapper carries the tooltip. */}
|
||||
<span className="inline-flex">{children}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={4}>
|
||||
{reason}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Input } from '../ui/input'
|
||||
import { Label } from '../ui/label'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'
|
||||
import { Switch } from '../ui/switch'
|
||||
import { SettingsDisabledControlTooltip } from './SettingsDisabledControlTooltip'
|
||||
import { normalizeColor } from '@/lib/terminal-theme'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -275,6 +276,9 @@ type NumberFieldProps = {
|
||||
placeholder?: string
|
||||
suffix?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
/** Hover explanation shown only while the field is disabled. */
|
||||
disabledReason?: string
|
||||
}
|
||||
|
||||
export function ColorField({
|
||||
@@ -323,7 +327,9 @@ export function NumberField({
|
||||
onClear,
|
||||
placeholder,
|
||||
suffix,
|
||||
className
|
||||
className,
|
||||
disabled = false,
|
||||
disabledReason
|
||||
}: NumberFieldProps): React.JSX.Element {
|
||||
const [draft, setDraft] = useState(Number.isFinite(value) ? String(value) : '')
|
||||
const [prevValue, setPrevValue] = useState(value)
|
||||
@@ -335,6 +341,9 @@ export function NumberField({
|
||||
}
|
||||
|
||||
const commit = (): void => {
|
||||
if (disabled) {
|
||||
return
|
||||
}
|
||||
const trimmed = draft.trim()
|
||||
if (trimmed === '') {
|
||||
if (onClear) {
|
||||
@@ -374,23 +383,26 @@ export function NumberField({
|
||||
}
|
||||
control={
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
aria-label={label}
|
||||
placeholder={placeholder}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
commit()
|
||||
}
|
||||
}}
|
||||
className="number-input-clean w-24 tabular-nums"
|
||||
/>
|
||||
<SettingsDisabledControlTooltip reason={disabled ? disabledReason : undefined}>
|
||||
<Input
|
||||
type="number"
|
||||
disabled={disabled}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
aria-label={label}
|
||||
placeholder={placeholder}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
commit()
|
||||
}
|
||||
}}
|
||||
className="number-input-clean w-24 tabular-nums"
|
||||
/>
|
||||
</SettingsDisabledControlTooltip>
|
||||
{suffix ? <span className="shrink-0 text-xs text-muted-foreground">{suffix}</span> : null}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -3,6 +3,46 @@ import { createLocalizedCatalog } from '@/i18n/localized-catalog'
|
||||
import type { SettingsSearchEntry } from './settings-search'
|
||||
import { formatPrimaryShortcutLabel } from '@/hooks/useShortcutLabel'
|
||||
|
||||
export function chatTerminalControlledHint(): string {
|
||||
return translate('settings.appearance.chat.terminalControlledHint', 'Set by terminal interface.')
|
||||
}
|
||||
|
||||
export function chatTerminalControlledTooltip(): string {
|
||||
return translate(
|
||||
'settings.appearance.chat.terminalControlledTooltip',
|
||||
'Matching your terminal interface. Turn off Match terminal interface to change this.'
|
||||
)
|
||||
}
|
||||
|
||||
export const getChatContrastEntriesByKey = createLocalizedCatalog(
|
||||
() =>
|
||||
({
|
||||
matchTerminalInterface: {
|
||||
title: translate(
|
||||
'settings.appearance.chat.matchTerminalInterface',
|
||||
'Match terminal interface'
|
||||
),
|
||||
description: translate(
|
||||
'settings.appearance.chat.matchTerminalInterfaceDescription',
|
||||
"Use your terminal interface's font and colors for the whole chat. Stays in sync if you change your terminal theme later."
|
||||
),
|
||||
targetSectionId: 'chat-match-terminal-interface'
|
||||
},
|
||||
contrast: {
|
||||
title: translate('settings.appearance.chat.contrast', 'Contrast'),
|
||||
description: translate(
|
||||
'settings.appearance.chat.contrastDescription',
|
||||
'How bright chat text is against the background. The right end matches the older, brighter look.'
|
||||
),
|
||||
targetSectionId: 'chat-contrast'
|
||||
}
|
||||
}) satisfies Record<string, SettingsSearchEntry>
|
||||
)
|
||||
|
||||
export function getChatContrastSearchEntries(): SettingsSearchEntry[] {
|
||||
return Object.values(getChatContrastEntriesByKey())
|
||||
}
|
||||
|
||||
const getChatAppearanceCatalog = createLocalizedCatalog(
|
||||
() =>
|
||||
({
|
||||
@@ -63,10 +103,9 @@ export function getChatAppearanceEntriesByKey(shortcuts?: { increase: string; de
|
||||
|
||||
export function getChatAppearanceSearchEntries(): SettingsSearchEntry[] {
|
||||
return [
|
||||
{
|
||||
title: translate('auto.components.settings.Settings.2b4474780a', 'Appearance')
|
||||
},
|
||||
...Object.values(getChatAppearanceEntriesByKey())
|
||||
{ title: translate('auto.components.settings.Settings.2b4474780a', 'Appearance') },
|
||||
...Object.values(getChatAppearanceEntriesByKey()),
|
||||
...getChatContrastSearchEntries()
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -209,6 +209,21 @@ describe('registerZoomIpcBridge', () => {
|
||||
expect(zoom.setEditorFontZoomLevel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('consumes menu chat zoom while matching without changing saved size or app zoom', async () => {
|
||||
const zoom = await mountZoomBridge({
|
||||
activeTabType: 'terminal',
|
||||
activeElement: makeTarget({ chatClosest: true }),
|
||||
nativeChatAppearance: { fontSize: 18, matchTerminalInterface: true }
|
||||
})
|
||||
zoom.fire('in')
|
||||
zoom.fire('out')
|
||||
zoom.fire('reset')
|
||||
await vi.waitFor(() => expect(zoom.settings.nativeChatAppearance?.fontSize).toBe(18))
|
||||
expect(zoom.updateSettings).not.toHaveBeenCalled()
|
||||
expect(zoom.applyUIZoom).not.toHaveBeenCalled()
|
||||
expect(zoom.setEditorFontZoomLevel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies app zoom for an active browser tab', async () => {
|
||||
const zoom = await mountZoomBridge({ activeTabType: 'browser' })
|
||||
|
||||
|
||||
@@ -233,7 +233,15 @@
|
||||
"wide": "Wide",
|
||||
"full": "Full",
|
||||
"pixels": "px",
|
||||
"reset": "Reset"
|
||||
"reset": "Reset",
|
||||
"matchTerminalInterface": "Match terminal interface",
|
||||
"matchTerminalInterfaceDescription": "Use your terminal interface's font and colors for the whole chat. Stays in sync if you change your terminal theme later.",
|
||||
"contrast": "Contrast",
|
||||
"contrastDescription": "How bright chat text is against the background. The right end matches the older, brighter look.",
|
||||
"softer": "Softer",
|
||||
"sharper": "Sharper",
|
||||
"terminalControlledHint": "Set by terminal interface.",
|
||||
"terminalControlledTooltip": "Matching your terminal interface. Turn off Match terminal interface to change this."
|
||||
}
|
||||
},
|
||||
"browser": {
|
||||
|
||||
@@ -70,7 +70,15 @@
|
||||
"wide": "Amplio",
|
||||
"full": "Completo",
|
||||
"pixels": "px",
|
||||
"reset": "Restablecer"
|
||||
"reset": "Restablecer",
|
||||
"matchTerminalInterface": "Usar la interfaz del terminal",
|
||||
"matchTerminalInterfaceDescription": "Usa la fuente y los colores de la interfaz del terminal para todo el chat. Se mantiene sincronizado si cambias el tema del terminal.",
|
||||
"contrast": "Contraste",
|
||||
"contrastDescription": "Ajusta el brillo del texto del chat sobre el fondo. El extremo derecho recupera el aspecto anterior, más brillante.",
|
||||
"softer": "Más suave",
|
||||
"sharper": "Más nítido",
|
||||
"terminalControlledHint": "Lo controla la interfaz del terminal.",
|
||||
"terminalControlledTooltip": "Coincide con la interfaz del terminal. Desactiva Usar la interfaz del terminal para cambiarlo."
|
||||
}
|
||||
},
|
||||
"browser": {
|
||||
|
||||
@@ -169,7 +169,15 @@
|
||||
"wide": "Large",
|
||||
"full": "Pleine",
|
||||
"pixels": "px",
|
||||
"reset": "Réinitialiser"
|
||||
"reset": "Réinitialiser",
|
||||
"matchTerminalInterface": "Utiliser l’interface du terminal",
|
||||
"matchTerminalInterfaceDescription": "Utilise la police et les couleurs de l’interface du terminal pour tout le chat. Les changements de thème du terminal sont appliqués automatiquement.",
|
||||
"contrast": "Contraste",
|
||||
"contrastDescription": "Ajuste la luminosité du texte du chat sur le fond. L’extrémité droite retrouve l’ancien aspect, plus lumineux.",
|
||||
"softer": "Plus doux",
|
||||
"sharper": "Plus net",
|
||||
"terminalControlledHint": "Défini par l’interface du terminal.",
|
||||
"terminalControlledTooltip": "Suit l’interface du terminal. Désactivez Utiliser l’interface du terminal pour le modifier."
|
||||
}
|
||||
},
|
||||
"browser": {
|
||||
|
||||
@@ -70,7 +70,15 @@
|
||||
"wide": "広い",
|
||||
"full": "全幅",
|
||||
"pixels": "px",
|
||||
"reset": "リセット"
|
||||
"reset": "リセット",
|
||||
"matchTerminalInterface": "ターミナルの外観に合わせる",
|
||||
"matchTerminalInterfaceDescription": "チャット全体にターミナルと同じフォントと配色を使用します。ターミナルのテーマを変更すると、チャットにも反映されます。",
|
||||
"contrast": "コントラスト",
|
||||
"contrastDescription": "背景に対するチャットの文字の明るさを調整します。右端にすると、以前の明るい表示になります。",
|
||||
"softer": "柔らかく",
|
||||
"sharper": "くっきり",
|
||||
"terminalControlledHint": "ターミナルの外観設定に従います。",
|
||||
"terminalControlledTooltip": "ターミナルの外観に合わせています。変更するには「ターミナルの外観に合わせる」をオフにしてください。"
|
||||
}
|
||||
},
|
||||
"browser": {
|
||||
|
||||
@@ -70,7 +70,15 @@
|
||||
"wide": "넓게",
|
||||
"full": "전체",
|
||||
"pixels": "px",
|
||||
"reset": "초기화"
|
||||
"reset": "초기화",
|
||||
"matchTerminalInterface": "터미널 모양에 맞추기",
|
||||
"matchTerminalInterfaceDescription": "전체 채팅에 터미널과 같은 글꼴과 색상을 사용합니다. 터미널 테마를 변경하면 채팅에도 반영됩니다.",
|
||||
"contrast": "대비",
|
||||
"contrastDescription": "배경에 대한 채팅 텍스트의 밝기를 조절합니다. 오른쪽 끝으로 이동하면 이전의 더 밝은 모습으로 표시됩니다.",
|
||||
"softer": "부드럽게",
|
||||
"sharper": "선명하게",
|
||||
"terminalControlledHint": "터미널 인터페이스 설정을 따릅니다.",
|
||||
"terminalControlledTooltip": "터미널 모양에 맞추는 중입니다. 변경하려면 터미널 모양에 맞추기를 끄세요."
|
||||
}
|
||||
},
|
||||
"browser": {
|
||||
|
||||
@@ -70,7 +70,15 @@
|
||||
"wide": "宽",
|
||||
"full": "全宽",
|
||||
"pixels": "px",
|
||||
"reset": "重置"
|
||||
"reset": "重置",
|
||||
"matchTerminalInterface": "匹配终端外观",
|
||||
"matchTerminalInterfaceDescription": "整个聊天使用与终端相同的字体和配色。更改终端主题后,聊天会自动同步。",
|
||||
"contrast": "对比度",
|
||||
"contrastDescription": "调整聊天文字相对于背景的亮度。滑到最右端可恢复以前更明亮的外观。",
|
||||
"softer": "更柔和",
|
||||
"sharper": "更清晰",
|
||||
"terminalControlledHint": "由终端界面设置。",
|
||||
"terminalControlledTooltip": "正在匹配终端外观。要更改此项,请关闭“匹配终端外观”。"
|
||||
}
|
||||
},
|
||||
"browser": {
|
||||
|
||||
@@ -8,21 +8,36 @@ import {
|
||||
describe('native chat appearance normalization', () => {
|
||||
it('preserves unknown future fields while normalizing only known fields', () => {
|
||||
const future = {
|
||||
contrast: 151,
|
||||
matchTerminalInterface: true,
|
||||
futureSetting: { nested: 'keep' }
|
||||
}
|
||||
expect(
|
||||
normalizeNativeChatAppearanceSettings({
|
||||
...future,
|
||||
contrast: 151,
|
||||
matchTerminalInterface: true,
|
||||
fontSize: 99,
|
||||
codeFontSize: 12,
|
||||
width: 'comfortable'
|
||||
})
|
||||
).toEqual({ ...future, fontSize: 20 })
|
||||
).toEqual({ ...future, fontSize: 20, contrast: 150, matchTerminalInterface: true })
|
||||
expect(normalizeNativeChatAppearanceSettings(future)).toEqual(future)
|
||||
})
|
||||
|
||||
it('preserves future fields without letting known defaults or malformed values survive', () => {
|
||||
const future = { futureSetting: { nested: 'keep' } }
|
||||
for (const contrast of [100, Number.NaN, Number.POSITIVE_INFINITY, '150', null]) {
|
||||
for (const matchTerminalInterface of [false, 'true', null]) {
|
||||
expect(
|
||||
normalizeNativeChatAppearanceSettings({
|
||||
...future,
|
||||
contrast,
|
||||
matchTerminalInterface
|
||||
})
|
||||
).toEqual(future)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('derives defaults without storing them', () => {
|
||||
expect(
|
||||
normalizeNativeChatAppearanceSettings({
|
||||
@@ -34,27 +49,35 @@ describe('native chat appearance normalization', () => {
|
||||
expect(resolveNativeChatAppearanceSettings(undefined)).toEqual({
|
||||
fontSize: 14,
|
||||
codeFontSize: 12,
|
||||
width: 'comfortable'
|
||||
width: 'comfortable',
|
||||
contrast: 100,
|
||||
matchTerminalInterface: false
|
||||
})
|
||||
})
|
||||
it('resets only known controls while keeping settings from a newer version', () => {
|
||||
it('resets all five known controls while keeping settings from a newer version', () => {
|
||||
const fromNewerVersion = {
|
||||
fontSize: 18,
|
||||
codeFontSize: 16,
|
||||
width: 'wide' as const,
|
||||
contrast: 151,
|
||||
matchTerminalInterface: true
|
||||
matchTerminalInterface: true,
|
||||
futureSetting: { nested: 'keep' }
|
||||
}
|
||||
expect(resetNativeChatAppearanceSettings(fromNewerVersion)).toEqual({
|
||||
contrast: 151,
|
||||
matchTerminalInterface: true
|
||||
futureSetting: { nested: 'keep' }
|
||||
})
|
||||
expect(resetNativeChatAppearanceSettings({ fontSize: 18 })).toBeUndefined()
|
||||
})
|
||||
it('clamps and rounds values on read', () => {
|
||||
expect(
|
||||
resolveNativeChatAppearanceSettings({ fontSize: 99, codeFontSize: 0, width: 'full' })
|
||||
).toEqual({ fontSize: 20, codeFontSize: 10, width: 'full' })
|
||||
).toEqual({
|
||||
fontSize: 20,
|
||||
codeFontSize: 10,
|
||||
width: 'full',
|
||||
contrast: 100,
|
||||
matchTerminalInterface: false
|
||||
})
|
||||
expect(
|
||||
normalizeNativeChatAppearanceSettings({ fontSize: 15.6, codeFontSize: 13.2, width: 'wide' })
|
||||
).toEqual({ fontSize: 16, codeFontSize: 13, width: 'wide' })
|
||||
@@ -72,3 +95,36 @@ describe('native chat appearance normalization', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('chat contrast and terminal interface normalization', () => {
|
||||
it('removes default fields and the empty object', () => {
|
||||
expect(
|
||||
normalizeNativeChatAppearanceSettings({ contrast: 100, matchTerminalInterface: false })
|
||||
).toBeUndefined()
|
||||
expect(
|
||||
normalizeNativeChatAppearanceSettings({
|
||||
fontSize: 16,
|
||||
contrast: 100,
|
||||
matchTerminalInterface: false
|
||||
})
|
||||
).toEqual({ fontSize: 16 })
|
||||
expect(
|
||||
normalizeNativeChatAppearanceSettings({
|
||||
codeFontSize: 13,
|
||||
contrast: 120,
|
||||
matchTerminalInterface: true
|
||||
})
|
||||
).toEqual({ codeFontSize: 13, contrast: 120, matchTerminalInterface: true })
|
||||
})
|
||||
|
||||
it('clamps and rounds persisted contrast, accepting only an explicit true toggle', () => {
|
||||
expect(
|
||||
resolveNativeChatAppearanceSettings({ contrast: 999, matchTerminalInterface: 'true' })
|
||||
).toMatchObject({ contrast: 150, matchTerminalInterface: false })
|
||||
expect(normalizeNativeChatAppearanceSettings({ contrast: -5 })).toEqual({ contrast: 50 })
|
||||
expect(normalizeNativeChatAppearanceSettings({ contrast: 120.6 })).toEqual({ contrast: 121 })
|
||||
for (const contrast of [Number.NaN, Number.POSITIVE_INFINITY, '150', null]) {
|
||||
expect(normalizeNativeChatAppearanceSettings({ contrast })).toBeUndefined()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,10 @@ export type NativeChatAppearanceSettings = {
|
||||
codeFontSize?: number
|
||||
/** Transcript and composer width; absent = comfortable (46rem). */
|
||||
width?: 'comfortable' | 'wide' | 'full'
|
||||
/** Chat contrast, 50–150; absent = 100. */
|
||||
contrast?: number
|
||||
/** Follow the terminal interface font and theme; absent = false. */
|
||||
matchTerminalInterface?: boolean
|
||||
}
|
||||
|
||||
export type NativeChatGlobalSettings = {
|
||||
@@ -13,6 +17,7 @@ export type NativeChatGlobalSettings = {
|
||||
|
||||
export const DEFAULT_NATIVE_CHAT_FONT_SIZE = 14
|
||||
export const DEFAULT_NATIVE_CHAT_CODE_FONT_SIZE = 12
|
||||
export const DEFAULT_NATIVE_CHAT_CONTRAST = 100
|
||||
|
||||
function normalizeSize(value: unknown, min: number, max: number, fallback: number): number {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
@@ -33,15 +38,23 @@ export function normalizeNativeChatAppearanceSettings(
|
||||
18,
|
||||
12
|
||||
)
|
||||
const contrast = normalizeSize('contrast' in value ? value.contrast : undefined, 50, 150, 100)
|
||||
const matchTerminalInterface =
|
||||
'matchTerminalInterface' in value && value.matchTerminalInterface === true
|
||||
const width =
|
||||
'width' in value && (value.width === 'wide' || value.width === 'full') ? value.width : undefined
|
||||
const normalized: NativeChatAppearanceSettings = {
|
||||
...Object.fromEntries(
|
||||
Object.entries(value).filter(([key]) => !['fontSize', 'codeFontSize', 'width'].includes(key))
|
||||
Object.entries(value).filter(
|
||||
([key]) =>
|
||||
!['fontSize', 'codeFontSize', 'width', 'contrast', 'matchTerminalInterface'].includes(key)
|
||||
)
|
||||
),
|
||||
...(fontSize !== DEFAULT_NATIVE_CHAT_FONT_SIZE ? { fontSize } : {}),
|
||||
...(codeFontSize !== DEFAULT_NATIVE_CHAT_CODE_FONT_SIZE ? { codeFontSize } : {}),
|
||||
...(width ? { width } : {})
|
||||
...(width ? { width } : {}),
|
||||
...(contrast !== DEFAULT_NATIVE_CHAT_CONTRAST ? { contrast } : {}),
|
||||
...(matchTerminalInterface ? { matchTerminalInterface } : {})
|
||||
}
|
||||
return Object.keys(normalized).length ? normalized : undefined
|
||||
}
|
||||
@@ -53,7 +66,9 @@ export function resolveNativeChatAppearanceSettings(
|
||||
return {
|
||||
fontSize: normalized?.fontSize ?? DEFAULT_NATIVE_CHAT_FONT_SIZE,
|
||||
codeFontSize: normalized?.codeFontSize ?? DEFAULT_NATIVE_CHAT_CODE_FONT_SIZE,
|
||||
width: normalized?.width ?? 'comfortable'
|
||||
width: normalized?.width ?? 'comfortable',
|
||||
contrast: normalized?.contrast ?? DEFAULT_NATIVE_CHAT_CONTRAST,
|
||||
matchTerminalInterface: normalized?.matchTerminalInterface ?? false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +78,8 @@ export function resetNativeChatAppearanceSettings(
|
||||
return normalizeNativeChatAppearanceSettings(
|
||||
Object.fromEntries(
|
||||
Object.entries(appearance ?? {}).filter(
|
||||
([key]) => key !== 'fontSize' && key !== 'codeFontSize' && key !== 'width'
|
||||
([key]) =>
|
||||
!['fontSize', 'codeFontSize', 'width', 'contrast', 'matchTerminalInterface'].includes(key)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user