feat: strengthen private runtime and adaptive UI behavior

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
lofyer
2026-08-02 21:51:53 +08:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent b3fdf96962
commit e8dc4d03fd
32 changed files with 1390 additions and 128 deletions
+97 -1
View File
@@ -12,6 +12,7 @@ import type { AgentEvent, DesktopApi } from '../../shared/contracts'
import App from './App'
let agentListener: ((event: AgentEvent) => void) | undefined
let newConversationListener: (() => void) | undefined
const run = vi.fn<DesktopApi['agent']['run']>()
const modelProfileId = '00000000-0000-4000-8000-000000000001'
const projectId = '00000000-0000-4000-8000-000000000101'
@@ -38,7 +39,12 @@ const api: DesktopApi = {
show: vi.fn(async () => {}),
hide: vi.fn(async () => {}),
clearLocalData: vi.fn(async () => {}),
onNewConversation: vi.fn(() => () => {}),
onNewConversation: vi.fn((listener) => {
newConversationListener = listener
return () => {
newConversationListener = undefined
}
}),
onOpenSettings: vi.fn(() => () => {})
},
agent: {
@@ -380,7 +386,10 @@ const api: DesktopApi = {
describe('App', () => {
beforeEach(() => {
localStorage.clear()
delete document.documentElement.dataset.theme
document.documentElement.style.colorScheme = ''
vi.clearAllMocks()
newConversationListener = undefined
vi.mocked(api.agent.getStatus).mockResolvedValue({
id: 'model',
label: 'sonnet-5',
@@ -434,6 +443,93 @@ describe('App', () => {
expect(await screen.findByText('这是回答内容')).toBeInTheDocument()
})
it('keeps a draft in chat when Enter is pressed while the runtime loads', async () => {
vi.mocked(api.agent.getStatus).mockReturnValue(
new Promise(() => {})
)
render(<App />)
const composer = screen.getByLabelText('向 GoodBuddy 提问')
fireEvent.change(composer, {
target: { value: '等待 Runtime' }
})
fireEvent.keyDown(composer, { key: 'Enter' })
expect(composer).toHaveValue('等待 Runtime')
expect(
screen.queryByRole('heading', { name: '设置中心' })
).not.toBeInTheDocument()
expect(
await screen.findByText('Agent Runtime 正在加载,请稍后重试')
).toBeInTheDocument()
expect(run).not.toHaveBeenCalled()
})
it('keeps a new-conversation draft in chat when the runtime is unavailable', async () => {
vi.mocked(api.agent.getStatus).mockResolvedValue({
id: 'setup',
label: '需要配置模型',
available: false,
supportsToolExecution: false,
detail: '请配置模型'
})
render(<App />)
expect(
await screen.findByRole('heading', { name: '设置中心' })
).toBeInTheDocument()
const newConversation = screen.getByRole('button', {
name: //u
})
fireEvent.click(newConversation)
const composer = screen.getByLabelText('向 GoodBuddy 提问')
await waitFor(() => expect(composer).toHaveFocus())
fireEvent.change(composer, {
target: { value: '保留这条草稿' }
})
fireEvent.keyDown(composer, { key: 'Enter' })
expect(composer).toHaveValue('保留这条草稿')
expect(
screen.queryByRole('heading', { name: '设置中心' })
).not.toBeInTheDocument()
expect(
await screen.findByText(/ Agent Runtime/u)
).toBeInTheDocument()
expect(run).not.toHaveBeenCalled()
})
it('opens chat and focuses the composer for tray conversations', async () => {
render(<App />)
fireEvent.click(await screen.findByText('本地工作区'))
expect(
await screen.findByRole('heading', { name: '设置中心' })
).toBeInTheDocument()
act(() => newConversationListener?.())
const composer = await screen.findByLabelText('向 GoodBuddy 提问')
await waitFor(() => expect(composer).toHaveFocus())
})
it('applies and persists a dark appearance from Settings', async () => {
render(<App />)
fireEvent.click(await screen.findByText('本地工作区'))
fireEvent.click(screen.getByRole('tab', { name: '外观' }))
fireEvent.click(screen.getByRole('radio', { name: //u }))
await waitFor(() =>
expect(document.documentElement.dataset.theme).toBe('dark')
)
expect(document.documentElement.style.colorScheme).toBe('dark')
expect(localStorage.getItem('goodbuddy.appearance-theme')).toBe(
'dark'
)
})
it('loads token usage in activity and refreshes it when a run finishes', async () => {
vi.mocked(api.usage.getTokenSummary).mockResolvedValueOnce({
totals: {
+84 -26
View File
@@ -80,6 +80,15 @@ import {
type SidebarArtifact
} from './RightAssistantSidebar'
import { SettingsPanel } from './SettingsPanel'
import goodbuddyDarkIcon from './assets/goodbuddy-dark.png'
import goodbuddyLightIcon from './assets/goodbuddy-light.png'
import {
applyAppearanceTheme,
loadAppearanceTheme,
resolveAppearanceTheme,
saveAppearanceTheme,
type AppearanceTheme
} from './theme'
type ToolActivity = {
callId?: string
@@ -454,6 +463,17 @@ function App(): React.JSX.Element {
const [runtimeSettings, setRuntimeSettings] = useState<RuntimeSettings>()
const [runtimeMenuOpen, setRuntimeMenuOpen] = useState(false)
const [runtimeSwitching, setRuntimeSwitching] = useState(false)
const [appearanceTheme, setAppearanceTheme] =
useState<AppearanceTheme>(loadAppearanceTheme)
const [systemPrefersDark, setSystemPrefersDark] = useState(
() =>
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-color-scheme: dark)').matches
)
const resolvedAppearanceTheme = resolveAppearanceTheme(
appearanceTheme,
systemPrefersDark
)
const effectiveWorkMode =
workMode === 'execute' && runtime?.supportsToolExecution === false
? 'ask'
@@ -495,6 +515,47 @@ function App(): React.JSX.Element {
const inputRef = useRef<HTMLTextAreaElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const startNewConversation = useCallback((projectId?: string): void => {
const conversation = createConversation(projectId)
setConversations((current) => [conversation, ...current])
setActiveId(conversation.id)
setView('chat')
setInput('')
setAttachments((current) => {
for (const attachment of current) {
void window.goodbuddy.context.remove(attachment.id)
}
return []
})
requestAnimationFrame(() => inputRef.current?.focus())
}, [])
useEffect(() => {
saveAppearanceTheme(appearanceTheme)
}, [appearanceTheme])
useEffect(() => {
applyAppearanceTheme(resolvedAppearanceTheme)
}, [resolvedAppearanceTheme])
useEffect(() => {
if (appearanceTheme !== 'system') {
return
}
if (typeof window.matchMedia !== 'function') {
return
}
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
const updateSystemTheme = (): void => {
setSystemPrefersDark(systemTheme.matches)
}
updateSystemTheme()
systemTheme.addEventListener('change', updateSystemTheme)
return () => {
systemTheme.removeEventListener('change', updateSystemTheme)
}
}, [appearanceTheme])
useEffect(() => {
if (typeof window.matchMedia !== 'function') {
return
@@ -1352,18 +1413,9 @@ function App(): React.JSX.Element {
window.goodbuddy.agent.onEvent(handleAgentEvent)
const removeNewConversationListener =
window.goodbuddy.app.onNewConversation(() => {
const conversation = createConversation(
startNewConversation(
activeProjectIdRef.current || undefined
)
setConversations((current) => [conversation, ...current])
setActiveId(conversation.id)
setAttachments((current) => {
for (const attachment of current) {
void window.goodbuddy.context.remove(attachment.id)
}
return []
})
inputRef.current?.focus()
})
const removeOpenSettingsListener =
window.goodbuddy.app.onOpenSettings(() => setView('settings'))
@@ -1372,7 +1424,7 @@ function App(): React.JSX.Element {
removeNewConversationListener()
removeOpenSettingsListener()
}
}, [handleAgentEvent])
}, [handleAgentEvent, startNewConversation])
useEffect(() => {
const frame = requestAnimationFrame(() => {
@@ -1429,16 +1481,7 @@ function App(): React.JSX.Element {
}
const newConversation = (): void => {
const conversation = createConversation(activeProjectId || undefined)
setConversations((current) => [conversation, ...current])
setActiveId(conversation.id)
setView('chat')
setInput('')
for (const attachment of attachments) {
void window.goodbuddy.context.remove(attachment.id)
}
setAttachments([])
inputRef.current?.focus()
startNewConversation(activeProjectId || undefined)
}
const setMemoryStatus = async (
@@ -1576,8 +1619,11 @@ function App(): React.JSX.Element {
if (!prompt || !activeConversation) {
return
}
if (!runtime?.available) {
setView('settings')
if (!runtime) {
setNotice('Agent Runtime 正在加载,请稍后重试')
return
}
if (!runtime.available) {
setNotice('请先配置可用的模型或 Agent Runtime')
return
}
@@ -1980,7 +2026,15 @@ function App(): React.JSX.Element {
<aside className={sidebarOpen ? 'sidebar' : 'sidebar sidebar--closed'}>
<div className="brand">
<div className="brand__mark">
<Bot size={20} strokeWidth={2.4} />
<img
alt=""
aria-hidden="true"
src={
resolvedAppearanceTheme === 'dark'
? goodbuddyDarkIcon
: goodbuddyLightIcon
}
/>
</div>
<div className="brand__copy">
<strong>GoodBuddy</strong>
@@ -2233,7 +2287,9 @@ function App(): React.JSX.Element {
title={runtime?.detail}
>
<span className="runtime-status__dot" />
{runtime?.label ?? '正在检测运行时'}
<span className="runtime-status__label">
{runtime?.label ?? '正在检测运行时'}
</span>
{runtime?.capability === 'image-generation' && (
<span className="runtime-capability-badge"></span>
)}
@@ -3018,7 +3074,7 @@ function App(): React.JSX.Element {
/>
</div>
) : view === 'heartbeat' ? (
<div className="workspace-panel-scroll">
<div className="workspace-panel-scroll workspace-panel-scroll--heartbeat">
<HeartbeatCenter
configs={assistantHeartbeats}
entries={heartbeatEntries}
@@ -3037,7 +3093,9 @@ function App(): React.JSX.Element {
</div>
) : view === 'settings' ? (
<SettingsPanel
appearanceTheme={appearanceTheme}
heartbeats={assistantHeartbeats}
onAppearanceThemeChange={setAppearanceTheme}
onClearLocalData={clearLocalData}
onClose={() => setView('chat')}
onCreateHeartbeat={createHeartbeat}
@@ -25,6 +25,9 @@ const ready = true
).toBeInTheDocument()
expect(screen.getByRole('checkbox')).toBeChecked()
expect(screen.getByRole('table')).toBeInTheDocument()
expect(
screen.getByRole('region', { name: '表格,可横向滚动' })
).toContainElement(screen.getByRole('table'))
expect(screen.getByText('const ready = true')).toBeInTheDocument()
})
+13
View File
@@ -10,6 +10,19 @@ const components: Components = {
{children}
</a>
)
},
table: ({ children, node, ...properties }) => {
void node
return (
<div
aria-label="表格,可横向滚动"
className="markdown-table-scroll"
role="region"
tabIndex={0}
>
<table {...properties}>{children}</table>
</div>
)
}
}
+25
View File
@@ -175,6 +175,28 @@ describe('SettingsPanel runtime files', () => {
cleanup()
})
it('offers system, light, and dark appearance modes', async () => {
const onAppearanceThemeChange = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
appearanceTheme="system"
onAppearanceThemeChange={onAppearanceThemeChange}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '外观' }))
expect(
screen.getByRole('radio', { name: //u })
).toBeChecked()
fireEvent.click(screen.getByRole('radio', { name: //u }))
expect(onAppearanceThemeChange).toHaveBeenCalledWith('dark')
})
it('automatically detects runtimes and displays path, version, and detail', async () => {
render(
<SettingsPanel
@@ -230,6 +252,9 @@ describe('SettingsPanel runtime files', () => {
expect(
screen.getByText(/仅在实际请求高风险工具时暂停/)
).toBeInTheDocument()
expect(
screen.getByText(/不会匿名加载远程默认模型/)
).toBeInTheDocument()
fireEvent.click(within(field).getByRole('button', { name: '清除' }))
expect(input).toHaveValue('')
+71 -4
View File
@@ -4,6 +4,7 @@ import {
KeyRound,
LockKeyhole,
Plus,
SunMoon,
TerminalSquare,
Trash2,
X
@@ -28,8 +29,10 @@ import {
import { McpSettingsSection } from './McpSettingsSection'
import { SkillsSettingsSection } from './SkillsSettingsSection'
import { HeartbeatSettings } from './HeartbeatSettings'
import type { AppearanceTheme } from './theme'
type SettingsTab =
| 'appearance'
| 'model'
| 'runtime'
| 'security'
@@ -55,6 +58,8 @@ type SettingsPanelProps = {
) => Promise<void>
onRemoveHeartbeat: (heartbeatId: string) => Promise<void>
onRunHeartbeat: (heartbeatId: string) => Promise<void>
appearanceTheme?: AppearanceTheme
onAppearanceThemeChange?: (theme: AppearanceTheme) => void
}
const credentialLabels: Record<
@@ -86,7 +91,9 @@ export function SettingsPanel({
onCreateHeartbeat,
onSetHeartbeatPaused,
onRemoveHeartbeat,
onRunHeartbeat
onRunHeartbeat,
appearanceTheme = 'system',
onAppearanceThemeChange = () => {}
}: SettingsPanelProps): React.JSX.Element | null {
const [settings, setSettings] = useState<RuntimeSettings>()
const [provider, setProvider] =
@@ -517,6 +524,16 @@ export function SettingsPanel({
<div className="settings-panel__body">
<nav aria-label="设置分类" className="settings-tabs">
<button
aria-label="外观"
aria-selected={activeTab === 'appearance'}
onClick={() => setActiveTab('appearance')}
role="tab"
type="button"
>
<strong></strong>
<small></small>
</button>
<button
aria-label="模型连接"
aria-selected={activeTab === 'model'}
@@ -580,6 +597,50 @@ export function SettingsPanel({
</nav>
<div className="settings-panel__content">
{activeTab === 'appearance' && (
<div className="settings-section appearance-settings">
<div className="settings-section__title">
<SunMoon size={17} />
<div>
<strong></strong>
<small></small>
</div>
</div>
<div
aria-label="界面主题"
className="appearance-options"
role="radiogroup"
>
{(
[
['system', '跟随系统', '随操作系统自动切换'],
['light', '亮色', '明亮、清晰的工作界面'],
['dark', '暗色', '降低暗光环境下的亮度']
] as const
).map(([value, label, description]) => (
<label key={value}>
<input
checked={appearanceTheme === value}
name="appearance-theme"
onChange={() => onAppearanceThemeChange(value)}
type="radio"
value={value}
/>
<span
aria-hidden="true"
className={`appearance-options__preview appearance-options__preview--${value}`}
>
<i />
<i />
<i />
</span>
<strong>{label}</strong>
<small>{description}</small>
</label>
))}
</div>
</div>
)}
{activeTab === 'runtime' && (
<>
{settings?.warning && (
@@ -827,7 +888,7 @@ export function SettingsPanel({
)
}
>
<option value="platform">使 Continue </option>
<option value="platform">使 Continue </option>
{modelProfiles.map((profile) => (
<option
disabled={!isContinueCompatible(profile)}
@@ -843,7 +904,7 @@ export function SettingsPanel({
</select>
<small>
Continue Anthropic MessagesOpenAI Chat
Completions
Completions
</small>
</label>
<label className="field">
@@ -887,7 +948,7 @@ export function SettingsPanel({
onChange={(event) =>
setContinueConfigPath(event.target.value)
}
placeholder="留空使用工具默认配置"
placeholder="选择可信的本地 Continue 配置文件"
value={continueConfigPath}
/>
<button
@@ -912,6 +973,12 @@ export function SettingsPanel({
</button>
</div>
</label>
{continueModelSource.kind === 'platform' &&
!continueConfigPath && (
<p className="settings-warning">
Continue
</p>
)}
{continueBinaryPath && (
<p className="settings-warning">
Continue
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+13
View File
@@ -1,6 +1,11 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import {
applyAppearanceTheme,
loadAppearanceTheme,
resolveAppearanceTheme
} from './theme'
import './styles.css'
const root = document.getElementById('root')
@@ -9,6 +14,14 @@ if (!root) {
throw new Error('Root element not found')
}
applyAppearanceTheme(
resolveAppearanceTheme(
loadAppearanceTheme(),
typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-color-scheme: dark)').matches
)
)
createRoot(root).render(
<StrictMode>
<App />
+552 -11
View File
@@ -88,6 +88,13 @@ textarea:focus-visible {
color: #1677ff;
}
.brand__mark img {
width: 100%;
height: 100%;
border-radius: 7px;
object-fit: cover;
}
.brand__copy {
display: flex;
flex-direction: column;
@@ -439,10 +446,12 @@ textarea:focus-visible {
.topbar {
display: flex;
min-width: 0;
align-items: center;
padding: 0 21px;
border-bottom: 1px solid #f0f0f0;
background: #fff;
gap: 3px;
}
.icon-button {
@@ -467,6 +476,8 @@ textarea:focus-visible {
}
.topbar__expert {
width: clamp(86px, 11vw, 130px);
min-width: 0;
max-width: 130px;
padding: 6px 8px;
border: 1px solid #d9d9d9;
@@ -821,6 +832,7 @@ textarea:focus-visible {
min-width: 0;
flex-direction: column;
gap: 13px;
container: heartbeat-settings / inline-size;
}
.heartbeat-settings__intro h3 {
@@ -1045,6 +1057,7 @@ textarea:focus-visible {
.conversation-title {
display: flex;
min-width: 0;
align-items: center;
padding: 7px 9px;
border-radius: 8px;
@@ -1057,6 +1070,16 @@ textarea:focus-visible {
font-weight: 650;
}
.conversation-title span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.conversation-title svg {
flex: 0 0 auto;
}
.conversation-title:hover {
background: #e6f4ff;
color: #1677ff;
@@ -1064,13 +1087,17 @@ textarea:focus-visible {
.topbar__actions {
display: flex;
min-width: 0;
align-items: center;
flex: 0 1 auto;
margin-left: auto;
gap: 3px;
}
.runtime-status {
display: flex;
min-width: 0;
max-width: clamp(108px, 18vw, 220px);
align-items: center;
padding: 6px 10px;
border: 1px solid #d9d9d9;
@@ -1082,6 +1109,16 @@ textarea:focus-visible {
gap: 6px;
}
.runtime-status__dot {
flex: 0 0 auto;
}
.runtime-status__label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.runtime-status__dot {
width: 6px;
height: 6px;
@@ -1149,7 +1186,10 @@ textarea:focus-visible {
max-width: 720px;
margin: 0 auto;
gap: 10px;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(
auto-fit,
minmax(min(180px, 100%), 1fr)
);
}
.quick-actions button {
@@ -1349,10 +1389,24 @@ textarea:focus-visible {
font-size: 0.9em;
}
.markdown-table-scroll {
max-width: 100%;
margin: 0.8em 0;
overflow-x: auto;
overscroll-behavior-inline: contain;
}
.markdown-table-scroll:focus-visible {
border-radius: 4px;
outline: 2px solid #1677ff;
outline-offset: 2px;
}
.markdown-content table {
width: 100%;
min-width: max-content;
border-collapse: collapse;
margin: 0.8em 0;
margin: 0;
font-size: 11px;
}
@@ -1870,7 +1924,7 @@ textarea:focus-visible {
padding: 3px;
border-radius: 9px;
background: #f5f5f5;
grid-template-columns: repeat(6, 1fr);
grid-template-columns: repeat(7, 1fr);
}
.settings-tabs button {
@@ -2561,6 +2615,10 @@ textarea:focus-visible {
container-type: inline-size;
}
.workspace-panel-scroll--heartbeat {
container-type: inline-size;
}
.knowledge-workspace {
width: 100%;
min-height: max(520px, calc(100dvh - 114px));
@@ -3074,6 +3132,7 @@ textarea:focus-visible {
.activity-panel__filters {
display: flex;
flex-wrap: wrap;
margin-bottom: 12px;
gap: 6px;
}
@@ -3109,6 +3168,8 @@ textarea:focus-visible {
.activity-item__header,
.activity-item__labels {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 7px;
}
@@ -3159,6 +3220,7 @@ textarea:focus-visible {
min-height: 100%;
flex-direction: column;
margin: 0 auto;
container: heartbeat-center / inline-size;
}
.heartbeat-center__hero {
@@ -3857,6 +3919,485 @@ textarea:focus-visible {
background: #fff;
}
.appearance-settings {
gap: 14px;
}
.appearance-options {
display: grid;
gap: 10px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.appearance-options label {
display: flex;
min-width: 0;
padding: 10px;
border: 1px solid #d9d9d9;
border-radius: 10px;
background: #fff;
cursor: pointer;
flex-direction: column;
gap: 5px;
}
.appearance-options label:has(input:checked) {
border-color: #1677ff;
box-shadow: 0 0 0 2px rgb(22 119 255 / 12%);
}
.appearance-options label:has(input:focus-visible) {
outline: 2px solid #1677ff;
outline-offset: 2px;
}
.appearance-options input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.appearance-options__preview {
display: grid;
height: 76px;
padding: 9px;
border: 1px solid #d9d9d9;
border-radius: 8px;
background: #f5f5f5;
gap: 5px;
grid-template-columns: 30% 1fr;
grid-template-rows: 12px 1fr;
}
.appearance-options__preview i {
display: block;
border-radius: 3px;
background: #fff;
}
.appearance-options__preview i:first-child {
grid-column: 1 / -1;
}
.appearance-options__preview i:nth-child(2) {
grid-row: 2;
}
.appearance-options__preview i:nth-child(3) {
background: #e6f4ff;
grid-column: 2;
grid-row: 2;
}
.appearance-options__preview--dark {
border-color: #344258;
background: #0b111b;
}
.appearance-options__preview--dark i {
background: #1c2738;
}
.appearance-options__preview--dark i:nth-child(3) {
background: #15345f;
}
.appearance-options__preview--system {
background: linear-gradient(90deg, #f5f5f5 0 50%, #0b111b 50% 100%);
}
.appearance-options__preview--system i {
background: linear-gradient(90deg, #fff 0 50%, #1c2738 50% 100%);
}
.appearance-options__preview--system i:nth-child(3) {
background: linear-gradient(90deg, #e6f4ff 0 50%, #15345f 50% 100%);
}
:root[data-theme='dark'] {
color: #edf4ff;
background: #0b111b;
}
:root[data-theme='dark'] .app-shell {
background: #0b111b;
color: #edf4ff;
}
:root[data-theme='dark'] :where(
.settings-page,
.composer-wrap
) {
background: #0b111b;
}
:root[data-theme='dark'] :where(
.sidebar,
.topbar,
.assistant-sidebar,
.project-create-card,
.settings-panel,
.composer,
.runtime-picker__menu,
.knowledge-scope__popover,
.knowledge-panel__document,
.token-usage,
.activity-item,
.heartbeat-center__section,
.heartbeat-center__metrics > div,
.heartbeat-center__plans .heartbeat-settings,
.heartbeat-settings__item,
.capability-card,
.quick-actions button,
.secondary-button
) {
border-color: #293548;
background: #111827;
}
:root[data-theme='dark'] :where(
.app-shell,
.sidebar,
.topbar,
.assistant-sidebar,
.settings-panel,
.project-create-card,
.composer,
.workspace-panel-scroll
) :where(input, textarea, select) {
border-color: #344258;
background: #172033;
color: #edf4ff;
}
:root[data-theme='dark'] :where(
.app-shell,
.sidebar,
.topbar,
.assistant-sidebar,
.settings-panel,
.project-create-card,
.composer
) :where(input, textarea)::placeholder {
color: #718096;
}
:root[data-theme='dark'] :where(
.sidebar-search,
.settings-section,
.settings-tabs,
.settings-panel__footer,
.assistant-sidebar__row,
.assistant-sidebar__library,
.assistant-sidebar__schedule,
.assistant-sidebar__diff,
.assistant-sidebar__preview pre,
.tool-activity,
.approval-card,
.context-chip,
.token-usage__group,
.token-usage__stats div,
.activity-panel__stats div,
.activity-filter,
.heartbeat-center__tab,
.heartbeat-center__config-card,
.heartbeat-center__suggestion,
.heartbeat-center__run,
.heartbeat-settings--sidebar .heartbeat-settings__item,
.knowledge-graph__toolbar,
.markdown-content pre,
.markdown-content :not(pre) > code
) {
border-color: #293548;
background: #172033;
}
:root[data-theme='dark'] :where(
.primary-nav,
.sidebar-footer,
.topbar,
.assistant-sidebar,
.assistant-sidebar__header,
.assistant-sidebar__tabs,
.assistant-sidebar__context,
.assistant-sidebar__preview > header,
.message + .message,
.divider,
.runtime-picker__divider,
.settings-panel__header,
.settings-panel__footer,
.knowledge-workspace__sidebar,
.knowledge-workspace__header,
.knowledge-panel__header,
.activity-panel__header,
.heartbeat-center__hero,
.heartbeat-center__config-card,
.heartbeat-center__suggestion,
.heartbeat-center__run
) {
border-color: #293548;
}
:root[data-theme='dark'] :where(
h1,
h2,
h3,
h4,
.conversation-title,
.welcome h1,
.message__meta strong,
.markdown-content,
.settings-section__title strong,
.field,
.capability-card strong,
.knowledge-panel__document-info strong,
.token-usage__header h3,
.activity-item h3,
.heartbeat-center__latest > p,
.heartbeat-center__run strong
) {
color: #edf4ff;
}
:root[data-theme='dark'] .app-shell strong {
color: #edf4ff;
}
:root[data-theme='dark'] :where(
.nav-item,
.conversation-item,
.user-card,
.icon-button,
.assistant-sidebar__row,
.runtime-status,
.welcome__description,
.tool-activity,
.context-chip,
.settings-tabs button,
.credential-state,
.capability-card p,
.secondary-button,
.check-field,
.knowledge-panel__limits,
.knowledge-panel__summary,
.token-usage th,
.token-usage td,
.activity-filter,
.activity-item p,
.heartbeat-center__hero p:not(.eyebrow),
.heartbeat-center__tab,
.heartbeat-center__config-card,
.heartbeat-center__suggestion > p,
.heartbeat-center__run small
) {
color: #aebbd0;
}
:root[data-theme='dark'] :where(
.brand__copy span,
.nav-item__hint,
.section-label,
.conversation-item small,
.user-card__copy small,
.assistant-sidebar__tab,
.assistant-sidebar__empty,
.runtime-picker__menu > strong,
.runtime-picker__menu > button > small,
.message__meta span,
.message__status,
.composer-hint,
.settings-panel__description,
.settings-tabs button small,
.settings-section__title small,
.field small,
.settings-notice,
.settings-empty,
.knowledge-panel__empty,
.knowledge-panel__loading,
.activity-panel__empty,
.heartbeat-center__live,
.heartbeat-center__empty,
.heartbeat-center__legend
) {
color: #8290a6;
}
:root[data-theme='dark'] :where(
.icon-button:hover,
.icon-button--active,
.nav-item:hover,
.conversation-item:hover,
.conversation-item--active,
.user-card:hover,
.secondary-button:hover,
.activity-filter--active,
.settings-tabs button[aria-selected='true'],
.heartbeat-center__tab--active
) {
background: #1f2a3d;
}
:root[data-theme='dark'] .settings-page
.settings-tabs
button[aria-selected='true'] {
background: #1f2a3d;
color: #69adff;
}
:root[data-theme='dark'] :where(
.nav-item--active,
.brand__mark,
.composer__mode--ask,
.composer__mode--plan,
.runtime-capability-badge,
.model-capability-badge,
.heartbeat-center__tab > span
) {
border-color: #245fa8;
background: #15345f;
color: #69adff;
}
:root[data-theme='dark'] :where(
.settings-warning,
.approval-card,
.heartbeat-center__error
) {
border-color: #70511d;
background: #302511;
color: #f3c969;
}
:root[data-theme='dark'] .markdown-content blockquote {
border-left-color: #4c9aff;
color: #aebbd0;
}
:root[data-theme='dark'] .message--user .message__content {
border-color: #245fa8;
background: #15345f;
color: #edf4ff;
}
:root[data-theme='dark'] .heartbeat-center__hero p:not(.eyebrow),
:root[data-theme='dark'] .heartbeat-center__metrics small {
color: #aebbd0;
}
:root[data-theme='dark'] .assistant-sidebar__tab--active {
background: #15345f;
color: #69adff;
}
:root[data-theme='dark'] :where(
.runtime-picker__menu > button,
.quick-actions strong,
.user-card__copy strong
) {
color: #edf4ff;
}
:root[data-theme='dark'] .markdown-content :where(th, td) {
border-color: #344258;
}
:root[data-theme='dark'] .appearance-options label {
border-color: #344258;
background: #172033;
}
:root[data-theme='dark'] .appearance-options label:has(input:checked) {
border-color: #4c9aff;
box-shadow: 0 0 0 2px rgb(76 154 255 / 20%);
}
@container heartbeat-settings (max-width: 680px) {
.heartbeat-settings__form,
.heartbeat-settings__form--weekly {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.heartbeat-settings__form .primary-button {
grid-column: 1 / -1;
}
.heartbeat-settings__item {
align-items: stretch;
flex-direction: column;
}
.heartbeat-settings__actions {
flex-wrap: wrap;
}
}
@container heartbeat-settings (max-width: 420px) {
.heartbeat-settings__form,
.heartbeat-settings__form--weekly {
grid-template-columns: 1fr;
}
.heartbeat-settings__form .primary-button {
grid-column: auto;
}
.heartbeat-settings__actions button {
padding-inline: 8px;
flex: 1 1 auto;
}
}
@container heartbeat-center (max-width: 820px) {
.heartbeat-center__metrics {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.heartbeat-center__overview-grid,
.heartbeat-center__suggestions,
.heartbeat-center__history {
grid-template-columns: 1fr;
}
}
@container heartbeat-center (max-width: 620px) {
.heartbeat-center__hero {
flex-direction: column;
}
.heartbeat-center__hero-actions {
width: 100%;
}
.heartbeat-center__hero-actions button {
flex: 1;
}
.heartbeat-center__config-card > div:last-child {
flex-wrap: wrap;
}
}
@container heartbeat-center (max-width: 460px) {
.heartbeat-center__metrics {
grid-template-columns: 1fr;
}
.heartbeat-center__tabs {
display: grid;
grid-template-columns: 1fr 1fr;
}
.heartbeat-center__tab {
justify-content: center;
}
.heartbeat-center__trend-row {
grid-template-columns: 70px minmax(0, 1fr) 18px;
}
}
@keyframes pulse {
0%,
100% {
@@ -3896,14 +4437,6 @@ textarea:focus-visible {
min-width: 206px;
}
.quick-actions {
grid-template-columns: 1fr;
}
.quick-actions button {
min-height: 88px;
}
.assistant-sidebar--open {
width: min(390px, calc(100vw - 36px));
flex-basis: min(390px, calc(100vw - 36px));
@@ -3912,11 +4445,15 @@ textarea:focus-visible {
.settings-page .settings-panel__body {
display: flex;
padding: 20px;
align-items: stretch;
justify-content: flex-start;
}
.settings-page .settings-tabs {
position: static;
display: grid;
width: 100%;
flex: 0 0 auto;
grid-template-columns: repeat(3, 1fr);
}
@@ -3957,6 +4494,10 @@ textarea:focus-visible {
.heartbeat-center__history {
grid-template-columns: 1fr;
}
.appearance-options {
grid-template-columns: 1fr;
}
}
@media (max-width: 520px) {
+33
View File
@@ -0,0 +1,33 @@
import { beforeEach, describe, expect, it } from 'vitest'
import {
applyAppearanceTheme,
loadAppearanceTheme,
resolveAppearanceTheme,
saveAppearanceTheme
} from './theme'
describe('appearance theme', () => {
beforeEach(() => {
localStorage.clear()
delete document.documentElement.dataset.theme
document.documentElement.style.colorScheme = ''
})
it('defaults to system and persists valid user choices', () => {
expect(loadAppearanceTheme()).toBe('system')
saveAppearanceTheme('dark')
expect(loadAppearanceTheme()).toBe('dark')
localStorage.setItem('goodbuddy.appearance-theme', 'invalid')
expect(loadAppearanceTheme()).toBe('system')
})
it('resolves system preference and applies it to the document', () => {
expect(resolveAppearanceTheme('system', true)).toBe('dark')
expect(resolveAppearanceTheme('system', false)).toBe('light')
expect(resolveAppearanceTheme('light', true)).toBe('light')
applyAppearanceTheme('dark')
expect(document.documentElement.dataset.theme).toBe('dark')
expect(document.documentElement.style.colorScheme).toBe('dark')
})
})
+39
View File
@@ -0,0 +1,39 @@
export type AppearanceTheme = 'system' | 'light' | 'dark'
export type ResolvedAppearanceTheme = 'light' | 'dark'
const storageKey = 'goodbuddy.appearance-theme'
export function loadAppearanceTheme(): AppearanceTheme {
try {
const value = localStorage.getItem(storageKey)
return value === 'light' || value === 'dark' ? value : 'system'
} catch {
return 'system'
}
}
export function saveAppearanceTheme(theme: AppearanceTheme): void {
try {
localStorage.setItem(storageKey, theme)
} catch {
// Theme persistence is optional when browser storage is unavailable.
}
}
export function resolveAppearanceTheme(
theme: AppearanceTheme,
systemPrefersDark: boolean
): ResolvedAppearanceTheme {
return theme === 'system'
? systemPrefersDark
? 'dark'
: 'light'
: theme
}
export function applyAppearanceTheme(
theme: ResolvedAppearanceTheme
): void {
document.documentElement.dataset.theme = theme
document.documentElement.style.colorScheme = theme
}