mirror of
https://github.com/NanmiCoder/claude-code-haha.git
synced 2026-10-10 11:53:10 +08:00
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:
@@ -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)]">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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} • {mockStatusBar.username} • {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>
|
||||
)
|
||||
}
|
||||
@@ -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')} />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user