fix(web): align browse button and select chevrons (#1279)

* fix(web): align new-session browse button

* fix(web): inset select chevrons
This commit is contained in:
Ananovo
2026-08-01 22:55:21 +08:00
committed by GitHub
parent 08fbea5311
commit 6aae44498d
15 changed files with 156 additions and 37 deletions
@@ -8,6 +8,7 @@ import {
DialogDescription
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { SelectControl } from '@/components/ui/select-control'
import { useTranslation } from '@/lib/use-translation'
import { readCodexImportedSessions, subscribeCodexImportedSessions } from '@/lib/codexImportedSessions'
@@ -290,8 +291,8 @@ export function CodexSessionSyncDialog(props: {
{sessions.length > 0 ? (
<label className="block min-w-0 text-xs text-[var(--app-hint)]">
<span className="mb-1 block">{t('codexSync.confirm.cwdFilter')}</span>
<select
className="h-8 w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-2 text-xs text-[var(--app-fg)] outline-none focus:ring-2 focus:ring-[var(--app-link)]"
<SelectControl
className="h-8 rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] pl-2 text-xs text-[var(--app-fg)] outline-none focus:ring-2 focus:ring-[var(--app-link)]"
value={workdirFilter}
disabled={isPending || isLoading || workdirOptions.length === 0}
onChange={(event) => setWorkdirFilter(event.target.value)}
@@ -304,7 +305,7 @@ export function CodexSessionSyncDialog(props: {
{directory}
</option>
))}
</select>
</SelectControl>
</label>
) : null}
@@ -1,6 +1,7 @@
import { getCodexCollaborationModeOptions, type CodexCollaborationMode } from '@hapi/protocol'
import { useTranslation } from '@/lib/use-translation'
import type { AgentType } from './types'
import { SelectControl } from '@/components/ui/select-control'
export function CollaborationModeSelector(props: {
agent: AgentType
@@ -20,18 +21,18 @@ export function CollaborationModeSelector(props: {
{t('newSession.collaborationMode')}{' '}
<span className="font-normal">({t('newSession.model.optional')})</span>
</label>
<select
<SelectControl
value={props.value}
onChange={(e) => props.onChange(e.target.value as CodexCollaborationMode)}
disabled={props.isDisabled}
className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
className="py-2 pl-3 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
>
{getCodexCollaborationModeOptions().map((option) => (
<option key={option.mode} value={option.mode}>
{option.label}
</option>
))}
</select>
</SelectControl>
</div>
)
}
@@ -0,0 +1,30 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { DirectorySection } from './DirectorySection'
vi.mock('@/lib/use-translation', () => ({
useTranslation: () => ({ t: (key: string) => key })
}))
describe('DirectorySection', () => {
it('stretches the Browse button to the directory input height', () => {
render(
<DirectorySection
directory=""
suggestions={[]}
selectedIndex={0}
isDisabled={false}
recentPaths={[]}
onDirectoryChange={vi.fn()}
onDirectoryFocus={vi.fn()}
onDirectoryBlur={vi.fn()}
onDirectoryKeyDown={vi.fn()}
onSuggestionSelect={vi.fn()}
onPathClick={vi.fn()}
onChooseFolder={vi.fn()}
/>
)
expect(screen.getByRole('button', { name: 'newSession.browse' })).toHaveClass('self-stretch')
})
})
@@ -90,7 +90,7 @@ export function DirectorySection(props: {
type="button"
onClick={props.onChooseFolder}
disabled={props.isDisabled}
className="shrink-0 flex items-center gap-1 rounded-md border border-[var(--app-border)] bg-[var(--app-subtle-bg)] px-2 py-2 text-xs text-[var(--app-fg)] hover:bg-[var(--app-secondary-bg)] transition-colors disabled:opacity-50"
className="self-stretch shrink-0 flex items-center gap-1 rounded-md border border-[var(--app-border)] bg-[var(--app-subtle-bg)] px-2 py-2 text-xs text-[var(--app-fg)] hover:bg-[var(--app-secondary-bg)] transition-colors disabled:opacity-50"
title={t('newSession.browse')}
>
<FolderIcon className="h-3.5 w-3.5" />
@@ -1,5 +1,6 @@
import { useTranslation } from '@/lib/use-translation'
import type { NewSessionServiceTier } from './types'
import { SelectControl } from '@/components/ui/select-control'
export type { NewSessionServiceTier }
@@ -21,15 +22,15 @@ export function FastModeSelector(props: {
{t('newSession.fastMode')}{' '}
<span className="font-normal">({t('newSession.model.optional')})</span>
</label>
<select
<SelectControl
value={props.value}
onChange={(e) => props.onChange(e.target.value as NewSessionServiceTier)}
disabled={props.isDisabled}
className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
className="py-2 pl-3 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
>
<option value="standard">{t('misc.fastModeStandard')}</option>
<option value="fast">{t('misc.fastModeFast')}</option>
</select>
</SelectControl>
</div>
)
}
@@ -4,6 +4,7 @@ import {
} from '@hapi/protocol'
import { useTranslation } from '@/lib/use-translation'
import type { AgentType } from './types'
import { SelectControl } from '@/components/ui/select-control'
export function GrokPermissionModeSelector(props: {
agent: AgentType
@@ -21,11 +22,11 @@ export function GrokPermissionModeSelector(props: {
<label className="text-xs font-medium text-[var(--app-hint)]">
{t('misc.permissionMode')}
</label>
<select
<SelectControl
value={props.value}
onChange={(event) => props.onChange(event.target.value as GrokPermissionMode)}
disabled={props.isDisabled}
className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
className="py-2 pl-3 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
>
{getPermissionModeOptionsForFlavor('grok').map((option) => {
const unavailable = option.mode === 'auto'
@@ -36,7 +37,7 @@ export function GrokPermissionModeSelector(props: {
</option>
)
})}
</select>
</SelectControl>
{props.autoPermissionModeSupported === false ? (
<span className="text-xs text-[var(--app-hint)]">
{t('newSession.grokAutoUnavailableDesc')}
@@ -1,6 +1,7 @@
import type { AgentType, LaunchEffort } from './types'
import { CLAUDE_EFFORT_OPTIONS, GROK_EFFORT_OPTIONS } from './types'
import { useTranslation } from '@/lib/use-translation'
import { SelectControl } from '@/components/ui/select-control'
export function LaunchEffortSelector(props: {
agent: AgentType
@@ -25,18 +26,18 @@ export function LaunchEffortSelector(props: {
{t('newSession.effort')}{' '}
<span className="font-normal">({t('newSession.model.optional')})</span>
</label>
<select
<SelectControl
value={props.effort}
onChange={(e) => props.onEffortChange(e.target.value)}
disabled={props.isDisabled}
className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
className="py-2 pl-3 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</SelectControl>
</div>
)
}
@@ -1,5 +1,6 @@
import type { Machine } from '@/types/api'
import { useTranslation } from '@/lib/use-translation'
import { SelectControl } from '@/components/ui/select-control'
function getMachineTitle(machine: Machine): string {
if (machine.metadata?.displayName) return machine.metadata.displayName
@@ -21,11 +22,11 @@ export function MachineSelector(props: {
<label className="text-xs font-medium text-[var(--app-hint)]">
{t('newSession.machine')}
</label>
<select
<SelectControl
value={props.machineId ?? ''}
onChange={(e) => props.onChange(e.target.value)}
disabled={props.isDisabled}
className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] p-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
className="rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] py-2 pl-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
>
{props.isLoading && (
<option value="">{t('loading.machines')}</option>
@@ -39,7 +40,7 @@ export function MachineSelector(props: {
{m.metadata?.platform ? ` (${m.metadata.platform})` : ''}
</option>
))}
</select>
</SelectControl>
</div>
)
}
@@ -1,6 +1,7 @@
import type { AgentType } from './types'
import { MODEL_OPTIONS } from './types'
import { useTranslation } from '@/lib/use-translation'
import { SelectControl } from '@/components/ui/select-control'
export function ModelSelector(props: {
agent: AgentType
@@ -26,18 +27,18 @@ export function ModelSelector(props: {
<span className="font-normal">({t('newSession.model.optional')})</span>
) : null}
</label>
<select
<SelectControl
value={props.model}
onChange={(e) => props.onModelChange(e.target.value)}
disabled={props.isDisabled || props.isLoading}
className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
className="py-2 pl-3 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</SelectControl>
{props.error ? (
<div className="text-xs text-red-600">
{props.error}
@@ -2,6 +2,7 @@ import type { AgentType, CodexReasoningEffort } from './types'
import { CODEX_REASONING_EFFORT_OPTIONS } from './types'
import { useTranslation } from '@/lib/use-translation'
import { getCodexComposerReasoningEffortOptions } from '@/components/AssistantChat/codexReasoningEffortOptions'
import { SelectControl } from '@/components/ui/select-control'
export function ReasoningEffortSelector(props: {
agent: AgentType
@@ -31,18 +32,18 @@ export function ReasoningEffortSelector(props: {
{t('newSession.reasoningEffort')}{' '}
<span className="font-normal">({t('newSession.model.optional')})</span>
</label>
<select
<SelectControl
value={props.value}
onChange={(e) => props.onChange(e.target.value as CodexReasoningEffort)}
disabled={props.isDisabled}
className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
className="py-2 pl-3 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</SelectControl>
</div>
)
}
+8 -6
View File
@@ -4,6 +4,7 @@ import type { ApiClient } from '@/api/client'
import type { Machine, MachineDirectoryEntry } from '@/types/api'
import { queryKeys } from '@/lib/query-keys'
import { useTranslation } from '@/lib/use-translation'
import { SelectControl } from '@/components/ui/select-control'
function FolderIcon(props: { className?: string }) {
return (
@@ -271,11 +272,12 @@ export function WorkspaceBrowser(props: {
const machineSelector = (
<div className="flex items-center gap-2">
<MachineIcon className="h-4 w-4 text-[var(--app-hint)] shrink-0" />
<select
<SelectControl
value={machineId ?? ''}
onChange={e => setMachineId(e.target.value || null)}
disabled={machinesLoading}
className="flex-1 bg-transparent text-sm text-[var(--app-fg)] outline-none"
containerClassName="flex-1"
className="bg-transparent text-sm text-[var(--app-fg)] outline-none"
>
{machines.map(m => (
<option key={m.id} value={m.id}>
@@ -286,7 +288,7 @@ export function WorkspaceBrowser(props: {
{machines.length === 0 && (
<option value="">{machinesLoading ? t('loading') : t('misc.noMachines')}</option>
)}
</select>
</SelectControl>
</div>
)
@@ -329,17 +331,17 @@ export function WorkspaceBrowser(props: {
{workspaceRoots.length > 1 && (
<div className="mt-2">
<select
<SelectControl
value={selectedRoot ?? ''}
onChange={(e) => setSelectedRoot(e.target.value || null)}
className="w-full bg-transparent text-xs text-[var(--app-hint)] outline-none"
className="bg-transparent text-xs text-[var(--app-hint)] outline-none"
>
{workspaceRoots.map((root) => (
<option key={root} value={root}>
{root}
</option>
))}
</select>
</SelectControl>
</div>
)}
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { SelectControl } from '@/components/ui/select-control'
import {
DEFAULT_VOICE_CHARACTER,
DEFAULT_VOICE_IDENTITY,
@@ -197,13 +198,13 @@ export function VoicePersonaControls(props: {
<span className="mb-1 block text-sm text-[var(--app-fg)]">
{props.t('settings.voice.character.preset.label')}
</span>
<select value={prefs.preset}
<SelectControl value={prefs.preset}
onChange={(e) => setPreset(e.target.value as VoicePersonalityPresetId)}
className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-2 py-2 text-sm text-[var(--app-fg)]">
className="rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] py-2 pl-2 text-sm text-[var(--app-fg)]">
{VOICE_PERSONALITY_PRESETS.map((preset) => (
<option key={preset.id} value={preset.id}>{props.t(preset.labelKey)}</option>
))}
</select>
</SelectControl>
<p className="mt-1 text-xs text-[var(--app-hint)]">
{props.t(getVoicePersonalityPreset(prefs.preset).descriptionKey)}
</p>
@@ -0,0 +1,43 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { SelectControl } from './select-control'
describe('SelectControl', () => {
it('replaces the native arrow with an inset decorative chevron', () => {
render(
<SelectControl aria-label="Machine" defaultValue="local">
<option value="local">Local</option>
</SelectControl>
)
const select = screen.getByRole('combobox', { name: 'Machine' })
const chevron = select.nextElementSibling
expect(select).toHaveClass('appearance-none', 'pr-10')
expect(chevron).toHaveAttribute('aria-hidden', 'true')
expect(chevron).toHaveClass('pointer-events-none', 'right-3')
})
it('preserves native select behavior and wrapper sizing classes', () => {
const onChange = vi.fn()
render(
<SelectControl
aria-label="Language"
containerClassName="max-w-[55%]"
defaultValue="en"
onChange={onChange}
>
<option value="en">English</option>
<option value="zh">Chinese</option>
</SelectControl>
)
const select = screen.getByRole('combobox', { name: 'Language' })
expect(select.parentElement).toHaveClass('max-w-[55%]')
fireEvent.change(select, { target: { value: 'zh' } })
expect(onChange).toHaveBeenCalledOnce()
expect(select).toHaveValue('zh')
})
})
+33
View File
@@ -0,0 +1,33 @@
import { forwardRef, type SelectHTMLAttributes } from 'react'
export interface SelectControlProps extends SelectHTMLAttributes<HTMLSelectElement> {
containerClassName?: string
}
export const SelectControl = forwardRef<HTMLSelectElement, SelectControlProps>(
function SelectControl({ children, className = '', containerClassName = '', ...props }, ref) {
return (
<span className={`relative block min-w-0 ${containerClassName}`}>
<select
ref={ref}
className={`peer w-full appearance-none pr-10 ${className}`}
{...props}
>
{children}
</select>
<svg
aria-hidden="true"
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--app-fg)] peer-disabled:opacity-50"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
</span>
)
}
)