refactor(desktop): adopt the component library in pages

Native buttons in `pages/`: 88 -> 33.

An earlier pass over this directory converted only 15 sites and reported
the blocker precisely: `Button` offered h-5/h-6/h-9/h-10 while nearly
every page button is h-8. Adding `base` unblocked 17 conversions on its
own. `IconButton`'s `bordered` and `hoverTone` unblocked the trace row
actions, whose treatment is transparent-with-a-border and whose delete
must not sit red at rest.

- `ComputerUseSettings` moves 21 stock palette classes onto token pairs,
  retiring 11 alpha modifiers with them. Its allowlist entry is gone
  rather than reduced.
- `ActivitySettings`' hand-rolled `createPortal` dialog becomes `Modal`.
- `TraceList`'s `RowAction` and `TraceSession`'s `IconAction` helpers are
  deleted in favor of `IconButton`.

Three dead files removed:

- `pages/NewTaskModal.tsx` — zero importers; the live one is
  `components/tasks/NewTaskModal.tsx`. It was also the largest remaining
  holder of the deprecated `--color-primary`.
- `pages/ToolInspection.tsx` and `pages/AgentTeams.tsx` — neither appears
  among the nine pages `ContentRouter` and `AppShell` import. Only
  `pages.test.tsx` referenced them.

`Settings.tsx` keeps two stock palette classes. They sit inside a
hardcoded white QR box that scanners need for contrast; theme tokens go
light-on-white under the dark theme. Converted, reverted, and commented.

Left native (33): settings nav rows, the dnd drag handle, tree nodes,
`role="tab"` and `role="gridcell"` elements, and the theme/language
pickers — `SegmentedControl` emits radio semantics while two tests pin
`role="button"` with `aria-pressed`, which is an a11y decision rather
than a styling one.
This commit is contained in:
程序员阿江(Relakkes)
2026-07-26 16:03:19 +08:00
parent fecc437be7
commit f64745e569
20 changed files with 581 additions and 1424 deletions
+11 -8
View File
@@ -22,6 +22,8 @@ import {
useTerminalPanelStore,
} from '../stores/terminalPanelStore'
import { useTranslation } from '../i18n'
import { Button } from '@/components/ui/Button'
import { LoadingState } from '@/components/ui/LoadingState'
import { MessageList } from '../components/chat/MessageList'
import { ChatInput } from '../components/chat/ChatInput'
import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermissionModal'
@@ -539,7 +541,10 @@ export function ActiveSession() {
{t('teams.memberSessionHint')}
</p>
</div>
<button
<Button
variant="ghost"
size="sm"
className="shrink-0"
onClick={() => {
if (activeTeam?.leadSessionId) {
useTabStore.getState().openTab(
@@ -550,11 +555,10 @@ export function ActiveSession() {
}
}}
disabled={!activeTeam?.leadSessionId}
className="flex shrink-0 items-center gap-1 text-xs font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors disabled:opacity-50 disabled:hover:text-[var(--color-text-secondary)]"
icon={<span className="material-symbols-outlined text-[14px]">arrow_back</span>}
>
<span className="material-symbols-outlined text-[14px]">arrow_back</span>
{t('teams.backToLeader')}
</button>
</Button>
</div>
</div>
)}
@@ -653,7 +657,7 @@ export function ActiveSession() {
{session && getSessionWorkspaceState(session) !== 'available' && (
<div className={`mt-2 inline-flex max-w-full items-center gap-2 rounded-lg border px-3 py-1.5 text-[11px] ${
getSessionWorkspaceState(session) === 'worktree_removed'
? 'border-[var(--color-border)] bg-[var(--color-surface-secondary)] text-[var(--color-text-secondary)]'
? 'border-[var(--color-border)] bg-[var(--color-surface-container)] text-[var(--color-text-secondary)]'
: 'border-[var(--color-error)]/20 bg-[var(--color-error)]/8 text-[var(--color-error)]'
}`}>
<span className="material-symbols-outlined text-[14px]">
@@ -676,9 +680,8 @@ export function ActiveSession() {
)}
{isHistoryLoading ? (
<div role="status" className="flex flex-1 items-center justify-center p-8 text-sm text-[var(--color-text-secondary)]">
<span className="material-symbols-outlined mr-2 animate-spin text-[18px]">progress_activity</span>
{t('common.loading')}
<div className="flex flex-1 items-center justify-center p-8">
<LoadingState label={t('common.loading')} variant="inline" size="md" />
</div>
) : historyError ? (
<div role="alert" className="flex flex-1 items-center justify-center p-8 text-sm text-[var(--color-error)]">
+79 -106
View File
@@ -1,11 +1,12 @@
import { useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react'
import { createPortal } from 'react-dom'
import { activityStatsApi, type ActivityStatsResponse, type DailyActivity } from '../api/activityStats'
import {
desktopUiPreferencesApi,
getProfileAvatarUrl,
type DesktopProfilePreferences,
} from '../api/desktopUiPreferences'
import { Button } from '@/components/ui/Button'
import { Modal } from '@/components/ui/Modal'
import { type Locale, useTranslation } from '../i18n'
import { useSettingsStore } from '../stores/settingsStore'
import { publicAssetPath } from '../lib/publicAsset'
@@ -638,6 +639,13 @@ export function ActivitySettings() {
{ mode: 'cumulative', label: t('settings.activity.mode.cumulative'), help: t('settings.activity.modeHelp.cumulative') },
]
const cancelProfileEdit = () => {
setIsEditingProfile(false)
setDraftDisplayName(profile.displayName)
setDraftSubtitle(profile.subtitle)
setProfileError(null)
}
const saveProfile = async () => {
setIsSavingProfile(true)
setProfileError(null)
@@ -789,114 +797,79 @@ export function ActivitySettings() {
)}
</section>
{isEditingProfile && createPortal(
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-[var(--color-overlay-scrim)] px-4 py-8" role="dialog" aria-modal="true" aria-labelledby="activity-profile-dialog-title">
<div className="w-full max-w-[420px] rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] p-5 shadow-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<h2 id="activity-profile-dialog-title" className="text-base font-semibold text-[var(--color-text-primary)]">{t('settings.activity.editProfile')}</h2>
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">{t('settings.activity.displayNameHelper')}</p>
</div>
<button
type="button"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
onClick={() => {
setIsEditingProfile(false)
setDraftDisplayName(profile.displayName)
setDraftSubtitle(profile.subtitle)
setProfileError(null)
}}
aria-label={t('settings.activity.cancelEdit')}
<Modal
open={isEditingProfile}
onClose={cancelProfileEdit}
title={t('settings.activity.editProfile')}
width={420}
footer={
<>
<Button variant="ghost" size="base" onClick={cancelProfileEdit}>
{t('settings.activity.cancelEdit')}
</Button>
<Button variant="primary" size="base" onClick={saveProfile} disabled={isSavingProfile}>
{t('settings.activity.saveProfile')}
</Button>
</>
}
>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.activity.displayNameHelper')}</p>
<div className="mt-5 grid gap-4">
<div className="grid gap-2">
<label htmlFor="activity-profile-display-name" className="text-xs font-medium text-[var(--color-text-secondary)]">
{t('settings.activity.displayName')}
</label>
<input
id="activity-profile-display-name"
value={draftDisplayName}
onChange={(event) => setDraftDisplayName(event.target.value)}
className="h-10 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-sm text-[var(--color-text-primary)] outline-none transition-colors focus:border-[var(--color-border-focus)]"
/>
</div>
<div className="grid gap-2">
<label htmlFor="activity-profile-subtitle" className="text-xs font-medium text-[var(--color-text-secondary)]">
{t('settings.activity.subtitle')}
</label>
<input
id="activity-profile-subtitle"
value={draftSubtitle}
onChange={(event) => setDraftSubtitle(event.target.value)}
className="h-10 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-sm text-[var(--color-text-primary)] outline-none transition-colors focus:border-[var(--color-border-focus)]"
/>
</div>
<div className="grid gap-2">
<div className="text-xs font-medium text-[var(--color-text-secondary)]">{t('settings.activity.avatar')}</div>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.activity.avatarHelper')}</p>
<div className="flex flex-wrap gap-2">
<input
ref={avatarInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={handleAvatarChange}
/>
<Button
variant="secondary"
size="base"
onClick={() => avatarInputRef.current?.click()}
icon={<span className="material-symbols-outlined text-[15px]" aria-hidden="true">upload</span>}
>
<span className="material-symbols-outlined text-[17px]" aria-hidden="true">close</span>
</button>
</div>
<div className="mt-5 grid gap-4">
<div className="grid gap-2">
<label htmlFor="activity-profile-display-name" className="text-xs font-medium text-[var(--color-text-secondary)]">
{t('settings.activity.displayName')}
</label>
<input
id="activity-profile-display-name"
value={draftDisplayName}
onChange={(event) => setDraftDisplayName(event.target.value)}
className="h-10 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-sm text-[var(--color-text-primary)] outline-none transition-colors focus:border-[var(--color-border-focus)]"
/>
</div>
<div className="grid gap-2">
<label htmlFor="activity-profile-subtitle" className="text-xs font-medium text-[var(--color-text-secondary)]">
{t('settings.activity.subtitle')}
</label>
<input
id="activity-profile-subtitle"
value={draftSubtitle}
onChange={(event) => setDraftSubtitle(event.target.value)}
className="h-10 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-sm text-[var(--color-text-primary)] outline-none transition-colors focus:border-[var(--color-border-focus)]"
/>
</div>
<div className="grid gap-2">
<div className="text-xs font-medium text-[var(--color-text-secondary)]">{t('settings.activity.avatar')}</div>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.activity.avatarHelper')}</p>
<div className="flex flex-wrap gap-2">
<input
ref={avatarInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
className="hidden"
onChange={handleAvatarChange}
/>
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-[var(--color-border)] px-2.5 text-xs font-medium text-[var(--color-text-secondary)] transition-[background-color,transform] hover:bg-[var(--color-surface-hover)] active:translate-y-[1px]"
onClick={() => avatarInputRef.current?.click()}
>
<span className="material-symbols-outlined text-[15px]" aria-hidden="true">upload</span>
{t('settings.activity.changeAvatar')}
</button>
{profile.avatarFile && (
<button
type="button"
className="inline-flex h-8 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium text-[var(--color-text-tertiary)] transition-[background-color,transform] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] active:translate-y-[1px]"
onClick={removeAvatar}
>
{t('settings.activity.removeAvatar')}
</button>
)}
</div>
</div>
</div>
{profileError && <div className="mt-4 rounded-md border border-[var(--color-error)]/30 bg-[var(--color-error)]/10 px-3 py-2 text-xs text-[var(--color-error)]">{profileError}</div>}
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
className="h-8 rounded-md px-3 text-xs font-medium text-[var(--color-text-secondary)] transition-[background-color,transform] hover:bg-[var(--color-surface-hover)] active:translate-y-[1px]"
onClick={() => {
setIsEditingProfile(false)
setDraftDisplayName(profile.displayName)
setDraftSubtitle(profile.subtitle)
setProfileError(null)
}}
>
{t('settings.activity.cancelEdit')}
</button>
<button
type="button"
className="h-8 rounded-md bg-[var(--color-text-primary)] px-3 text-xs font-medium text-[var(--color-surface)] transition-[opacity,transform] active:translate-y-[1px] disabled:opacity-50"
onClick={saveProfile}
disabled={isSavingProfile}
>
{t('settings.activity.saveProfile')}
</button>
{t('settings.activity.changeAvatar')}
</Button>
{profile.avatarFile && (
<Button variant="ghost" size="base" onClick={removeAvatar}>
{t('settings.activity.removeAvatar')}
</Button>
)}
</div>
</div>
</div>,
document.body,
)}
</div>
{profileError && <div className="mt-4 rounded-md border border-[var(--color-error)]/30 bg-[var(--color-error)]/10 px-3 py-2 text-xs text-[var(--color-error)]">{profileError}</div>}
</Modal>
<div className="mt-10">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
+13 -12
View File
@@ -1,10 +1,11 @@
import { useState, useEffect, useCallback } from 'react'
import { useAdapterStore } from '../stores/adapterStore'
import { useTranslation } from '../i18n'
import { Input } from '../components/shared/Input'
import { Button } from '../components/shared/Button'
import { DirectoryPicker } from '../components/shared/DirectoryPicker'
import { ConfirmDialog } from '../components/shared/ConfirmDialog'
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { LoadingState } from '@/components/ui/LoadingState'
import { DirectoryPicker } from '@/components/composite/DirectoryPicker'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import QRCode from 'qrcode'
type ImTab = 'telegram' | 'feishu' | 'wechat' | 'dingtalk' | 'whatsapp'
@@ -488,9 +489,8 @@ export function AdapterSettings() {
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined animate-spin text-[20px] mr-2">progress_activity</span>
Loading...
<div className="flex items-center justify-center py-12">
<LoadingState label={t('common.loading')} variant="inline" size="md" />
</div>
)
}
@@ -505,7 +505,7 @@ export function AdapterSettings() {
href={IM_CONFIG_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium text-[var(--color-brand)] transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-bg)]"
className="inline-flex items-center gap-1 font-medium text-[var(--color-brand)] transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]"
>
{t('settings.adapters.configurationDocs')}
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">open_in_new</span>
@@ -569,12 +569,13 @@ export function AdapterSettings() {
{new Date(user.pairedAt).toLocaleDateString()}
</span>
</div>
<button
<Button
variant="danger-outline"
size="sm"
onClick={() => handleUnbind(user.platform, user.userId)}
className="text-xs text-[var(--color-error)] hover:underline cursor-pointer"
>
{t('settings.adapters.unbind')}
</button>
</Button>
</div>
))}
</div>
@@ -602,7 +603,7 @@ export function AdapterSettings() {
{/* IM Adapter Tabs */}
<section className="rounded-xl border border-[var(--color-border)] overflow-hidden">
<div role="tablist" aria-label="IM adapter" className="flex items-stretch border-b border-[var(--color-border)] bg-[var(--color-surface-hover)]">
<div role="tablist" aria-label={t('settings.adapters.imTabs')} className="flex items-stretch border-b border-[var(--color-border)] bg-[var(--color-surface-hover)]">
<ImTabButton
label={t('settings.adapters.telegram')}
active={activeIm === 'telegram'}
-200
View File
@@ -1,200 +0,0 @@
import { useState } from 'react'
import { mockTeam, mockTeamMessages } from '../mocks/data'
// ─── Inline keyframes for pulse-subtle animation ─────────────────
const pulseSubtleStyle = `
@keyframes pulse-subtle {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; transform: scale(0.98); }
}
.animate-pulse-subtle {
animation: pulse-subtle 2s ease-in-out infinite;
}
`
export function AgentTeams() {
const [inputValue, setInputValue] = useState('')
return (
<>
<style>{pulseSubtleStyle}</style>
<div className="flex-1 flex flex-col relative overflow-hidden bg-[var(--color-surface)] text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-body)' }}>
{/* Code Content Area */}
<div className="flex-1 overflow-y-auto p-6 md:p-10 max-w-5xl mx-auto w-full">
<div className="space-y-8">
{/* ─── Message Thread ─── */}
<div className="space-y-6">
{/* USER message */}
<div className="flex gap-4 group">
<div className="w-8 h-8 rounded-full bg-[var(--color-primary-fixed)] flex-shrink-0 flex items-center justify-center text-[var(--color-on-primary)] font-bold text-xs">
U
</div>
<div className="space-y-2">
<p className="text-xs font-semibold text-[var(--color-text-tertiary)] uppercase tracking-widest">
User
</p>
<p className="text-[var(--color-text-primary)] leading-relaxed">
{mockTeamMessages.userMessage}
</p>
</div>
</div>
{/* CLAUDE COMPANION response */}
<div className="flex gap-4 group">
<div className="w-8 h-8 rounded-full bg-[var(--color-tertiary-container)] flex-shrink-0 flex items-center justify-center text-[var(--color-tertiary)]">
<span
className="material-symbols-outlined text-sm"
style={{ fontVariationSettings: "'FILL' 1" }}
>
smart_toy
</span>
</div>
<div className="space-y-4 flex-1">
<p className="text-xs font-semibold text-[var(--color-text-tertiary)] uppercase tracking-widest">
Claude Companion
</p>
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-5 shadow-[var(--shadow-dropdown)]">
<p className="mb-4 text-[var(--color-text-primary)]">
{mockTeamMessages.assistantMessage}
</p>
<div className="rounded-lg bg-[var(--color-surface-container-high)] p-4 font-[var(--font-mono)] text-[13px] text-[var(--color-text-secondary)] overflow-x-auto">
<span className="text-[var(--color-brand)]">info:</span> spawning child_processes for parallel development
<br />
<span className="text-[var(--color-secondary)]">active:</span> session-dev cluster initiated
<br />
<span className="text-[var(--color-tertiary)]">ready:</span> 4 agents assigned
</div>
</div>
</div>
</div>
</div>
{/* ─── TEAM STRIP ─── */}
<div className="relative py-8">
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 h-px bg-[var(--color-border-separator)]" />
<div className="relative glass-panel p-4 rounded-2xl flex flex-col md:flex-row md:items-center gap-4 overflow-hidden">
{/* Team label */}
<div className="flex items-center gap-3 pr-4 md:border-r border-[var(--color-border-separator)]">
<div className="p-2 bg-[var(--color-primary-fixed)]/20 rounded-lg">
<span className="material-symbols-outlined text-[var(--color-brand)] text-xl">
groups
</span>
</div>
<div>
<h3 className="text-sm font-bold text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>
Team: {mockTeam.name}
</h3>
<p className="text-[11px] font-medium text-[var(--color-text-tertiary)] uppercase tracking-tighter">
{mockTeam.memberCount} members
</p>
</div>
</div>
{/* Agent Chips */}
<div className="flex flex-wrap gap-2 items-center flex-1">
{mockTeam.members.map((member) => {
if (member.status === 'completed') {
return (
<div
key={member.id}
className="flex items-center gap-2 px-3 py-1.5 bg-[var(--color-surface-container-high)] rounded-full border border-[var(--color-success)]/20 group hover:border-[var(--color-success)]/50 transition-all cursor-pointer"
>
<div className="w-2 h-2 rounded-full bg-[var(--color-success)] shadow-[0_0_8px_rgba(126,219,139,0.4)]" />
<span className="text-xs font-semibold text-[var(--color-text-primary)]">
{member.role}
</span>
<span
className="material-symbols-outlined text-[14px] text-[var(--color-success)]"
style={{ fontVariationSettings: "'FILL' 1" }}
>
check_circle
</span>
</div>
)
}
if (member.status === 'running') {
return (
<div
key={member.id}
className="flex items-center gap-2 px-3 py-1.5 bg-[var(--color-surface-container-high)] rounded-full border border-[var(--color-brand)]/20 animate-pulse-subtle group hover:border-[var(--color-brand)]/50 transition-all cursor-pointer"
>
<div className="w-2 h-2 rounded-full bg-[var(--color-warning)] shadow-[0_0_8px_rgba(247,196,108,0.4)]" />
<span className="text-xs font-semibold text-[var(--color-text-primary)]">
{member.role}
</span>
<span
className="material-symbols-outlined text-[14px] text-[var(--color-warning)]"
style={{ fontVariationSettings: "'FILL' 1" }}
>
sync
</span>
</div>
)
}
return (
<div
key={member.id}
className="flex items-center gap-2 px-3 py-1.5 bg-[var(--color-surface-container-low)] rounded-full border border-[var(--color-border)] grayscale group hover:grayscale-0 hover:border-[var(--color-secondary)]/50 transition-all cursor-pointer"
>
<div className="w-2 h-2 rounded-full bg-[var(--color-text-tertiary)] shadow-[0_0_8px_rgba(135,115,109,0.2)]" />
<span className="text-xs font-semibold text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-primary)]">
{member.role}
</span>
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">
{member.role === 'Tester' ? 'schedule' : 'pause_circle'}
</span>
</div>
)
})}
</div>
{/* Expand button */}
<button className="ml-auto p-2 hover:bg-[var(--color-surface-hover)] rounded-full transition-colors text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined text-sm">expand_more</span>
</button>
</div>
</div>
{/* ─── Chat Composer ─── */}
<div className="max-w-3xl mx-auto w-full mt-auto">
<div className="glass-panel relative rounded-xl p-1.5 flex items-center gap-2 transition-all">
<div className="p-2 text-[var(--color-text-secondary)]">
<span className="material-symbols-outlined">attach_file</span>
</div>
<input
className="flex-1 bg-transparent border-none focus:ring-0 focus:outline-none text-sm text-[var(--color-text-primary)] py-2"
placeholder="Type a command or ask Claude..."
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
<button className="bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] w-9 h-9 rounded-lg flex items-center justify-center transition-all hover:brightness-105 active:scale-95">
<span
className="material-symbols-outlined text-lg"
style={{ fontVariationSettings: "'FILL' 1" }}
>
send
</span>
</button>
</div>
<div className="mt-3 flex justify-center gap-4">
<div className="flex items-center gap-1.5 text-[10px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-widest">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)]" />
Auto-run enabled
</div>
<div className="flex items-center gap-1.5 text-[10px] text-[var(--color-text-tertiary)] font-semibold uppercase tracking-widest">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-secondary)]" />
Local LLM
</div>
</div>
</div>
</div>
</div>
</div>
</>
)
}
+64 -52
View File
@@ -1,6 +1,9 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { computerUseApi, type ComputerUseStatus, type SetupResult, type InstalledApp, type AuthorizedApp } from '../api/computerUse'
import { useTranslation } from '../i18n'
import { Button } from '@/components/ui/Button'
import { ErrorState } from '@/components/ui/ErrorState'
import { LoadingState } from '@/components/ui/LoadingState'
import { getDesktopHost } from '../lib/desktopHost'
type CheckState = 'loading' | 'ready' | 'error'
@@ -14,9 +17,9 @@ function StatusIcon({ ok }: { ok: boolean | null }) {
return <span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">help</span>
}
return ok ? (
<span className="material-symbols-outlined text-[18px] text-green-500" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
<span className="material-symbols-outlined text-[18px] text-[var(--color-success)]" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
) : (
<span className="material-symbols-outlined text-[18px] text-red-400" style={{ fontVariationSettings: "'FILL' 1" }}>cancel</span>
<span className="material-symbols-outlined text-[18px] text-[var(--color-error)]" style={{ fontVariationSettings: "'FILL' 1" }}>cancel</span>
)
}
@@ -299,24 +302,24 @@ export function ComputerUseSettings() {
</div>
{!computerUseEnabled && (
<div className="px-4 py-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30 text-sm text-yellow-700">
<div className="px-4 py-3 rounded-lg border border-[var(--color-warning)] bg-[var(--color-warning-container)] text-sm text-[var(--color-on-warning-container)]">
{t('settings.computerUse.disabledHint')}
</div>
)}
{checkState === 'loading' ? (
<div className="py-8 text-center text-sm text-[var(--color-text-tertiary)]">
{t('common.loading')}
</div>
<LoadingState size="md" label={t('common.loading')} />
) : checkState === 'error' ? (
<div className="py-8 text-center text-sm text-red-400">
Failed to check status.
<button onClick={fetchStatus} className="ml-2 underline">{t('common.retry')}</button>
</div>
<ErrorState
size="lg"
title="Failed to check status."
retryLabel={t('common.retry')}
onRetry={fetchStatus}
/>
) : status ? (
<>
{!status.supported && (
<div className="px-4 py-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30 text-sm text-yellow-600">
<div className="px-4 py-3 rounded-lg border border-[var(--color-warning)] bg-[var(--color-warning-container)] text-sm text-[var(--color-on-warning-container)]">
{t('settings.computerUse.notSupported')}
</div>
)}
@@ -356,31 +359,35 @@ export function ComputerUseSettings() {
placeholder={t('settings.computerUse.pythonPathPlaceholder')}
className="min-w-[220px] flex-1 rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-container)] px-3 py-2 font-mono text-xs text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-brand)] focus:outline-none"
/>
<button
<Button
variant="secondary"
size="base"
onClick={choosePythonPath}
disabled={pythonPathSaving}
className="flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] px-3 py-2 text-xs font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
icon={<span className="material-symbols-outlined text-[16px]">folder_open</span>}
>
<span className="material-symbols-outlined text-[16px]">folder_open</span>
{t('settings.computerUse.pythonPathBrowse')}
</button>
<button
</Button>
<Button
variant="primary"
size="base"
onClick={() => savePythonPath()}
disabled={pythonPathSaving || !pythonPathDirty}
className="flex items-center gap-1.5 rounded-lg bg-[var(--color-brand)] px-3 py-2 text-xs font-semibold text-[var(--color-on-primary)] hover:opacity-90 disabled:opacity-50"
disabled={!pythonPathDirty}
loading={pythonPathSaving}
icon={<span className="material-symbols-outlined text-[16px]">save</span>}
>
<span className="material-symbols-outlined text-[16px]">{pythonPathSaving ? 'hourglass_empty' : 'save'}</span>
{t('settings.computerUse.pythonPathSave')}
</button>
</Button>
{pythonPathSaved && (
<button
<Button
variant="secondary"
size="base"
onClick={() => savePythonPath('')}
disabled={pythonPathSaving}
className="flex items-center gap-1.5 rounded-lg border border-[var(--color-border)] px-3 py-2 text-xs font-medium text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
icon={<span className="material-symbols-outlined text-[16px]">restart_alt</span>}
>
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
{t('settings.computerUse.pythonPathAuto')}
</button>
</Button>
)}
</div>
<p className="text-xs text-[var(--color-text-tertiary)]">
@@ -410,26 +417,28 @@ export function ComputerUseSettings() {
}
/>
{(accessibilityNeedsAttention || screenRecordingNeedsAttention) && (
<div className="flex flex-col gap-2 px-4 py-3 rounded-lg bg-yellow-500/5 border border-yellow-500/20">
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.computerUse.permRestartHint')}</p>
<div className="flex flex-col gap-2 px-4 py-3 rounded-lg border border-[var(--color-warning)] bg-[var(--color-warning-container)]">
<p className="text-xs text-[var(--color-on-warning-container)]">{t('settings.computerUse.permRestartHint')}</p>
<div className="flex gap-2">
{accessibilityNeedsAttention && (
<button
<Button
variant="secondary"
size="base"
onClick={() => openSystemSettings('Privacy_Accessibility')}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-[var(--color-text-accent)] border border-[var(--color-border)] rounded-lg hover:bg-[var(--color-surface-hover)]"
icon={<span className="material-symbols-outlined text-[14px]">open_in_new</span>}
>
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
{t('settings.computerUse.openAccessibility')}
</button>
</Button>
)}
{screenRecordingNeedsAttention && (
<button
<Button
variant="secondary"
size="base"
onClick={() => openSystemSettings('Privacy_ScreenCapture')}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-[var(--color-text-accent)] border border-[var(--color-border)] rounded-lg hover:bg-[var(--color-surface-hover)]"
icon={<span className="material-symbols-outlined text-[14px]">open_in_new</span>}
>
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
{t('settings.computerUse.openScreenRecording')}
</button>
</Button>
)}
</div>
</div>
@@ -438,15 +447,15 @@ export function ComputerUseSettings() {
)}
{allReady && (status.platform !== 'darwin' || (status.permissions.accessibility && screenRecordingReady)) && (
<div className="px-4 py-3 rounded-lg bg-green-500/10 border border-green-500/30 text-sm text-green-600 flex items-center gap-2">
<div className="px-4 py-3 rounded-lg border border-[var(--color-success)] bg-[var(--color-success-container)] text-sm text-[var(--color-on-success-container)] flex items-center gap-2">
<span className="material-symbols-outlined text-[18px]" style={{ fontVariationSettings: "'FILL' 1" }}>verified</span>
{t('settings.computerUse.allReady')}
</div>
)}
{setupResult && (
<div className={`rounded-lg border p-4 space-y-2 ${setupResult.success ? 'bg-green-500/5 border-green-500/30' : 'bg-red-500/5 border-red-500/30'}`}>
<div className={`text-sm font-medium ${setupResult.success ? 'text-green-600' : 'text-red-400'}`}>
<div className={`rounded-lg border p-4 space-y-2 ${setupResult.success ? 'border-[var(--color-success)] bg-[var(--color-success-container)]' : 'border-[var(--color-error)] bg-[var(--color-error-container)]'}`}>
<div className={`text-sm font-medium ${setupResult.success ? 'text-[var(--color-on-success-container)]' : 'text-[var(--color-on-error-container)]'}`}>
{setupResult.success ? t('settings.computerUse.setupSuccess') : t('settings.computerUse.setupFail')}
</div>
{setupResult.steps.map((step, i) => (
@@ -461,31 +470,34 @@ export function ComputerUseSettings() {
{/* Action buttons */}
<div className="flex gap-3">
{!status.python.installed && (
<button
<Button
variant="primary"
size="lg"
onClick={() => openExternalUrl(pythonDownloadUrl)}
className="flex items-center gap-2 px-5 py-2.5 bg-[var(--color-brand)] text-[var(--color-on-primary)] text-sm font-semibold rounded-lg hover:opacity-90 transition-opacity"
icon={<span className="material-symbols-outlined text-[18px]">open_in_new</span>}
>
<span className="material-symbols-outlined text-[18px]">open_in_new</span>
{t('settings.computerUse.downloadPython')}
</button>
</Button>
)}
{!envReady && status.python.installed && (
<button
<Button
variant="primary"
size="lg"
onClick={handleSetup}
disabled={setupRunning}
className="flex items-center gap-2 px-5 py-2.5 bg-[var(--color-brand)] text-[var(--color-on-primary)] text-sm font-semibold rounded-lg hover:opacity-90 disabled:opacity-50 transition-opacity"
loading={setupRunning}
icon={<span className="material-symbols-outlined text-[18px]">download</span>}
>
<span className="material-symbols-outlined text-[18px]">{setupRunning ? 'hourglass_empty' : 'download'}</span>
{setupRunning ? t('settings.computerUse.setupRunning') : t('settings.computerUse.setupBtn')}
</button>
</Button>
)}
<button
<Button
variant="secondary"
size="lg"
onClick={fetchStatus}
className="flex items-center gap-2 px-4 py-2.5 text-sm text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] border border-[var(--color-border)] rounded-lg hover:bg-[var(--color-surface-hover)] transition-colors"
icon={<span className="material-symbols-outlined text-[18px]">refresh</span>}
>
<span className="material-symbols-outlined text-[18px]">refresh</span>
{t('settings.computerUse.recheckBtn')}
</button>
</Button>
</div>
{/* ─── App Authorization Section ─── */}
@@ -495,7 +507,7 @@ export function ComputerUseSettings() {
<h3 className="text-base font-semibold text-[var(--color-text-primary)] flex items-center gap-2">
{t('settings.computerUse.appsTitle')}
{appsSaved && (
<span className="text-xs font-normal text-green-500 flex items-center gap-1">
<span className="text-xs font-normal text-[var(--color-success)] flex items-center gap-1">
<span className="material-symbols-outlined text-[14px]" style={{ fontVariationSettings: "'FILL' 1" }}>check</span>
{t('settings.computerUse.appsSaved')}
</span>
@@ -567,7 +579,7 @@ export function ComputerUseSettings() {
: 'border-[var(--color-border)]'
}`}>
{isAuthorized && (
<span className="material-symbols-outlined text-[14px] text-white" style={{ fontVariationSettings: "'FILL' 1" }}>check</span>
<span className="material-symbols-outlined text-[14px] text-[var(--color-on-primary)]" style={{ fontVariationSettings: "'FILL' 1" }}>check</span>
)}
</div>
<div className="flex-1 min-w-0">
+11 -9
View File
@@ -6,13 +6,13 @@ import {
type LocalIndexState,
type LocalIndexStatus,
} from '../api/diagnostics'
import { Button } from '../components/shared/Button'
import { copyTextToClipboard } from '../components/chat/clipboard'
import { Button } from '@/components/ui/Button'
import { copyTextToClipboard } from '@/lib/clipboard'
import { useTranslation } from '../i18n'
import { formatBytes } from '../lib/formatBytes'
import { useUIStore } from '../stores/uiStore'
import { DoctorPanel } from '../components/doctor/DoctorPanel'
import { ConfirmDialog } from '../components/shared/ConfirmDialog'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
export function DiagnosticsSettings() {
const t = useTranslation()
@@ -522,25 +522,27 @@ function EventRow({
)}
</div>
<div className="text-xs text-[var(--color-text-secondary)] mt-1 break-words">{event.summary}</div>
<button
type="button"
className="mt-1 inline-flex max-w-full items-center gap-1 text-[11px] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)]"
<Button
variant="ghost"
size="xs"
className="mt-1 max-w-full"
aria-label={`${copyEventIdLabel}: ${event.id}`}
onClick={async () => {
const copied = await copyTextToClipboard(event.id)
addToast({ type: copied ? 'success' : 'error', message: copied ? eventIdCopiedLabel : eventIdCopyFailedLabel })
}}
icon={<span className="material-symbols-outlined text-[13px]" aria-hidden="true">content_copy</span>}
iconPosition="end"
>
<span>{eventIdLabel}:</span>
<span className="font-mono truncate">{event.id}</span>
<span className="material-symbols-outlined text-[13px]" aria-hidden="true">content_copy</span>
</button>
</Button>
{detailsText && (
<details className="mt-2">
<summary className="cursor-pointer text-xs text-[var(--color-text-tertiary)] select-none">
{detailsLabel}
</summary>
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md border border-[var(--color-border)] bg-[var(--color-bg-secondary)] p-2 text-[11px] leading-relaxed text-[var(--color-text-secondary)]">
<pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container)] p-2 text-[11px] leading-relaxed text-[var(--color-text-secondary)]">
{detailsText}
</pre>
</details>
+1 -1
View File
@@ -98,7 +98,7 @@ vi.mock('@tauri-apps/api/webview', () => ({
}),
}))
vi.mock('../components/shared/DirectoryPicker', () => ({
vi.mock('@/components/composite/DirectoryPicker', () => ({
DirectoryPicker: ({ value, onChange }: { value: string; onChange: (path: string) => void }) => (
<button type="button" aria-label="Pick project" data-value={value} onClick={() => onChange('/workspace/project')}>
Pick project
+46 -70
View File
@@ -1,4 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useDismissable } from '@/hooks/useDismissable'
import { Button } from '@/components/ui/Button'
import { IconButton } from '@/components/ui/IconButton'
import { ApiError } from '../api/client'
import { agentsApi } from '../api/agents'
import { skillsApi } from '../api/skills'
@@ -11,7 +14,7 @@ import { useSessionRuntimeStore, DRAFT_RUNTIME_SELECTION_KEY } from '../stores/s
import { useSettingsStore } from '../stores/settingsStore'
import { useUIStore } from '../stores/uiStore'
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
import { RepositoryLaunchControls } from '../components/shared/RepositoryLaunchControls'
import { RepositoryLaunchControls } from '@/components/chat/RepositoryLaunchControls'
import { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
import { ModelSelector, type ModelSelectorHandle } from '../components/controls/ModelSelector'
import { AttachmentGallery } from '../components/chat/AttachmentGallery'
@@ -137,65 +140,36 @@ export function EmptySession() {
textareaRef.current?.focus()
}, [])
useEffect(() => {
if (!plusMenuOpen) return
const handleClick = (event: MouseEvent) => {
if (plusMenuRef.current && !plusMenuRef.current.contains(event.target as Node)) {
setPlusMenuOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [plusMenuOpen])
useDismissable({
open: plusMenuOpen,
refs: [plusMenuRef],
onDismiss: () => setPlusMenuOpen(false),
})
useEffect(() => {
if (!slashMenuOpen) return
const handleClick = (event: MouseEvent) => {
if (
slashMenuRef.current &&
!slashMenuRef.current.contains(event.target as Node) &&
textareaRef.current &&
!textareaRef.current.contains(event.target as Node)
) {
setSlashMenuOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [slashMenuOpen])
useDismissable({
open: slashMenuOpen,
refs: [slashMenuRef, textareaRef],
onDismiss: () => setSlashMenuOpen(false),
})
useEffect(() => {
if (!localSlashPanel) return
const handleClick = (event: MouseEvent) => {
if (
slashMenuRef.current &&
!slashMenuRef.current.contains(event.target as Node) &&
textareaRef.current &&
!textareaRef.current.contains(event.target as Node)
) {
setLocalSlashPanel(null)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [localSlashPanel])
useDismissable({
open: !!localSlashPanel,
refs: [slashMenuRef, textareaRef],
onDismiss: () => setLocalSlashPanel(null),
})
useEffect(() => {
if (!fileSearchOpen) return
const handleClick = (event: MouseEvent) => {
useDismissable({
open: fileSearchOpen,
refs: [textareaRef],
onDismiss: () => setFileSearchOpen(false),
// See ChatInput: this menu is found by id, and its absence used to mean
// "ignore the press".
isExempt: (target) => {
const menu = document.getElementById('file-search-menu')
if (
menu &&
!menu.contains(event.target as Node) &&
textareaRef.current &&
!textareaRef.current.contains(event.target as Node)
) {
setFileSearchOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [fileSearchOpen])
if (!menu) return true
return target instanceof Node && menu.contains(target)
},
})
useEffect(() => {
let cancelled = false
@@ -744,15 +718,16 @@ export function EmptySession() {
}`}>
<div className="flex shrink-0 items-center gap-2">
<div ref={plusMenuRef} className="relative">
<button
<IconButton
icon="add"
label={t('chat.composerTools')}
showTooltip={false}
tone="secondary"
size={isMobileComposer ? 'xl' : 'md'}
className={isMobileComposer ? 'h-11 w-11' : undefined}
aria-expanded={plusMenuOpen}
onClick={() => setPlusMenuOpen((prev) => !prev)}
aria-label="Open composer tools"
className={`text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] ${
isMobileComposer ? 'inline-flex h-11 w-11 items-center justify-center rounded-xl' : 'rounded-lg p-1.5'
}`}
>
<span className="material-symbols-outlined text-[18px]">add</span>
</button>
/>
{plusMenuOpen && (
<div className={`absolute bottom-full left-0 mb-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] py-1 shadow-[var(--shadow-dropdown)] ${
@@ -794,18 +769,19 @@ export function EmptySession() {
compact={isMobileComposer}
/>
<ModelSelector ref={modelSelectorRef} runtimeKey={DRAFT_RUNTIME_SELECTION_KEY} disabled={isSubmitting} compact={isMobileComposer} />
<button
<Button
variant="primary"
size="base"
onClick={handleSubmit}
disabled={!canSubmit}
aria-label={t('common.run')}
title={isMobileComposer ? t('common.run') : undefined}
className={`flex shrink-0 items-center justify-center gap-1 rounded-lg bg-[image:var(--gradient-btn-primary)] text-xs font-semibold text-[var(--color-btn-primary-fg)] shadow-[var(--shadow-button-primary)] transition-all hover:brightness-105 disabled:opacity-30 ${
isMobileComposer ? 'h-11 w-11 rounded-xl px-0 py-0' : 'w-[112px] px-3 py-1.5'
}`}
className={`shrink-0 ${isMobileComposer ? 'h-11 w-11' : 'w-[112px]'}`}
icon={<span className="material-symbols-outlined text-[14px]">arrow_forward</span>}
iconPosition="end"
>
{!isMobileComposer && t('common.run')}
<span className="material-symbols-outlined text-[14px]">arrow_forward</span>
</button>
</Button>
</div>
</div>
</div>
+1 -1
View File
@@ -6,7 +6,7 @@ import { useUIStore } from '../stores/uiStore'
import { InstallConfirmDialog } from '../components/market/InstallConfirmDialog'
import { MarketHome } from '../components/market/MarketHome'
import { MarketSkillDetail } from '../components/market/MarketSkillDetail'
import { ConfirmDialog } from '../components/shared/ConfirmDialog'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import type { NormalizedSkill } from '../types/market'
export function Market() {
+68 -103
View File
@@ -1,8 +1,15 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button } from '../components/shared/Button'
import { DirectoryPicker } from '../components/shared/DirectoryPicker'
import { Input } from '../components/shared/Input'
import { ConfirmDialog } from '../components/shared/ConfirmDialog'
import { Button } from '@/components/ui/Button'
import { Badge } from '@/components/ui/Badge'
import { Switch } from '@/components/ui/Switch'
import { LoadingState } from '@/components/ui/LoadingState'
import { EmptyState } from '@/components/ui/EmptyState'
import { ErrorState } from '@/components/ui/ErrorState'
import { IconButton } from '@/components/ui/IconButton'
import { mcpStatusTone } from '@/lib/mcpStatus'
import { DirectoryPicker } from '@/components/composite/DirectoryPicker'
import { Input } from '@/components/ui/Input'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { useTranslation } from '../i18n'
import { useUIStore } from '../stores/uiStore'
import { useMcpStore } from '../stores/mcpStore'
@@ -68,14 +75,6 @@ const MCP_GROUP_ORDER: McpGroupKey[] = [
const WRITABLE_SCOPES: McpWritableScope[] = ['local', 'project', 'user']
const STATUS_TONE: Record<McpServerRecord['status'], string> = {
connected: 'bg-[var(--color-inspector-success-bg)] text-[var(--color-inspector-success)] border-[var(--color-border)]',
checking: 'bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] border-[var(--color-border)]',
'needs-auth': 'bg-[var(--color-surface-container-low)] text-[var(--color-warning)] border-[var(--color-border)]',
failed: 'bg-[var(--color-inspector-danger-bg)] text-[var(--color-inspector-danger)] border-[var(--color-border)]',
disabled: 'bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] border-[var(--color-border)]',
}
const SENSITIVE_MCP_FIELD = /(?:api[_-]?key|auth[_-]?token|authorization|bearer|token|secret|password|credential)/i
const SENSITIVE_CLI_FLAG = /^--(?:api-key|api_key|auth-token|auth_token|authorization|bearer|token|secret|password|credential)$/i
const REDACTED_INPUT_VALUE = '[redacted]'
@@ -305,9 +304,9 @@ function scopeLabel(server: McpServerRecord, t: ReturnType<typeof useTranslation
function StatusBadge({ server }: { server: McpServerRecord }) {
return (
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold ${STATUS_TONE[server.status]}`}>
<Badge tone={mcpStatusTone(server.status)} size="md" bordered className="font-semibold">
{server.statusLabel}
</span>
</Badge>
)
}
@@ -319,35 +318,6 @@ function getServerIdentityKey(server: Pick<McpServerRecord, 'name' | 'scope' | '
return `${server.scope}:${server.name}`
}
function ToggleSwitch({
checked,
disabled,
onChange,
}: {
checked: boolean
disabled?: boolean
onChange: () => void
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={onChange}
className={`relative inline-flex h-8 w-14 items-center rounded-full transition-colors ${
checked ? 'bg-[var(--color-switch-checked-bg)]' : 'bg-[var(--color-border)]'
} ${disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'}`}
>
<span
className={`inline-block h-6 w-6 transform rounded-full bg-[var(--color-switch-thumb)] shadow-sm transition-transform ${
checked ? 'translate-x-7' : 'translate-x-1'
}`}
/>
</button>
)
}
function ArraySection({
title,
rows,
@@ -389,24 +359,27 @@ function ArraySection({
onChange={(event) => onChange(row.id, 'value', event.target.value)}
placeholder={valuePlaceholder}
/>
<button
type="button"
<IconButton
icon="delete"
label={addLabel}
showTooltip={false}
size="md"
tone="muted"
className="mt-1 h-10"
onClick={() => onRemove(row.id)}
className="mt-1 flex h-10 w-8 items-center justify-center rounded-[var(--radius-md)] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
aria-label={addLabel}
>
<span className="material-symbols-outlined text-[18px]">delete</span>
</button>
/>
</div>
))}
<button
type="button"
<Button
variant="secondary"
size="lg"
block
className="h-12"
onClick={onAdd}
className="flex h-12 w-full items-center justify-center gap-2 rounded-[var(--radius-lg)] bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)]"
icon={<span className="material-symbols-outlined text-[18px]">add</span>}
>
<span className="material-symbols-outlined text-[18px]">add</span>
{addLabel}
</button>
</Button>
</div>
</section>
)
@@ -424,19 +397,6 @@ function StatCard({ label, value, icon }: { label: string; value: number; icon:
)
}
function LoadingState({ label }: { label: string }) {
return (
<div
role="status"
aria-live="polite"
className="flex min-h-[220px] flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-center"
>
<div className="h-7 w-7 animate-spin rounded-full border-2 border-[var(--color-brand)] border-t-transparent" />
<div className="text-sm font-medium text-[var(--color-text-secondary)]">{label}</div>
</div>
)
}
function ServerRow({
server,
isBusy,
@@ -479,16 +439,23 @@ function ServerRow({
)}
</div>
<button
type="button"
<IconButton
icon="settings"
label={t('settings.mcp.openServer', { name: server.name })}
showTooltip={false}
size="xl"
shape="circle"
tone="secondary"
onClick={onOpen}
className="flex h-10 w-10 items-center justify-center rounded-full text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
aria-label={`Open ${server.name}`}
>
<span className="material-symbols-outlined text-[20px]">settings</span>
</button>
/>
<ToggleSwitch checked={server.enabled} disabled={isBusy || !server.canToggle} onChange={onToggle} />
<Switch
label={server.name}
labelHidden
checked={server.enabled}
disabled={isBusy || !server.canToggle}
onChange={onToggle}
/>
</div>
)
}
@@ -803,17 +770,18 @@ export function McpSettings() {
return (
<>
<div className="max-w-5xl min-w-0">
<button
type="button"
<Button
variant="ghost"
size="md"
className="mb-5"
onClick={() => {
setView({ type: 'list' })
selectServer(null)
}}
className="mb-5 inline-flex items-center gap-2 text-sm text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)]"
icon={<span className="material-symbols-outlined text-[18px]">arrow_back</span>}
>
<span className="material-symbols-outlined text-[18px]">arrow_back</span>
{t('settings.mcp.form.back')}
</button>
</Button>
<div className="flex items-start justify-between gap-4 mb-8">
<div>
@@ -878,17 +846,18 @@ export function McpSettings() {
return (
<>
<div className="max-w-5xl min-w-0">
<button
type="button"
<Button
variant="ghost"
size="md"
className="mb-5"
onClick={() => {
setView({ type: 'list' })
selectServer(null)
}}
className="mb-5 inline-flex items-center gap-2 text-sm text-[var(--color-text-secondary)] transition-colors hover:text-[var(--color-text-primary)]"
icon={<span className="material-symbols-outlined text-[18px]">arrow_back</span>}
>
<span className="material-symbols-outlined text-[18px]">arrow_back</span>
{t('settings.mcp.form.back')}
</button>
</Button>
<div className="flex items-start justify-between gap-4 mb-8">
<div>
@@ -1137,7 +1106,7 @@ export function McpSettings() {
</div>
{showListLoading ? (
<LoadingState label={t('common.loading')} />
<LoadingState label={t('common.loading')} variant="dashed" size="lg" />
) : (
<>
<div className="grid gap-4 md:grid-cols-3 mb-8">
@@ -1147,23 +1116,19 @@ export function McpSettings() {
</div>
{error ? (
<div className="text-center py-16 rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<span className="material-symbols-outlined text-[40px] text-[var(--color-error)] mb-3 block">error</span>
<p className="text-sm text-[var(--color-error)] mb-3">{error}</p>
<button
type="button"
onClick={() => void fetchServers(projectPathsForFetchRef.current, currentWorkDir)}
className="text-sm text-[var(--color-text-accent)] hover:underline"
>
{t('common.retry')}
</button>
</div>
<ErrorState
size="lg"
title={error}
retryLabel={t('common.retry')}
onRetry={() => void fetchServers(projectPathsForFetchRef.current, currentWorkDir)}
/>
) : servers.length === 0 ? (
<div className="text-center py-16 rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)]">
<span className="material-symbols-outlined text-[40px] text-[var(--color-text-tertiary)] mb-3 block">dns</span>
<p className="text-sm text-[var(--color-text-secondary)] mb-1">{t('settings.mcp.empty')}</p>
<p className="text-xs text-[var(--color-text-tertiary)]">{t('settings.mcp.emptyHint')}</p>
</div>
<EmptyState
size="md"
icon={<span className="material-symbols-outlined text-[20px]" aria-hidden="true">dns</span>}
title={t('settings.mcp.empty')}
description={t('settings.mcp.emptyHint')}
/>
) : (
<div className="flex flex-col gap-6">
{MCP_GROUP_ORDER.map((group) => {
+16 -25
View File
@@ -1,7 +1,9 @@
import { useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import { BookOpenText, ChevronDown, ChevronRight, Database, FileText, Folder, FolderGit2, PencilLine, RefreshCw, RotateCcw, Save, Search, X } from 'lucide-react'
import { Button } from '../components/shared/Button'
import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/ui/EmptyState'
import { IconButton } from '@/components/ui/IconButton'
import { MarkdownRenderer } from '../components/markdown/MarkdownRenderer'
import { useTranslation } from '../i18n'
import { formatBytes } from '../lib/formatBytes'
@@ -273,9 +275,9 @@ export function MemorySettings() {
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2">
{projects.length === 0 && !isLoadingProjects ? (
<EmptyState icon={<FolderGit2 size={18} />} text={t('settings.memory.emptyProjects')} />
<EmptyState icon={<FolderGit2 size={18} />} description={t('settings.memory.emptyProjects')} variant="plain" size="sm" />
) : filteredProjects.length === 0 ? (
<EmptyState icon={<Search size={18} />} text={t('settings.memory.noProjectMatches')} />
<EmptyState icon={<Search size={18} />} description={t('settings.memory.noProjectMatches')} variant="plain" size="sm" />
) : (
<div className="py-1">
{filteredProjects.map((project) => {
@@ -406,7 +408,7 @@ export function MemorySettings() {
)
) : (
<div className="flex min-h-0 flex-1 items-center justify-center p-8">
<EmptyState icon={<FileText size={20} />} text={isLoadingFile ? t('common.loading') : t('settings.memory.selectFile')} />
<EmptyState icon={<FileText size={20} />} description={isLoadingFile ? t('common.loading') : t('settings.memory.selectFile')} variant="plain" size="sm" />
</div>
)}
</section>
@@ -426,10 +428,11 @@ function Breadcrumb({
fallbackProject: string
fallbackFile: string
}) {
const t = useTranslation()
const projectLabel = project ? projectDisplayName(project.label) : fallbackProject
const parts = filePath ? [projectLabel, ...filePath.split('/').filter(Boolean)] : [projectLabel, fallbackFile]
return (
<nav aria-label="Memory file path" className="flex min-w-0 items-center gap-1 text-sm text-[var(--color-text-tertiary)]">
<nav aria-label={t('settings.memory.filePath')} className="flex min-w-0 items-center gap-1 text-sm text-[var(--color-text-tertiary)]">
{parts.map((part, index) => {
const isLast = index === parts.length - 1
return (
@@ -473,14 +476,15 @@ function SearchField({
className="h-10 w-full rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] pl-9 pr-9 text-sm text-[var(--color-text-primary)] outline-none transition-colors duration-150 placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-border-focus)] focus:shadow-[var(--shadow-focus-ring)]"
/>
{value ? (
<button
type="button"
aria-label={clearLabel}
<IconButton
icon={<X size={14} aria-hidden="true" />}
label={clearLabel}
showTooltip={false}
size="sm"
tone="muted"
onClick={() => onChange('')}
className="absolute right-2 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-[var(--radius-sm)] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
>
<X size={14} aria-hidden="true" />
</button>
className="absolute right-2 top-1/2 -translate-y-1/2"
/>
) : null}
</div>
)
@@ -675,19 +679,6 @@ function Badge({ children }: { children: string }) {
)
}
function EmptyState({ icon, text }: { icon?: ReactNode; text: string }) {
return (
<div className="grid place-items-center gap-2 px-3 py-8 text-center text-sm text-[var(--color-text-tertiary)]">
{icon ? (
<span className="flex h-9 w-9 items-center justify-center rounded-md border border-[var(--color-border)] bg-[var(--color-surface-container-low)] text-[var(--color-text-tertiary)]">
{icon}
</span>
) : null}
<span>{text}</span>
</div>
)
}
function normalizeSearch(value: string): string {
return value.toLowerCase().replace(/\\/g, '/').replace(/\/+/g, '/').trim()
}
-335
View File
@@ -1,335 +0,0 @@
import { useState } from 'react'
import { mockNewTaskDefaults, mockSessions, mockStatusBar } from '../mocks/data'
/**
* NewTaskModal page -- pixel-perfect replica of the HTML prototype.
* Renders the full app chrome (header, sidebar, content, footer) dimmed/blurred
* behind a centered "New Scheduled Task" modal overlay.
*
* Everything lives in this single file. Mock data is imported from ../mocks/data.
*/
export default function NewTaskModal() {
/* ── form state ─────────────────────────────────────────────────── */
const [taskName, setTaskName] = useState('')
const [description, setDescription] = useState('')
const [systemPrompt, setSystemPrompt] = useState('')
const [model, setModel] = useState(mockNewTaskDefaults.models[0])
const [rootFolder, setRootFolder] = useState('')
const [frequency, setFrequency] = useState(mockNewTaskDefaults.frequencies[1])
const [worktree, setWorktree] = useState(false)
return (
<div className="bg-[var(--color-background)] text-[var(--color-on-surface)] font-[var(--font-body)] min-h-screen flex flex-col">
{/* ─── TopAppBar / Header ──────────────────────────────────── */}
<header className="bg-[var(--color-background)] flex justify-between items-center px-6 h-12 w-full z-40">
<div className="flex items-center gap-6">
<span className="text-sm font-bold text-[var(--color-text-primary)] uppercase tracking-tighter font-[var(--font-headline)]">
Claude Code Companion
</span>
<nav className="hidden md:flex gap-4 font-[var(--font-headline)] font-semibold tracking-wide text-sm">
<a className="text-[var(--color-text-tertiary)] hover:text-[var(--color-brand)] transition-colors cursor-pointer">Code</a>
<a className="text-[var(--color-text-tertiary)] hover:text-[var(--color-brand)] transition-colors cursor-pointer">Terminal</a>
<a className="text-[var(--color-text-tertiary)] hover:text-[var(--color-brand)] transition-colors cursor-pointer">History</a>
</nav>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-[var(--color-brand)]">
<span className="material-symbols-outlined cursor-pointer active:opacity-70 text-sm">arrow_back_ios</span>
<span className="material-symbols-outlined cursor-pointer active:opacity-70 text-sm">arrow_forward_ios</span>
</div>
<span className="font-[var(--font-headline)] font-semibold tracking-wide text-sm text-[var(--color-text-tertiary)] cursor-pointer hover:text-[var(--color-brand)] transition-colors">
Settings
</span>
</div>
</header>
{/* header divider */}
<div className="bg-[var(--color-surface-container-low)] h-px w-full" />
{/* ─── Main area (sidebar + content + overlay) ──────────────── */}
<main className="flex-1 flex overflow-hidden relative">
{/* ── Sidebar (dimmed) ────────────────────────────────────── */}
<aside className="hidden md:flex flex-col p-4 gap-2 bg-[var(--color-surface-container-low)] h-full w-[280px] opacity-30 pointer-events-none">
{/* project selector */}
<div className="flex items-center gap-3 px-2 py-3 mb-2">
<div className="w-8 h-8 rounded bg-[var(--color-surface-dim)] flex items-center justify-center">
<span className="material-symbols-outlined text-[var(--color-outline)]">filter</span>
</div>
<div>
<div className="text-[var(--color-on-surface)] font-semibold text-xs">All projects</div>
<div className="text-[10px] text-[var(--color-outline)]">Active Session</div>
</div>
</div>
{/* nav items -- session counts derived from mockSessions */}
<div className="font-[var(--font-body)] text-sm font-medium space-y-1">
<div className="flex items-center gap-3 px-3 py-2 text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined">add</span>New session
</div>
<div className="flex items-center gap-3 px-3 py-2 bg-[var(--color-background)] text-[var(--color-text-primary)] rounded-lg relative before:content-[''] before:absolute before:left-[-8px] before:w-1 before:h-4 before:bg-[var(--color-brand)] before:rounded-full">
<span className="material-symbols-outlined">calendar_today</span>Scheduled
</div>
<div className="flex items-center gap-3 px-3 py-2 text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined">history</span>Today
{mockSessions.today.length > 0 && (
<span className="ml-auto text-[10px] text-[var(--color-text-tertiary)]">{mockSessions.today.length}</span>
)}
</div>
<div className="flex items-center gap-3 px-3 py-2 text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined">event_note</span>Previous 7 Days
{mockSessions.previous7Days.length > 0 && (
<span className="ml-auto text-[10px] text-[var(--color-text-tertiary)]">{mockSessions.previous7Days.length}</span>
)}
</div>
</div>
</aside>
{/* ── Content area (dimmed canvas) ────────────────────────── */}
<div className="flex-1 bg-[var(--color-surface-container-low)] flex flex-col p-8 overflow-y-auto relative">
{/* faded background content */}
<div className="max-w-4xl mx-auto w-full space-y-6 opacity-20">
<h1 className="font-[var(--font-headline)] text-3xl font-bold tracking-tight">
Scheduled Tasks
</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="h-40 bg-[var(--color-surface-container-lowest)] rounded-xl border border-[var(--color-outline-variant)]/20 p-4" />
<div className="h-40 bg-[var(--color-surface-container-lowest)] rounded-xl border border-[var(--color-outline-variant)]/20 p-4" />
</div>
</div>
{/* ═══ MODAL OVERLAY ═══════════════════════════════════════ */}
<div className="absolute inset-0 z-50 flex items-center justify-center p-4 bg-[var(--color-on-surface)]/40 backdrop-blur-sm">
<div
className="bg-[var(--color-surface-container-lowest)] w-full max-w-lg rounded-xl overflow-hidden flex flex-col"
style={{
boxShadow:
'0 4px 20px rgba(27,28,26,0.04), 0 12px 40px rgba(27,28,26,0.08)',
}}
>
{/* ── Modal Header ─────────────────────────────────── */}
<div className="px-6 py-4 flex items-center justify-between border-b border-[var(--color-outline-variant)]/10">
<h2 className="font-[var(--font-headline)] font-bold text-lg text-[var(--color-on-surface)]">
New Scheduled Task
</h2>
<span className="material-symbols-outlined text-[var(--color-outline)] cursor-pointer hover:text-[var(--color-on-surface)] transition-colors">
close
</span>
</div>
{/* ── Modal Body ───────────────────────────────────── */}
<div
className="p-6 overflow-y-auto space-y-5"
style={{
maxHeight: 768,
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
>
{/* Info Banner */}
<div className="bg-[var(--color-surface-container-high)]/50 rounded-lg p-3 flex gap-3 items-start">
<span className="material-symbols-outlined text-[var(--color-primary)] text-sm mt-0.5">
info
</span>
<p className="text-xs text-[var(--color-on-surface-variant)] font-medium">
Local tasks only run while your computer is awake.
</p>
</div>
{/* ── Form ───────────────────────────────────────── */}
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4">
{/* Task Name */}
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-[var(--color-outline)] px-1">
Task Name
</label>
<input
type="text"
value={taskName}
onChange={(e) => setTaskName(e.target.value)}
placeholder="e.g., Weekly Code Audit"
className="w-full bg-[var(--color-surface-container)] rounded-lg border-none focus:ring-1 focus:ring-[var(--color-primary)] text-sm placeholder:text-[var(--color-outline)]/50 px-4 py-2.5 transition-all outline-none"
/>
</div>
{/* Description */}
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-[var(--color-outline)] px-1">
Description
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief purpose of this schedule..."
className="w-full bg-[var(--color-surface-container)] rounded-lg border-none focus:ring-1 focus:ring-[var(--color-primary)] text-sm placeholder:text-[var(--color-outline)]/50 px-4 py-2.5 transition-all outline-none"
/>
</div>
{/* System Prompt */}
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-[var(--color-outline)] px-1">
System Prompt
</label>
<textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
placeholder="Define the agent's goal and behavior..."
rows={3}
className="w-full bg-[var(--color-surface-container)] rounded-lg border-none focus:ring-1 focus:ring-[var(--color-primary)] text-sm placeholder:text-[var(--color-outline)]/50 px-4 py-2.5 transition-all resize-none outline-none"
/>
</div>
</div>
{/* Two-column row: Permission Mode + Model */}
<div className="grid grid-cols-2 gap-4">
{/* Permission Mode */}
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-[var(--color-outline)] px-1">
Permission Mode
</label>
<div className="w-full bg-[var(--color-surface-container)] rounded-lg border-none text-sm px-4 py-2.5 outline-none flex items-center gap-2 text-[var(--color-on-surface)]">
<span className="material-symbols-outlined text-[16px] text-[var(--color-error)]">
gavel
</span>
Full Access
</div>
</div>
{/* Model */}
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-[var(--color-outline)] px-1">
Model
</label>
<div className="relative">
<select
value={model}
onChange={(e) => setModel(e.target.value)}
className="w-full bg-[var(--color-surface-container)] rounded-lg border-none focus:ring-1 focus:ring-[var(--color-primary)] text-sm px-4 py-2.5 appearance-none cursor-pointer outline-none"
>
{mockNewTaskDefaults.models.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
<span className="material-symbols-outlined absolute right-3 top-2.5 pointer-events-none text-[var(--color-outline)] text-sm">
unfold_more
</span>
</div>
</div>
</div>
{/* Root Folder Path */}
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-[var(--color-outline)] px-1">
Root Folder Path
</label>
<div className="flex gap-2">
<input
type="text"
value={rootFolder}
onChange={(e) => setRootFolder(e.target.value)}
placeholder="/users/projects/claude-app"
className="flex-1 bg-[var(--color-surface-container)] rounded-lg border-none focus:ring-1 focus:ring-[var(--color-primary)] text-sm placeholder:text-[var(--color-outline)]/50 px-4 py-2.5 transition-all outline-none"
/>
<button className="bg-[var(--color-surface-container-high)] px-3 rounded-lg flex items-center justify-center hover:bg-[var(--color-surface-variant)] transition-colors">
<span className="material-symbols-outlined text-[var(--color-on-surface-variant)] text-sm">
folder_open
</span>
</button>
</div>
</div>
{/* Frequency */}
<div className="space-y-1.5">
<label className="text-[11px] font-bold uppercase tracking-wider text-[var(--color-outline)] px-1">
Frequency
</label>
<div className="relative">
<select
value={frequency}
onChange={(e) => setFrequency(e.target.value)}
className="w-full bg-[var(--color-surface-container)] rounded-lg border-none focus:ring-1 focus:ring-[var(--color-primary)] text-sm px-4 py-2.5 appearance-none cursor-pointer outline-none"
>
{mockNewTaskDefaults.frequencies.map((f) => (
<option key={f} value={f}>
{f}
</option>
))}
</select>
<span className="material-symbols-outlined absolute right-3 top-2.5 pointer-events-none text-[var(--color-outline)] text-sm">
schedule
</span>
</div>
</div>
{/* Worktree Checkbox */}
<div className="flex items-center gap-3 pt-2">
<div className="relative flex items-center">
<input
id="worktree"
type="checkbox"
checked={worktree}
onChange={(e) => setWorktree(e.target.checked)}
className="w-4 h-4 rounded text-[var(--color-primary)] focus:ring-[var(--color-primary)] border-[var(--color-outline-variant)] bg-[var(--color-surface-container)] accent-[var(--color-primary)]"
/>
</div>
<label
htmlFor="worktree"
className="text-sm text-[var(--color-on-surface)] font-medium cursor-pointer"
>
Create separate worktree for execution
</label>
</div>
</div>
</div>
{/* ── Modal Footer ─────────────────────────────────── */}
<div className="px-6 py-4 bg-[var(--color-surface-container-low)]/50 flex items-center justify-end gap-3">
<button className="px-5 py-2 text-sm font-semibold text-[var(--color-outline)] hover:text-[var(--color-on-surface)] transition-colors">
Cancel
</button>
<button
className="px-6 py-2 rounded-lg text-sm font-bold text-[var(--color-on-primary)] shadow-sm hover:opacity-90 active:scale-95 transition-all"
style={{
backgroundImage:
'linear-gradient(to bottom, var(--color-primary), var(--color-primary-container))',
}}
>
Create task
</button>
</div>
</div>
</div>
</div>
</main>
{/* ─── Footer / Status Bar ─────────────────────────────────── */}
<footer className="bg-[var(--color-background)] flex items-center justify-between px-4 z-50 fixed bottom-0 left-0 w-full h-8 border-t border-[var(--color-border)]/20 font-[var(--font-body)] text-xs tracking-tight">
<div className="flex items-center gap-3">
<span className="text-[var(--color-text-tertiary)]">
{mockStatusBar.user} &bull; {mockStatusBar.username} &bull; {mockStatusBar.plan}
</span>
<div className="flex items-center gap-4 ml-4">
<span className="text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-container-low)] px-2 py-0.5 rounded cursor-pointer transition-colors">
{mockStatusBar.branch}
</span>
<span className="text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-container-low)] px-2 py-0.5 rounded cursor-pointer transition-colors">
{mockStatusBar.worktreeToggle}
</span>
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-[var(--color-brand)] font-bold cursor-pointer">
{mockStatusBar.localSwitch}
</span>
<div className="flex items-center gap-1 text-[var(--color-text-tertiary)]">
<span className="material-symbols-outlined text-[14px]">terminal</span>
<span>{mockStatusBar.status === 'Ready' ? 'Active' : mockStatusBar.status}</span>
</div>
</div>
</footer>
</div>
)
}
+3 -2
View File
@@ -2,7 +2,8 @@ import { useEffect, useState } from 'react'
import { useTaskStore } from '../stores/taskStore'
import { useUIStore } from '../stores/uiStore'
import { useTranslation } from '../i18n'
import { Button } from '../components/shared/Button'
import { Button } from '@/components/ui/Button'
import { Spinner } from '@/components/ui/Spinner'
import { TaskList } from '../components/tasks/TaskList'
import { TaskEmptyState } from '../components/tasks/TaskEmptyState'
import { NewTaskModal } from '../components/tasks/NewTaskModal'
@@ -45,7 +46,7 @@ export function ScheduledTasks() {
{/* Content */}
{!initialized && isLoading ? (
<div className="flex items-center justify-center py-16">
<div className="animate-spin w-6 h-6 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
<Spinner size={24} tone="brand" label={t('common.loading')} />
</div>
) : tasks.length === 0 ? (
<TaskEmptyState onCreateTask={() => openModal('new-task')} />
+52 -57
View File
@@ -21,11 +21,14 @@ import { Copy, Eye, EyeOff, GripVertical, PowerOff, QrCode, RotateCw } from 'luc
import { useSettingsStore, UI_ZOOM_DEFAULT, UI_ZOOM_MIN, UI_ZOOM_MAX, UI_ZOOM_STEP } from '../stores/settingsStore'
import { useProviderStore } from '../stores/providerStore'
import { useTranslation, type TranslationKey } from '../i18n'
import { Modal } from '../components/shared/Modal'
import { ConfirmDialog } from '../components/shared/ConfirmDialog'
import { Input } from '../components/shared/Input'
import { Button } from '../components/shared/Button'
import { Dropdown } from '../components/shared/Dropdown'
import { Modal } from '@/components/ui/Modal'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { IconButton } from '@/components/ui/IconButton'
import { Badge } from '@/components/ui/Badge'
import { Spinner } from '@/components/ui/Spinner'
import { Dropdown } from '@/components/ui/Dropdown'
import { PermissionModeSelector } from '../components/controls/PermissionModeSelector'
import type { ThemeMode, UpdateProxyMode, NetworkProxyMode, WebSearchMode, AppMode, ChatSendBehavior, OutputStyleSource } from '../types/settings'
import type { Locale } from '../i18n'
@@ -80,7 +83,7 @@ import {
restoreSettingsJsonSecrets,
stripProviderSettingsJsonEnv,
} from '../lib/providerSettingsJson'
import { copyTextToClipboard } from '../components/chat/clipboard'
import { copyTextToClipboard } from '@/lib/clipboard'
const NETWORK_TIMEOUT_MIN_SECONDS = 30
const NETWORK_TIMEOUT_MAX_SECONDS = 1800
@@ -607,7 +610,7 @@ function ProviderSettings() {
{isLoading && providers.length === 0 ? (
<div className="flex justify-center py-8">
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-brand)] border-t-transparent rounded-full" />
<Spinner size={20} tone="brand" label={t('common.loading')} />
</div>
) : null}
@@ -1657,13 +1660,10 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
width="100%"
className="block w-full"
trigger={
<button
type="button"
className="flex h-10 w-full items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-left text-sm text-[var(--color-text-primary)] outline-none transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-low)] focus-visible:border-[var(--color-border-focus)] focus-visible:shadow-[var(--shadow-focus-ring)]"
>
<span className="min-w-0 flex-1 truncate">{selectedApiFormatLabel}</span>
<Button variant="secondary" size="md" block className="h-10 gap-3">
<span className="min-w-0 flex-1 truncate text-left">{selectedApiFormatLabel}</span>
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-text-secondary)]">expand_more</span>
</button>
</Button>
}
/>
{apiFormat !== 'anthropic' && (
@@ -1689,13 +1689,10 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
width="100%"
className="block w-full"
trigger={
<button
type="button"
className="flex min-h-10 w-full items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-left text-sm text-[var(--color-text-primary)] outline-none transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-low)] focus-visible:border-[var(--color-border-focus)] focus-visible:shadow-[var(--shadow-focus-ring)]"
>
<span className="min-w-0 flex-1 truncate">{selectedAuthStrategyLabel}</span>
<Button variant="secondary" size="md" block className="h-auto min-h-10 gap-3 py-2">
<span className="min-w-0 flex-1 truncate text-left">{selectedAuthStrategyLabel}</span>
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-text-secondary)]">expand_more</span>
</button>
</Button>
}
/>
</div>
@@ -1760,16 +1757,15 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
placeholder="sk-..."
className="h-10 w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 pr-10 text-sm text-[var(--color-text-primary)] outline-none transition-colors duration-150 placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-border-focus)] focus:shadow-[var(--shadow-focus-ring)]"
/>
<button
type="button"
<IconButton
icon={showApiKey ? 'visibility_off' : 'visibility'}
label={t(showApiKey ? 'settings.providers.hideApiKey' : 'settings.providers.showApiKey')}
showTooltip={false}
size="sm"
tone="muted"
onClick={() => setShowApiKey((visible) => !visible)}
aria-label={showApiKey ? 'Hide API Key' : 'Show API Key'}
className="absolute right-1.5 top-1/2 flex h-7 w-7 -translate-y-1/2 cursor-pointer items-center justify-center rounded-[var(--radius-sm)] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus:outline-none focus:shadow-[var(--shadow-focus-ring)]"
>
<span className="material-symbols-outlined text-[16px]">
{showApiKey ? 'visibility_off' : 'visibility'}
</span>
</button>
className="absolute right-1.5 top-1/2 -translate-y-1/2"
/>
</div>
</div>
@@ -2549,8 +2545,9 @@ export function GeneralSettings() {
<span className="min-w-[48px] rounded-md bg-[var(--color-surface-container-low)] px-2 py-1 text-center text-sm font-medium text-[var(--color-text-secondary)]">
{uiZoomPercent}%
</span>
<button
type="button"
<Button
variant="secondary"
size="base"
aria-label={t('settings.general.uiZoomReset')}
title={t('settings.general.uiZoomReset')}
onClick={() => {
@@ -2558,11 +2555,10 @@ export function GeneralSettings() {
setUiZoomDraft(UI_ZOOM_DEFAULT)
setUiZoom(UI_ZOOM_DEFAULT)
}}
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-[var(--color-border)] px-2 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
icon={<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />}
>
<RotateCw className="h-3.5 w-3.5" aria-hidden="true" />
100%
</button>
</Button>
</div>
</div>
<div
@@ -2665,14 +2661,16 @@ export function GeneralSettings() {
maxHeight={320}
className="mb-8 block w-full"
trigger={
<button
type="button"
<Button
variant="secondary"
size="md"
block
className="h-10 gap-3"
aria-label={t('settings.general.responseLangTitle')}
className="flex h-10 w-full items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-left text-sm text-[var(--color-text-primary)] outline-none transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-low)] focus-visible:border-[var(--color-border-focus)] focus-visible:shadow-[var(--shadow-focus-ring)]"
>
<span className="min-w-0 flex-1 truncate">{selectedResponseLanguageLabel}</span>
<span className="min-w-0 flex-1 truncate text-left">{selectedResponseLanguageLabel}</span>
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-text-secondary)]">expand_more</span>
</button>
</Button>
}
/>
@@ -2688,14 +2686,16 @@ export function GeneralSettings() {
maxHeight={360}
className="block w-full"
trigger={
<button
type="button"
<Button
variant="secondary"
size="md"
block
className="h-auto min-h-10 gap-3 py-2"
aria-label={t('settings.general.outputStyleSelectLabel')}
disabled={outputStylesLoading}
className="flex min-h-10 w-full items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-left text-sm text-[var(--color-text-primary)] outline-none transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-low)] focus-visible:border-[var(--color-border-focus)] focus-visible:shadow-[var(--shadow-focus-ring)] disabled:cursor-not-allowed disabled:opacity-60"
icon={<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-text-secondary)]">format_paint</span>}
>
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-text-secondary)]">format_paint</span>
<span className="min-w-0 flex-1">
<span className="min-w-0 flex-1 text-left">
<span className="block truncate font-medium">
{outputStylesLoading
? t('settings.general.outputStyleLoading')
@@ -2708,7 +2708,7 @@ export function GeneralSettings() {
)}
</span>
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-text-secondary)]">expand_more</span>
</button>
</Button>
}
/>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
@@ -3581,15 +3581,9 @@ function H5AccessSettings() {
</span>
</span>
</label>
<span
className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${
h5Access.enabled
? 'bg-[var(--color-success)]/10 text-[var(--color-success)]'
: 'bg-[var(--color-surface)] text-[var(--color-text-tertiary)] border border-[var(--color-border)]'
}`}
>
<Badge tone={h5Access.enabled ? 'success' : 'neutral'} size="sm" bordered={!h5Access.enabled}>
{h5Access.enabled ? t('settings.general.h5AccessStatusEnabled') : t('settings.general.h5AccessDisabledValue')}
</span>
</Badge>
</div>
{h5AccessDiagnostics?.storedHostStaleness === 'unreachable' && h5AccessDiagnostics.storedPublicBaseUrl ? (
@@ -3742,6 +3736,10 @@ function H5AccessSettings() {
/>
) : (
<div className="flex flex-col items-center gap-3 px-4 text-center">
{/* Stock neutrals on purpose: the QR box is a hardcoded
`bg-white` (scanners need the contrast), so this text
must stay dark in all three themes. Theme tokens would
go light-on-white under `data-theme="dark"`. */}
<QrCode className="h-12 w-12 text-neutral-400" aria-hidden="true" />
<p className="text-xs leading-5 text-neutral-500">
{t('settings.general.h5AccessQrEmptyHint')}
@@ -4072,12 +4070,9 @@ function AboutSettings() {
<div className="mt-1 flex items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
<span>{t('settings.about.version')} {version}</span>
<span className="text-[var(--color-border)]">·</span>
<button
onClick={() => openUrl(GITHUB_RELEASES)}
className="rounded-[var(--radius-sm)] text-[var(--color-text-accent)] transition-colors hover:text-[var(--color-brand)] focus:outline-none focus:shadow-[var(--shadow-focus-ring)]"
>
<Button variant="link" size="xs" onClick={() => openUrl(GITHUB_RELEASES)}>
{t('settings.about.changelog')}
</button>
</Button>
</div>
)}
+10 -7
View File
@@ -7,6 +7,7 @@ import {
} from '../api/subagents'
import { buildRenderModel, MessageBlock } from '../components/chat/MessageList'
import { ToolCallGroup } from '../components/chat/ToolCallGroup'
import { IconButton } from '@/components/ui/IconButton'
import { useTranslation } from '../i18n'
import { mapHistoryMessagesToUiMessages, useChatStore } from '../stores/chatStore'
import type { AgentTaskNotification, UIMessage } from '../types/chat'
@@ -83,15 +84,17 @@ export function SubagentRunPage({
{sourceSessionId} / {toolUseId}
</p>
</div>
<button
type="button"
aria-label={t('subagentRun.refresh')}
{/* The icon spins in place while loading rather than using IconButton's
`loading` prop, which would swap RefreshCw for the generic Spinner. */}
<IconButton
icon={<RefreshCw size={15} strokeWidth={2.2} aria-hidden="true" className={loading ? 'animate-spin' : undefined} />}
label={t('subagentRun.refresh')}
showTooltip={false}
size="md"
tone="muted"
onClick={() => void load()}
disabled={loading}
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] disabled:cursor-not-allowed disabled:opacity-60"
>
<RefreshCw size={15} strokeWidth={2.2} aria-hidden="true" className={loading ? 'animate-spin' : undefined} />
</button>
/>
</header>
<main className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
@@ -3,6 +3,7 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSettingsStore } from '../stores/settingsStore'
import { useUIStore } from '../stores/uiStore'
import { destroyTerminalRuntime } from '../lib/terminalRuntime'
import { browserHost } from '../lib/desktopHost/browserHost'
@@ -11,6 +12,9 @@ const terminalMocks = vi.hoisted(() => {
cols: 80,
rows: 24,
element: null as HTMLElement | null,
// Real xterm terminals always expose `options`; the theme bridge assigns
// `options.theme` when the app theme changes.
options: {} as { theme?: Record<string, string> },
loadAddon: vi.fn(),
open: vi.fn(),
dispose: vi.fn(),
@@ -378,6 +382,28 @@ describe('TerminalSettings', () => {
destroyTerminalRuntime('shared-runtime')
})
it('repaints a running terminal when the app theme changes', async () => {
terminalMocks.available = true
useUIStore.getState().setTheme('dark')
render(<TerminalSettings runtimeId="theme-runtime" />)
await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalledTimes(1))
const darkTheme = terminalMocks.terminalInstance.options.theme
expect(darkTheme).toBeDefined()
// The runtime outlives the component, so a terminal opened under one theme
// used to keep that palette for the rest of the session.
act(() => { useUIStore.getState().setTheme('white') })
await waitFor(() => {
expect(terminalMocks.terminalInstance.options.theme).not.toBe(darkTheme)
})
expect(terminalMocks.terminalInstance.options.theme).toHaveProperty('background')
destroyTerminalRuntime('theme-runtime')
})
it('shows Windows-only startup shell controls in settings mode', () => {
vi.stubGlobal('navigator', {
...navigator,
+103 -107
View File
@@ -3,9 +3,14 @@ import { Info } from 'lucide-react'
import { useTranslation, type TranslationKey } from '../i18n'
import { terminalApi } from '../api/terminal'
import { useSettingsStore } from '../stores/settingsStore'
import { Dropdown } from '../components/shared/Dropdown'
import { Input } from '../components/shared/Input'
import { Button } from '../components/shared/Button'
import { useUIStore } from '../stores/uiStore'
import { readTerminalPalette } from '../lib/terminalTheme'
import { Dropdown } from '@/components/ui/Dropdown'
import { Input } from '@/components/ui/Input'
import { Button } from '@/components/ui/Button'
import { IconButton } from '@/components/ui/IconButton'
import { EmptyState } from '@/components/ui/EmptyState'
import { Badge, StatusDot, type Tone } from '@/components/ui/Badge'
import type { DesktopTerminalStartupShell } from '../types/settings'
import { getDesktopHost } from '../lib/desktopHost'
import {
@@ -72,6 +77,7 @@ export function TerminalSettings({
preserveOnUnmount = false,
}: TerminalSettingsProps = {}) {
const t = useTranslation()
const theme = useUIStore((state) => state.theme)
const desktopTerminal = useSettingsStore((state) => state.desktopTerminal)
const setDesktopTerminal = useSettingsStore((state) => state.setDesktopTerminal)
const hostRef = useRef<HTMLDivElement | null>(null)
@@ -226,28 +232,7 @@ export function TerminalSettings({
fontSize: 12,
lineHeight: 1.25,
scrollback: 4000,
theme: {
background: '#121212',
foreground: '#d7d2d0',
cursor: '#ffb59f',
selectionBackground: '#5f4a40',
black: '#1f1f1f',
red: '#ff6d67',
green: '#7ef18a',
yellow: '#f8c55f',
blue: '#77a8ff',
magenta: '#d699ff',
cyan: '#61d6d6',
white: '#d7d2d0',
brightBlack: '#8f8683',
brightRed: '#ff8a85',
brightGreen: '#9ff7a7',
brightYellow: '#ffdd7a',
brightBlue: '#a6c5ff',
brightMagenta: '#e3b8ff',
brightCyan: '#8ceeee',
brightWhite: '#ffffff',
},
theme: readTerminalPalette(),
})
fit = new FitAddonModule.FitAddon()
const activeTerminal = terminal
@@ -376,6 +361,15 @@ export function TerminalSettings({
}
}, [active, resizeSession])
// Repaint an already-running terminal when the app theme changes. The
// runtime outlives this component, so a terminal started under one theme
// would otherwise keep that palette for the rest of the session.
useEffect(() => {
const terminal = runtime.terminal
if (!terminal) return
terminal.options.theme = readTerminalPalette()
}, [runtime, theme])
const clearTerminal = () => {
runtime.terminal?.clear()
}
@@ -472,52 +466,52 @@ export function TerminalSettings({
<div className="flex shrink-0 items-center gap-1.5">
{onOpenInTab && (
<button
type="button"
<Button
variant="secondary"
size="base"
onClick={onOpenInTab}
className="inline-flex h-8 items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--color-border)] px-2.5 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
icon={<span className="material-symbols-outlined text-[16px]">open_in_new</span>}
>
<span className="material-symbols-outlined text-[16px]">open_in_new</span>
{t('terminal.openInTab')}
</button>
</Button>
)}
{onNewTerminal && (
<button
type="button"
<Button
variant="secondary"
size="base"
onClick={onNewTerminal}
className="inline-flex h-8 items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--color-border)] px-2.5 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
icon={<span className="material-symbols-outlined text-[16px]">add</span>}
>
<span className="material-symbols-outlined text-[16px]">add</span>
{t('terminal.newTab')}
</button>
</Button>
)}
<button
type="button"
<Button
variant="secondary"
size="base"
onClick={clearTerminal}
disabled={!runtime.terminal}
className="inline-flex h-8 items-center gap-1.5 rounded-[var(--radius-md)] border border-[var(--color-border)] px-2.5 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] disabled:cursor-not-allowed disabled:opacity-50"
icon={<span className="material-symbols-outlined text-[16px]">mop</span>}
>
<span className="material-symbols-outlined text-[16px]">mop</span>
{t('settings.terminal.clear')}
</button>
<button
type="button"
</Button>
<Button
variant="primary"
size="base"
onClick={() => void startTerminal()}
disabled={status === 'starting'}
className="inline-flex h-8 items-center gap-1.5 rounded-[var(--radius-md)] bg-[var(--color-text-primary)] px-2.5 text-xs font-medium text-[var(--color-surface)] transition-colors hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] disabled:cursor-not-allowed disabled:opacity-50"
icon={<span className="material-symbols-outlined text-[16px]">restart_alt</span>}
>
<span className="material-symbols-outlined text-[16px]">restart_alt</span>
{t('settings.terminal.restart')}
</button>
</Button>
{onClose && (
<button
type="button"
<IconButton
icon="close"
label={t('terminal.closePanel')}
showTooltip={false}
size="md"
tone="muted"
onClick={onClose}
aria-label={t('terminal.closePanel')}
className="inline-flex h-8 w-8 items-center justify-center rounded-[var(--radius-md)] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
<span className="material-symbols-outlined text-[17px]">close</span>
</button>
/>
)}
</div>
</div>
@@ -555,13 +549,15 @@ export function TerminalSettings({
}}
width="100%"
trigger={
<button
type="button"
className="flex h-10 w-full items-center justify-between rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-sm text-[var(--color-text-primary)]"
>
<span>{shellItems.find((item) => item.value === startupShell)?.label ?? startupShell}</span>
// `flex-1` on the label, not `justify-between`: Button pins
// `justify-center`, and a className override of it would win
// or lose on stylesheet order rather than on class order.
<Button variant="secondary" size="lg" block>
<span className="flex-1 truncate text-left font-normal text-[var(--color-text-primary)]">
{shellItems.find((item) => item.value === startupShell)?.label ?? startupShell}
</span>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">expand_more</span>
</button>
</Button>
}
/>
</div>
@@ -606,19 +602,13 @@ export function TerminalSettings({
)}
{status === 'unavailable' ? (
<div className="flex flex-1 items-center justify-center rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-8 text-center">
<div>
<span className="material-symbols-outlined mb-3 block text-[32px] text-[var(--color-text-tertiary)]">
desktop_windows
</span>
<p className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.terminal.unavailableTitle')}
</p>
<p className="mt-1 text-sm text-[var(--color-text-tertiary)]">
{t('settings.terminal.unavailableBody')}
</p>
</div>
</div>
<EmptyState
className="flex-1"
size="md"
icon={<span className="material-symbols-outlined text-[20px]" aria-hidden="true">desktop_windows</span>}
title={t('settings.terminal.unavailableTitle')}
description={t('settings.terminal.unavailableBody')}
/>
) : (
<div
data-testid="settings-terminal-frame"
@@ -718,19 +708,23 @@ function TerminalHelpHint({ compact = false }: { compact?: boolean }) {
return (
<span className="group relative inline-flex shrink-0">
<button
type="button"
aria-label={t('settings.terminal.infoLabel')}
{/* showTooltip=false: the sibling `role="tooltip"` span below is the tooltip.
A native `title` would duplicate it and, per the a11y baseline, tooltips
are wired with aria-describedby rather than becoming the accessible name. */}
<IconButton
icon={<Info className={compact ? 'h-3.5 w-3.5' : 'h-4 w-4'} aria-hidden="true" strokeWidth={2.2} />}
label={t('settings.terminal.infoLabel')}
showTooltip={false}
size={compact ? 'xs' : 'sm'}
shape="circle"
tone="muted"
aria-describedby={tooltipId}
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
onKeyDown={(event) => {
if (event.key === 'Escape') setOpen(false)
}}
className={`${compact ? 'h-6 w-6' : 'h-7 w-7'} inline-flex items-center justify-center rounded-full text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]`}
>
<Info className={compact ? 'h-3.5 w-3.5' : 'h-4 w-4'} aria-hidden="true" strokeWidth={2.2} />
</button>
/>
<span
id={tooltipId}
role="tooltip"
@@ -742,21 +736,25 @@ function TerminalHelpHint({ compact = false }: { compact?: boolean }) {
)
}
function StatusPill({ status, label, compact = false }: { status: TerminalStatus; label: string; compact?: boolean }) {
const color =
status === 'running'
? 'bg-[var(--color-success)]'
: status === 'error'
? 'bg-[var(--color-error)]'
: status === 'starting'
? 'bg-[var(--color-warning)]'
: 'bg-[var(--color-text-tertiary)]'
const STATUS_TONE: Record<TerminalStatus, Tone> = {
running: 'success',
error: 'danger',
starting: 'warning',
idle: 'neutral',
exited: 'neutral',
unavailable: 'neutral',
}
function StatusPill({ status, label, compact = false }: { status: TerminalStatus; label: string; compact?: boolean }) {
return (
<span className={`inline-flex ${compact ? 'h-5 px-2 text-[10px]' : 'h-6 px-2.5 text-[11px]'} shrink-0 items-center gap-1.5 rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-low)] font-medium text-[var(--color-text-secondary)]`}>
<span className={`h-1.5 w-1.5 rounded-full ${color}`} />
<Badge
tone="neutral"
bordered
size={compact ? 'xs' : 'md'}
icon={<StatusDot tone={STATUS_TONE[status]} />}
>
{label}
</span>
</Badge>
)
}
@@ -845,29 +843,27 @@ function BashPathSettings({ isTauri }: { isTauri: boolean }) {
placeholder={t('settings.terminal.bashPathLabel')}
className="flex-1 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-sm font-mono text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-border-focus)]"
/>
<button
type="button"
<IconButton
icon="folder_open"
label={t('settings.terminal.bashPathBrowse')}
showTooltip={false}
size="md"
tone="secondary"
bordered
className="w-10"
onClick={handleBrowse}
className="inline-flex h-8 items-center gap-1 rounded-[var(--radius-sm)] border border-[var(--color-border)] px-3 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="material-symbols-outlined text-[16px]">folder_open</span>
</button>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="inline-flex h-8 items-center gap-1 rounded-[var(--radius-sm)] bg-[var(--color-text-primary)] px-3 text-xs font-medium text-[var(--color-surface)] transition-colors hover:opacity-90 disabled:opacity-50"
>
/>
<Button variant="primary" size="base" onClick={handleSave} disabled={saving}>
{saved ? t('settings.terminal.bashPathSaved') : t('settings.terminal.bashPathSave')}
</button>
<button
type="button"
</Button>
<Button
variant="secondary"
size="base"
onClick={handleReset}
disabled={saving || bashPath === null}
className="inline-flex h-8 items-center gap-1 rounded-[var(--radius-sm)] border border-[var(--color-border)] px-3 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
>
{t('settings.terminal.bashPathReset')}
</button>
</Button>
</div>
{invalid && (
<p className="mt-1.5 text-xs text-[var(--color-error)]">
-235
View File
@@ -1,235 +0,0 @@
import { useState } from 'react'
import { mockToolInspection } from '../mocks/data'
export function ToolInspection() {
const [activeDiffTab, setActiveDiffTab] = useState<'split' | 'unified'>('split')
const { toolType, toolName, description, filePath, dryRunStatus, linesChanged, diffLines } =
mockToolInspection
return (
<div className="flex-1 flex flex-col overflow-hidden bg-[var(--color-surface)]">
{/* Separator */}
<div className="h-px w-full bg-[var(--color-surface-container)]" />
{/* Content */}
<main className="flex-1 overflow-y-auto p-8 max-w-6xl mx-auto w-full">
<div className="flex flex-col gap-6">
{/* ── Title row + action buttons ─────────────────── */}
<div className="flex items-start justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-[var(--color-primary-fixed)] text-[var(--color-on-primary)] text-[10px] font-bold rounded uppercase tracking-widest">
{toolType}
</span>
<h1 className="font-[var(--font-headline)] font-extrabold text-2xl text-[var(--color-on-surface)] tracking-tight">
{toolName}
</h1>
</div>
<p className="text-[var(--color-on-surface-variant)] font-medium">{description}</p>
</div>
<div className="flex gap-2">
<button className="px-4 py-2 bg-[var(--color-surface-container-high)] rounded-lg text-sm font-semibold hover:bg-[var(--color-surface-variant)] transition-all">
Revert Change
</button>
<button className="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-on-primary)] rounded-lg text-sm font-semibold shadow-sm hover:opacity-90 transition-all">
Apply to All
</button>
</div>
</div>
{/* ── Metadata cards ─────────────────────────────── */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-[var(--color-surface-container-low)] rounded-xl p-4 flex flex-col gap-1">
<span className="text-[10px] font-bold text-[var(--color-outline)] uppercase tracking-wider">
Target File
</span>
<div className="flex items-center gap-2 text-[var(--color-on-surface)]">
<span className="material-symbols-outlined text-[18px]">description</span>
<span className="font-[var(--font-mono)] text-sm">{filePath}</span>
</div>
</div>
<div className="bg-[var(--color-surface-container-low)] rounded-xl p-4 flex flex-col gap-1">
<span className="text-[10px] font-bold text-[var(--color-outline)] uppercase tracking-wider">
Status
</span>
<div className="flex items-center gap-2 text-[var(--color-tertiary)]">
<span
className="material-symbols-outlined text-[18px]"
style={{ fontVariationSettings: "'FILL' 1" }}
>
check_circle
</span>
<span className="font-semibold text-sm">{dryRunStatus}</span>
</div>
</div>
<div className="bg-[var(--color-surface-container-low)] rounded-xl p-4 flex flex-col gap-1">
<span className="text-[10px] font-bold text-[var(--color-outline)] uppercase tracking-wider">
Lines Modified
</span>
<div className="flex items-center gap-2 text-[var(--color-on-surface)]">
<span className="material-symbols-outlined text-[18px]">edit_note</span>
<span className="font-semibold text-sm">
+{linesChanged.added} / -{linesChanged.removed} lines
</span>
</div>
</div>
</div>
{/* ── Diff Viewer ────────────────────────────────── */}
<div className="bg-[var(--color-surface-dim)] rounded-xl overflow-hidden border border-[var(--color-outline-variant)]/20 shadow-sm">
<div className="px-4 py-2.5 bg-[var(--color-surface-container-high)] flex items-center justify-between border-b border-[var(--color-outline-variant)]/20">
<div className="flex items-center gap-3">
<div className="flex gap-1.5">
<div className="w-2.5 h-2.5 rounded-full bg-[var(--color-error)] opacity-30" />
<div className="w-2.5 h-2.5 rounded-full bg-[var(--color-primary-fixed-dim)]" />
<div className="w-2.5 h-2.5 rounded-full bg-[var(--color-tertiary-container)] opacity-30" />
</div>
<span className="font-[var(--font-mono)] text-xs text-[var(--color-outline)] px-2 border-l border-[var(--color-outline-variant)]/30">
{filePath} — Diff View
</span>
</div>
<div className="flex items-center gap-4">
<span className="text-[11px] text-[var(--color-outline)] font-medium">
L{diffLines[0]?.lineNo ?? 1} — L{diffLines[diffLines.length - 1]?.lineNo ?? 1}
</span>
<div className="flex bg-[var(--color-surface-container-low)] rounded p-0.5">
<button
onClick={() => setActiveDiffTab('split')}
className={`px-2 py-1 text-[10px] font-bold uppercase ${
activeDiffTab === 'split'
? 'bg-[var(--color-surface)] rounded shadow-sm text-[var(--color-on-surface)]'
: 'text-[var(--color-outline)]'
}`}
>
Split
</button>
<button
onClick={() => setActiveDiffTab('unified')}
className={`px-2 py-1 text-[10px] font-bold uppercase ${
activeDiffTab === 'unified'
? 'bg-[var(--color-surface)] rounded shadow-sm text-[var(--color-on-surface)]'
: 'text-[var(--color-outline)]'
}`}
>
Unified
</button>
</div>
</div>
</div>
<div className="font-[var(--font-mono)] text-[13px] leading-relaxed p-4 overflow-x-auto whitespace-pre">
{diffLines.map((line, idx) => {
const isAdded = line.type === 'added'
const isRemoved = line.type === 'removed'
let rowBg = ''
if (isAdded) rowBg = 'bg-[var(--color-diff-added-bg)]'
else if (isRemoved) rowBg = 'bg-[var(--color-diff-removed-bg)]'
let lineNoColor = 'text-[var(--color-outline)] opacity-40'
if (isAdded) lineNoColor = 'text-[var(--color-tertiary)] opacity-40'
else if (isRemoved) lineNoColor = 'text-[var(--color-error)] opacity-40'
const prefix = isAdded ? '+ ' : isRemoved ? '- ' : ' '
return (
<div key={idx} className={`flex w-full ${rowBg}`}>
<span
className={`w-10 flex-shrink-0 text-right pr-4 select-none ${lineNoColor}`}
>
{line.lineNo}
</span>
<span className="text-[var(--color-on-surface-variant)]">
{prefix}
{line.content}
</span>
</div>
)
})}
</div>
</div>
{/* ── Implementation Context ─────────────────────── */}
<div className="p-6 bg-[var(--color-surface-container-lowest)] rounded-2xl border border-[var(--color-outline-variant)]/10">
<h3 className="font-[var(--font-headline)] font-bold text-sm text-[var(--color-on-surface)] mb-4">
Implementation Context
</h3>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="mt-1 w-6 h-6 rounded bg-[var(--color-primary-fixed)] flex items-center justify-center">
<span className="material-symbols-outlined text-[14px] text-[var(--color-on-primary)]">
psychology
</span>
</div>
<div>
<p className="text-xs font-bold uppercase tracking-widest text-[var(--color-outline)] mb-1">
Reasoning
</p>
<p className="text-sm text-[var(--color-on-surface-variant)] leading-relaxed">
The <code className="font-[var(--font-mono)]">legacyAuthService</code> was
deprecated in RFC-204. The new SDK provides automatic session refresh and
better error typing. This migration ensures the login flow is compliant with
the new security standards.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="mt-1 w-6 h-6 rounded bg-[var(--color-diff-added-bg)] flex items-center justify-center">
<span className="material-symbols-outlined text-[14px] text-[var(--color-diff-added-text)]">
science
</span>
</div>
<div>
<p className="text-xs font-bold uppercase tracking-widest text-[var(--color-outline)] mb-1">
Impact Analysis
</p>
<p className="text-sm text-[var(--color-on-surface-variant)] leading-relaxed">
No changes needed in calling components. The interface remains compatible but
internal state management is improved.
</p>
</div>
</div>
</div>
<div className="flex items-center justify-center">
<div className="w-full h-32 rounded-xl bg-[var(--color-surface-container)] relative overflow-hidden group">
<div className="absolute inset-0 bg-gradient-to-br from-[var(--color-primary)]/5 to-[var(--color-secondary)]/5" />
<div className="absolute inset-0 flex items-center justify-center gap-4">
<div className="flex flex-col items-center gap-1">
<div className="p-2 bg-[var(--color-surface)] rounded-lg shadow-sm">
<span className="material-symbols-outlined text-[var(--color-outline)]">
description
</span>
</div>
<span className="text-[9px] font-bold text-[var(--color-outline)]">
auth.ts
</span>
</div>
<span className="material-symbols-outlined text-[var(--color-outline)] animate-pulse">
keyboard_double_arrow_right
</span>
<div className="flex flex-col items-center gap-1">
<div className="p-2 bg-[var(--color-surface)] rounded-lg shadow-sm border border-[var(--color-tertiary)]/20">
<span className="material-symbols-outlined text-[var(--color-tertiary)]">
check_circle
</span>
</div>
<span className="text-[9px] font-bold text-[var(--color-tertiary)]">
Verified
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
)
}
+28 -52
View File
@@ -1,12 +1,14 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { KeyboardEvent, MouseEvent, ReactNode } from 'react'
import type { KeyboardEvent } from 'react'
import { ExternalLink, RefreshCw, Search, Trash2, Workflow } from 'lucide-react'
import { tracesApi } from '../api/traces'
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
import { useUIStore } from '../stores/uiStore'
import { useTranslation } from '../i18n'
import { Button } from '../components/shared/Button'
import { ConfirmDialog } from '../components/shared/ConfirmDialog'
import { Button } from '@/components/ui/Button'
import { IconButton } from '@/components/ui/IconButton'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { EmptyState } from '@/components/ui/EmptyState'
import { getDesktopHost } from '../lib/desktopHost'
import type { TraceSessionList, TraceSessionListItem } from '../types/trace'
@@ -249,11 +251,13 @@ function TraceRows({
if (traces.length === 0) {
return (
<div className="flex flex-1 items-start justify-center px-6 py-10">
<div className="w-full max-w-md rounded-2xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-6 py-12 text-center">
<Workflow className="mx-auto h-8 w-8 text-[var(--color-text-tertiary)]" strokeWidth={2} aria-hidden="true" />
<h2 className="mt-3 text-sm font-semibold text-[var(--color-text-primary)]">{t('trace.list.emptyTitle')}</h2>
<p className="mt-2 text-sm leading-6 text-[var(--color-text-secondary)]">{t('trace.list.emptyBody')}</p>
</div>
<EmptyState
className="w-full max-w-md"
headingLevel={2}
icon={<Workflow className="h-5 w-5" strokeWidth={2} />}
title={t('trace.list.emptyTitle')}
description={t('trace.list.emptyBody')}
/>
</div>
)
}
@@ -364,71 +368,43 @@ function TraceRow({
</div>
</button>
<div className="flex w-[92px] shrink-0 items-center justify-end gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<RowAction
<IconButton
size="sm"
tone="secondary"
label={t('trace.open')}
icon={<Workflow className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />}
onClick={(event) => {
event.stopPropagation()
open()
}}
>
<Workflow className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
</RowAction>
<RowAction
/>
<IconButton
size="sm"
tone="secondary"
label={t('trace.openWindow')}
icon={<ExternalLink className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />}
onClick={(event) => {
event.stopPropagation()
onOpenWindow(trace.sessionId)
}}
>
<ExternalLink className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
</RowAction>
<RowAction
/>
<IconButton
size="sm"
tone="secondary"
hoverTone="danger"
label={t('trace.delete')}
tone="danger"
icon={<Trash2 className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />}
disabled={isDeleting}
onClick={(event) => {
event.stopPropagation()
onDelete(trace)
}}
>
<Trash2 className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
</RowAction>
/>
</div>
</div>
)
}
function RowAction({
label,
onClick,
children,
tone = 'default',
disabled = false,
}: {
label: string
onClick: (event: MouseEvent<HTMLButtonElement>) => void
children: ReactNode
tone?: 'default' | 'danger'
disabled?: boolean
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className={`flex h-7 w-7 items-center justify-center rounded-[var(--radius-sm)] text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-container-high)] active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50 ${
tone === 'danger'
? 'hover:text-[var(--color-error)]'
: 'hover:text-[var(--color-text-primary)]'
}`}
>
{children}
</button>
)
}
function MetricCell({ label, value }: { label: string; value: string }) {
return (
<div className="text-right">
+49 -42
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
AlertTriangle,
CheckCircle2,
@@ -16,7 +16,10 @@ import type { TraceSession as TraceSessionData } from '../types/trace'
import { getDesktopHost } from '../lib/desktopHost'
import { buildTraceWindowUrl } from '../lib/traceLaunch'
import { formatClockTime, formatDurationMs, formatTokenCount } from '../lib/trace/formatters'
import { CopyButton } from '../components/shared/CopyButton'
import { Button } from '@/components/ui/Button'
import { CopyButton } from '@/components/ui/CopyButton'
import { EmptyState } from '@/components/ui/EmptyState'
import { IconButton } from '@/components/ui/IconButton'
import { TraceSplitLayout } from '../components/trace/TraceSplitLayout'
import { TraceTree } from '../components/trace/TraceTree'
import { TraceDetail } from '../components/trace/TraceDetail'
@@ -229,14 +232,15 @@ export function TraceSession({
{t('trace.loadFailed')}
</div>
<p className="mt-2 text-sm text-[var(--color-text-secondary)]">{state.message}</p>
<button
type="button"
<Button
variant="secondary"
size="base"
className="mt-4"
onClick={refresh}
className="mt-4 inline-flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-1.5 text-xs font-semibold text-[var(--color-text-primary)] transition-transform active:scale-[0.98]"
icon={<RefreshCw size={14} strokeWidth={2} />}
>
<RefreshCw size={14} strokeWidth={2} />
{t('common.retry')}
</button>
</Button>
</div>
</div>
</div>
@@ -376,15 +380,28 @@ function TraceHeader({
copiedLabel={t('common.copied')}
displayLabel={<Copy size={14} strokeWidth={2} />}
displayCopiedLabel={<CheckCircle2 size={14} strokeWidth={2} />}
className="inline-flex h-7 w-7 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-focus)] hover:text-[var(--color-text-primary)] active:scale-[0.98]"
// Mirrors IconButton size="sm" tone="secondary" bordered, so this
// sits flush with the two IconButtons beside it. CopyButton owns its
// own copied/reset state, so it cannot be swapped for IconButton.
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
/>
<IconButton
size="sm"
tone="secondary"
bordered
label={t('trace.refresh')}
onClick={onRefresh}
icon={<RefreshCw size={14} strokeWidth={2} className={refreshing ? 'animate-spin' : ''} />}
/>
<IconAction label={t('trace.refresh')} onClick={onRefresh}>
<RefreshCw size={14} strokeWidth={2} className={refreshing ? 'animate-spin' : ''} />
</IconAction>
{!standalone ? (
<IconAction label={t('trace.openWindow')} onClick={onOpenWindow}>
<ExternalLink size={14} strokeWidth={2} />
</IconAction>
<IconButton
size="sm"
tone="secondary"
bordered
label={t('trace.openWindow')}
onClick={onOpenWindow}
icon={<ExternalLink size={14} strokeWidth={2} />}
/>
) : null}
</div>
</div>
@@ -428,25 +445,27 @@ function DiagnosisBanner({
{diagnosisReasonLabel(diagnosis.reason, t)}
</span>
{focusSpan ? (
<button
type="button"
<Button
variant="secondary"
size="xs"
className="shrink-0"
onClick={() => onSelect(focusSpan.id)}
className="shrink-0 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-0.5 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-focus)] hover:text-[var(--color-text-primary)] active:scale-[0.98]"
>
{t('trace.focus')}
</button>
</Button>
) : null}
<div className="ml-auto flex min-w-0 items-center justify-end gap-1.5 overflow-hidden">
{evidenceSpans.map((span) => (
<button
<Button
key={span.id}
type="button"
variant="secondary"
size="xs"
className="max-w-[200px]"
onClick={() => onSelect(span.id)}
className="inline-flex max-w-[200px] items-center gap-1.5 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-0.5 text-[11px] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-focus)] hover:text-[var(--color-text-primary)] active:scale-[0.98]"
icon={<TypeIcon span={span} size={13} />}
>
<TypeIcon span={span} size={13} />
<span className="truncate">{spanDisplayTitle(span, t)}</span>
</button>
</Button>
))}
</div>
</section>
@@ -466,20 +485,6 @@ function diagnosisReasonLabel(reason: TraceViewModel['diagnosis']['reason'], t:
}
}
function IconAction({ label, onClick, children }: { label: string; onClick?: () => void; children: ReactNode }) {
return (
<button
type="button"
aria-label={label}
title={label}
onClick={onClick}
className="inline-flex h-7 w-7 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-text-secondary)] transition-colors hover:border-[var(--color-border-focus)] hover:text-[var(--color-text-primary)] active:scale-[0.98]"
>
{children}
</button>
)
}
function TraceSkeleton() {
return (
<div className="flex min-h-0 flex-1 flex-col border-t border-[var(--color-border)] lg:flex-row" data-testid="trace-skeleton">
@@ -508,11 +513,13 @@ function TraceEmpty() {
const t = useTranslation()
return (
<div className="flex flex-1 items-center justify-center border-t border-[var(--color-border)] p-8">
<div className="max-w-sm rounded-[var(--radius-md)] border border-dashed border-[var(--color-border)] px-6 py-8 text-center">
<RadioTower size={22} strokeWidth={2} className="mx-auto text-[var(--color-text-tertiary)]" />
<h2 className="mt-3 text-sm font-semibold text-[var(--color-text-primary)]">{t('trace.emptyTitle')}</h2>
<p className="mt-2 text-sm leading-6 text-[var(--color-text-secondary)]">{t('trace.emptyBody')}</p>
</div>
<EmptyState
className="max-w-sm"
headingLevel={2}
icon={<RadioTower size={20} strokeWidth={2} />}
title={t('trace.emptyTitle')}
description={t('trace.emptyBody')}
/>
</div>
)
}